Docker Compose start_interval vs a One-Shot Healthcheck: Containers That Flip Healthy Before Migrations Run
Ivo Markham
August 25, 2026
I have a stack that looks like a textbook. Postgres, an API, a worker, a reverse proxy. depends_on with condition: service_healthy. A healthcheck on the database that runs pg_isready. Compose starts. The API comes up. The first request is a 500 about a missing column that the migration was supposed to add. The dashboard is green. The product is not ready.
That is not a Compose bug. It is a category error. I asked Docker whether a process would accept a TCP handshake. I treated the answer as whether the system could do work. Those questions diverged the day I added a migration that takes longer than a retry window. They diverge harder now that start_interval can poll the cheap check every second during startup and declare victory sooner.
If you only remember one thing: a faster healthcheck is not a better healthcheck. It is a louder one. If the probe is a one-shot liveness sniff, speeding it up just lies faster. The slower version of the same lie is a Compose stack that looks up but is not ready.
What a Compose healthcheck actually measures
A Docker healthcheck is a command inside the container. It returns 0 or it does not. Engine turns that into starting, healthy, or unhealthy. Compose uses that state if you asked it to. Nothing in that pipeline knows what a migration is. Nothing knows whether your ORM’s ready() is the same as “schema version 47 applied.”
The classic knobs are test, interval, timeout, retries, and start_period. During start_period, failed checks do not count toward retries. The container can sit in starting while Postgres replays WAL or while your app compiles a cache. After the period, failures start the death clock.
start_interval is the newer sibling. It is how often to run the probe during start_period. The point is reasonable: if you gave Postgres two minutes to become ready, you should not wait a default thirty seconds between attempts and then wait again. You want to notice the moment it is actually up.
The danger is also reasonable. If “up” means pg_isready, you will notice the moment the postmaster accepts connections. That moment is often before your migrate job finished, especially if migrate is a command in the API image that runs in parallel because you did not make it a separate service.
The one-shot probe people copy from READMEs
Most copied healthchecks are one-shots in spirit even when they loop. pg_isready -U app. redis-cli ping. curl -f http://localhost:8080/health. wget -qO- http://127.0.0.1/ready that hits a handler someone added in ten minutes that returns 200 if the process is alive.
A one-shot probe answers: can I talk to this process right now? That is liveness, or a weak readiness. It is the right check for “restart this container if it wedged.” It is the wrong check for “let the next service start.” Compose cannot tell the difference. You taught it that healthy equals “TCP worked once.”
I still see people put test: ["CMD", "true"] or a shell that exits 0 after a sleep. That is not a healthcheck. That is a timer wearing a lab coat. It will go green on a crashed app if the crash happens after the sleep. It will go green during a migration because you told it to.
The slightly more respectable version is a curl against / that succeeds as soon as the HTTP server binds. Bind happens in the first seconds. Migrations, warmup, connection pools, and “did we load the feature flag file” happen later. Your proxy starts routing. Users see 502s that last exactly as long as the real boot, plus however long it takes you to notice the green lie.

start_interval makes the cheap check worse
Before start_interval, a long start_period with a slow interval meant you might wait an extra twenty seconds after Postgres was accepting connections. That slack sometimes hid a race. The migrate container (or the API’s entrypoint) got a little more time. You thought you were disciplined. You were lucky.
With start_interval: 1s and start_period: 60s, Compose can mark Postgres healthy on the first successful pg_isready. The API’s depends_on trips. The API starts its own migrate, or worse, skips migrate because you run migrate in CI and forgot the homelab path. Or the worker starts consuming a queue whose tables do not exist yet and poison-pills the jobs.
I am not telling you to avoid start_interval. I use it. I use it on probes that mean readiness. A one-second poll of a real ready endpoint is how you get a tight boot without a two-minute nap. A one-second poll of pg_isready is how you recreate the race with better documentation.
If your Engine is old enough that start_interval is ignored, you get the slack back by accident. Do not build a mental model on an ignored field. Check the Engine version on the box that actually runs the stack, not the laptop you use to edit YAML.
Migrations are a different kind of ready
A database that accepts connections is a socket. A database that has your schema is a product. Those converge only if something applies the schema and something waits for that apply to finish and the wait is what you called healthy.
The pattern that stopped lying for me is three services, not two:
- Postgres, with a healthcheck that only means “accepts connections.” That is fine, because nothing user-facing depends on it directly.
- A migrate service: same image as the API, command is the migrator,
restart: "no",depends_onPostgres healthy. Composeservice_completed_successfullyis the gate. - API and workers
depends_onthe migrate service completing, not merely on Postgres being healthy.
That turns the one-shot into the right one-shot. Migrate is supposed to run once and exit 0. Its success is the readiness signal. You do not need a clever probe on Postgres to infer that Alembic finished. You watched Alembic finish.
People skip this because it is another service in the YAML and because local dev wants to start faster. Then they add start_interval to Postgres to start faster anyway, and they get the outage they optimized for.
If you refuse a migrate service, the API healthcheck has to do the work. Hit an endpoint that runs SELECT version FROM schema_migrations and compares it to the version the image expects. Return 503 until it matches. Then start_interval is your friend: you will flip healthy at the first second the schema is real, not thirty seconds later. That endpoint is not optional decoration. It is the only sentence Compose can read.
Liveness and readiness are not a Kubernetes-only sermon
Homelab Compose users roll their eyes at the kube distinction. You still have both problems. Liveness says “restart me, I am wedged.” Readiness says “do not send me traffic, I am not useful.” Docker has one health bit. You must pick which problem you are solving, or you must encode both in one command that is honest about the stricter one.
If you use the same /health for “restart if dead” and “dependents may start,” you will eventually make the check so strict that a slow dependency marks you unhealthy and Compose restarts you in a loop. Or you will make it so loose that dependents start early. There is no third health bit. Split the work: cheap liveness if you must, strict readiness for depends_on, migrate as a completed service when the work is a job.
Redis is the cute version of the same lie. PING works while AOF is still loading if you are not careful about when the server starts answering. Your cache looks healthy. Your app stamps empty keys over a restore you thought you were waiting for. The green light is correct about the protocol and wrong about the data.

