Deployment with Coolify
Buddora deploys through Coolify (ADR-012) on a single self-hosted VPS. Coolify builds every image from source, on the deployment host — nothing is pulled from a registry — and runs the production Compose stack, terminating HTTPS at its bundled Traefik with Let's Encrypt.
Two documents in the repository are canonical and this page does not restate them:
docs/architecture/DEPLOYMENT.md for what is true of the running system, and
docs/operations/DEPLOYMENT.md for the procedure — the SSH command that queues a build,
how to poll the deployment queue, the domain-routing trap that leaves a service healthy and
unreachable, and Keycloak redirect-URI registration.
Deployment is not automatic
There is no webhook on the repository. Pushing to main deploys nothing, and there is no
deploy script or deploy job anywhere in the tree. A deployment is queued against the Coolify
instance by hand, from the host — Coolify listens on 8000 and the host firewall opens only
22, 80 and 443, so reaching its UI or API means reaching the host first.
Somebody who pushes expecting a deployment will not get one, nothing warns them, and no status in GitHub reflects what is running in production.
Components
The production stack is infrastructure/coolify/docker-compose.prod.yml — twenty services,
eight of them portals.
| Service | Image source | Notes |
|---|---|---|
api | apps/api/Dockerfile | Go binary, health /healthz, ready /readyz |
relay, notifier, trust-consumer | apps/api/cmd/*/Dockerfile | long-running workers, own health ports |
migrate | apps/api/Dockerfile, command: ["migrate","up"] | one-shot; every other service depends on it completing |
reconcile | apps/api/cmd/reconcile/Dockerfile | one-shot, intended as a scheduled task |
web, admin, pro, provider, academy, connect | apps/*/Dockerfile, repo-root build context | Next.js standalone, ports 3000/3200/3300/3500/3600/3700 |
seller, shelter | apps/*/Dockerfile, app-local build context | Next.js standalone, both on 3400 |
docs | apps/docs/Dockerfile | this site, static, served by nginx |
postgres | postgis/postgis:16-3.4 | TLS on |
redis, nats, openfga, coturn | images | coturn is the only service publishing real host ports — TURN is UDP and Traefik is an HTTP/TCP proxy |
Keycloak and MinIO are not in this stack. The deployment reuses a shared infra-partagee
layer already running on the same host, reached over the external shared-infra Docker
network. apps/ai is not deployed at all.
Only Traefik is internet-reachable; every application service uses expose: rather than
ports:.
What gates a build
The gates live in the Dockerfiles, because that is the only place a failure can stop a
deployment here — GitHub Actions does not execute for this project. See
Build and deployment for the per-image inventory and for what
scripts/verify.sh covers instead.
Environment variables & secrets
Copy infrastructure/coolify/.env.prod.example; set real values as Coolify secrets and never
commit them. API_DEV_AUTH_BYPASS must be false in production (the API refuses to start
otherwise when API_ENV != development).
Two that are easy to miss:
API_TRUSTED_PROXY_CIDRS— without it, every "per client IP" rate limiter is a single global bucket, because the API sees only Traefik's address (ADR-030). Unset is the default and preserves the old behaviour rather than guessing a range; the API logs a warning at every startup while it is unset. Setting it in Coolify is not enough on its own — Compose forwards only what it names, so the variable must also appear under theapiservice.- Redirect URIs — Keycloak rejects an unregistered
redirect_urion its own error page, so nothing appears in the portal's logs: the container passes its healthcheck, the deployment looks entirely healthy, and that one portal simply has no working login. All eight portals' callbacks must be registered on the realm.
Health, readiness & zero-downtime
- Liveness
GET /healthz; readinessGET /readyz, which pings Postgres only — a NATS outage does not surface there at all. See Runbooks. - The Go images are distroless and have no shell, so they self-health-check with a binary
subcommand. The Next.js images check
/api/healthz;HOSTNAME=0.0.0.0in each runtime stage is load-bearing, because Docker otherwise setsHOSTNAMEto the container id and Next's standalone server binds to it. - Coolify performs a health check before switching traffic. Zero-downtime deploys require replicas > 1, which this deployment does not run.
Upgrades & rollback
- Upgrade: queue a build in Coolify by hand. Confirm the one-shot
migrateservice completed — migrations do not run at API start-up, so skipping it starts the API against whatever schema is already there. - Rollback: untested. ADR-012 claims one-click rollback to a previous deployment as a
Coolify property; it has never been exercised on this instance, and
api migrate downhas never been run against production. Treat both as vendor/mechanism claims rather than tested procedures, and prefer forward fixes.
Backups & restore
See Backup & Restore.
Scaling
The API is stateless in principle, but rate limiting is in-process and per-container, so it must move to Redis before more than one replica runs (ADR-012 already records this). Postgres scales vertically first, then read replicas. There is no staging environment, no canary and no progressive delivery.