Keyring

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

ResourceDirectoryServicesSecrets it holds
datadeploy/dataPostgres 17, a one-shot migrate, the rollup worker (usage rollup, mail drainer, retention)Database passwords, SMTP
apideploy/apiThe control plane, RedisThe app database password, the pepper, the Redis password, embed signing keys
dashboarddeploy/dashboardThe dashboardOne session-cookie secret
docsdeploy/docsThis siteNone

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

  1. The network, once. Create a Docker network named keyring-net on the server, as a Coolify Destination or over SSH with docker network create --attachable keyring-net. Every compose file declares it external: 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-wide coolify network, where another resource's redis answered Keyring's own DNS lookup half the time in measurement.
  2. data, then wait for it to be healthy. Migrations run on every deploy of this zone.
  3. api. Its health check is GET /readyz, which borrows a database connection, so it stays unhealthy until data is reachable, and Coolify's proxy does not route to it until then.
  4. dashboard. Sign up through it.
  5. 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.

On this page