# Self-hosting

> Production: HTTPS and the reverse proxy, email, health and metrics, backups, upgrades and key runbooks.

Running authbase for real: what it needs, how to put it behind HTTPS,
what to back up, how to upgrade, and the runbooks for rotating keys.
Start with the [quick start](https://authbase.burakmetehan.com.tr/docs/quickstart.md) if you have not run it yet.

## What it needs

- **authbase**: one stateless binary, from a release archive (Linux or
  macOS, amd64 or arm64, with checksums and an SBOM) or the container
  image built from `deploy/Dockerfile`; `authbase version` says which
  build it is. Memory follows CPUs: a password check holds 19 MiB
  for a moment and at most max(2, CPUs) run at once. Measured: 30 MiB idle,
  about 410 MiB on 12 CPUs during a burst of 60 simultaneous logins. 1–2
  vCPUs and 512 MB serve a small product comfortably.
- **PostgreSQL 16 or newer** (CI tests 16 and 18). All state lives here.
- **One secret, the master key** (`AUTHBASE_MASTER_KEY`, 32 random bytes,
  base64): it encrypts the apps' signing keys and queued email. Generate
  it with `openssl rand -base64 32` or `authbase keygen`.
- **An SMTP relay**, if you want password reset, verification and security
  emails.

Every setting is an environment variable; `deploy/.env.example` lists them
with comments. Secrets never go in
the image, the compose file or the logs. `authbase serve --check-config`
validates the configuration and exits.

## Deploy with Docker Compose

`deploy/compose.yaml` is the reference deployment: authbase and
PostgreSQL, configured from `deploy/.env`. The image is built from source
on the server. The `authbase-self-host` skill
([Claude skills](https://authbase.burakmetehan.com.tr/docs/skills.md)) walks through a fresh server step by
step.

```sh
cp deploy/.env.example deploy/.env      # set AUTHBASE_MASTER_KEY, POSTGRES_PASSWORD, AUTHBASE_PUBLIC_URL
docker compose -f deploy/compose.yaml up -d --build
```

For production, in `deploy/.env`:

```sh
AUTHBASE_PUBLIC_URL=https://auth.example.com   # https: Secure, __Host- cookies and HSTS follow from it
AUTHBASE_PORT=127.0.0.1:8080                   # publish on loopback only; the reverse proxy is the way in
AUTHBASE_TRUSTED_PROXIES=172.16.0.0/12         # where the proxy's requests come from, see below
AUTHBASE_ALLOW_SIGNUP=false                    # once every developer who needs an account has one
```

Anything else that runs a container and a PostgreSQL works the same way:
the container needs the variables above plus `AUTHBASE_DATABASE_URL`.

## HTTPS and the reverse proxy

authbase speaks plain HTTP and expects a reverse proxy to terminate TLS.
Two things matter:

1. `AUTHBASE_PUBLIC_URL` is the https origin users see. It is the token
   issuer (changing it later invalidates every issued token and every
   integration's configuration), the base of email links, and it turns on
   `Secure` + `__Host-` session cookies and HSTS.
2. `AUTHBASE_TRUSTED_PROXIES` lists the addresses the proxy's requests
   arrive from (CIDRs). Only then is `X-Forwarded-For` believed, so rate
   limits, lockout and the audit log see the real client IP. A proxy on
   the Docker host reaches the container from the Docker network's
   gateway (within `172.16.0.0/12` by default); check the `ip` of a
   request in the audit log if unsure. Leave it empty when nothing sits in
   front.

Caddy (certificates included):

```
auth.example.com {
    reverse_proxy 127.0.0.1:8080
}
```

nginx:

```nginx
server {
    listen 443 ssl;
    server_name auth.example.com;
    # ssl_certificate …; ssl_certificate_key …;
    location / {
        proxy_pass http://127.0.0.1:8080;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto https;
    }
}
```

Serve authbase on its own host name; the dashboard, the API and the
hosted pages share it, and the dashboard needs no CORS.

## Email

Set `AUTHBASE_SMTP_HOST`, `_PORT`, `_USER`, `_PASSWORD` and `_FROM`.
`AUTHBASE_SMTP_TLS` is `starttls` by default and then required (a relay
without it is refused), `tls` for implicit TLS (port 465), or `none` for a
local relay only. Mail is queued in the database, encrypted, and sent in
the background with retries, so requests never wait for SMTP. Without
SMTP, email endpoints answer `503 email_not_configured` and apps cannot
turn email verification on. Templates and hosted pages can be overridden
with files under `AUTHBASE_TEMPLATES_DIR`.

## Health and metrics

- `GET /healthz`: 200 while the process runs (liveness).
- `GET /readyz`: 200 when the database answers and migrations are
  current, 503 otherwise (readiness).
- `/metrics` (Prometheus) is served only on its own listener, off unless
  `AUTHBASE_METRICS_ADDR` is set, and never on the public port. With
  compose, set `AUTHBASE_METRICS_ADDR=:9090` and publish it to loopback
  (`127.0.0.1:9090:9090`) or your monitoring network only.

Worth alerting on: `authbase_http_requests_total{code=~"5.."}` rising,
`authbase_rejections_total` (429s: an attack or a misconfigured client),
`authbase_audit_events_total{action="token.reuse_detected"}` (possible
token theft), `authbase_email_outbox_queued` growing (SMTP down), and the
database pool (`authbase_db_connections_*`).

## Backups

A backup is **the database and the master key**, and they must be kept
apart: the database alone cannot sign tokens (its signing keys are
encrypted), and the key alone is useless.

```sh
# database, compressed custom format
docker compose -f deploy/compose.yaml exec -T postgres pg_dump -U authbase -Fc authbase > authbase-$(date +%F).dump
# restore, with authbase stopped
docker compose -f deploy/compose.yaml exec -T postgres pg_restore -U authbase -d authbase --clean --if-exists < authbase-2026-09-28.dump
```

Keep the master key in a password manager or secret store, never next
to the dumps. A dump is readable with the master key that was current
when it was taken: after a master key rotation, keep the old key as long
as you keep older dumps. Practise a restore now and then; a restored
instance started with the right key answers `/readyz` and signs tokens.

## Upgrades

1. Back up the database.
2. Update the checkout (or image) and restart:
   `git pull && docker compose -f deploy/compose.yaml up -d --build`.
3. Migrations run at start-up (`AUTHBASE_AUTO_MIGRATE=true`, the default).
   With `false`, run `authbase migrate up` yourself; until then `/readyz`
   answers 503.

Going back to an older version means restoring the backup taken before
the upgrade. Several instances can run side by side (lockout, email
sending and the background sweeper coordinate through the database); the
per-IP rate limits are counted per instance.

## Runbooks

### Rotate an app's signing key

When: on a schedule (yearly, say) or if you suspect the key leaked.

Dashboard: **App → API keys → Signing keys → Rotate signing key**, or
`POST /v1/apps/{appId}/signing-keys/rotate`. New tokens are signed with
the new key immediately; the old key stays in the JWKS until the tokens
it signed have expired (access-token lifetime + 5 minutes), then is
retired. Verifiers that refetch the JWKS on an unknown `kid` (both SDKs,
the examples) pick it up by themselves; ones that only refresh on a timer
reject new tokens until their next refresh.

### Rotate or revoke an API key

Routine: **Rotate**, which creates a new key and gives the old ones a grace
period (the app's setting, 24 hours by default). Put the new key in your
backend's environment and deploy; the old key stops working when the
grace period ends.

Leaked: **Revoke** it (it stops at once), create a new one, deploy. Then
check the audit log, filtered by the key's id as actor, for what it was
used for.

### Rotate the master key

When: on a schedule, when someone who knew it leaves, or if it may have
leaked. Takes a few seconds of downtime.

1. **Back up the database** (above).
2. **Generate the new key** and keep it in your secret store:
   `openssl rand -base64 32`.
3. **Stop authbase** (every instance): `docker compose -f deploy/compose.yaml stop authbase`.
4. **Re-seal everything under the new key.** The new key is read from the
   environment, never a flag; `read -s` keeps it out of the shell history:

   ```sh
   read -rs AUTHBASE_NEW_MASTER_KEY && export AUTHBASE_NEW_MASTER_KEY   # paste the new key
   docker compose -f deploy/compose.yaml run --rm -e AUTHBASE_NEW_MASTER_KEY authbase admin rotate-master-key
   unset AUTHBASE_NEW_MASTER_KEY
   ```

   It re-encrypts every signing key and queued email in one transaction
   and records `instance.master_key_rotated` in the audit log. If it fails,
   nothing changed. Running it again is harmless.
5. **Switch the key**: set `AUTHBASE_MASTER_KEY` in `deploy/.env` (or your
   secret store) to the new key.
6. **Start authbase**: `docker compose -f deploy/compose.yaml up -d authbase`.
   authbase checks at start-up that its key opens the database's signing
   keys; an instance still on the old key refuses to start with
   `AUTHBASE_MASTER_KEY does not open this database's signing keys`.

Keep the old key as long as you keep backups taken before step 4. Tokens,
API keys and passwords are unaffected: users notice nothing.

### After a leak

| What leaked | Do |
|---|---|
| An API key | Revoke it; issue a new one (above). |
| A database dump | Nothing is directly usable: passwords are Argon2id hashes, API keys and refresh tokens SHA-256 hashes, signing keys and queued email encrypted. Emails, usernames, roles and metadata are exposed; judge whom to inform. Rotate the master key if it could have leaked as well. |
| The master key alone | Rotate the master key. |
| The master key **and** the database | Rotate the master key, then rotate **every app's signing key** (an attacker could have minted tokens), and tell integrators. Consider a password reset for all users if hashes should be treated as exposed. |
| A dashboard account | Reset its password; its sessions end. Review the apps' audit logs. |
