Skip to main content
The control plane is one container and one Postgres database, so it fits anywhere that runs containers. What follows is the shape of each option and the checklist they share.

Pick a shape

All four need the same five things: a container, a Postgres 17+ database, two hostnames on one site, TLS on the API, and a GitHub App.

The database

Postgres 17 or newer
18 recommended.
session mode only
The reconciler’s advisory lock needs a session, so connect through a session-mode pooler or directly — never a transaction-mode pooler. On hosted providers that means the session port, not the transaction port.
sslmode=require
Unless you connect over a Unix socket.
once per release
Before the new API starts.

The checklist

Whatever you deploy on, these have to be true.
Not just by browsers. Cache-enabled runners fetch the proxy and its credentials from it. Plain http outside development fails at startup.
app.example.com and api.example.com. Different sites means the session cookie is not sent and nobody can sign in.
It is inlined when the dashboard is built, not read at runtime. A production build with the localhost default fails on purpose.
openssl rand -base64 32, in a secret store, backed up as you would a root key. Losing it loses every connected cloud. The development example key is refused outside development.
DATABASE_URL, CREDENTIALS_KEY, GITHUB_CLIENT_SECRET, GITHUB_WEBHOOK_SECRET and the private key.
Once per release, as its own step — not from the API’s own startup.
DEV_LOGIN fails startup outside development. The *_ENDPOINT variables would silently point launches at a test emulator.
Build from the API’s Dockerfile, which embeds it for linux/amd64 and linux/arm64. A plain go build leaves it out and cache-enabled runners quietly lose acceleration.
It is the single point of truth for the mapping from runners to machines. Machines still destroy themselves at their deadlines if you lose it, so nothing runs forever — but you may be left with machines to clean up by tag.
And /docs is where the API documents itself.

Scaling

Several API replicas are safe

Launchers claim runners with row locks; the reconciler elects a leader through a Postgres advisory lock. No sticky sessions, no shared disk.

Postgres is the thing to watch

Connection count first. The control plane is not CPU-bound; it waits on GitHub and on cloud APIs.

Upgrading

1

Run the migration

Migrations are additive within a release line, so the previous version keeps running against the new schema while you roll forward.
2

Roll the API replicas

In-flight launches hold leases. A replica that goes away mid-launch has its work taken over by another once the lease expires.
3

Deploy the dashboard

With NEXT_PUBLIC_API_URL set at build time.
4

Bump RUNNER_VERSION when actions/runner releases

GitHub stops sending jobs to runners more than 30 days behind the latest release, so this is the one variable worth a calendar reminder.

Operating it

What a self-hosted control plane does not need

No cloud permissions of its own, no inbound access to runners, no persistent disk, no queue, no cache, no sticky sessions. Postgres and a container.