<!-- generated-by: gsd-doc-writer -->

# FamilySync — Configuration Reference

All runtime configuration is supplied via environment variables. There are no JSON or YAML config files beyond Docker Compose. Copy `.env.example` to `.env` at the repo root and fill in the values before starting any service.

---

## Environment Variables

### Database

| Variable           | Required     | Default      | Description                                                                                                              |
| ------------------ | ------------ | ------------ | ------------------------------------------------------------------------------------------------------------------------ |
| `DB_HOST`          | Yes          | `localhost`  | MariaDB hostname. Use `mariadb` inside Docker Compose; use `localhost` (or `127.0.0.1`) for host-side dev runs.          |
| `DB_PORT`          | No           | `3306`       | MariaDB port.                                                                                                            |
| `DB_USER`          | No           | `familysync` | Database user.                                                                                                           |
| `DB_PASSWORD`      | **Required** | _(none)_     | Database password. Also used by the `mariadb` service as `MARIADB_PASSWORD`.                                             |
| `DB_NAME`          | No           | `familysync` | Database name.                                                                                                           |
| `DB_ROOT_PASSWORD` | **Required** | _(none)_     | MariaDB root password. Used only by the `mariadb` Docker service (`MARIADB_ROOT_PASSWORD`). Not read by the API process. |
| `DB_ROOT_USER`     | No           | `root`       | MariaDB root username. Read only by `apps/api/test/global-setup.ts` during local test provisioning. Never used by the API or Docker Compose in production. |

Five of these variables — `DB_HOST`, `DB_USER`, `DB_PASSWORD`, `DB_NAME`, `DB_PORT` — are read by `drizzle.config.ts` when running migrations (`db:generate` / `db:migrate`) and by the API process to build its connection pool. `DB_ROOT_PASSWORD` is **not** read by either; it is consumed only by the `mariadb` Docker service. `DB_ROOT_USER` is only used by the local Vitest global setup to create and grant the `familysync_test` database.

**Important:** Do not use `drizzle-kit push` against this MariaDB. The `mysql` dialect mis-reads MariaDB 11.x metadata and schedules false destructive operations. Always use `db:generate` + `db:migrate`.

---

### OIDC / Authelia Authentication

| Variable                 | Required     | Default                                  | Description                                                                                                                                                                                                                                                                       |
| ------------------------ | ------------ | ---------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `OIDC_AUTH_SECRET`       | **Required** | `placeholder_change_me` (Docker default) | 32+ character random string used to sign the `oidc-auth` session JWT cookie. Generate with `openssl rand -base64 32`.                                                                                                                                                             |
| `OIDC_ISSUER`            | **Required** | _(none)_                                 | Authelia base URL, e.g. `https://auth.DOMAIN`. The middleware fetches `/.well-known/openid-configuration` from this URL.                                                                                                                                                          |
| `OIDC_CLIENT_ID`         | No           | `familysync`                             | Registered client ID in Authelia.                                                                                                                                                                                                                                                 |
| `OIDC_CLIENT_SECRET`     | **Required** | _(none)_                                 | Plaintext client secret matching the pbkdf2 hash stored in Authelia's `configuration.yml`. Do **not** use the hash here.                                                                                                                                                          |
| `OIDC_REDIRECT_URI`      | **Required** | _(none)_                                 | Full callback URL registered in Authelia, e.g. `https://familysync.DOMAIN/callback`.                                                                                                                                                                                              |
| `OIDC_AUTH_EXTERNAL_URL` | **Required** | _(none)_                                 | Public-facing base URL of the app, e.g. `https://familysync.DOMAIN`. **Mandatory behind the Pangolin/Newt tunnel.** Without it, `@hono/oidc-auth` builds the redirect URI from the internal container hostname, which will not match the registered URI and breaks the OIDC flow. |
| `OIDC_SCOPES`            | No           | `openid profile email offline_access`    | Space-separated list of OIDC scopes to request. `offline_access` is required for refresh-token session persistence. Authelia rejects unknown scopes, so do not add scopes that are not configured on the Authelia client.                                                         |
| `OIDC_AUTH_EXPIRES`      | No           | `86400`                                  | Session cookie `Max-Age` in seconds (default 1 day). Governs the persistent session cookie set by `persistSessionCookie` middleware.                                                                                                                                              |
| `OIDC_COOKIE_NAME`       | No           | `oidc-auth`                              | Name of the session cookie. Override only if you need to run multiple instances under the same domain.                                                                                                                                                                            |
| `OIDC_COOKIE_PATH`       | No           | `/`                                      | Cookie `Path` attribute.                                                                                                                                                                                                                                                          |
| `OIDC_COOKIE_DOMAIN`     | No           | _(not set)_                              | Cookie `Domain` attribute. Set this when the API and PWA are served from different subdomains under the same apex domain. Must match the Authelia and app domains (same-parent-domain requirement).                                                                               |

