Every Compose file I have written for a real app has the same two services in it that aren't really services. One runs the migrations. One seeds the database. They exist because something has to run before the app and Compose had no way to say that, so you fake it with depends_on and a condition and hope nobody runs docker compose ps -a.
Compose 5.3 shipped pre_start in July. It is the thing I have been faking for years, so I spent an evening on it with a Postgres 18 container and a small Node app, and tried to break it in the ways I would actually break it.
What it replaces
The pattern most of us ended up with looks like this, trimmed down:
services:
migrate:
image: postgres:18
depends_on:
db: { condition: service_healthy }
command: ["psql", "$DATABASE_URL", "-f", "/migrations/001_init.sql"]
seed:
build: .
depends_on:
migrate: { condition: service_completed_successfully }
command: ["node", "seed.js"]
app:
build: .
depends_on:
seed: { condition: service_completed_successfully }
It works. It also leaves two exited containers lying around after every start, and I had forgotten quite how leaky it is until I ran it again and watched the second docker compose up -d re-run the migrate and seed services, because to Compose they are just stopped services that should be running. My seed script had been written to be idempotent for exactly this reason, and I had stopped noticing that was a workaround.
The new shape
The same thing as steps on the service that needs them:
services:
app:
build: .
depends_on:
db: { condition: service_healthy }
environment:
DATABASE_URL: postgres://postgres:demo@db/postgres
volumes:
- ./migrations:/migrations:ro
pre_start:
- image: postgres:18
command: ["psql", "postgres://postgres:demo@db/postgres", "-v", "ON_ERROR_STOP=1", "-f", "/migrations/001_init.sql"]
- command: ["node", "seed.js"]
environment:
SEED_ROWS: "5"
Each step is a container that runs to completion, in order, and the service only starts once the last one exits 0. A step can use its own image, which is how the migration runs psql from the Postgres image while the app is a Node image, and a step without an image uses the service's. Steps get the service's network, environment and mounts, which is why the migrations bind mount on app is visible to a step running a different image. depends_on is honoured before the first step runs, so the migration finds a healthy database.
After up:
SERVICE STATE
app running
db running
That is ps -a, not ps. There is nothing to hide any more.
Where the steps went
They are quick, and they are anonymous. I had docker events running during a recreate:
1788867636 create cranky_clarke image=postgres:18
1788867636 destroy cranky_clarke image=postgres:18
1788867636 create crazy_bohr image=new-app
1788867637 destroy crazy_bohr image=new-app
Two containers with random names, each alive for under a second, gone before docker ps -a could catch them. Cold start with --wait, three runs each:
no steps 3.9 s
two pre_start steps 5.2 s
old pattern 6.7 s
So about 0.65 s per step here, and still faster than the two-service version, which pays for its own dependency waits.
When they run again
This is the part I actually cared about, because a migration that runs when I don't expect it to is worse than one I have to run by hand. The seed step writes a row each time it runs, so I could count:
docker compose up -d no rerun
docker compose restart app no rerun
docker compose up -d --force-recreate app both steps ran
change SEED_ROWS in the step definition both steps ran
docker compose up -d --scale app=3 ran once, not three times
Which matches the spec: steps re-run when the service container is recreated, when the step definition changes, or when the previous run failed. A plain up on a running stack does nothing, which is exactly what the old pattern got wrong.
The scaling one has a consequence. Steps run once per service, not per replica, and the spec has a per_replica: true flag for the other case. Compose 5.5 accepts it in config and rejects it at up:
service "app" pre_start[1]: per_replica is not yet supported; remove per_replica or set it to false
Fine, but note where that error arrives: after the database has come up healthy, not when the file is parsed. Also note what once-per-service means for storage. Anything a step writes to a tmpfs or an anonymous volume belongs to nobody, because the container that wrote it is already gone. Write to a named volume or a bind mount or don't bother.
When a step fails
I made the seed step exit 3:
service "app" pre_start[1] exited with code 3 (hook container f66e72377c81 retained for inspection)
up -d returns 1. The app stays in Created and never starts. A third service depending on the app stays in Created too. The failed step's container is kept, labelled with the project, and docker logs f66e72377c81 shows the step's output. docker compose down removes it along with everything else, and the next up runs the step again rather than assuming it passed. I could not find anything wrong with any of that.
The one thing I don't like
When a step succeeds, its output goes nowhere.
Not in the up output. Not in up without -d, attached. Not with --progress plain. Not with --verbose. Not in docker compose logs, because the container that produced it was deleted a second after it exited. The only time you see what a step printed is when it fails.
If your migration tool prints "applied 3 migrations, 2 pending" that line is gone. If your seed script prints which environment it thinks it is seeding, gone. The two things I would most like to have in a terminal when something is subtly wrong are the two things that only appear when something is loudly wrong. My workaround was to have the steps write a row to a table, which is how I counted the reruns above, and it is not a workaround I would want to explain to someone.
My guess is this gets fixed, because keeping the hook container around for a bit, the way the failed one already is, doesn't sound like much work. Until then it's the reason half my migrations are still on the old pattern.
What I got wrong on the way
Two things, both mine. I scaled the service to three with a host port still bound on it and got "port is already allocated", then briefly wondered if scaling and pre_start didn't mix. They do. Ports and scaling don't.
Then per_replica. I piped the up output through a grep, the grep ate the error line, I saw three containers appear and wrote down "silently ignored". Compose had said no in plain English one line above the part I kept. Third post in a row where the harness was the bug, so I'll stop pretending that's a coincidence.
Run it yourself
You need Compose 5.3 or newer. Docker Desktop has shipped it since July, and docker compose version will tell you where you are. One database, one service, one step:
services:
db:
image: postgres:18
environment: { POSTGRES_PASSWORD: demo }
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 1s
retries: 30
app:
image: postgres:18
depends_on:
db: { condition: service_healthy }
command: ["sleep", "infinity"]
pre_start:
- command: ["psql", "postgres://postgres:demo@db/postgres", "-c", "CREATE TABLE IF NOT EXISTS hello (at timestamptz DEFAULT now()); INSERT INTO hello DEFAULT VALUES;"]
docker compose up -d --wait
docker compose up -d --wait # again: no new row
docker compose up -d --wait --force-recreate app # new row
docker compose exec db psql -U postgres -c "SELECT * FROM hello"
Everything in this post ran on Compose v5.5.1 and Engine 29.7.2, with Postgres 18 and Node 22, on the same laptop as the last two posts.
What I would take from this
I moved the migrations and I'm leaving them there. The rerun rules are the ones I would have designed, a failed step stops the app and everything behind it, and the two exited containers I have been scrolling past in ps -a for years are gone. I kept the scripts idempotent, because a recreate runs them again and I recreate more often than I'd admit.
The seed step is staying on the old pattern for now, purely because I want to read what it printed. When step logs survive a successful run, that moves too.
Top comments (0)