# IRDB Admin Manual > Audience: operators running the IRDB Compose stack on a host they > own. Covers the day-to-day deployment lifecycle — updating, rebuilding, > rolling back, troubleshooting, and disk hygiene. For the in-app admin > workflows (creating reporters, tuning policies, reading the audit > log) see [`user-manual.md`](./user-manual.md). For architecture see > [`architecture.md`](./architecture.md). This document picks up where the [`README.md` Quickstart](../README.md#quickstart-5-minutes) leaves off: the stack is running, you have admin access, now you need to keep it running across upgrades, recover from breakage, and reason about the moving parts. --- ## 1. Update workflow ### 1.1 The standard loop ```bash git pull docker compose -f docker-compose.yml -f compose.scheduler.yml up --build -d docker compose logs -f ``` That's the whole thing for the happy path. `Ctrl-C` out of the logs once you see `migrate` exit 0, `api` reach healthy, and `ui` reach healthy — `-d` keeps everything running. What each piece does: | Step | What it actually does | |------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------| | `git pull` | Updates the source tree on the host — Dockerfiles, compose file, app code, migrations, scheduler crontab. | | `--build` | Rebuilds local images. Docker layer cache reuses everything that didn't change. App-code-only changes rebuild in seconds; `composer.lock` changes re-run `composer install`. | | `up -d` | Compares each service's desired image+config against what's running and recreates **only** the containers whose image hash or config changed. | | `docker compose logs -f` | Follows logs from all services. Useful while waiting for migrations to apply. | The dependency chain in `docker-compose.yml` orders things correctly without any manual sequencing: 1. `migrate` runs first (Phinx applies any new migrations, exits 0). 2. `api` waits for `migrate` to complete successfully, then starts. 3. `ui` waits for `api`'s `/healthz` to pass, then starts. 4. `scheduler` (overlay) waits for `api` to be healthy. ### 1.2 What you don't need to do These are common confusions — none of them apply to a normal update: - **`docker compose pull`** — that's for fetching images from a registry. This stack builds locally from `./api` and `./ui`, so a `pull` does nothing useful. - **`docker compose restart`** — kicks the existing container without changing the image. **Does not pick up new code.** Don't use this for updates. - **`docker compose down` then `up`** — works, but unnecessary churn. `up --build -d` does selective recreation in one step. - **`docker compose down -v`** — **destroys** the `irdb-data` volume and your SQLite database with it. Don't run this casually. - **`sudo docker ...`** — on Linux, your user should be in the `docker` group; on macOS / Docker Desktop, sudo isn't needed at all. ### 1.3 Verifying the new code is live ```bash docker compose ps # all should show "Up (healthy)" except migrate ("Exited (0)") docker compose images # CREATED column should show your fresh build timestamp curl -s http://localhost:8081/healthz | jq # api responds, db reachable, jobs fresh curl -s http://localhost:8080/healthz # ui can reach api ``` If you want to spot-check a specific code change: ```bash docker compose exec api cat /app/src/some/changed/File.php | head -20 ``` (You can't `git rev-parse HEAD` inside the container — `.dockerignore` excludes the `.git` directory from the build context.) ### 1.4 Rebuild scope: the whole stack vs one service For most updates, rebuild the whole stack — it's only marginally slower because of layer caching, and it avoids "half-updated" states. If you really only want one service, e.g., the UI: ```bash docker compose up --build -d ui ``` But notice: if your change touched `api` (which `ui` depends on), `ui` won't see the new api until `api` is also recreated. Whole-stack rebuild dodges this. ### 1.5 Force rebuild (ignore cache) Rare — usually only when debugging a build that produced obviously stale output despite a code change: ```bash docker compose build --no-cache docker compose up -d ``` This re-runs every Dockerfile step from scratch. On the api image that means re-fetching Alpine packages and re-running `composer install`; expect a few minutes. ### 1.6 Pulling with the scheduler overlay If you deploy with the scheduler sidecar (the `compose.scheduler.yml` overlay), include it in **every** compose command, otherwise the scheduler container ends up orphaned: ```bash docker compose -f docker-compose.yml -f compose.scheduler.yml up --build -d docker compose -f docker-compose.yml -f compose.scheduler.yml logs -f scheduler docker compose -f docker-compose.yml -f compose.scheduler.yml down ``` Set `COMPOSE_FILE=docker-compose.yml:compose.scheduler.yml` in your shell to avoid retyping the `-f` flags: ```bash export COMPOSE_FILE=docker-compose.yml:compose.scheduler.yml docker compose up --build -d ``` --- ## 2. Image and container lifecycle ### 2.1 Inspecting state ```bash docker compose ps # services in this project docker compose images # which image is each service running docker compose top # processes inside each container docker compose port api 8081 # host port mapping docker compose config # effective merged compose file ``` ### 2.2 Rebuilding a single service ```bash docker compose build api # rebuild api image without recreating containers docker compose up -d api # then recreate the api container ``` Equivalent shortcut: ```bash docker compose up --build -d api ``` ### 2.3 Recreating without rebuilding (rare) When config changed but you don't want a rebuild — e.g., you only edited `.env`: ```bash docker compose up -d # detects env diff, recreates affected containers ``` ### 2.4 Stopping vs killing ```bash docker compose stop # graceful: SIGTERM, then SIGKILL after grace period docker compose kill # immediate SIGKILL — only when stop hangs docker compose start # bring stopped containers back up (no recreate, no rebuild) ``` `stop` then `start` keeps the same container instance and writable layer. `up -d` on a stopped project recreates if the image or config changed since stop. --- ## 3. Volume management ### 3.1 Volumes you should know about | Volume name | Mounted at | Contents | |--------------------|-----------------------------|--------------------------------------------------------------------| | `irdb_irdb-data` | `/data` in `migrate` + `api` | SQLite db (`irdb.sqlite`), GeoIP MMDBs (`geoip/*.mmdb`), backups. | | `irdb_mysql-data` | `/var/lib/mysql` in `mysql` | (Optional) MySQL data files. Only when `DB_DRIVER=mysql`. | The volume name prefix (`irdb_`) is the Compose project name — it defaults to the directory name, so if you cloned into `irdb/` you get `irdb_irdb-data`. If you cloned somewhere else, run `docker volume ls | grep -i irdb-data` to find it. ### 3.2 Inspecting a volume's contents You can't `cd` into the volume from the host on macOS (it lives inside the Docker Desktop VM) and on Linux it requires root. Use a one-shot container instead: ```bash docker run --rm -v irdb_irdb-data:/data alpine ls -la /data docker run --rm -v irdb_irdb-data:/data alpine du -sh /data /data/geoip ``` ### 3.3 Fixing ownership after a uid change The `api` and `migrate` containers run as **uid 1000** (the `app` user). If a volume was created when the containers ran as root — e.g., from a pre-`SEC_REVIEW F18` deployment — the files inside are root-owned and the new uid 1000 process cannot write to them. The symptom is: ``` PDOException: SQLSTATE[HY000]: General error: 8 attempt to write a readonly database ``` Fix without losing data: ```bash docker compose down # stop, keep volume docker run --rm -u 0 -v irdb_irdb-data:/data alpine \ chown -R 1000:1000 /data docker compose up --build -d ``` Verify: ```bash docker run --rm -v irdb_irdb-data:/data alpine stat -c '%u' /data # should print 1000 ``` If the volume's data is disposable (dev / fresh environment), the nuclear option is faster: ```bash docker compose down -v && docker compose up --build -d ``` ### 3.4 Backing up the volume (whole-tarball) The SQLite-API-aware backup is documented in [`README.md` § Backups](../README.md#backups). For a whole-volume tarball — useful before any risky volume operation: ```bash docker compose stop api docker run --rm -v irdb_irdb-data:/data -v "$(pwd):/backup" alpine \ tar czf /backup/irdb-volume-$(date +%F).tar.gz -C /data . docker compose start api ``` Restore: ```bash docker compose down docker run --rm -v irdb_irdb-data:/data -v "$(pwd):/backup" alpine \ sh -c 'rm -rf /data/* /data/.[!.]* && tar xzf /backup/irdb-volume-2026-05-01.tar.gz -C /data' docker compose up -d ``` ### 3.5 Removing a volume explicitly ```bash docker compose down # stop containers (don't use -v) docker volume rm irdb_irdb-data # then remove the volume by name ``` `docker volume rm` refuses to remove a volume that's still attached to a container — that's a safety feature, don't fight it. Stop the relevant container first. --- ## 4. Troubleshooting ### 4.1 "attempt to write a readonly database" Cause: the SQLite file or its directory isn't writable by uid 1000. Most commonly a pre-F18 volume — see § 3.3 for the chown fix. Other causes worth ruling out: - The host filesystem the volume lives on is full → `df -h` (Linux) or `docker system df` (any). - The host filesystem is read-only (e.g., emergency-mounted /) → `mount | grep ' / '`. - A previous migrate run left a `.journal` file owned by a different uid — the chown in § 3.3 fixes this too. ### 4.2 Migration fails The `migrate` container exits non-zero, `api` doesn't start because the `service_completed_successfully` gate isn't satisfied. Diagnosis: ```bash docker compose logs migrate ``` Common failures: - **Schema conflict** — a migration tries to add a column that already exists, usually because the migration was edited after being applied. Phinx tracks applied migrations in `phinxlog`. Rolling back manually: ```bash docker compose run --rm migrate vendor/bin/phinx rollback \ --configuration=config/phinx.php --target= ``` - **Permission error** — see § 3.3. - **Constraint violation** during data migration — fix the data or the migration; rerun. After fixing, just `docker compose up --build -d` again. Phinx resumes from the last successful version (it's idempotent on already- applied migrations). ### 4.3 Healthcheck never passes `api` shows `Up (unhealthy)` or `ui` won't start because `api` isn't healthy. Diagnose: ```bash docker compose exec api wget -qO- http://localhost:8081/healthz docker compose logs api --tail 100 ``` Likely culprits: - Missing or invalid env var (api validates required env on boot — look for the explicit error near the top of the log). - `migrate` did not actually finish before `api` started — happens if you bypassed compose with manual `docker run`. - Database unreachable (MySQL mode only — check `mysql` is up + healthy first). ### 4.4 Container won't start at all ```bash docker compose ps -a # see exit code docker compose logs ``` Common: - **`.env` missing** — Compose validates `env_file: .env` and refuses to start. `cp .env.example .env` and fill in secrets. - **Port conflict** — host port 8080 or 8081 in use by something else. Either stop the other process, or remap in `docker-compose.yml`. - **Image build failed** — read the build output above the start attempt. ### 4.5 Migrate looks stuck Phinx output is buffered through PHP's stdout. If a single migration takes a while (large data backfill), it can look frozen. Check liveness: ```bash docker compose top migrate ``` If you see a `php` or `phinx` process consuming CPU, it's working. If the process is gone but the container is still listed, check exit code with `docker compose ps -a`. ### 4.6 Disk fills up Symptoms: builds fail with "no space left on device", or `api` crashes with SQLite "disk I/O error". ```bash docker system df # what's using the docker disk docker image prune -f # remove dangling images (safe) docker builder prune -f # remove old build cache (safe) docker volume ls -f dangling=true # volumes with no associated container ``` Don't run `docker system prune --volumes` casually — it removes unused volumes including ones you might still want. Use the targeted commands above. For the `irdb-data` volume specifically, the audit log is the largest growing table. The `cleanup-audit` job prunes it according to `JOB_AUDIT_RETENTION_DAYS` (default 180 days). If audit log has run away, lower the retention and run the job once-shot: ```bash docker compose exec api curl -s -X POST http://localhost:8081/internal/jobs/cleanup-audit \ -H "Authorization: Bearer ${INTERNAL_JOB_TOKEN}" ``` --- ## 5. Rolling back If a deploy breaks things and you need to get back to the prior version: ```bash git log --oneline -10 # find the previous good commit git checkout docker compose -f docker-compose.yml -f compose.scheduler.yml up --build -d ``` **Caveat: migrations are not automatically reversed.** If the broken deploy added a migration, the schema is still at the new version when you check out the old code. Two paths: 1. **Old code is forward-compatible with the new schema** (most additive migrations — new columns, new tables — qualify). Just redeploy old code; it ignores the new shape. 2. **Old code can't run against the new schema** (column rename, type change, dropped column). Roll the migration back too: ```bash docker compose run --rm migrate vendor/bin/phinx rollback \ --configuration=config/phinx.php --target= ``` Then redeploy old code. If the broken deploy corrupted data, restore from a backup ([`README.md` § Backups](../README.md#backups) — use the SQLite `.backup` file or the volume tarball, not a partial state). After investigating and fixing on `main`, return to the branch tip: ```bash git checkout main git pull docker compose -f docker-compose.yml -f compose.scheduler.yml up --build -d ``` --- ## 6. Scheduler operations The scheduler sidecar (`compose.scheduler.yml` overlay) runs busybox `crond` and posts to `/internal/jobs/tick` once a minute. The endpoint is bound to RFC1918 + loopback only — see `api/docker/Caddyfile`. ### 6.1 Verify the scheduler is firing ```bash docker compose logs -f scheduler # one POST per minute docker compose exec api curl -s http://localhost:8081/healthz | jq .jobs ``` The `jobs` block in `/healthz` shows the most-recent successful tick; if it's > 5 minutes stale and the scheduler is "running", investigate. ### 6.2 Force a job run Each job has its own endpoint under `/internal/jobs/`: ```bash docker compose exec api curl -s -X POST \ http://localhost:8081/internal/jobs/ \ -H "Authorization: Bearer ${INTERNAL_JOB_TOKEN}" ``` Available jobs: `recompute-scores`, `cleanup-audit`, `cleanup-expired-manual-blocks`, `enrich-pending`, `refresh-geoip`, `tick` (dispatcher). `GET /internal/jobs/status` returns the latest run record per job. Admins logged into the UI can also trigger a job from the Settings → Jobs screen, which posts to `/api/v1/admin/jobs/trigger/{name}` — that path uses an admin token, not `INTERNAL_JOB_TOKEN`. ### 6.3 Switching scheduler styles Three options — pick exactly one to avoid double-firing: | Style | When to use | How | |----------------|--------------------------------------------------------------------------|----------------------------------------------------------------------------------------------| | Sidecar | Default. Self-contained, no host setup. | Include `compose.scheduler.yml` overlay. | | Host cron | You already manage cron centrally (e.g., Ansible). | Drop `examples/scheduler/host.crontab` into `/etc/cron.d/`. Don't include the overlay. | | systemd timer | Modern Linux without crond, you want timer accuracy + journal logging. | Install `examples/scheduler/irdb-tick.{service,timer}` into `/etc/systemd/system`. Don't include the overlay. | If you migrate from sidecar to host cron / systemd: stop the sidecar (`docker compose stop scheduler && docker compose rm -f scheduler`, and stop including `compose.scheduler.yml` on subsequent `up` invocations), enable the host driver, and verify exactly one tick per minute lands by tailing api logs. --- ## 7. Multi-host and scaling notes The default deployment is single-host. A few caveats if you scale: - **SQLite mode is single-host only.** Move to MySQL (see [`README.md` § MySQL](../README.md#mysql-optional)) before adding a second api replica. - **The `migrate` container must run exactly once** per deploy, not once per replica. If you orchestrate manually, gate api startup on a single-shot migrate completion. - **The UI session store is local file-backed** (`/tmp` in the ui container). Multiple ui replicas need either sticky sessions at the load balancer, or a shared session store (Redis) — currently not configured out of the box. - **The scheduler must run exactly once globally**, not per host. If you run host cron on every node, you'll multi-fire jobs. Pick one node. --- ## 8. Security update workflow The base images (`dunglas/frankenphp:1-php8.3-alpine` for api/ui, `alpine:3.21@sha256:…` digest-pinned for the scheduler) are referenced in their Dockerfiles. To pull security fixes: ```bash docker compose build --pull # forces re-pull of base images docker compose up -d ``` `--pull` is the difference: without it, the build reuses the locally- cached base image even when the registry has a newer tag. For app-level security updates (Composer dependencies): ```bash cd api composer update --no-dev # updates composer.lock on the host cd .. docker compose up --build -d # rebuild picks up the new lock file ``` Review the changelog of any updated package before deploying. The [`doc/SEC_REVIEW.md`](./SEC_REVIEW.md) document tracks security findings + their fixes per commit; `git log --grep SEC_REVIEW` surfaces the pattern. --- ## 9. Operational quick reference Daily operations: ```bash docker compose ps # are all services up? docker compose logs -f api ui # tail app logs docker compose exec api sh # shell into api (read-only rootfs; /tmp is writable) ``` Deploys: ```bash git pull && docker compose -f docker-compose.yml -f compose.scheduler.yml up --build -d ``` Backups (SQLite, online): ```bash docker compose exec api sh -c \ 'sqlite3 /data/irdb.sqlite ".backup /data/irdb-backup.sqlite"' docker compose cp api:/data/irdb-backup.sqlite ./irdb-backup-$(date +%F).sqlite ``` Health: ```bash curl -s http://localhost:8081/healthz | jq curl -s http://localhost:8080/healthz ``` Disk: ```bash docker system df docker image prune -f && docker builder prune -f ``` Volume rescue: ```bash docker run --rm -u 0 -v irdb_irdb-data:/data alpine chown -R 1000:1000 /data ``` --- ## 10. See also - [`README.md`](../README.md) — quickstart, secrets, reverse proxy, MySQL/OIDC setup, backups. - [`user-manual.md`](./user-manual.md) — UI walkthrough, screen-by-screen. - [`architecture.md`](./architecture.md) — system design, container topology, where state lives. - [`security.md`](./security.md) — threat model, hardening choices. - [`SEC_REVIEW.md`](./SEC_REVIEW.md) — security findings log. - [`auth-flows.md`](./auth-flows.md) — local admin, OIDC, token kinds. - [`api-overview.md`](./api-overview.md) — REST surface, common conventions.