**Locked Authelia client parameters** (these are fixed by the project; do not change):

- `response_types: [code]`
- `grant_types: [authorization_code, refresh_token]`
- `require_pkce: true`, `pkce_challenge_method: S256`
- `token_endpoint_auth_method: client_secret_basic`

---

### Local Authentication (No-OIDC Mode)

These variables govern the stateless local-auth path introduced in Phase 19. Local auth issues a separate `local-session` JWT cookie (distinct from `oidc-auth`) signed with `LOCAL_SESSION_SECRET`.

| Variable                | Required                 | Default  | Description                                                                                                                                                                                                                                    |
| ----------------------- | ------------------------ | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `LOCAL_SESSION_SECRET`  | **Required** (non-bypass) | _(none)_ | 32+ character secret used to sign and verify `local-session` JWT cookies (HS256). Generate with `openssl rand -base64 32`. The API refuses to start with a fatal error if this is absent or shorter than 32 characters, unless `DEV_AUTH_BYPASS=true`. |
| `LOCAL_SESSION_EXPIRES` | No                       | `86400`  | `local-session` cookie `Max-Age` in seconds (default 1 day). Mirrors `OIDC_AUTH_EXPIRES` but applies to the local-auth cookie. Malformed (non-numeric) values silently fall back to the default. Source: `apps/api/src/auth/localSession.ts`. |

**Security note:** `LOCAL_SESSION_SECRET` must be a distinct value from `OIDC_AUTH_SECRET`. Both are JWT signing keys, but they govern different cookies and must not be shared.

---

### Broker Encryption

| Variable                      | Required     | Default  | Description                                                                                                                                                                                                                                                     |
| ----------------------------- | ------------ | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `APP_PASSWORD_ENCRYPTION_KEY` | **Required** | _(none)_ | 64-character hex string (32 bytes) used as the AES-256-GCM key for encrypting Fastmail app passwords at rest. Generate with: `node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"`. Startup throws if this is missing or the wrong length. |

---

### Web Push (VAPID)

| Variable            | Required | Default  | Description                                                                                                                                |
| ------------------- | -------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
| `VAPID_PUBLIC_KEY`  | No\*     | _(none)_ | URL-safe base64 VAPID public key. Served to the PWA via `GET /api/push/vapid-public-key`. Non-secret.                                      |
| `VAPID_PRIVATE_KEY` | No\*     | _(none)_ | URL-safe base64 VAPID private key. Used server-side to sign push requests. **Never expose to clients.**                                    |
| `VAPID_SUBJECT`     | No\*     | _(none)_ | Contact URI identifying the operator, e.g. `mailto:admin@example.com` or `https://familysync.DOMAIN`. Must be a `mailto:` or `https:` URL. |

\*All three VAPID variables are optional in the sense that the server starts without them, but push notifications will not function. A startup warning is logged if any are missing. Generate a key pair with:

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

---

### Runtime Mode

| Variable          | Required | Default     | Description                                                                                                                                                                                                                 |
| ----------------- | -------- | ----------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `NODE_ENV`        | No       | _(not set)_ | Set to `production` in the production Docker Compose. When `production`, the dev-auth bypass is unconditionally disabled regardless of `DEV_AUTH_BYPASS`.                                                                   |
| `DEV_AUTH_BYPASS` | No       | _(not set)_ | Set to `true` to bypass OIDC authentication for local development without a live Authelia instance. **Only active when `NODE_ENV !== 'production'`.** The production `docker-compose.yml` must never include this variable. |
| `TZ`              | No       | _(not set)_ | IANA timezone identifier (e.g. `America/Toronto`) used as the server-side fallback for the household timezone when no value is stored in `app_config`. The full fallback chain is: stored DB value → `TZ` env → `Intl.DateTimeFormat().resolvedOptions().timeZone`. Empty or whitespace values are ignored. Source: `apps/api/src/lib/householdTimezone.ts`. |

---

### Developer / Test-Only Variables

These variables are never needed in production and should not appear in the production `.env`.

