forked / Architecture

7. Deployment View

forked is deployed as one static PWA image and a CouchDB. The announcement served by the PWA image decides the sync mode (ADR-038). This repository contains the self-hosting stack; the operator’s gateway and website are outside it, and Section 7.3 states the contract they fulfil.

A PWA without sync needs only static hosting. It is not modeled separately.

7.1 Self-Hosting Stack

One stack is one household: the PWA image, a CouchDB, an nginx proxy as the single entry point, and an optional TLS terminator. No application server of ours exists; devices replicate with CouchDB through the proxy (ADR-028). Any CouchDB-compatible server can replace the bundled one.

graph LR
    Device["Devices<br/>PWA + PouchDB"]

    subgraph "Self-hosting stack (Docker Compose)"
        Caddy["caddy<br/>optional, profile tls"]
        Proxy["proxy (nginx)<br/>allowlist, limit_req"]
        PWA["pwa (nginx)<br/>static app, CSP,<br/>forked-config.json"]
        CouchDB[("couchdb<br/>database forked")]
        Init["couchdb-init<br/>one-shot"]
    end

    Device -->|HTTPS| Caddy
    Caddy -->|unix socket| Proxy
    Proxy -->|"/"| PWA
    Proxy -->|"/couchdb/forked"| CouchDB
    Init -.->|database, user, members| CouchDB

    style Device fill:#DBEAFE,stroke:#1F2937,color:#111827
    style PWA fill:#E5E7EB,stroke:#1F2937,color:#111827
    style Proxy fill:#DBEAFE,stroke:#1F2937,color:#111827
    style Caddy fill:#DBEAFE,stroke:#1F2937,color:#111827
    style Init fill:#DCFCE7,stroke:#1F2937,color:#111827
    style CouchDB fill:#FCE7F3,stroke:#1F2937,color:#111827

The Compose file in the repository root with its .env.example runs the stack; the supporting configuration is in docker/.

ServiceResponsibility
proxynginx with a dedicated proxy configuration. / goes to pwa, /couchdb/forked to CouchDB, everything else under /couchdb/ answers 404 (no _utils, _users, _session, _security, other databases, dot segments, encoded escapes). Applies limit_req. The only published port (loopback by default).
pwaBuilt from the root Dockerfile; nginx with the CSP. Serves /forked-config.json (Section 7.2) with no gateway.
couchdbcouchdb:3.5 on the internal network, unpublished. Server configuration: single node, require_valid_user = true except /_up, document and request size limits.
couchdb-initIdempotent one-shot job: system databases, database forked, user forked (password FORKED_SYNC_PASSWORD, updated on every run), and the database’s _security members.
caddyOptional (--profile tls): automatic HTTPS for FORKED_HOST. Reaches the proxy through a unix socket, so the proxy can trust the client address Caddy forwards.

Networks: edge (proxy, Caddy) and internal (internal: true; pwa, CouchDB, init).

Variables: COUCHDB_PASSWORD (admin, server side only) and FORKED_SYNC_PASSWORD (typed once per device) are required. Optional: COUCHDB_USER, FORKED_HOST, FORKED_COUCHDB_URL, FORKED_BIND (default 127.0.0.1), FORKED_HTTP_PORT (default 8080), FORKED_CSP_CONNECT_SRC. The development override publishes CouchDB on loopback with CORS for the Vite dev server and is never deployed.

Operating rules

  • TLS: production exposes HTTPS only (installing the PWA and offline use need it). Basic credentials travel in every request.
  • Network exposure: CouchDB stays on the internal network; only the proxy allowlist reaches it. A CouchDB on another origin needs CORS for the PWA origin and that origin in FORKED_CSP_CONNECT_SRC (ADR-026).
  • Proxy headers: the proxy sets headers per location, strips Cookie, hides Set-Cookie and WWW-Authenticate (no browser login dialog, no session cookie that replaces the password), never forwards a client X-Forwarded-For, and passes Authorization through. Only the Caddy socket is trusted for the client address (set_real_ip_from unix:).
  • Abuse limits: 20 requests per second per client address with burst 100 (429). CouchDB 3.5 locks a user out per client address for a few minutes after 5 wrong passwords (403). Behind a terminator that hides client addresses all devices share one address, so one wrong password can briefly lock out every device.
  • Admin account: nginx cannot tell users apart, so the CouchDB admin also authenticates on the proxied path. COUCHDB_PASSWORD must be long and random and is never entered on a device.
  • Password change: docker compose up -d couchdb-init applies a new FORKED_SYNC_PASSWORD; every device re-enters it. Per-device CouchDB users are an operator recipe in the README, not a product feature.
  • Backup: the CouchDB data volume (couchdb_data) is the only server-side copy of household data.
  • Volumes: an existing CouchDB volume is reusable because the database name is always forked.

