Production is deliberately boring: one virtual machine runs every platform process as a
container next to Caddy, Postgres either runs on the same machine or as a managed database, and
files live in an S3-compatible bucket outside the machine. Content never needs a deploy;
publishing purges caches through webhooks. Only code does. This page describes the shape and
the procedures; it contains no addresses or secrets, which live in the env files on the server.
docs/INFRA.md explains the hosting choices and docs/GO-LIVE.md the order of work for a first
go-live.
The shape
┌──────────────── one VM (docker compose: infra/docker-compose.prod.yml) ───────────────┐
visitors ──────►│ caddy :80/:443 ──► site-<name> ──(delivery API)──► web :4101 │
agencies, ─────►│ │ └──► dashboard :4100 ──► worker (pg-boss) agent :4110 (assistant) │
clients │ └─────────► docs :80 (static developer docs) │
│ backup (nightly) postgres :5432 (profile vm-db, loopback only) │
└───────────────────────────────┬───────────────────────────────────┬──────────────────┘
▼ ▼
Postgres (on the VM or managed) S3-compatible bucket + asset domainThe Dockerfile
One multi-target Dockerfile at the repository root builds every process:
| Target | Base | Runs | Port | Health check |
|---|---|---|---|---|
dashboard | Node 22 slim, Next.js standalone output | node apps/dashboard/server.js | 4100 | GET /api/health |
web | Node 22 slim, Next.js standalone output | node apps/web/server.js | 4101 | GET /api/health |
worker | the installed workspace | tsx src/worker/index.ts in apps/dashboard, JOBS_INLINE=0 | none | none |
agent | the installed workspace | tsx src/server.ts in apps/agent | 4110 | none in the image |
migrate | the installed workspace | drizzle-kit migrate in packages/db | none | none (one-off) |
docs | caddy:2.10.2-alpine | the static export of apps/docs on :80 | 80 | none |
How it is built:
- Prune.
turbo prune @kwisple/dashboard @kwisple/web @kwisple/agent @kwisple/docs --dockercopies only those apps and the packages they use; thejson/half is installed first so dependency layers stay cached (pnpm install --frozen-lockfilewith a BuildKit cache mount). - Source. The pruned sources plus
tsconfig.base.json,docs/handleiding(served on/hulp, read at request time) anddocs/developers(built into the docs site). - Builds.
next buildwithNEXT_OUTPUT=standalonefor the dashboard and web.next buildimports modules that readDATABASE_URLandBETTER_AUTH_SECRET, so the build steps get harmless placeholders throughBUILD_ENV; nothing connects during the build and the runtime images never see them. - Runtimes run as the
nodeuser withNODE_ENV=production. The worker, agent and migrate targets keep the installed workspace because they run TypeScript throughtsx.
Build arguments. Next.js bakes a few values into the build (the image host for
next/image, the content security policy), so they are build args, not runtime env:
| Build arg | Used by | Meaning |
|---|---|---|
STORAGE_PUBLIC_URL | dashboard, web | the public origin of stored files |
DASHBOARD_URL | dashboard, web | the dashboard's own URL |
KWISPLE_ASSET_ORIGIN | client sites (their own Dockerfile) | the storage's public origin, for next/image |
Compose passes them from .env.production and refuses to start when one is missing. Changing
one means rebuilding the images. Everything else (secrets, database) is runtime environment.
Compose: infra/docker-compose.prod.yml
Compose project kwisple-prod. Every app service restarts unless-stopped, logs to rotated
JSON files and has a memory limit.
| Service | Target / image | Env file | Profile | Notes |
|---|---|---|---|---|
postgres | postgres:17-alpine | POSTGRES_PASSWORD from .env.production | vm-db | Data on the postgres-data volume; port bound to 127.0.0.1 only. Leave the profile off once the database is managed. |
migrate | migrate | .env.production | tools | One-off: dc run --rm migrate. |
dashboard | dashboard | .env.production | always | JOBS_INLINE=0, ASSISTANT_URL=http://agent:4110. |
web | web | .env.web | always | The delivery API and hosted sites. |
worker | worker | .env.production | always | pg-boss jobs (Jobs and the worker). |
agent | agent | .env.agent | always | The only container with the model key. |
site-<name> | the client repo's own Dockerfile | the site's .env.production (optional) | sites | A coded client site hosted on the VM; its env file is also a BuildKit secret at build. |
site-<name>-staging | same, staging branch worktree | the site's .env.staging (optional) | staging | Its staging deployment. |
docs | docs | none | always | The developer documentation, static. |
caddy | caddy:2.10.2-alpine | host names only | always | Ports 80, 443 and 443/udp; waits for healthy dashboard and web. |
backup | infra/backup | .env.backup | always | Nightly dump and file copy. |
Client sites sit behind profiles, so dc up -d and dc build only touch the platform. A site
is built after the platform is up, because its build reads the live delivery API through Caddy.
On the server every command uses one alias:
alias dc='docker compose --env-file .env.production -f infra/docker-compose.prod.yml'Env files: one per service
Least privilege: each service gets only its own file. Names only here; the .example files in
the repository root list them with comments.
| File | Read by | Holds (names) |
|---|---|---|
.env.production | dashboard, worker, migrate; compose itself (hosts, build args, profiles) | DASHBOARD_HOST, WEB_HOST, DOCS_HOST, per-site host names, ACME_EMAIL, DASHBOARD_URL, BETTER_AUTH_URL, WEB_URL, PLATFORM_DOMAIN, SITE_SERVER_ADDRESSES, KWISPLE_ASSET_ORIGIN, COMPOSE_PROFILES, POSTGRES_PASSWORD, DATABASE_URL, DATABASE_PREPARE, DATABASE_POOL_MAX, BETTER_AUTH_SECRET, REVALIDATE_SECRET, PREVIEW_SECRET, HOSTING_SECRET_KEY, SOCIAL_TOKEN_KEY, ASSISTANT_TOKEN, ASSISTANT_URL, STORAGE_*, TRUST_PROXY, TRUSTED_IP_HEADER, TRUSTED_PROXY_HOPS, TRUSTED_PROXIES, AUTH_RATE_LIMIT_STORAGE, JOBS_INLINE, SMTP_*, MAIL_FROM, CLOUDFLARE_*, META_*, LINKEDIN_*, STRIPE_*, AGENT_MCP_URL, ALLOW_SIGNUP, OUTBOUND_ALLOW_PRIVATE |
.env.web | web | DATABASE_URL, DATABASE_PREPARE, REVALIDATE_SECRET, PREVIEW_SECRET, WEB_URL, DASHBOARD_URL, PLATFORM_DOMAIN, STORAGE_*, TRUST_PROXY, TRUSTED_IP_HEADER, TRUSTED_PROXY_HOPS |
.env.agent | agent | ANTHROPIC_API_KEY, ASSISTANT_TOKEN, AGENT_MODEL |
.env.backup | backup | DATABASE_URL, STORAGE_* (read is enough), BACKUP_ENDPOINT, BACKUP_REGION, BACKUP_BUCKET, BACKUP_ACCESS_KEY_ID, BACKUP_SECRET_ACCESS_KEY, BACKUP_RETENTION_DAYS, BACKUP_HOUR |
Rules that matter:
REVALIDATE_SECRETandPREVIEW_SECRETmust be identical in.env.productionand.env.web;ASSISTANT_TOKENidentical in.env.productionand.env.agent.BETTER_AUTH_SECRET,REVALIDATE_SECRETandPREVIEW_SECRETare at least 32 characters; in production the dashboard refuses to start otherwise (src/instrumentation.ts). RotatingBETTER_AUTH_SECRETsigns everyone out. SetHOSTING_SECRET_KEYexplicitly: without it a key is derived fromBETTER_AUTH_SECRET, and rotating that would make stored deploy hooks and webhook secrets unreadable.TRUST_PROXY=1stays on in.env.productionand.env.web, because Caddy sets the client IP; behind another proxy also setTRUSTED_IP_HEADERandTRUSTED_PROXIES.- Production uses
pnpm db:migrate(themigratecontainer), neverdb:push.
Caddy: infra/Caddyfile
Caddy terminates TLS for every host and fetches and renews certificates itself (ACME_EMAIL).
Hosts come from environment variables that compose passes from .env.production:
| Variable | Proxies to | Extras |
|---|---|---|
DASHBOARD_HOST | dashboard:4100 | request bodies up to 30 MB (uploads) |
WEB_HOST | web:4101 | the delivery API host |
DOCS_HOST | docs:80 | defaults to http://docs.localhost (no certificate) when unset |
| a site's host | site-<name>:4103 | default security headers only where the site sets none |
| a site's alias | redirect | the other spelling (www or bare) → the main host, path kept |
| a site's staging host | site-<name>-staging:4103 | X-Robots-Tag: noindex, nofollow always |
Every block strips the Server header and compresses with zstd or gzip. A new co-hosted client
site is one more service in the compose file and one more host block; an agency's white-label
dashboard host is one more block that proxies to dashboard:4100 like {$DASHBOARD_HOST}. The
Caddyfile is mounted read-only; deploy.sh platform reloads Caddy so a changed host list takes
effect without a new container.
First setup: tools/go-live/server-setup.sh
Run as root on a fresh Ubuntu LTS VM, in two passes:
bash tools/go-live/server-setup.sh keys # installs Docker, prints read-only deploy keys to add on GitHub
bash tools/go-live/server-setup.sh clone # clones the repositories into /srv, writes the env fileskeys installs Docker from the official repository and creates one SSH deploy key per
repository (GitHub refuses one key on two repositories), with an SSH host alias for the site
repository. clone clones the platform and the site repositories side by side in /srv, then
writes .env.production, .env.web, .env.agent and .env.backup from their examples with
fresh random secrets (openssl rand -base64 32) for the database password,
BETTER_AUTH_SECRET, REVALIDATE_SECRET, PREVIEW_SECRET, HOSTING_SECRET_KEY,
SOCIAL_TOKEN_KEY and ASSISTANT_TOKEN, and copies the shared ones into the other files. It
never overwrites an existing env file (secrets would change under running containers), writes
everything with umask 077, adds the dc alias to root's shell, and lists what is still to be
filled in by hand: storage keys, SMTP, the backup bucket, the model key.
For the very first go-live, tools/go-live/transfer.sh copies a local install (database and
files) into an empty production database and bucket (it refuses a non-empty target), and
apps/dashboard/scripts/prepare-production.ts then cleans it for production: signs everyone
out, revokes local tokens, removes test accounts, sets the admin password and fixes local URLs.
Starting empty instead: dc run --rm migrate, and the first account created becomes platform
admin.
Deploys: tools/go-live/deploy.sh
Run as root in the platform checkout on the VM:
| Command | What it does |
|---|---|
tools/go-live/deploy.sh platform | git pull --ff-only; builds dashboard web worker agent migrate backup docs; runs dc run --rm migrate; dc up -d for the platform services and Caddy (only what changed restarts); reloads the Caddyfile; prunes dangling images; dc ps |
tools/go-live/deploy.sh site <name> | pulls ../kwisple-<name>, builds and restarts site-<name> with the sites profile |
tools/go-live/deploy.sh site <name> staging | the same for ../kwisple-<name>-staging and site-<name>-staging |
tools/go-live/deploy.sh status | containers of every profile, memory and CPU per container, disk use |
Migrations
Migrations run once per release, before the new containers start, through the migrate target
(drizzle-kit migrate with packages/db/drizzle and its journal). They only ever add
(expand → migrate → contract), so while new images start, and after a rollback, the other
version works on the newer schema. See
Adding things.
Pruning
deploy.sh runs docker image prune -f after every deploy, which removes dangling images (the
previous untagged builds). Keep a tagged copy of what you may want to roll back to.
Rollback
Tag the images of a release before deploying the next one, for example
docker tag kwisple-prod-dashboard:latest kwisple-prod-dashboard:<commit> (and the same for
web, worker, agent), and point compose back at the previous tag to roll back. Because
migrations are additive, the previous version keeps working on the newer schema; no down
migration is needed. Content is in the database, so a rollback never loses it.
Backups
The backup container (infra/backup: Postgres 17 client, rclone, Europe/Brussels) runs
backup.sh --loop: every night at BACKUP_HOUR (default 03:00) it
- dumps the database with
pg_dump --format=customstraight into<BACKUP_BUCKET>/db/<stamp>.dump; - copies the live bucket into
<BACKUP_BUCKET>/files(copy only, never delete).
The script never deletes: old dumps and overwritten files are removed by a lifecycle rule on the
backup bucket (BACKUP_RETENTION_DAYS, default 30), so the backup key can be write-and-list
only and a purge in the live bucket never reaches the backup. Use another provider or account
for the backup bucket, with versioning or object lock and encryption at rest.
dc logs backup # shows the next run
dc exec backup backup.sh # one run now
dc exec -e RESTORE_URL=postgres://…/restore_check backup restore-check.shrestore-check.sh restores the newest dump into a scratch database that must be named
restore_check (it refuses any other name and refuses the live DATABASE_URL, because it
wipes the target), then prints the number of agencies, workspaces, users, entries and assets.
Repeat it monthly. Rehearse locally with pnpm backup:drill, which runs the same image and
scripts against the local database and RustFS and compares the counts
(Testing and checks).
Health
| Endpoint | Answers | Used by |
|---|---|---|
dashboard GET /api/health | { ok: true } when the database answers select 1 within 2 s, else 503 { ok: false } | the image HEALTHCHECK, Caddy's depends_on, uptime checks |
web GET /api/health | { ok: true } when the process runs (no database check) | the image HEALTHCHECK, Caddy's depends_on, uptime checks |
agent GET /health | liveness of the agent service | manual checks |
The public answers carry no details; Platformbeheer → Systeem in the dashboard shows the full picture. Add external uptime checks on the dashboard and API hosts and on every client site.
Storage and mail
Storage. An S3-compatible bucket outside the VM: STORAGE_ENDPOINT, STORAGE_REGION,
STORAGE_BUCKET, STORAGE_ACCESS_KEY_ID, STORAGE_SECRET_ACCESS_KEY, and
STORAGE_PUBLIC_URL for the public origin. Serve files from a separate, cookieless asset
domain with X-Content-Type-Options: nosniff and a sandboxing Content-Security-Policy added
by the CDN, and CORS headers for the dashboard origin (Studio exports draw images on a canvas).
Keys are workspaces/<id>/…, so deleting a client is a prefix delete
(The content engine).
Mail. SMTP through SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASSWORD and the sender
MAIL_FROM, on a sending domain with SPF, DKIM and DMARC. Every mail is a job, so a mail outage
delays mails but never fails a request; the mail jobs retry.
DNS notes
- The dashboard, API and docs hosts point at the VM (A and AAAA). Caddy gets their certificates on first start, once DNS resolves.
- A client site hosted on the VM: its domain points at the VM, DNS-only when the zone is on Cloudflare. A proxying CDN would cache the site's HTML, and the publish webhook cannot purge it, so "online within seconds" would break.
SITE_SERVER_ADDRESSES(the VM's IPv4 and IPv6) makes the dashboard offer "Op de server van het platform" in a site's Domein settings and show the A/AAAA records to give the client; "Controleer records" marks the domain as connected once they resolve.- Sites rendered by
apps/webget<slug>.<PLATFORM_DOMAIN>, which needs wildcard DNS and a wildcard certificate; the current Caddyfile has no wildcard block, so add one before serving hosted sites from the VM. Custom domains are verified with a TXT record. - An agency's white-label dashboard host is a CNAME to the dashboard host, set by a platform admin, plus a Caddy host block.
The documentation site
The developer documentation (apps/docs) is a static export (output: "export" in
apps/docs/next.config.ts), built from docs/developers/**.md in the build-docs stage. The
docs target copies apps/docs/out into a small Caddy image whose own Caddyfile
(apps/docs/Caddyfile) serves it on :80 with .html fallbacks, a 404.html and immutable
caching for /_next/static. The main Caddy terminates TLS for DOCS_HOST and proxies to it.
It needs no env file, no database and no secrets, and is rebuilt by every
deploy.sh platform.
Scaling
From docs/ARCHITECTURE.md §6 and docs/INFRA.md:
| Pressure | Step |
|---|---|
VM memory above about 70 % in deploy.sh status | a bigger VM (images are built on it too) |
| More dashboard or web traffic | dashboard and web are stateless: run 2+ instances behind the proxy. Then set AUTH_RATE_LIMIT_STORAGE=database (auth limits otherwise live in process memory) and move the public endpoints' MemoryRateLimiter to a shared store. |
| Growing queues | more worker containers; heavy rendering can get its own worker pool. @kwisple/jobs is the seam to a hosted queue later. |
| Database load | a managed Postgres with point-in-time recovery first, then a pooler (with DATABASE_PREPARE=false behind PgBouncer in transaction mode), read replicas for the delivery API and lists, a dedicated database for one large agency. Tenant tables are indexed on (workspace_id, …) and nothing joins across agencies. |
| Many coded client sites | the HostingProvider interface can move them to Cloudflare Pages instead of one service each on the VM. |
| Isolation | Postgres row-level security keyed on the workspace, with forWorkspace as the seam, before opening the platform wider. |
A CDN in front of apps/web | it must be purged with the same tags, or keep HTML no longer than a minute, because tag purges only reach apps/web's own cache. |
Keep the database in the same region as the apps: every query is a round trip.