| Variable                | Scope                  | Default                  | Description                                                                                                                                                              |
| ----------------------- | ---------------------- | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `FASTMAIL_EMAIL`        | Dev spike script only  | _(none)_                 | Fastmail account email. Read only by `apps/api/src/broker/spike.ts`, a standalone dev script for enumerating CalDAV collections. Not imported by the API or Docker image. |
| `FASTMAIL_APP_PASSWORD` | Dev spike script only  | _(none)_                 | Fastmail app password. Read only by `apps/api/src/broker/spike.ts`. **Never logged.** Not used by the API in any environment.                                            |
| `PLAYWRIGHT_BASE_URL`   | E2E tests only         | `http://localhost:5173`  | Base URL for Playwright e2e tests. Overridden to `http://127.0.0.1:5173` in CI to avoid IPv6 resolution failures. Source: `apps/pwa/playwright.config.ts`.               |

---

## Defaults Summary

Variables with non-empty defaults do not cause startup failure if absent, but should be reviewed for production:

| Variable                | Default                               | Source                                      |
| ----------------------- | ------------------------------------- | ------------------------------------------- |
| `DB_HOST`               | `localhost`                           | `apps/api/src/db/client.ts`                 |
| `DB_PORT`               | `3306`                                | `apps/api/src/db/client.ts`                 |
| `DB_USER`               | `familysync`                          | `apps/api/src/db/client.ts`                 |
| `DB_NAME`               | `familysync`                          | `apps/api/src/db/client.ts`                 |
| `OIDC_CLIENT_ID`        | `familysync`                          | `docker-compose.yml`                        |
| `OIDC_SCOPES`           | `openid profile email offline_access` | `docker-compose.yml`                        |
| `OIDC_AUTH_EXPIRES`     | `86400`                               | `apps/api/src/auth/persistSessionCookie.ts` |
| `OIDC_COOKIE_NAME`      | `oidc-auth`                           | `apps/api/src/auth/persistSessionCookie.ts` |
| `OIDC_COOKIE_PATH`      | `/`                                   | `apps/api/src/auth/persistSessionCookie.ts` |
| `LOCAL_SESSION_EXPIRES` | `86400`                               | `apps/api/src/auth/localSession.ts`         |

---

## Per-Environment Configuration

### Production (Docker Compose)

`docker-compose.yml` sets `NODE_ENV=production` and injects all secrets via `${VAR}` interpolation from a `.env` file on the Docker host. No `.env` file is committed to the repository.

Minimum production `.env`:

```dotenv
# Database
DB_PASSWORD=<strong-password>
DB_ROOT_PASSWORD=<strong-root-password>

# OIDC
OIDC_AUTH_SECRET=<openssl rand -base64 32>
OIDC_ISSUER=https://auth.DOMAIN
OIDC_CLIENT_SECRET=<plaintext secret>
OIDC_REDIRECT_URI=https://familysync.DOMAIN/callback
OIDC_AUTH_EXTERNAL_URL=https://familysync.DOMAIN

# Local auth (Phase 19)
LOCAL_SESSION_SECRET=<openssl rand -base64 32>

# Broker encryption
APP_PASSWORD_ENCRYPTION_KEY=<64-hex-chars>

# VAPID push (optional but recommended)
VAPID_PUBLIC_KEY=<base64>
VAPID_PRIVATE_KEY=<base64>
VAPID_SUBJECT=mailto:admin@example.com
```

### Local Development (host-side)

The dev Docker Compose override (`docker-compose.dev.yml`) exposes MariaDB on `localhost:3306` and Redis on `localhost:6379`. To run the API and PWA directly on the host:

