Kwisple
Browse the docs

The platform repository › Working on it

Deploying the platform

The production shape of the platform - one VM with Docker Compose, the multi-target Dockerfile, env files per service, Caddy, the go-live scripts, migrations, rollback, backups, health checks and how it scales.

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 domain

The Dockerfile

One multi-target Dockerfile at the repository root builds every process:

TargetBaseRunsPortHealth check
dashboardNode 22 slim, Next.js standalone outputnode apps/dashboard/server.js4100GET /api/health
webNode 22 slim, Next.js standalone outputnode apps/web/server.js4101GET /api/health
workerthe installed workspacetsx src/worker/index.ts in apps/dashboard, JOBS_INLINE=0nonenone
agentthe installed workspacetsx src/server.ts in apps/agent4110none in the image
migratethe installed workspacedrizzle-kit migrate in packages/dbnonenone (one-off)
docscaddy:2.10.2-alpinethe static export of apps/docs on :8080none

How it is built:

  1. Prune. turbo prune @kwisple/dashboard @kwisple/web @kwisple/agent @kwisple/docs --docker copies only those apps and the packages they use; the json/ half is installed first so dependency layers stay cached (pnpm install --frozen-lockfile with a BuildKit cache mount).
  2. Source. The pruned sources plus tsconfig.base.json, docs/handleiding (served on /hulp, read at request time) and docs/developers (built into the docs site).
  3. Builds. next build with NEXT_OUTPUT=standalone for the dashboard and web. next build imports modules that read DATABASE_URL and BETTER_AUTH_SECRET, so the build steps get harmless placeholders through BUILD_ENV; nothing connects during the build and the runtime images never see them.
  4. Runtimes run as the node user with NODE_ENV=production. The worker, agent and migrate targets keep the installed workspace because they run TypeScript through tsx.

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 argUsed byMeaning
STORAGE_PUBLIC_URLdashboard, webthe public origin of stored files
DASHBOARD_URLdashboard, webthe dashboard's own URL
KWISPLE_ASSET_ORIGINclient 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.

ServiceTarget / imageEnv fileProfileNotes
postgrespostgres:17-alpinePOSTGRES_PASSWORD from .env.productionvm-dbData on the postgres-data volume; port bound to 127.0.0.1 only. Leave the profile off once the database is managed.
migratemigrate.env.productiontoolsOne-off: dc run --rm migrate.
dashboarddashboard.env.productionalwaysJOBS_INLINE=0, ASSISTANT_URL=http://agent:4110.
webweb.env.webalwaysThe delivery API and hosted sites.
workerworker.env.productionalwayspg-boss jobs (Jobs and the worker).
agentagent.env.agentalwaysThe only container with the model key.
site-<name>the client repo's own Dockerfilethe site's .env.production (optional)sitesA coded client site hosted on the VM; its env file is also a BuildKit secret at build.
site-<name>-stagingsame, staging branch worktreethe site's .env.staging (optional)stagingIts staging deployment.
docsdocsnonealwaysThe developer documentation, static.
caddycaddy:2.10.2-alpinehost names onlyalwaysPorts 80, 443 and 443/udp; waits for healthy dashboard and web.
backupinfra/backup.env.backupalwaysNightly 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.

FileRead byHolds (names)
.env.productiondashboard, 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.webwebDATABASE_URL, DATABASE_PREPARE, REVALIDATE_SECRET, PREVIEW_SECRET, WEB_URL, DASHBOARD_URL, PLATFORM_DOMAIN, STORAGE_*, TRUST_PROXY, TRUSTED_IP_HEADER, TRUSTED_PROXY_HOPS
.env.agentagentANTHROPIC_API_KEY, ASSISTANT_TOKEN, AGENT_MODEL
.env.backupbackupDATABASE_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_SECRET and PREVIEW_SECRET must be identical in .env.production and .env.web; ASSISTANT_TOKEN identical in .env.production and .env.agent.
  • BETTER_AUTH_SECRET, REVALIDATE_SECRET and PREVIEW_SECRET are at least 32 characters; in production the dashboard refuses to start otherwise (src/instrumentation.ts). Rotating BETTER_AUTH_SECRET signs everyone out. Set HOSTING_SECRET_KEY explicitly: without it a key is derived from BETTER_AUTH_SECRET, and rotating that would make stored deploy hooks and webhook secrets unreadable.
  • TRUST_PROXY=1 stays on in .env.production and .env.web, because Caddy sets the client IP; behind another proxy also set TRUSTED_IP_HEADER and TRUSTED_PROXIES.
  • Production uses pnpm db:migrate (the migrate container), never db: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:

VariableProxies toExtras
DASHBOARD_HOSTdashboard:4100request bodies up to 30 MB (uploads)
WEB_HOSTweb:4101the delivery API host
DOCS_HOSTdocs:80defaults to http://docs.localhost (no certificate) when unset
a site's hostsite-<name>:4103default security headers only where the site sets none
a site's aliasredirectthe other spelling (www or bare) → the main host, path kept
a site's staging hostsite-<name>-staging:4103X-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 files

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

CommandWhat it does
tools/go-live/deploy.sh platformgit 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> stagingthe same for ../kwisple-<name>-staging and site-<name>-staging
tools/go-live/deploy.sh statuscontainers 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

  1. dumps the database with pg_dump --format=custom straight into <BACKUP_BUCKET>/db/<stamp>.dump;
  2. 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.sh

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

EndpointAnswersUsed 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 /healthliveness of the agent servicemanual 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/web get <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:

PressureStep
VM memory above about 70 % in deploy.sh statusa bigger VM (images are built on it too)
More dashboard or web trafficdashboard 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 queuesmore worker containers; heavy rendering can get its own worker pool. @kwisple/jobs is the seam to a hosted queue later.
Database loada 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 sitesthe HostingProvider interface can move them to Cloudflare Pages instead of one service each on the VM.
IsolationPostgres row-level security keyed on the workspace, with forWorkspace as the seam, before opening the platform wider.
A CDN in front of apps/webit 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.

Last updated 2026-10-30