← Files AkinatorARCHIVED FILE

templates/examples/ops-runbook.md

6.46 KB · Oct 3, 2026 · 06:33 UTC

↓ Download file

# Runbook - production schema migration

> Filled example of `templates/ops-runbook.md`, written for the fictional Nimbus
> product described in `templates/examples/README.md`. Paths and commands here
> are illustrative and do not exist in this repository.

## Trigger

- A release contains a file under `db/migrations/`.
- `npm run db:status -- --env production` reports `behind`.

If the change is local or staging only, use the `nimbus-schema-change` skill
instead - it is faster and has no point of no return.

## Operational classification

- **Action required:** full drop and rebuild of `api`, `worker` and `exports`,
  then migrate, then start.
- **Why:** the running containers hold pooled connections with cached column
  metadata against the old schema. A restart reuses the old image and the old
  pool, so the migration succeeds while the API keeps returning
  `column does not exist` - which reads as a failed migration and sends people
  debugging in the wrong direction.
- **Estimated duration:** 8-12 minutes, of which about 90 seconds is user-visible
  downtime.
- **Unavailable while running:** exports are queued, not lost. The API returns
  503 for roughly 90 seconds during steps 4 and 5.

## Preconditions

- [ ] The migration ran clean on staging within the last 24 hours -
      `npm run db:status -- --env staging` reports `up to date`
- [ ] A fresh backup exists and is verified -
      `./scripts/db-backup.sh --verify --env production`
      (this is what makes the point of no return survivable)
- [ ] The migration has a written rollback, or is explicitly marked
      irreversible - check the header comment in the migration file
- [ ] No other release is mid-deploy - `./scripts/deploy-status.sh`
- [ ] A second person is available. Do not run this alone.
- [ ] Machine has capacity - see `akinator-resource-guard`.

## Procedure

### Step 1 - Announce and drain

```bash
./scripts/maintenance.sh enable --message "Scheduled maintenance, ~10 minutes"
./scripts/drain.sh worker exports --timeout 300
```

**Verify:** `./scripts/queue-depth.sh` reports `in-flight: 0`. If jobs remain
after 300 seconds, stop and investigate - killing an in-flight export loses
customer work and consumes quota that then has to be refunded by hand.

### Step 2 - Back up

```bash
./scripts/db-backup.sh --tag "pre-$(git rev-parse --short HEAD)" --env production
```

**Verify:** the command prints a backup ID and `restore test: ok`. A backup that
has not been restore-tested is not a backup.

### Step 3 - Build the new images

> **Parallel-safe:** `api`, `worker` and `exports` build independently - they
> share only `postgres`, which is not rebuilt. Serial takes about six minutes;
> parallel about two.

```bash
docker compose -f compose.prod.yml build --parallel api worker exports
```

**Verify:** `docker compose -f compose.prod.yml images api worker exports` shows
three fresh image IDs. Build **before** stopping anything - this is the longest
step and it does not need downtime.

### Step 4 - Stop and remove the old containers

```bash
docker compose -f compose.prod.yml stop api worker exports
docker compose -f compose.prod.yml rm -f api worker exports
```

**Verify:** `docker compose -f compose.prod.yml ps -a` lists none of the three.
The `rm` is the step people skip, and skipping it is the whole failure mode this
runbook exists for.

### Step 5 - Migrate

> **Sequential - depends on step 3**, because the migration runner ships inside
> the `api` image and the old image does not contain the new migration.
>
> **Must NEVER be parallel:** exactly one migration process may run against the
> database. Two runners contend for the same advisory lock; the loser times out
> mid-transaction and leaves a partially applied migration that must be repaired
> by hand.

```bash
docker compose -f compose.prod.yml run --rm api npm run db:migrate
```

**Verify:**

```bash
docker compose -f compose.prod.yml run --rm api npm run db:status
```

reports `up to date`.

If it hangs at `acquiring lock` with no error: a previous runner died holding
the session lock, and the connection has not yet been reaped. Wait 30 seconds
and retry. **Do not force-release the lock** - the other process may still be
mid-transaction. See `memory/2026-05-02-postgres-advisory-lock.md`.

### Step 6 - Start and re-enable

```bash
docker compose -f compose.prod.yml up -d api worker exports
./scripts/maintenance.sh disable
```

**Verify:** see the Verification section.

## Point of no return

> After **step 5** completes, rollback by redeploying the old images is **not
> possible** for any migration that drops or rewrites a column - the old code
> cannot read the new shape, and the dropped data is not retained.
>
> Everything before step 5 is fully reversible: re-enable the old containers and
> disable maintenance.
>
> After step 5, the only path back is a **restore from the step 2 backup**,
> which loses every write made since the backup was taken. Check the migration's
> header comment: additive migrations (new nullable column, new table) are
> reversible past this point; destructive ones are not.

## Verification

- [ ] `curl -fsS https://api.nimbus.example/health` returns 200 with
      `"schema":"head"`
- [ ] `./scripts/queue-depth.sh` shows the drained jobs being consumed
- [ ] One export completes end to end - `npm run smoke:export -- --env production`
- [ ] Error rate is at baseline after 5 minutes - `./scripts/error-rate.sh 5m`
- [ ] No `prepared statement` errors in the last 5 minutes of logs

## Rollback

Valid **only before step 5**.

```bash
./scripts/maintenance.sh enable
docker compose -f compose.prod.yml up -d --force-recreate api worker exports
./scripts/deploy.sh --to "$PREVIOUS_SHA"
./scripts/maintenance.sh disable
```

After step 5, for a destructive migration:

```bash
./scripts/db-restore.sh --backup "<backup-id-from-step-2>" --env production
```

Restoring loses all writes since the backup. Involve the second person before
running it.

## Cleanup

```bash
docker image prune -f --filter "until=24h"
./scripts/backup-retention.sh --keep 7
```

**Verify:** `docker image ls -f dangling=true` is empty, and the maintenance
page is off.

## Related

- Skill: `nimbus-schema-change` - the local and staging equivalent
- Memory: `memory/2026-05-02-postgres-advisory-lock.md` - why step 5 can hang
- Context: `context/services.md` - service topology and dependencies

## Review when

- Last verified: 2026-08-14, by Ihab
- Review when: a service is added to the compose file, the deploy pipeline
  changes, or the database moves to a different managed plan.

SHA-256: 30f2cb92da193bfac56463e946092a187e8770891044d5c00e216304505622ef