```bash
# Build the API first (dev script runs compiled output)
pnpm --filter @familysync/api build

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

```bash
# PWA (separate terminal)
pnpm --filter @familysync/pwa dev
```

The `DB_HOST` override is necessary because `.env` sets `DB_HOST=mariadb` (the Docker network hostname), which does not resolve on the host. The `set -a; source .env; set +a` idiom exports all variables; the `DB_HOST=localhost` prefix on the same line overrides that one variable for the child process.

**`DEV_AUTH_BYPASS=true` is a local-only option.** The API hard-checks `NODE_ENV === 'production'` before reading `DEV_AUTH_BYPASS` — the bypass has zero effect in a production container even if the variable is present.

The dev Docker Compose (`docker-compose.dev.yml`) sets `LOCAL_SESSION_SECRET` to a fixed dev placeholder value (`dev-secret-change-me-0000000000000000`). This value is intentionally weak and public — it is never used in production.

### Test

Integration tests targeting the real database require the dev MariaDB running with the host port exposed and the following overrides:

```bash
DB_HOST=127.0.0.1 DB_PORT=3306 DB_USER=familysync DB_NAME=familysync DB_PASSWORD=<value>
```

The Vitest global setup (`apps/api/test/global-setup.ts`) also reads `DB_ROOT_USER` (default `root`) and `DB_ROOT_PASSWORD` (default `root`) to create and grant the `familysync_test` database on first run. These are local dev credentials only; CI uses hardcoded throwaway values (`familysync` / `testpass`) in ephemeral service containers.

See `docs/deployment.md` for the full `drizzle-kit migrate` command used to prepare the test database.

---

## Config File Reference

### Application Config Files

There are no application-level JSON/YAML config files. The two config files that read environment variables at dev/build time are:

| File                         | Purpose                                                          |
| ---------------------------- | ---------------------------------------------------------------- |
| `apps/api/drizzle.config.ts` | Drizzle Kit migration config — reads `DB_*` variables            |
| `apps/pwa/vite.config.ts`    | Vite build config — no env var reads; proxy rules for dev server |

The PWA Vite dev server proxies `/health`, `/api`, and `/callback` to `http://localhost:3000` so the frontend and API can be developed without CORS configuration.

### Lint and Format Config Files

These files live at the repo root and apply to both `apps/api` and `apps/pwa`.

#### `eslint.config.js`

ESLint 9 flat config (ESM). Pinned to ESLint **9.39.4** — do not upgrade to ESLint 10 until `eslint-plugin-react` resolves the `getFilename is not a function` incompatibility.

Key layers (in order):

1. **Global ignores** — `**/dist/**`, `**/node_modules/**`, `apps/api/src/db/migrations/**`, `pnpm-lock.yaml`.
2. **Base TS/TSX** (`apps/**/*.{ts,tsx}`) — `js.configs.recommended` + `tseslint.configs.recommendedTypeChecked` with `projectService: true` (auto-discovers all `tsconfig.json` files). `@typescript-eslint/no-unused-vars` allows `_`-prefixed names.
3. **React + Hooks** (`apps/pwa/**/*.{ts,tsx}` only) — `eslint-plugin-react` flat recommended + `eslint-plugin-react-hooks` flat recommended. React Compiler rules (immutability, purity, refs, etc.) are disabled — this codebase does not use the React Compiler.
4. **`disableTypeChecked` override** — applied to tool config files and test/e2e directories that are outside any `tsconfig` project (`apps/api/drizzle.config.ts`, `apps/api/vitest.config.ts`, `apps/pwa/vite.config.ts`, `apps/pwa/vitest.config.ts`, `apps/pwa/playwright.config.ts`, `apps/api/tests/**/*.ts`, `apps/pwa/e2e/**/*.ts`, `eslint.config.js`). Type-aware rules are disabled for these files; non-type-aware rules still apply.
5. **`eslint-config-prettier`** (last) — disables all ESLint formatting rules that conflict with Prettier.

Run lint: `pnpm lint` (delegates to `pnpm -r --if-present lint` across all workspaces).

#### `.prettierrc`

```json
{
  "semi": true,
  "singleQuote": true,
  "tabWidth": 2,
  "trailingComma": "all",
  "printWidth": 100
}
```

Run formatter: `pnpm format` (write) or `pnpm format:check` (CI check, no writes).

#### `.prettierignore`

Excludes `dist/`, `node_modules/`, `.pnpm-store/`, `pnpm-lock.yaml`, `apps/api/src/db/migrations/`, `*.html`, and `.planning/` from formatting. The `.pnpm-store/` exclusion covers CI runners that have no persistent global pnpm store and land the content-addressable store inside the workspace.

---

## CI Secrets

The Gitea Actions workflows in `.gitea/workflows/` require one repository secret.

| Secret         | Scope              | Description                                                                                                                                                                                                                                                                                                                                                                    |
| -------------- | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `REGISTRY_PAT` | `publish.yml` only | A Gitea user PAT with **`write:package`** scope. Used to authenticate `docker login` against the Gitea container registry before pushing the API image. **Must be named `REGISTRY_PAT`** — Gitea reserves the `GITEA_` prefix for built-in variables, so any `GITEA_`-prefixed secret name is rejected. `GITEA_TOKEN` and `GITHUB_TOKEN` do not have package-push permissions. |

### CI Database Credentials

The `ci.yml` `api` and `harness` jobs spin up a throwaway MariaDB service container with hardcoded credentials (`familysync` / `testpass`). These are ephemeral — scoped to a single job container — and are **not** production secrets. Do not reuse them outside CI.