What I put in the YAML after I got tired
For Postgres I keep pg_isready. I do not pretend it means schema. I give it a start_period that covers a cold volume on a spinning disk, because my backup restore box is not an NVMe demo. I set start_interval to a few seconds so I do not wait a full interval after the postmaster is up. Nothing except migrate depends on that healthy flag.
For the API I refuse a / probe. The check hits /ready, which verifies the expected migration version and a cheap SELECT 1. Timeouts are short. Retries are few. start_period covers compile-or-warmup if the language needs it. start_interval is aggressive only because the probe is expensive enough to be true.
For one-shot jobs — migrate, seed, “download the GeoIP file” — I do not use a healthcheck. I use exit codes and service_completed_successfully. A healthcheck on a container that is supposed to exit is how you get unhealthy after a successful run, or a restart policy that relaunches a job that already applied.
I also stopped putting curl in images that do not otherwise need it. Distroless and slim tags will fail the probe because the binary is missing, and then someone “fixes” it with CMD-SHELL true. If the image cannot probe itself, add a tiny ready binary or use the migrate-service pattern and probe less.
Uptime tools will bless the same lie
Uptime Kuma on the published port will go green the moment the proxy has a backend that returns 200. If that 200 is the loose liveness page, you have exported the Compose mistake to your phone. I point external checks at the same /ready I use internally, or I accept that the public check is only “the internet can reach me.” I do not let a public ping decide whether I migrate.
Healthchecks.io is for cron. It will not save a boot race. People add a ping at the end of an entrypoint and then use start_interval to rush dependents. The ping fires when the entrypoint thinks it is done. If the entrypoint’s idea of done is “HTTP bound,” you built a distributed one-shot.
When a one-shot is the right tool
A one-shot is correct when the work is a job: migrate, backup, certificate issue, “load fixtures for the test stack.” The success condition is exit 0. The waiter is Compose’s completed condition, or a wrapper that will not start the API until the job finishes. Speeding up probes on the database does not replace that waiter.
A repeating healthcheck is correct when the work is a process that should stay up. Then you decide if the process being up is enough. For a static file server, it is. For anything with a schema, a cache restore, or a config fetch, it is not.
start_interval is correct when the repeating check is the expensive truth and you want to notice it quickly during boot. It is incorrect as a way to make a cheap check feel modern.
The decision I actually make on a new stack
If the stack has a database and an app that owns schema, I add a migrate service first, before I tune intervals. If I cannot add it — some vendor images will not let you — I put the strict ready probe on the app and I let Postgres be merely “accepts connections.” I never let the app depend only on pg_isready plus hope.
If I am tempted to set start_interval: 1s on a curl / probe, I treat that as a smell. I either write /ready or I slow the interval back down and admit I am using slack as a mutex. Slack is a mutex that breaks the first time the disk is busy.
Docker will keep adding knobs that make startup feel tighter. None of them will learn your migration. The green state is a bit. You get to choose whether that bit means “socket” or “product.” A one-shot that means socket, polled every second, will flip healthy before the product exists. That is not a mystery. That is the spec doing what you asked.
Ask for the product. Then poll as fast as you want.