Files
familysync/docs/deployment.md
T
Lucas Berger 9b62887f0f feat(20-03): unify member editor + declutter admin members panel
- playwright-cli verified: Members tab shows tappable rows, no retired buttons
- Row tap opens 'Edit member' sheet; per-section saves keep sheet open
- 'Add member' trigger opens 'Add member' sheet in create mode
- Profile save fires 'Profile saved.' toast; sheet stays open (D-05)
- eslint + prettier + typecheck + vitest (275 tests) all pass
- Fix pre-existing prettier drift in docs/*, CLAUDE.md, README.md, api/admin.ts
2026-06-18 17:39:00 -04:00

337 lines
14 KiB
Markdown

<!-- generated-by: gsd-doc-writer -->
# FamilySync — Deployment Guide
Self-hosted Docker deployment on Unraid behind Authelia OIDC and a Pangolin/Newt outbound tunnel. The API serves the compiled React PWA as static files on a single port (3000), so only one route needs to be exposed through the tunnel.
---
## Deployment Targets
| Target | Config file |
| ----------------------------- | --------------------------------------------------------- |
| Docker Compose (production) | `docker-compose.yml` |
| Docker Compose (dev override) | `docker-compose.dev.yml` |
| Container image | `apps/api/Dockerfile` (multi-stage, built from repo root) |
The production compose file brings up three services:
| Service | Image | Purpose |
| --------- | ---------------------------------------------------- | ---------------------------------------------------------- |
| `api` | Built from `apps/api/Dockerfile` target `production` | Hono API + compiled React PWA, listens on port 3000 |
| `mariadb` | `mariadb:11` | Persistent MariaDB database |
| `redis` | `redis:7-alpine` | Present for live list sync (pub/sub); unused until Phase 4 |
---
## CI/CD Pipeline
FamilySync uses a self-hosted Gitea Actions runner. Two workflows govern the release path.
### PR gate — `.gitea/workflows/ci.yml`
Triggered on every pull request targeting `main`. The workflow runs a `changes` filter job first, then launches the following jobs in parallel:
| Job | Runs when | What it checks |
| ------------- | ---------------------- | ----------------------------------------------------------------------------------------------------------------- |
| `fast-checks` | Always | Lint (`pnpm lint`), format check (`pnpm format:check`), markdown lint (`pnpm md:lint`), typecheck, PWA unit tests |
| `api` | Code-changing PRs only | DB migrations + API integration tests against a live MariaDB service container |
| `harness` | Code-changing PRs only | Full Playwright E2E suite (iPhone + Pixel + desktop profiles) against the compiled API |
| `security` | Always | Secret scan (gitleaks, PR diff); dependency audit and outdated report on code-changing PRs |
| `gate` | Always | Aggregates results — fails if any non-skipped required job did not succeed |
The `api` and `harness` jobs are **skipped on doc-only PRs** (changes confined to `.gitea/**`, `.planning/**`, or `*.md` files). A doc-only PR must pass `fast-checks` and `security`; the heavy jobs are not required.
Branch protection on `main` blocks direct push and force push. Only PRs where both `CI / fast-checks` and `CI / gate` pass can merge.
### Publish — `.gitea/workflows/publish.yml`
Triggered on push to `main` (i.e., when any PR merges). Skipped when every changed file is under `.gitea/**` or `.planning/**`. Builds the `apps/api` Docker image and pushes it to the Gitea container registry.
**Registry:** `git.bergerhouse.net/luckberg/familysync-api`
**Image tags produced per merge:**
| Tag | Example | Purpose |
| ------------------------- | --------------- | ------------------------------------------ |
| `:latest` | `:latest` | Moving pointer for easy `docker pull` |
| `:<milestone>-<shortsha>` | `:v1.1-98acff8` | Immutable, rollback-traceable (7-char SHA) |
The current milestone prefix (`v1.1`) is set in the `MILESTONE` env var at the top of `publish.yml`. Update it at milestone boundaries.
The immutable `:<milestone>-<sha>` tag is pushed first. `:latest` is only moved after the immutable tag has landed, so a failed second push can never leave `:latest` advanced without a corresponding rollback tag.
Before pushing, the workflow runs two image hygiene assertions:
1. **Static assertions** — verifies `.dockerignore` contains all required exclusion patterns and that the build targets `--target production`.
2. **Boot-smoke** — starts the image with `NODE_ENV=production` and `DEV_AUTH_BYPASS=true` and asserts that it refuses to start (confirming the D-08 guard fires in the shipped image).
**Authentication — `REGISTRY_PAT` secret:**
The workflow authenticates with the Gitea container registry using a PAT stored in the `REGISTRY_PAT` Actions secret. The secret must have `write:package` scope. It is named `REGISTRY_PAT` — not `GITEA_REGISTRY_PAT` or any `GITEA_`-prefixed name, because Gitea reserves the `GITEA_` prefix and will reject those names at secret-creation time. `GITEA_TOKEN` and `GITHUB_TOKEN` cannot push packages.
The PAT is passed via `--password-stdin` (never via `-p`/`--password`) and is bound through `env:` so it is never interpolated into the script body:
```yaml
env:
REGISTRY_PAT: ${{ secrets.REGISTRY_PAT }}
run: |
printf '%s' "$REGISTRY_PAT" | \
docker login git.bergerhouse.net \
--username luckberg \
--password-stdin
```
The credential is purged from the runner with `docker logout` in an `if: always()` step after every push.
---
## Prerequisites
- Docker and Docker Compose available on the Unraid host.
- Authelia already deployed with a FamilySync OIDC client registered (see [Register the OIDC Client](#register-the-oidc-client)).
- Pangolin/Newt tunnel configured to route an external HTTPS hostname to the Docker host on port 3000 (see [Pangolin / Newt Tunnel](#pangolin--newt-tunnel)).
- A `.env` file at the repo root with all required secrets (see [Environment Setup](#environment-setup)).
---
## Register the OIDC Client
Add the following client block to your Authelia `configuration.yml` under `identity_providers.oidc.clients`:
```yaml
- client_id: familysync
client_name: FamilySync
client_secret: '<pbkdf2-hash-of-your-plaintext-secret>'
public: false
authorization_policy: one_factor
redirect_uris:
- https://familysync.DOMAIN/callback # replace DOMAIN with your actual domain
scopes:
- openid
- profile
- email
- offline_access
response_types:
- code
grant_types:
- authorization_code
- refresh_token
require_pkce: true
pkce_challenge_method: S256
token_endpoint_auth_method: client_secret_basic
userinfo_signed_response_alg: none
```
These parameters are fixed — do not change `response_types`, `grant_types`, `require_pkce`, `pkce_challenge_method`, or `token_endpoint_auth_method`.
To generate the pbkdf2 hash from your chosen plaintext secret:
```bash
# Authelia CLI — run on the host where Authelia is installed
authelia crypto hash generate pbkdf2 --variant sha512
```
<!-- VERIFY: Authelia CLI command syntax may vary by version; verify against your installed Authelia release -->
Store the **plaintext** secret in `.env` as `OIDC_CLIENT_SECRET`. Never use the hash in `.env`.
---
## Environment Setup
Copy `.env.example` to `.env` at the repo root and fill in every value. The file is gitignored and must never be committed.
Minimum production `.env`:
```dotenv
# Database
DB_PASSWORD=<strong-password>
DB_ROOT_PASSWORD=<strong-root-password>
# OIDC
OIDC_AUTH_SECRET=<run: openssl rand -base64 32>
OIDC_ISSUER=https://auth.DOMAIN
OIDC_CLIENT_SECRET=<plaintext-secret-matching-authelia-hash>
OIDC_REDIRECT_URI=https://familysync.DOMAIN/callback
OIDC_AUTH_EXTERNAL_URL=https://familysync.DOMAIN
# Broker (Fastmail app-password encryption)
APP_PASSWORD_ENCRYPTION_KEY=<run: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))">
# Web Push (VAPID) — optional but required for push notifications
VAPID_PUBLIC_KEY=<base64-public-key>
VAPID_PRIVATE_KEY=<base64-private-key>
VAPID_SUBJECT=mailto:admin@example.com
```
`OIDC_CLIENT_ID` defaults to `familysync` and does not need to be set unless you registered a different ID in Authelia.
`OIDC_SCOPES` defaults to `openid profile email offline_access`. Do not add scopes that are not configured on the Authelia client.
**`DEV_AUTH_BYPASS` must NOT appear in the production `.env` or `docker-compose.yml`.** The API enforces this in code: when `NODE_ENV=production` the bypass is unconditionally disabled regardless of other variables, but omitting it entirely is the correct posture.
Generate VAPID keys:
```bash
npx web-push generate-vapid-keys --json
```
See [docs/CONFIGURATION.md](CONFIGURATION.md) for the full variable reference including optional variables and defaults.
---
## Apply Database Migrations
The production image does **not** auto-migrate on startup. Migrations must be applied manually before the first container start and again after any schema change.
Migrations are run with `drizzle-kit`, which is a **devDependency**. The production image is built with `pnpm install --frozen-lockfile --prod` (see `apps/api/Dockerfile`), so `drizzle-kit` is **not** present inside the running `api` container — you cannot migrate by exec-ing into it. Instead, run migrations from a host that has the full (dev) dependencies and can reach MariaDB.
The production `docker-compose.yml` does not expose the MariaDB port externally, so bring the database up with the dev compose override (which binds port 3306), apply the migrations from the host, then start the rest of the stack:
```bash
# 1. Bring up only MariaDB with the host port exposed
docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d mariadb
# 2. Wait for it to be healthy
docker compose ps
# 3. Apply migrations from the host (requires dev deps installed: `pnpm install`)
DB_HOST=127.0.0.1 DB_PORT=3306 DB_USER=familysync DB_NAME=familysync DB_PASSWORD=<value> \
pnpm --filter @familysync/api db:migrate
# 4. Start the full stack
docker compose up -d
```
**Never use `drizzle-kit push` against this MariaDB.** The `mysql` dialect mis-reads MariaDB 11.x schema metadata and schedules false destructive operations (table truncation). Always use `db:generate` + `db:migrate`.
---
## Pangolin / Newt Tunnel
The app is exposed to the public internet via an outbound Pangolin/Newt tunnel — no inbound ports are opened on the Unraid host.
<!-- VERIFY: Pangolin/Newt route configuration steps depend on your Pangolin dashboard and site-specific settings -->
Configure the Pangolin route to forward HTTPS traffic for `https://familysync.DOMAIN` to `http://<docker-host-ip>:3000`. The `api` service in `docker-compose.yml` publishes port 3000 on the host:
```yaml
ports:
- '3000:3000'
```
Ensure `OIDC_AUTH_EXTERNAL_URL` and `OIDC_REDIRECT_URI` in `.env` match the public hostname Pangolin exposes. Without `OIDC_AUTH_EXTERNAL_URL`, the OIDC middleware builds the callback URI from the internal container hostname, which will not match the URI registered in Authelia and will break the login flow.
---
## Pulling the Published Image on Unraid
After a PR merges, `publish.yml` pushes two tags to the Gitea registry. To deploy the latest build on the Unraid host:
```bash
# Pull the moving :latest pointer
docker pull git.bergerhouse.net/luckberg/familysync-api:latest
# Or pin to a specific immutable tag (recommended for production)
docker pull git.bergerhouse.net/luckberg/familysync-api:v1.1-98acff8
```
<!-- VERIFY: Confirm the Gitea registry requires authentication for pulls on the Unraid host (docker login git.bergerhouse.net) -->
Update `docker-compose.yml` to reference the pre-built image instead of building locally:
```yaml
services:
api:
image: git.bergerhouse.net/luckberg/familysync-api:latest
# remove the build: block when using the published image
```
Then restart the service:
```bash
docker compose pull api && docker compose up -d api
```
---
## Build and Start (local build)
If you need to build locally rather than pull from the registry:
```bash
# From the repo root — builds both the API and the React PWA into one image
docker compose build
# Start all services
docker compose up -d
```
The Dockerfile uses a multi-stage build:
1. `builder` — compiles the TypeScript API (`pnpm --filter @familysync/api build`).
2. `pwa-builder` — builds the React PWA with Vite (`pnpm --filter @familysync/pwa build`).
3. `production` — installs production-only dependencies (`pnpm install --frozen-lockfile --prod`), copies the compiled API and the built PWA into `./public`. The API serves the PWA at `/` via `serveStatic`.
Both `builder` and `pwa-builder` stages run in parallel under BuildKit.
Rebuild after any source change:
```bash
docker compose build api && docker compose up -d api
```
The `api` service waits for the `mariadb` healthcheck to pass before starting (`depends_on: condition: service_healthy`).
---
## Health Check
The `/health` endpoint is unauthenticated and confirms a live database connection:
```bash
curl https://familysync.DOMAIN/health
# {"ok":true,"db":"up"}
```
A `503` response (`{"ok":false,"db":"down"}`) means the API cannot reach MariaDB. Check `docker compose logs api` and `docker compose logs mariadb`.
---
## Rollback
To revert to a specific prior build, use the immutable image tag produced by `publish.yml`:
```bash
# Identify the immutable tag from the Gitea registry or CI run history
# e.g. git.bergerhouse.net/luckberg/familysync-api:v1.1-98acff8
docker compose stop api
# Update docker-compose.yml image: line to the target immutable tag, then:
docker compose pull api && docker compose up -d api
```
If the rollback crosses a schema migration boundary, restore the MariaDB volume from a backup — schema downgrades are not supported by Drizzle Kit's migrate command.
Take a MariaDB dump before every deployment that includes a migration:
```bash
docker compose exec mariadb mariadb-dump -u root -p familysync > backup-$(date +%Y%m%d).sql
```
---
## Monitoring
No monitoring agent is configured in the repository. The `/health` endpoint is available for uptime monitoring tools.
Application logs are written to stdout/stderr and captured by Docker:
```bash
docker compose logs -f api
docker compose logs -f mariadb
```
<!-- VERIFY: No Sentry, Datadog, or OpenTelemetry dependency is present in package.json — confirm whether any external monitoring is wired at the infrastructure level -->