7.2 Runtime Configuration of the PWA Image

The PWA image’s nginx configuration is rendered from a template at startup (NGINX_ENVSUBST_FILTER=^FORKED_) and serves GET /forked-config.json with {"gatewayUrl": "...", "couchdbUrl": "..."}, Cache-Control: no-store, excluded from the service worker cache. Values go into JSON unescaped and must not contain quotes or backslashes.

VariableDefaultEffect
FORKED_GATEWAY_URLemptyFills gatewayUrl and is added to connect-src. Empty selects self-hosted mode; a value switches every device served by this origin to forked cloud mode (operator only).
FORKED_COUCHDB_URL/couchdbFills couchdbUrl: a path on the PWA origin or an absolute CouchDB URL (add its origin to FORKED_CSP_CONNECT_SRC).
FORKED_CSP_CONNECT_SRCemptySpace-separated origins appended to connect-src, for example an external CouchDB or a LAN AI server.

connect-src otherwise allows 'self', the AI provider origins (ADR-024) and loopback. Switching a deployment between modes or moving CouchDB needs only these variables, no rebuild. How the PWA consumes the announcement is in Section 5.2.

7.3 Operator Gateway Contract

An operator (for example the operated forked cloud service) runs a gateway and a website that this repository does not contain (ADR-038). The PWA image serves them when FORKED_GATEWAY_URL is set. The unchanged PWA works if the operator provides:

  • Announcement and CORS: /forked-config.json as in Section 7.2. The gateway answers CORS for the PWA origin and accepts credentialed requests from it.
  • Routes (the gateway client in src/infrastructure/auth/ and src/infrastructure/sync/ is authoritative; all calls send the session cookie with JSON bodies):
RoutePurpose
GET /api/auth/session{authenticated, deviceId?, accountId?}: is this device’s session valid
POST /api/devices/sync-token{token, expiresAt, couchdbUrl, database}: short-lived CouchDB JWT
POST /api/devices/approvalBody {deviceName}; returns {requestId, code, verificationUrl, expiresAt, intervalSeconds}
GET /api/devices/approval/{requestId}{status: pending, approved, denied, expired}; sets the session cookie in the response that first reports approved; 404 counts as expired
POST /api/devices/invite{code, expiresAt, link, pairingCode?}: invite for another device, short-lived and single-use
POST /api/devices/accept-inviteBody {code, deviceName} where code is the token or pairing code; sets the session cookie
POST /api/devices/disconnectRemoves the calling device and ends its session
  • Website pages (opened in the system browser, never embedded): the page behind verificationUrl (magic-link sign-in, shows the approval code, approve or deny) and <gatewayUrl>/account (devices, plan, billing).
  • Accounts and sign-in: magic-link sign-in and the email address as personal data are the operator’s responsibility.
  • Sync tokens: CouchDB accepts the issued JWTs, the proxy in front of it allows only the account’s database paths with Bearer tokens, and each account or household has its own database that a token grants access to exclusively.
  • Revocation: a removed device or deleted account gets 401 from the token endpoint; the PWA then shows the revoked state and keeps local data (Section 6.2). An issued token stays valid until it expires.
  • Plans and limits: enforced by the operator, for example by refusing an approval or invite. The PWA shows the returned error message and has no plan or device-list UI.
  • Security: the gateway implements its own request checks and abuse protection; the PWA enforces none of them.

7.4 Build and Delivery

The frontend CI workflow (.github/workflows/) runs lint, type check, Vitest and Playwright. On pushes to main, once lint and unit tests pass, it publishes ghcr.io/<owner>/forked and calls the operator’s deploy webhook (HMAC-SHA256, secret DEPLOY_WEBHOOK_SECRET). The webhook only queues an update; a periodic timer on the server also picks up new digests, so a failed call delays but does not block a rollout. Web clients update through the service worker (Section 8.7).

Docs website. docs/web (Astro, static) has its own pipeline: website-ci.yml builds the site and, from the repository root, the image defined by docs/web/Dockerfile (nginx serving the static output, nginx.conf). On pushes to main it publishes ghcr.io/<owner>/forked-docs for docs.forked.cc; there is no deploy webhook yet. The site holds no user data. Its only external request is the header badge reading public repository numbers (latest release, stars, open pull requests) from api.github.com, which the site’s CSP allows and which stays hidden when unavailable.

View source on GitHub