Gate your app on a Postgres migration job with service_completed_successfully A developer documented a Docker Compose pattern that gates an API service on a one-shot Postgres migration job using `condition: service_completed_successfully`, after an initial deploy failed when the migration container could not resolve the database hostname. The writeup adds a wait loop and a TCP-based `pg_isready` healthcheck to the standard three-service setup, and reports a second rollout completing in 59 seconds. Originally published on the Hubfly blog https://blog.hubfly.space/docker-compose-migration-job-service-completed-successfully?utm source=devto&utm campaign=docker-compose-migration-job-service-completed-successfully . TL;DR What you get: a 3-service Compose file Postgres, a one-shot migration job, an API where the API cannot start until the migration exits 0. Who it's for: anyone whose app has ever booted against a database with a missing table. Time: about 20 minutes. Our second rollout took 59 seconds from click to "Stack deployment finished". The twist: our first deploy failed. The migration job died in 2 seconds. The likely cause: a brand-new container couldn't resolve the database's hostname yet. The fix is a wait loop, and it's in the complete file at the end of Step 3. By Octave Nkurunziza · Drafted with AI, tested on Hubfly on 16 October 2026 You push a release. The API boots, runs its first query, and gets relation "todos" does not exist . The migration ran too, just 3 seconds later. Most Compose files only say depends on: db . That waits for the database container to start, not for the schema. Docker's docs say so plainly: Compose waits until a dependency is running, not until it is ready Docker docs https://docs.docker.com/compose/how-tos/startup-order/ . The fix is well known: a healthcheck on Postgres, a one-shot migration service, and condition: service completed successfully on the app. What the popular guides skip is what happens when the gate itself misfires. We ran this on real infrastructure and it misfired on the first try. This post shows the file that survived. db postgres:16-alpine stage 1 │ healthcheck: pg isready over TCP ▼ condition: service healthy migrate one-shot job, exits 0 stage 2 │ waits for DNS + port, then runs idempotent SQL ▼ condition: service completed successfully api PostgREST, serves /todos stage 3 The API is PostgREST https://postgrest.org , which turns a Postgres schema into a REST API. It's a good test subject because it reads the schema at boot. If the migration hasn't run, there is nothing to serve. Hubfly's composer turned these 3 services into 3 deploy stages. It marked migrate as a Job with restart policy Never , because of restart: "no" . db: image: postgres:16-alpine environment: POSTGRES DB: app POSTGRES USER: app POSTGRES PASSWORD: lab-only-change-me replace before you deploy volumes: - pgdata:/var/lib/postgresql/data healthcheck: test: "CMD-SHELL", "pg isready -h 127.0.0.1 -U app -d app" interval: 5s timeout: 3s retries: 10 Note the -h 127.0.0.1 . Without it, pg isready checks the Unix socket. On first boot, the official image starts a temporary server to create your database. In our logs, that temporary server listened only on the Unix socket. The real server came up afterwards with listening on IPv4 address "0.0.0.0", port 5432 . A socket check could go green during that setup window, so we switched to TCP as a precaution. A TCP check only passes once other containers can actually connect. migrate: image: postgres:16-alpine restart: "no" environment: PGHOST: "{{service:db.host}}" PGUSER: app PGPASSWORD: lab-only-change-me PGDATABASE: app command: - sh - -c - | timeout 60 sh -c 'until pg isready -t 2; do echo "waiting for db"; sleep 2; done' || exit 1 psql -v ON ERROR STOP=1 \ -c "CREATE TABLE IF NOT EXISTS todos id serial PRIMARY KEY, task text NOT NULL, done boolean NOT NULL DEFAULT false " \ -c "DO 'BEGIN IF NOT EXISTS SELECT FROM pg roles WHERE rolname = ''web anon'' THEN CREATE ROLE web anon NOLOGIN; END IF; END'" \ -c "GRANT USAGE ON SCHEMA public TO web anon" \ -c "GRANT SELECT ON todos TO web anon" \ -c "INSERT INTO todos task SELECT 'ship the migration gate' WHERE NOT EXISTS SELECT 1 FROM todos " depends on: db: condition: service healthy Three details matter here. timeout 60 keeps a broken database from hanging the rollout forever; after a minute the job exits 1 and the gate stays closed. ON ERROR STOP=1 . psql happily continues past a failed statement and exits 0. Your gate would open on a half-migrated schema. NOTICE: relation "todos" already exists, skipping and INSERT 0 0 , and exited 0. {{service:db.host}} is Hubfly's service reference. At deploy time it became the database's private hostname, db-1-7befe5b4.eucentral.hubfly.internal . On plain Docker Compose we didn't test this part , replace {{service:db.host}} with the service name db in both places: PGHOST: db and PGRST DB URI: postgres://app:…@db:5432/app . The DO '...' block uses ordinary quotes instead of $$ . That's deliberate: Compose treats $ as variable interpolation, and quoted SQL dodges the whole escaping question. api: image: postgrest/postgrest:v12.2.3 environment: PGRST DB URI: "postgres://app:lab-only-change-me@{{service:db.host}}:5432/app" PGRST DB SCHEMAS: public PGRST DB ANON ROLE: web anon PGRST SERVER PORT: "3000" ports: - "3000:3000" depends on: migrate: condition: service completed successfully volumes: pgdata: service completed successfully means exit code 0, nothing else. If the job exits 1, the API is never created. That's the whole point: a bad migration stops the release instead of shipping it. The anonymous role gets SELECT only. We checked: a POST /todos from the browser returned 401 with permission denied for table todos . For production, have PostgREST log in as a dedicated low-privilege authenticator role instead of the app owner. We kept one user here to keep the file short. Here's the whole file, exactly as we deployed it: services: db: image: postgres:16-alpine environment: POSTGRES DB: app POSTGRES USER: app POSTGRES PASSWORD: lab-only-change-me volumes: - pgdata:/var/lib/postgresql/data healthcheck: test: "CMD-SHELL", "pg isready -h 127.0.0.1 -U app -d app" interval: 5s timeout: 3s retries: 10 migrate: image: postgres:16-alpine restart: "no" environment: PGHOST: "{{service:db.host}}" PGUSER: app PGPASSWORD: lab-only-change-me PGDATABASE: app command: - sh - -c - | timeout 60 sh -c 'until pg isready -t 2; do echo "waiting for db"; sleep 2; done' || exit 1 psql -v ON ERROR STOP=1 \ -c "CREATE TABLE IF NOT EXISTS todos id serial PRIMARY KEY, task text NOT NULL, done boolean NOT NULL DEFAULT false " \ -c "DO 'BEGIN IF NOT EXISTS SELECT FROM pg roles WHERE rolname = ''web anon'' THEN CREATE ROLE web anon NOLOGIN; END IF; END'" \ -c "GRANT USAGE ON SCHEMA public TO web anon" \ -c "GRANT SELECT ON todos TO web anon" \ -c "INSERT INTO todos task SELECT 'ship the migration gate' WHERE NOT EXISTS SELECT 1 FROM todos " depends on: db: condition: service healthy api: image: postgrest/postgrest:v12.2.3 environment: PGRST DB URI: "postgres://app:lab-only-change-me@{{service:db.host}}:5432/app" PGRST DB SCHEMAS: public PGRST DB ANON ROLE: web anon PGRST SERVER PORT: "3000" ports: - "3000:3000" depends on: migrate: condition: service completed successfully volumes: pgdata: pgdata volume, and 2 DEPENDS arrows. The composer's monthly estimate for this stack on the shared tier was api container, open https://