---
title: "Docker Compose"
---

# Docker Compose

Run Neural Inverse Govern on a single host with Docker Compose, from first start to a production configuration.

The Docker Compose stack runs Govern (Keystone) on one host. Run every command from the root of the Keystone release; the root `docker-compose.yml` includes `deploy/docker-compose.yml`.

## Requirements

- Docker 24.0 or later
- Docker Compose v2.20 or later (`docker compose version`)
- 2 GB of available memory and 10 GB of disk

## Services

| Service | Port | Purpose |
|---|---|---|
| `postgres` | 5432 | PostgreSQL 17 |
| `db-roles` | | Creates the three database roles on every start, then exits |
| `migrate` | | Applies the migrations as `keystone_owner`, then exits |
| `server` | 8080 | The Govern server (`ni-auth`) |
| `dashboard` | 3000 | Web dashboard and sign-in UI |
| `mailpit` | 1025 (SMTP), 8025 (web) | Captures email for local development |

The server connects as `keystone_app` under row-level security and refuses to start as a superuser or a role that can bypass row-level security. The backend network is internal; only the published ports are reachable from the host.

## Configure

Copy the example environment file:

```bash
cp .env.example .env
```

The stack refuses to start until these variables have values. Generate each one separately:

| Variable | Generate |
|---|---|
| `KEYSTONE_OWNER_PASSWORD`, `KEYSTONE_APP_PASSWORD`, `KEYSTONE_PLATFORM_PASSWORD` | `openssl rand -hex 24` (three different values) |
| `KMS_MASTER_KEY` | `openssl rand -hex 32` |
| `NI_AUTH_API_KEY_PEPPER`, `NI_AUTH_OAUTH2_PEPPER`, `NI_AUTH_EMAIL_TOKEN_PEPPER` | `openssl rand -base64 48` |
| `NI_AUTH_AUDIT_HMAC_KEY`, `NI_AUTH_AUDIT_SIGNING_KEY` | `openssl rand -base64 48` |
| `NI_OBSERVABILITY_SIGNING_KEY` | `openssl rand -base64 48` |
| `NI_AUTH_VAULT_ENCRYPTION_KEY`, `NI_AUTH_SECRETS_ENCRYPTION_KEY` | `openssl rand -base64 48` |
| `NI_AUTH_PASSPORT_KEY`, `NI_AUTH_APPROVAL_SECRET` | `openssl rand -base64 48` |
| `NI_AUTH_TOTP_ENCRYPTION_KEY`, `NI_AUTH_LDAP_ENCRYPTION_KEY`, `NI_AUTH_SOCIAL_ENCRYPTION_KEY`, `NI_AUTH_WEBHOOK_ENCRYPTION_KEY` | `openssl rand -base64 32` |

`.env.example` contains placeholder values for some of these. With `NI_AUTH_DEPLOY_ENV` unset (production), the server refuses placeholders, all-zero keys and anything containing `change-me` or `replace-with`. For local development only, `NI_AUTH_DEPLOY_ENV=development` accepts them with a warning at every start.

The OIDC signing key is mounted from `deploy/dev/oidc-private.pem` by default, a development key that ships with the repository. For any other deployment, generate a key and point `NI_AUTH_OIDC_PRIVATE_KEY_HOST_FILE` at it:

```bash
openssl genrsa -out oidc-private.pem 4096
```

## Start

```bash
docker compose up --build -d
docker compose ps
curl http://localhost:8080/healthz
```

The server answers at `http://localhost:8080`, the dashboard at `http://localhost:3000` and Mailpit at `http://localhost:8025`.

## Run in production

### Put TLS in front of the server

The server does not terminate TLS. A minimal Caddy configuration:

```caddyfile
auth.example.com {
  reverse_proxy server:8080
}

app.example.com {
  reverse_proxy dashboard:3000
}
```

List the proxy's addresses in `NI_AUTH_TRUSTED_PROXIES` (CIDRs or IPs). Forwarding headers are believed only from the proxies you list, so a client cannot choose the address used for rate limits, lockouts, risk scoring and audit events. If a CDN sits in front of the proxies, also set `NI_AUTH_TRUSTED_CDN_PROXIES` and `NI_AUTH_CLIENT_IP_HEADER`.

### Set the public URLs and email

```bash
NI_AUTH_PUBLIC_URL=https://auth.example.com
NI_AUTH_JWT_ISSUER=https://auth.example.com
NI_AUTH_SMTP_HOST=smtp.example.com
NI_AUTH_SMTP_PORT=587
NI_AUTH_SMTP_STARTTLS=true
NI_AUTH_SMTP_USERNAME=postmaster@example.com
NI_AUTH_SMTP_PASSWORD=<smtp-password>
NI_AUTH_SMTP_FROM=no-reply@example.com
POSTGRES_PASSWORD=<strong-random-password>
```

