- Add 'Running locally (host-side, no Docker)' subsection after dev-auth bypass section - Explain why plain pnpm dev fails: dev script has no dotenv, root .env sets DB_HOST=mariadb - Document exact command: build first, then set -a; source .env; set +a && DEV_AUTH_BYPASS=true DB_HOST=localhost pnpm --filter @familysync/api dev - Add Terminal 2 (PWA) command: pnpm --filter @familysync/pwa dev - Explain why --env-file is intentionally absent from the dev script
14 KiB
FamilySync — Deployment & Live-Verification Runbook
This document is the operator runbook for getting FamilySync running behind Authelia (OIDC)
and Pangolin/Newt (public tunnel), and for executing the Phase 1 Gate 2 live-verification
items (01-HUMAN-UAT.md). It covers two deployment modes:
- Mode A — Local test rig (recommended for Gate 2): familysync + a Newt connector run on your dev box, routed through your existing Pangolin under a test subdomain. Validates the real external topology (HTTPS, Pangolin SSE pass-through, Authelia OIDC) without deploying to Unraid and without touching the production stack.
- Mode B — Unraid production: the real household deployment. Identical app + config; only the host and the Newt site differ.
The behaviours Gate 2 is checking — Authelia OIDC redirect/session, and SSE survival through the tunnel — live in Authelia and Pangolin/Newt, not in where the origin container runs. So Mode A is a faithful test of both. Reserve Unraid (Mode B) for go-live.
Topology
flowchart LR
subgraph Public
User[Browser / iOS PWA]
Pangolin[Pangolin edge<br/>public HTTPS + WAF]
end
subgraph Private[Private network - no inbound ports]
Newt[Newt connector<br/>outbound tunnel]
API[familysync api<br/>Hono :3000]
DB[(MariaDB)]
Redis[(Redis - Phase 4)]
end
Authelia[Authelia OIDC<br/>auth.DOMAIN]
User -->|https://familysync.DOMAIN| Pangolin
Pangolin -->|tunnel| Newt
Newt --> API
API --> DB
API -.Phase 4.-> Redis
User -->|OIDC redirect| Authelia
API -->|token exchange / userinfo| Authelia
Key property: Newt dials outbound to Pangolin — there are no open inbound ports on the private network (honours the project networking constraint). This is true for both modes.
Prerequisites (both modes)
- A Pangolin instance you control, with a wildcard or per-host cert for
*.DOMAIN. - Authelia already deployed and reachable at
https://auth.DOMAIN(project constraint). - The familysync image builds:
docker compose build(see repodocker-compose.yml). - A Fastmail app password per member (scope "Mail, Contacts & Calendars") — see
CAL-08-DECISION.md. Never commit it; it lives in a gitignored.env/.env.spike.
⚠️ Same-parent-domain requirement (Pitfall 1)
FamilySync must be served under the same parent domain as Authelia so the session cookie is
same-site. e.g. Authelia at auth.DOMAIN and the app at familysync.DOMAIN (Mode B) or
familysync-dev.DOMAIN (Mode A). A different apex domain will break the OIDC session cookie.
Step 1 — Register the OIDC client in Authelia
Authelia client registration is additive — adding a new client_id does not affect existing
clients, and is trivially reversible. For Mode A use a distinct id + redirect so it never collides
with the eventual production client.
Generate a hashed client secret:
authelia crypto hash generate pbkdf2 --variant sha512 --random --random.length 72
# Record BOTH the plaintext (for the app's OIDC_CLIENT_SECRET) and the hash (for Authelia).
Add to Authelia configuration.yml under identity_providers.oidc.clients:
identity_providers:
oidc:
clients:
- client_id: 'familysync' # Mode A: 'familysync-dev'
client_name: 'FamilySync'
client_secret: '$pbkdf2-sha512$...' # the HASH from the command above
public: false
authorization_policy: 'one_factor'
redirect_uris:
- 'https://familysync.DOMAIN/callback' # Mode A: https://familysync-dev.DOMAIN/callback
scopes: [openid, profile, email]
response_types: [code]
grant_types: [authorization_code, refresh_token]
token_endpoint_auth_method: client_secret_basic
require_pkce: true
pkce_challenge_method: S256
Reload Authelia (docker restart authelia or its reload mechanism). These match the locked
auth params in CLAUDE.md (code flow + PKCE S256 + client_secret_basic).
Step 2 — App environment (.env)
Copy .env.example → .env and fill in. Generate secrets as noted:
# Session cookie signing secret for @hono/oidc-auth
OIDC_AUTH_SECRET=$(openssl rand -base64 32)
# Broker app-password encryption key (32 bytes hex)
APP_PASSWORD_ENCRYPTION_KEY=$(node -e "console.log(require('crypto').randomBytes(32).toString('hex'))")
# Database
DB_HOST=mariadb
DB_PORT=3306
DB_USER=familysync
DB_PASSWORD=<strong>
DB_NAME=familysync
DB_ROOT_PASSWORD=<strong>
# OIDC (Authelia)
OIDC_AUTH_SECRET=<openssl rand -base64 32>
OIDC_ISSUER=https://auth.DOMAIN
OIDC_CLIENT_ID=familysync # or familysync-dev (Mode A)
OIDC_CLIENT_SECRET=<plaintext secret matching the Authelia hash>
OIDC_REDIRECT_URI=https://familysync.DOMAIN/callback
# MANDATORY behind a tunnel — without it @hono/oidc-auth builds redirect_uri from the
# internal container hostname, which will not match the registered URI.
OIDC_AUTH_EXTERNAL_URL=https://familysync.DOMAIN
# Broker
APP_PASSWORD_ENCRYPTION_KEY=<64-hex>
Step 3 — Apply the database schema
The image does not auto-migrate. Bring up MariaDB and apply the committed migrations once:
WARNING — do NOT use the
pushsubcommand of drizzle-kit on this MariaDB. Themysqldialect misreads MariaDB 11.x metadata and schedules a false truncate/recreate that wipes data. Thepushworkflow has been removed from the project scripts for this reason. Always use the committed-migration path:db:generateto author a new migration (diffsschema.tsagainst committed JSON snapshots, never the live DB),db:migrateto apply it.
docker compose up -d mariadb
# from the repo root, host-side (dev override exposes 3306):
docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d mariadb
DB_HOST=127.0.0.1 DB_PORT=3306 DB_USER=familysync DB_NAME=familysync DB_PASSWORD=<value> \
pnpm --filter @familysync/api exec drizzle-kit migrate
# verify: SHOW TABLES; -> users, member_credentials, calendars, calendar_events
To author a future schema change: edit apps/api/src/db/schema.ts, run
pnpm --filter @familysync/api run db:generate (diffs schema against committed snapshots — no DB
connection needed), commit the generated SQL, then apply with db:migrate.
Step 4 — Pangolin route + Newt connector
In Pangolin, create a resource/route for the hostname:
- Host:
familysync.DOMAIN(Mode A:familysync-dev.DOMAIN) - Upstream: the Newt connector →
http://<api-host>:3000 - Auth: leave Pangolin's own auth off for this route — FamilySync does its own Authelia OIDC at the app layer. (Do not double-gate.)
⚠️ SSE idle timeout (Phase 4 dependency, issue #1034)
FamilySync uses Server-Sent Events for live list sync (Phase 4). Long-lived SSE streams can be killed by a proxy idle timeout. In the Pangolin route config, ensure response buffering is off and the idle/read timeout is >= 120s (ideally higher). The Gate 2 SSE smoke test below is what confirms this end to end — it must pass before Phase 4 is built.
Newt connector
- Mode A (local): run Newt on your dev box pointing at your Pangolin site token. It dials out;
no local ports are exposed.
familysync(api) listens on:3000reachable by Newt. - Mode B (Unraid): run the Newt container in the same Unraid stack (see Step 6).
# Newt connector (example — use the site token Pangolin issues for this site)
docker run -d --name newt --restart unless-stopped \
-e PANGOLIN_ENDPOINT=https://pangolin.DOMAIN \
-e NEWT_ID=<site-id> -e NEWT_SECRET=<site-secret> \
fosrl/newt:latest
Step 5 — Bring up the app
docker compose up -d --build
curl -s http://localhost:3000/health # local sanity: {"ok":true,"db":"up"}
Then proceed to Gate 2 verification through the public URL.
Step 6 — Unraid production (Mode B only)
- Copy the repo (or just
docker-compose.yml,apps/api/Dockerfile, built image) to Unraid. - Create the
.envon the Unraid host (do not commit it; store via Unraid's secrets/template). - Add the
newtservice to the production compose (or run as a separate Unraid container) bound to the production Pangolin site. - Use a named Docker volume for
mariadb_dataon the array (not a throwaway volume). docker compose up -d --build, thendrizzle-kit migrateonce (Step 3) against the prod DB.- Register the production Authelia client (
client_id: familysync, prod redirect URI) if you usedfamilysync-devfor Mode A.
Differences from Mode A are limited to: host, Newt site token, volume location, and the OIDC
client id/redirect. The app code and docker-compose.yml are identical.
Gate 2 — Live verification checklist (01-HUMAN-UAT.md)
Run from an external network (phone on cellular is ideal for a true external path).
| # | Item | Pass condition |
|---|---|---|
| 1 | AUTH-01 login | https://familysync.DOMAIN → redirects to Authelia → after login, shell shows name + color + one cached event |
| 2 | AUTH-02 session | Fully close + reopen browser → no re-login |
| 3 | AUTH-03 colors | Wife logs in on iPhone → distinct, stable color |
| 4 | iOS PWA (pairs with Phase 3) | Add-to-Home-Screen, launch standalone, login completes in standalone mode (watch for redirect breaking out of standalone) |
| 5 | SSE smoke (gate before Phase 4) | Hold the stream open 5+ min without it being cut: |
# get the session cookie from the browser after logging in (DevTools → Application → Cookies)
curl -N -H "Cookie: oidc-auth=<value>" https://familysync.DOMAIN/api/sse/heartbeat
# expect a `heartbeat` event ~every 10s for 5+ minutes
- SSE PASS → SSE transport confirmed for Phase 4.
- SSE FAIL (stream cut early) → adjust Pangolin idle-timeout/buffering; if still failing, record as a Phase 4 constraint and plan a reconnect/fallback strategy.
Record results in .planning/phases/01-foundation-broker-spike/01-HUMAN-UAT.md.
Dev-auth bypass (Phase 2+ local development)
FamilySync builds Phase 2 and Phase 3 features behind a dev-auth bypass so live Authelia is not required during development (D-14). The bypass injects a fixed dev user into the request context and short-circuits the OIDC guard.
Activation (local dev only):
# In your local .env:
DEV_AUTH_BYPASS=true
NODE_ENV=development # or test, or any value other than 'production'
Hard production guard:
The bypass middleware's FIRST conditional is process.env.NODE_ENV === 'production'. If this is
true, the middleware returns a no-op passthrough regardless of any other env var. This means:
- Even if
DEV_AUTH_BYPASS=trueis accidentally present in a production container, it has zero effect. The OIDC guard fires normally. - The hard guard is checked before
DEV_AUTH_BYPASSis read — there is no code path where production + bypass = unauthenticated access.
Production Docker Compose prohibition:
The production docker-compose.yml MUST NOT include DEV_AUTH_BYPASS in the environment block.
The .env.example entry for DEV_AUTH_BYPASS is commented out by default as a reminder.
What the bypass does:
Sets c.set('user', DEV_USER) in the Hono context before oidcAuthMiddleware runs. Routes that
read c.get('user') receive a fixed dev user { id: 1, displayName: 'Dev User', color: '#4A90D9' }.
Routes that call getAuth(c) from @hono/oidc-auth will still return null (no OIDC cookie is
present) — those routes must be updated to prefer c.get('user') when building Phase 2+.
Running locally (host-side, no Docker)
Use this when you want to run the API and PWA directly on the host (no docker compose up for the
app containers), e.g. during Phase 2+ feature development with the dev-auth bypass active.
Prerequisite: dev MariaDB must be running with the host port exposed.
Use the dev compose override from Step 3 — it binds MariaDB to localhost:3306:
docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d mariadb
Why a plain pnpm --filter @familysync/api dev is not enough:
The API dev script is node --watch dist/index.js. It does not auto-load .env — there is
no dotenv call and no --env-file flag in the script. Without any env vars, db/client.ts
defaults DB_HOST to 'localhost', which works for a host-side run. However, once you source the
root .env to pick up OIDC secrets and other variables, the problem surfaces: root .env sets
DB_HOST=mariadb (the Docker service name, only resolvable inside the Docker network). On the
host, mariadb does not resolve, so the DB connection fails.
The fix is to source .env for all the other variables and then immediately override DB_HOST back
to localhost.
Run the API and PWA:
Open two terminals from the repo root.
Terminal 1 — API:
# Build first (the dev script runs the compiled output, not ts-node)
pnpm --filter @familysync/api build
# Source root .env, then override DB_HOST and activate the bypass
set -a; source .env; set +a && DEV_AUTH_BYPASS=true DB_HOST=localhost pnpm --filter @familysync/api dev
Terminal 2 — PWA:
pnpm --filter @familysync/pwa dev
The set -a; source .env; set +a idiom exports every variable from the root .env into the shell
environment. The DEV_AUTH_BYPASS=true DB_HOST=localhost prefix on the same command line then
overrides those two specific vars for the pnpm child process — DB_HOST=localhost wins over the
DB_HOST=mariadb that was exported from .env.
Why --env-file is not baked into the dev script:
If --env-file .env were added to the API dev script, it would load DB_HOST=mariadb
automatically on every pnpm dev invocation. That value only works inside the Docker network; on
the host it resolves to nothing and the DB connection fails. Keeping .env loading out of the
script is intentional — the developer sources it manually and overrides DB_HOST as shown above.