Skip to main content

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.

ServiceImage sourceNotes
apiapps/api/DockerfileGo binary, health /healthz, ready /readyz
relay, notifier, trust-consumerapps/api/cmd/*/Dockerfilelong-running workers, own health ports
migrateapps/api/Dockerfile, command: ["migrate","up"]one-shot; every other service depends on it completing
reconcileapps/api/cmd/reconcile/Dockerfileone-shot, intended as a scheduled task
web, admin, pro, provider, academy, connectapps/*/Dockerfile, repo-root build contextNext.js standalone, ports 3000/3200/3300/3500/3600/3700
seller, shelterapps/*/Dockerfile, app-local build contextNext.js standalone, both on 3400
docsapps/docs/Dockerfilethis site, static, served by nginx
postgrespostgis/postgis:16-3.4TLS on
redis, nats, openfga, coturnimagescoturn 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 the api service.
  • Redirect URIs — Keycloak rejects an unregistered redirect_uri on 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; readiness GET /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.0 in each runtime stage is load-bearing, because Docker otherwise sets HOSTNAME to 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 migrate service 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 down has 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.