Self-hosting on Coolify
The control plane, its database and the dashboard as three Coolify resources on one Docker network, plus the docs site as a fourth.
The production recipe is deploy/ in the repository, and deploy/README.md is its authoritative, measured guide. This page is the map; follow the README for each step's exact UI clicks and the failure modes it records.
Four resources, not one
| Resource | Directory | Services | Secrets it holds |
|---|---|---|---|
data | deploy/data | Postgres 17, a one-shot migrate, the rollup worker (usage rollup, mail drainer, retention) | Database passwords, SMTP |
api | deploy/api | The control plane, Redis | The app database password, the pepper, the Redis password, embed signing keys |
dashboard | deploy/dashboard | The dashboard | One session-cookie secret |
docs | deploy/docs | This site | None |
The split is what scopes a secret. Coolify injects every variable configured on a Compose resource into every container of that resource, so a single compose file hands every container every secret. Three resources give each zone its own environment, measured with docker exec … env: the api zone holds no admin password, the dashboard zone holds no pepper and no database password, and the docs zone holds nothing at all.
Order
- The network, once. Create a Docker network named
keyring-neton the server, as a Coolify Destination or over SSH withdocker network create --attachable keyring-net. Every compose file declares itexternal: true, and that declaration is what joins the containers to it. Do not use Coolify's "Connect To Predefined Network" toggle: it joins the server-widecoolifynetwork, where another resource'sredisanswered Keyring's own DNS lookup half the time in measurement. data, then wait for it to be healthy. Migrations run on every deploy of this zone.api. Its health check isGET /readyz, which borrows a database connection, so it stays unhealthy untildatais reachable, and Coolify's proxy does not route to it until then.dashboard. Sign up through it.docs, in any order; it depends on nothing.
Each resource is New Resource → Docker Compose with the base directory set to its deploy/<zone> folder, its environment variables pasted from that folder's .env.example, and its domain set with the container port as a suffix, https://api.example.com:3000, because the compose files use expose, never ports.
The docs resource
deploy/docs/docker-compose.yml builds packages/docs/Dockerfile, a standalone Next.js server on port 3000, with no environment variables, no database and no external network. Its health check fetches /. The site is static pages plus one search route, so it needs nothing from the other zones at runtime; the domain is the only thing to set.
Deploy order on an upgrade
Deploy the api image before running a migration that changes how usage is rolled up, never after. docs/architecture.md's "The deploy window between the two writers" carries the reasoning and the mandatory reconciliation for an installation that ever ran the older writer.
Blank variables
Coolify has no "block deployment while empty" guard for a Compose resource, and its own parser rewrites a bare-name environment: entry to an empty string. The recipe therefore carries every optional variable through env_file alone, and deploy/README.md's "What happens if you leave a required variable blank" has the per-variable table of what each blank does.
What the recipe does not do
- No payment integration. The billing view counts billable tenants; nothing charges a card.
- No password rotation automation for Postgres roles: the role bootstrap runs once on the volume's first boot.
- No SIGTERM handler in the api: a redeploy hard-kills requests in flight.