Files
familysync/docs/deployment.md
T
Lucas BergerandClaude Opus 4.8 6ade8d59bf
Publish / publish (push) Successful in 11s
docs: correct rrule drift (code uses ical.js RecurExpansion) + minor fixes
Recurrence expansion uses ical.js ICAL.RecurExpansion, not the rrule
library (never installed/imported). Also fix playwright-cli path
(/usr/bin), prod-compose service count (two, post-Redis-removal), and
CI job count (six). Found via /gsd-docs-update --verify-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 22:29:03 -04:00

14 KiB

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 two 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

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:

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).
  • Pangolin/Newt tunnel configured to route an external HTTPS hostname to the Docker host on port 3000 (see Pangolin / Newt Tunnel).
  • A .env file at the repo root with all required secrets (see Environment Setup).

Register the OIDC Client

Add the following client block to your Authelia configuration.yml under identity_providers.oidc.clients:

- 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:

# Authelia CLI — run on the host where Authelia is installed
authelia crypto hash generate pbkdf2 --variant sha512

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:

# 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:

npx web-push generate-vapid-keys --json

See docs/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:

# 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.

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:

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:

# 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

Update docker-compose.yml to reference the pre-built image instead of building locally:

services:
  api:
    image: git.bergerhouse.net/luckberg/familysync-api:latest
    # remove the build: block when using the published image

Then restart the service:

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:

# 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:

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:

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:

# 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:

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:

docker compose logs -f api
docker compose logs -f mariadb