Run Codex Pooler with Docker Compose
Docker Compose is the quickest self-host path for a laptop, lab server, or small single-node install. It runs the Codex Pooler release image with a local Postgres database and writes the release secrets into a private .env file. Release images are published for linux/amd64 and linux/arm64, so an ARM server works as well as an x86 one.
Use it when you want a simple first install before moving to Kubernetes.
Prerequisites
Section titled “Prerequisites”You need:
- Docker with Compose
- Git, if you are cloning the repository
openssl- A checked-out Codex Pooler repository
git clone https://github.com/icoretech/codex-pooler.gitcd codex-poolerGenerate the local environment
Section titled “Generate the local environment”For every self-hosted install, choose a tagged stable release before creating
.env. Find the latest tagged stable release on GitHub Releases. The latest tag follows verified stable releases and does not move backward when an older version is published again. A version tag keeps the installation reproducible.
export CODEX_POOLER_IMAGE=ghcr.io/icoretech/codex-poolerexport CODEX_POOLER_IMAGE_TAG=0.15.1scripts/self-host/generate-env.shThe generated .env contains release secrets, encryption roots, database settings, and image settings such as CODEX_POOLER_IMAGE and CODEX_POOLER_IMAGE_TAG. Keep it private. Don’t commit it, paste it into tickets, or reuse values between public installs.
If you already generated .env, set CODEX_POOLER_IMAGE_TAG to the target
tagged stable release before docker compose pull. Pinning a version tag makes the install
reproducible. If port 4000 is already in use, set CODEX_POOLER_HTTP_PORT
before generation or edit it in .env before starting the stack.
Start Codex Pooler
Section titled “Start Codex Pooler”Pull and start the stack:
docker compose pulldocker compose up -dThe first run pulls the app and Postgres images, waits for Postgres health, runs the migration container, then starts the web app.
Open the local site:
http://localhost:4000On the first visit, create the owner account at /bootstrap. After sign-in, start with /admin/pools.
To verify the first-run redirect from the shell:
curl -sS -D - -o /dev/null http://localhost:4000/ | grep -i '^location: /bootstrap'curl -fsS http://localhost:4000/bootstrap/statusThe status endpoint should return {"status":"ok","bootstrap":"pending"} on a
fresh database. Use http://localhost:4000 for the default Compose stack even
if the Phoenix startup banner prints an endpoint URL such as
https://localhost; the Compose port mapping is the local URL to open. A
release image includes the OS timezone database used for operator timezone
display.
Bootstrap runtime access
Section titled “Bootstrap runtime access”After the owner account exists:
- Create a Pool in
/admin/pools - Import or connect upstream accounts in
/admin/upstreams - Create a Pool API key in
/admin/api-keys - Point clients at one of the runtime base URLs
Codex backend compatibility route:http://localhost:4000/backend-api/codex
OpenAI-compatible /v1 surface:http://localhost:4000/v1
Operator MCP endpoint:http://localhost:4000/mcpUse a Pool API key for /backend-api and /v1. Use an operator MCP token for /mcp.
For a deployed host, replace http://localhost:4000 with https://codex-pooler.example.com.
Health and readiness
Section titled “Health and readiness”Check that the web process responds before handing traffic to clients:
curl -fsS http://localhost:4000/healthzcurl -fsS http://localhost:4000/readyz/healthz confirms the endpoint is alive. /readyz is the safer check for accepting runtime traffic.
You can inspect the Compose stack with:
docker compose psdocker compose logs -f appUpgrade and run migrations
Section titled “Upgrade and run migrations”Check TOTP before changing the image
Section titled “Check TOTP before changing the image”Use the preflight helper from the target release checkout against the still-running old application, before changing its image or encryption configuration. It reads the effective runtime key and every stored TOTP record, including pending and disabled enrollments, without changing records or returning secrets. It needs the existing release RPC connection, not a local Elixir installation or a successfully started new image.
docker ps --format 'table {{.ID}}\t{{.Names}}\t{{.Image}}'Select the exact existing app container from that inventory and replace the value below. Do not obtain it through the new Compose file: its required-key interpolation can fail for the missing-key installation that needs this check.
old_container='<existing-app-container-id>'sh scripts/self-host/totp-upgrade-preflight.sh -- \ docker exec "$old_container" /app/bin/codex_poolerThe production key must be 32 raw bytes or valid base64 that decodes to 32 bytes. A configured string of exactly 32 bytes is used verbatim first, even if it looks like base64; other strings are decoded. Do not trim, truncate, hash or reinterpret an existing working key. Keep the same private key and key version when all stored records authenticate with it; changing the version label alone does not select another decryption key.
| Preflight disposition | Action before upgrade |
|---|---|
ready |
Preserve the working private key and version, then continue |
replace_totp_key |
TOTP storage is empty. Set only CODEX_POOLER_TOTP_ENCRYPTION_KEY to a securely generated private 32-byte key, preferably base64-encoded. Preserve every unrelated .env value; do not rerun environment generation |
offline_reencryption_required |
Existing records authenticate with the legacy fallback. Follow the offline procedure below; never copy or adopt that public fallback as configuration |
restore_key_or_investigate |
Preserve the database and configuration. Restore the correct prior private key or investigate malformed, mixed-key or unauthenticated ciphertext; do not rotate or reset factors to bypass the refusal |
inventory_limit_exceeded |
Stop and arrange a bounded operator review; the inventory is not certified ready |
preflight_failed or transport_failed |
Fix the RPC/database/target problem and rerun; absence of a result is not permission to upgrade |
Only ready exits 0. Action-required results exit 2; failed checks exit 3. Keep the bounded result and exit code, not keys, ciphertext, authenticator codes or RPC error bodies. A read-only check is a snapshot: prevent concurrent enrollment or key changes between the check and the upgrade, and check every distinct runtime configuration if replicas differ.
Apply the upgrade after preflight
Section titled “Apply the upgrade after preflight”After preflight and any required recovery succeed, set
CODEX_POOLER_IMAGE_TAG in .env to the target tagged stable release. Keep a
versioned tag to make the selected upgrade reproducible. Then pull and restart
the stack:
docker compose pulldocker compose up -dThe Compose file includes a one-shot migrate service. It waits for Postgres,
runs the release migrations, imports the bundled pricing snapshot, and exits
before the app service starts. Normal app boot does not run migrations by
itself, so keep upgrades on this Compose path instead of starting only the app
container with docker run.
If you need to run migrations manually after fixing an environment or database issue, start the database and run the migration service once:
docker compose up -d dbdocker compose run --rm migratedocker compose up -d appWhen a migration fails, inspect the migration container logs before restarting traffic:
docker compose logs migrateIndex migrations run concurrently, so they wait for other open transactions on
the same database, such as a backup or a psql session left idle inside a
transaction. The migration waits up to five minutes in total for them and logs
the blocking sessions while it waits; a table lock held by another session stops
it after ten seconds. If it fails with a message that names blocking sessions,
end those sessions or let them finish, then run docker compose run --rm migrate
again. An interrupted index build is rebuilt on the next run.
Recover legacy TOTP encryption offline
Section titled “Recover legacy TOTP encryption offline”This procedure is only for stored records that the preflight identifies as legacy-fallback encrypted. It is not general key rotation and cannot recover ciphertext encrypted by a missing private key. Keep the strict startup check enabled.
-
Enter a maintenance window. Stop every application replica, worker, scheduler and other writer using this database, and prevent automatic restarts. For the standard single-app Compose stack, stop
app; additional deployments or maintenance processes must also be stopped. Keep Postgres available. -
Take a consistent private database backup and retain the exact matching old
.env, image and secret references as a rollback pair. Never upload that backup or configuration to a ticket. -
Prepare a private
.env.totp-maintenancecopy with the target release image, a new strong private TOTP key and a nonempty newCODEX_POOLER_TOTP_KEY_VERSION. Preserve the database, upstream encryption key and all other release settings. Before maintenance, privately verify that the database target and unrelated settings match the backed-up installation, and record the old and target image identities. Do not print the resolved Compose configuration: it contains secrets. Save this one destination key securely and reuse it for verification; do not generate a different key after an uncertain result. -
Run the new release as a one-shot maintenance container. The acknowledgement asserts that all writers are stopped; it does not stop them for you.
Terminal window env -u CODEX_POOLER_TOTP_ENCRYPTION_KEY -u CODEX_POOLER_TOTP_KEY_VERSION \-u CODEX_POOLER_IMAGE_TAG -u CODEX_POOLER_IMAGE \docker compose --env-file .env.totp-maintenance run --rm --no-deps \--entrypoint /app/bin/codex_pooler \-e CODEX_POOLER_TOTP_OFFLINE_ACK=all-writers-stopped \migrate eval 'CodexPooler.Release.reencrypt_legacy_totp()' -
While writers remain stopped, verify every stored record with the same destination configuration in a separate invocation.
Terminal window env -u CODEX_POOLER_TOTP_ENCRYPTION_KEY -u CODEX_POOLER_TOTP_KEY_VERSION \-u CODEX_POOLER_IMAGE_TAG -u CODEX_POOLER_IMAGE \docker compose --env-file .env.totp-maintenance run --rm --no-deps \--entrypoint /app/bin/codex_pooler \migrate eval 'CodexPooler.Release.verify_totp_encryption()' -
Only after successful verification, publish that same TOTP key and version to the serving
.envand every other role or replica, run the release migrations, then restart. Check health, readiness and the intended operator login using an owned test account; do not consume real operators’ recovery codes merely to prove a rollout.
The env -u prefixes prevent a previously exported key, version or image from overriding the private maintenance file. Check other exported release settings against the intended database and configuration as well. Re-encryption authenticates all stored TOTP rows before updating them in one transaction. Malformed, mixed-key or wrong-source records refuse the whole operation. The operation changes only encrypted TOTP secrets and their key-version metadata, preserving enrollments, replay state and recovery-code records; verification is read-only. Mutation refuses a running application, so use the one-shot eval, never live RPC. A lock refusal means writers or another transaction still hold the required tables. Keep writers stopped and investigate rather than extending maintenance into a live rolling operation.
If the process or connection is lost around commit, the outcome is unknown. Do not blindly rerun conversion or replace the key: run the read-only verification with the saved destination key. If verification fails, keep the system offline and investigate. A rollback restores the database backup together with its matching old configuration and compatible image; restoring only the key or only the database breaks decryption. A restored legacy deployment is temporary recovery, not a secure final state.
Restore a database backup
Section titled “Restore a database backup”A pg_dump backup leaves out planner statistics unless it was taken with
--statistics, so a restored database has no column statistics until
autovacuum analyzes each table. After restoring with psql or pg_restore,
analyze the database before Codex Pooler serves traffic again:
docker compose exec db sh -c 'vacuumdb --username="$POSTGRES_USER" --dbname="$POSTGRES_DB" --analyze-in-stages'docker compose up -dGraceful stop
Section titled “Graceful stop”When the app container stops, for an upgrade, a restart or docker compose down, it drains first: it refuses new work and lets in-flight turns finish
within the drain budget, CODEX_POOLER_WEBSOCKET_DRAIN_TIMEOUT_MS (50 seconds
unless set in .env). Turns get about the budget minus 10.7 seconds. The
Compose file sets stop_grace_period: 135s on the app, so Docker waits for the
drain and the rest of the shutdown instead of killing the container after its
short default timeout. If you raise the drain budget, raise the app’s
stop_grace_period by the same amount in a Compose override file.
Stop or reset
Section titled “Stop or reset”Stop the stack while keeping the database volume:
docker compose downStop the stack and remove the local database volume:
docker compose down -vOnly use down -v when you intentionally want to remove local Codex Pooler data.
Compose limits
Section titled “Compose limits”Docker Compose is a simple self-host flow, not a high-availability topology. It is best for a single web process and local Postgres.
Backend websocket sessions keep live upstream websocket state inside the web app process. Don’t scale Compose web replicas unless you also design clustering and owner-forwarding for that environment. If you need multiple web replicas, use the Helm chart and review the current chart requirements in that deployment plan.