> ## Documentation Index
> Fetch the complete documentation index at: https://docs.airshipci.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Deploying

> Where to run the control plane, the checklist every deployment has to pass, and how to operate it.

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

| Shape | API on | Database | Good for |
| - | - | - | - |
| **Managed containers + managed Postgres** | A container service with a load balancer in front | The platform's own Postgres | Teams already on a cloud; the most control over networking |
| **Serverless containers** | A scale-to-zero container runtime | A managed instance, over a private connection or socket | The least to operate |
| **Platform-as-a-service** | A web service, dashboard deployed separately | A hosted Postgres provider | No cloud account to run; dashboards instead of a CLI |
| **One server** | Docker Compose behind a reverse proxy with automatic TLS | Postgres in a container | Small teams, trials, on-premises |

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](https://docs.airshipci.com/self-hosting/github-app.md).

## The database

**Version** `Postgres 17 or newer`

18 recommended.

**Pooling** `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.

**TLS** `sslmode=require`

Unless you connect over a Unix socket.

**Migrations** `once per release`

Before the new API starts.

```bash theme={null}
docker run --rm --entrypoint migrate -e DATABASE_URL=... airship-api up
```

## The checklist

Whatever you deploy on, these have to be true.

> **PUBLIC\_URL is https and reachable by runner machines**
>
> Not just by browsers. Cache-enabled runners fetch the proxy and its
> credentials from it. Plain http outside development fails at startup.

> **The dashboard and the API are on the same site**
>
> `app.example.com` and `api.example.com`. Different sites means the session
> cookie is not sent and nobody can sign in.

> **NEXT\_PUBLIC\_API\_URL is set in the build environment**
>
> It is inlined when the dashboard is built, not read at runtime. A
> production build with the `localhost` default fails on purpose.

> **CREDENTIALS\_KEY is generated, stored and backed up**
>
> `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.

> **Secrets are in a secret store, not in environment files**
>
> `DATABASE_URL`, `CREDENTIALS_KEY`, `GITHUB_CLIENT_SECRET`,
> `GITHUB_WEBHOOK_SECRET` and the private key.

> **The migration runs before the new version starts**
>
> Once per release, as its own step — not from the API's own startup.

> **DEV\_LOGIN and the provider endpoint overrides are unset**
>
> `DEV_LOGIN` fails startup outside development. The `*_ENDPOINT` variables
> would silently point launches at a test emulator.

> **The image includes the cache proxy**
>
> 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.

> **The database is backed up**
>
> 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.

> **/healthz is wired to your load balancer**
>
> 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

| Watch | Because |
| - | - |
| Queue wait times (p50, p90 on **Overview**) | The first sign of a cap, a quota or a failing cloud credential |
| `runner.launch_failed` on **Events** | Carries your cloud's own message |
| `cache.unavailable` on **Events** | A silent degradation: jobs pass, slowly |
| `cloud.credentials_rejected` | An expired or deleted credential, before users notice |
| Database connections | The first resource to run out |
| Webhook delivery failures in GitHub's app settings | A 401 means the webhook secret does not match |

> **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.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.