Everything runs on a single VPS — a Hetzner CX22-class box is plenty to
start. Traefik terminates TLS with Let's Encrypt; the app trusts
X-Forwarded-For only from the Docker network; the database is never
exposed.
# 0. Point your DNS A/AAAA records at the server; install Docker.
# 1. Shared proxy network + Traefik (once per host)
docker network create traefik
ACME_EMAIL=you@example.com docker compose -f deploy/traefik/docker-compose.yml up -d
# 2. Configure the app
cp .env.example .env
# set: APP_ENV=production, BASE_URL=https://app.example.com, DOMAIN=app.example.com,
# POSTGRES_PASSWORD=<random>, CSRF_KEY=$(openssl rand -hex 32),
# MAILER=resend + RESEND_API_KEY, STRIPE_* keys, SUPER_ADMIN_EMAIL=you@example.com
# 3. Ship it
docker compose -f docker-compose.prod.yml up -d --buildUpdates are a pull and a rebuild:
git pull && docker compose -f docker-compose.prod.yml up -d --buildAPP_ENV=production refuses to start with a missing CSRF_KEY, a
plain-http BASE_URL, or a demo placeholder price ID in BILLING_PLANS.
A misconfigured deployment fails loudly at boot, not quietly at the first
payment.
The pgdata volume (pg_dump) and the uploads volume — blobs live
outside the database, so a dump alone does not restore avatars. With
STORAGE=s3 the object store holds them instead and only the database is
yours to back up.
Point a Stripe webhook endpoint at
https://app.example.com/api/billing/webhook and set its signing secret
as STRIPE_WEBHOOK_SECRET. The events to subscribe to, and why:
Stripe.
The release pipeline ships with the kit and publishes your product — the image name follows your repository, nothing to search-and-replace:
git tag v1.0.0 && git push origin v1.0.0
gh release create v1.0.0 --generate-notesLinux amd64/arm64 binaries from one frontend build, a smoke test before
anything is published, a multi-arch image on GHCR, checksums.txt, a
CycloneDX SBOM, SLSA provenance and a keyless Cosign signature.