- `NI_AUTH_PUBLIC_URL` must be HTTPS in production, and `NI_AUTH_JWT_ISSUER` must match it exactly.
- If the API is served on its own host, set `NI_AUTH_API_PUBLIC_URL` (for example `https://api.example.com`). Model and MCP gateway addresses and other client-facing URLs then use it, while sign-in paths keep `NI_AUTH_PUBLIC_URL`.
- `NI_AUTH_REGISTRATION_MODE` is `invite` by default (an invitation is required); `open` lets anyone create an account.
- Never set `NI_AUTH_SMTP_INSECURE_SKIP_VERIFY=true` in production.

### Keep the database private

Change `POSTGRES_PASSWORD`, enable TLS to PostgreSQL, and do not publish port 5432 beyond the host. The internal worker API (port 9090) is published on loopback only; never bind it to a public address.

### Store files

Exports and other files are written to the `keystone_data` volume (`NI_OBJECT_STORE_DIR`). To use an S3 or S3-compatible bucket instead, set `NI_OBJECT_STORE_S3_BUCKET` with its region, KMS key, prefix and endpoint variables.

### Turn on optional features

| Feature | Variables |
|---|---|
| Model Access manifest signing | `NI_AUTH_MODELS_SIGNING_KEY_PEM`, `NI_AUTH_MODELS_SIGNING_KEY_ID` (Ed25519 PKCS#8); without them `/api/v1/models/resolved` answers 503 |
| Agent policy manifest signing | `NI_AUTH_AGENTPOLICY_SIGNING_KEY_PEM`, `NI_AUTH_AGENTPOLICY_SIGNING_KEY_ID`; without them the resolved policy answers 503 |
| MCP manifest signing | `KEYSTONE_MCP_SIGNING_KEY_PEM` (or `_FILE`), `KEYSTONE_MCP_SIGNING_KEY_ID` |
| DLP tokenization | `NI_AUTH_DLP_TOKEN_ENCRYPTION_KEY` (`openssl rand -base64 32`); without it tokenization answers 503 and stores nothing |
| Audit tiering | `NI_AUTH_AUDIT_TIERING_*`; see [Retention and tiering](https://neuralinverse.com/docs/evidence/retention-and-tiering/) |
| Prometheus scrapes | `NI_AUTH_METRICS_TOKEN` |

Generate an Ed25519 signing key with `openssl genpkey -algorithm ed25519`. Keep each key ID stable until you rotate.

## Migrations

The `migrate` service applies migrations on every `docker compose up`, as the schema owner `keystone_owner`, and exits with code 0 when they are all applied. To run them by hand:

```bash
docker compose run --rm migrate
```

See [Upgrades and migrations](https://neuralinverse.com/docs/deploy/upgrades/) for the database roles involved and what to do when a migration fails.

## Backup and restore

PostgreSQL data is in the `postgres_data` volume, and exports and artifacts in the `keystone_data` volume. Back up the database in the custom format with the bootstrap superuser:

```bash
docker compose exec -T postgres pg_dump -U ni_auth -d ni_auth \
  --format custom --compress 9 > keystone-$(date +%Y%m%d-%H%M%S).pgdump
```

Restore into an empty database with the three roles in place, then let the role bootstrap and the migrations run: the steps are in [Backup and restore](https://neuralinverse.com/docs/deploy/backup-restore/). Keep the keys from `.env` with the backup's recovery plan; the database is not usable without them.

## Upgrade

See [Upgrades and migrations](https://neuralinverse.com/docs/deploy/upgrades/) for the full procedure. In short:

1. Read the release notes for steps the release requires before it starts.
2. Back up the database.
3. Pull or build the new version and restart: `docker compose up -d`. The `migrate` service applies new migrations first.

## Stop

```bash
docker compose down
```

`docker compose down -v` also deletes the PostgreSQL volume and every record in it.

## Troubleshooting

More symptoms and fixes are in [Troubleshooting](https://neuralinverse.com/docs/deploy/troubleshooting/).

| Symptom | Likely cause | Fix |
|---|---|---|
| Compose refuses to start and names a variable | A required variable is empty | Set it as shown under Configure |
| `server` exits at start | A missing or placeholder key | Read `docker compose logs server` for the variable name |
| Sign-in fails with 500 | No OIDC private key | Set `NI_AUTH_OIDC_PRIVATE_KEY_FILE` or `NI_AUTH_OIDC_PRIVATE_KEY_PEM` |
| Token validation fails | Issuer mismatch | Make `NI_AUTH_JWT_ISSUER` match `NI_AUTH_PUBLIC_URL` exactly |
| Email is not delivered | SMTP not configured | Check the `NI_AUTH_SMTP_*` variables; use Mailpit locally |
