DEV Community

Cover image for The Compose Race Your Coding Agent Inherits From You
Kunal Das
Kunal Das

Posted on

The Compose Race Your Coding Agent Inherits From You

Hero artwork: a robotic arm stamping green checkmarks on a conveyor belt of identical generated compose files, the finished stack toppling into a wall labeled connection refused

Ask a coding agent for a Docker Compose file with Postgres, then cold-boot the result on a machine that has never run it. There is a good chance the app container starts, immediately asks for a database that does not exist yet, and fails its first connections. Wait a few seconds, try again, and everything works. The agent will tell you it tested the file. Both things are true, and the bug is older than the agent: it inherited the race from the tutorials it was trained on, and it also inherited the half-fix that looks like the fix.

Docker Compose is a tool for defining and running multi-container applications from one YAML file. I tested the generated shape, the half-fixed shape, and the corrected shape on my own host this week: three cold starts on fresh volumes (Docker Engine 29.1.3, Compose v5.1.4), the inherited race, the inherited half-fix that still races, and the three-line correction that actually holds. Everything below is from that run, with the run methodology and the timestamps as captured.

Key Takeaways

  • A bare depends_on: list only orders container startup. Compose explicitly does not wait for "ready", only "running" (Docker docs).
  • The most-copied Postgres healthcheck, pg_isready -U postgres, probes the Unix socket, and during first-boot initialization a temporary Postgres server answers on that socket before the real one is up (docker-library/postgres issue #146).
  • In my run, that socket-probe healthcheck reported the database healthy 4 seconds before Postgres actually accepted a TCP connection. The gate was wired correctly. The probe lied.
  • The fix is three edits: condition: service_healthy, restart: true, and -h 127.0.0.1 on the probe. My corrected file connected on attempt one, on a cold volume.

In this post:

What does the agent actually write?

Two lines date a compose file, and agents write both of them together.

The first is the bare dependency list. In the long form of depends_on, per the compose-file services reference, this same key takes a condition instead, which is the part the 2021 corpus never learned:

services:
  app:
    depends_on:
      - db
Enter fullscreen mode Exit fullscreen mode

The second is the socket-probe healthcheck on the database. healthcheck is a container configuration that makes the runtime probe the service at a fixed interval and report a health status:

  db:
    image: postgres:16-alpine
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
Enter fullscreen mode Exit fullscreen mode

Neither line is broken on its own. The first one does exactly what it says: start db before app. The second one does exactly what it says: exit 0 when something answers on the Postgres Unix socket. The problem is what they don't say, and they cluster in generated files because they cluster in the training corpus. Both come from the same generation of quick-start guides, the ones that shipped years before the long-form depends_on with conditions was common knowledge.

The evidence that this is a corpus problem, not a reasoning problem, is now measured. Work on updating model knowledge after API changes found that models continue generating outdated API calls "even with access to current documentation" (arXiv:2506.20495). A study of deprecated-API recommendations reports that 37.4 percent of GPT-3.5's API predictions were deprecated, and that fixing this by retraining is impractical because library APIs change too fast (arXiv:2511.21022). Compose YAML is not a Python library call, but the mechanism transfers exactly: the spec moved, the tutorials didn't, and the average of the corpus is the 2021 shape.

Diagram of the inheritance pipeline: dated tutorial books flow into a glowing model core that outputs a compose file card, which feeds a container stack where a race condition plays out

Why does the race survive a working demo?

Because a warm volume hides it.

Postgres only does its slow first-boot work when the data directory is empty. The entrypoint script runs initdb, starts a temporary server for the setup phase, runs anything mounted in /docker-entrypoint-initdb.d/, and only then hands over to the real server as PID 1. On a volume that has already been initialized, the container skips all of that, and the gap between "container running" and "accepting connections" shrinks to near zero. Whoever verifies the compose file, human or agent, usually verifies it on a warm volume. The demo passes. The agent's confidence is not fake; the test genuinely passed on the machine it had.

The first cold start is the one that pays for it: CI runner, teammate's laptop, fresh VM. That is the boot where the app container starts a beat after the database container and asks a half-initialized Postgres for a connection.

Here is the output of that exact cold start from my run, an alpine app probing db:5432 every half second in a loop. Consider it the shape of every "it works on the second try" bug report you have ever closed:

app-1  | ATTEMPT 1: CONNECTION-REFUSED
app-1  | ATTEMPT 2: CONNECTION-REFUSED
app-1  | ATTEMPT 3: CONNECTION-REFUSED
app-1  | ATTEMPT 4: CONNECTION-REFUSED
app-1  | ATTEMPT 5: CONNECTED
Enter fullscreen mode Exit fullscreen mode

Two seconds of refused connections on a fast local disk. The database log tells you why: the "ready to accept connections" line lands at 08:10:28, after the app has already been asking. On a slower disk, or with init scripts in /docker-entrypoint-initdb.d/, the window stretches to seconds more. If your app has restart: unless-stopped, Compose quietly restarts it until it sticks, the stack comes up, and the race is invisible again until the morning it crash-loops.

I documented the anatomy of this race in the healthcheck deep-dive earlier in this series. The part that is new here is where the YAML came from, and the fact that the obvious fix, asking the agent to add a healthcheck, often doesn't fix it.

What did I actually run?

Three cold starts on my own host: Docker Engine 29.1.3, Docker Compose v5.1.4, with alpine:3.20 as the app and postgres:16-alpine as the database. Every run started from a deleted volume, so Postgres went through its full first-boot initialization each time. The app is deliberately dumb, a shell loop that tries db:5432 every half second and prints the result, so the timing is visible and nothing else can mask it:

services:
  app:
    image: alpine:3.20
    depends_on:
      - db
    command: >
      sh -c 'i=0;
      while [ $$i -lt 30 ]; do
        i=$$((i+1));
        if nc -z -w 1 db 5432 2>/dev/null; then
          echo "ATTEMPT $$i: CONNECTED";
          exit 0;
        else
          echo "ATTEMPT $$i: CONNECTION-REFUSED";
        fi;
        sleep 0.5;
      done;
      echo "GAVE UP"'
Enter fullscreen mode Exit fullscreen mode

For the third run, to make the initialization window wide enough to observe, I mounted a one-line init script into /docker-entrypoint-initdb.d/ that sleeps four seconds. The sleep does not create the gap, it only stretches the same initialization phase that already existed in the first run, and every timestamp below comes from docker inspect and the Postgres log, not from my watch. The three runs, all from the same host:

Run File shape Outcome
1 bare depends_on list 4 refused connections, connected on attempt 5
2 service_healthy + TCP probe connected on attempt 1, app held until Healthy
3 service_healthy + socket probe "healthy" 4s early, 7 refused connections after the verdict

Why doesn't the copied healthcheck fix it?

Here is where the inherited fix becomes worse than the inherited race. Ask an agent to fix the startup race and it will, correctly, reach for the long-form dependency. service_healthy is the depends_on condition that holds the dependent container back until the dependency's healthcheck reports healthy:

  app:
    depends_on:
      db:
        condition: service_healthy
Enter fullscreen mode Exit fullscreen mode

That is the right gate. Compose will not start the app until the database's healthcheck reports healthy. But the healthcheck it copies, the same one from the same tutorials, is pg_isready -U postgres, and pg_isready without a host flag connects to the Unix socket. pg_isready is the Postgres client's readiness probe; it exits 0 when a server accepts connections. On the official Postgres image, first boot runs a temporary server for initialization that listens on the Unix socket only. The probe passes while the real server, the one your app connects to over TCP, is not up yet. The maintainers have been explaining this in issue #146 for years: "Simply adding -h 127.0.0.1 to the pg_isready command line should be enough to detect readiness correctly."

I ran that combination, the correct gate with the copied probe, and watched docker inspect while it happened. The healthcheck went starting at +0.78 seconds and healthy at +3.02 seconds. The real server accepted its first TCP connection at +7.06 seconds. Four seconds of lie. Compose trusted the verdict, started the app, and the app did this:

app-1  | ATTEMPT 1: CONNECTION-REFUSED
app-1  | ATTEMPT 2: CONNECTION-REFUSED
app-1  | ATTEMPT 3: CONNECTION-REFUSED
app-1  | ATTEMPT 4: CONNECTION-REFUSED
app-1  | ATTEMPT 5: CONNECTION-REFUSED
app-1  | ATTEMPT 6: CONNECTION-REFUSED
app-1  | ATTEMPT 7: CONNECTION-REFUSED
app-1  | ATTEMPT 8: CONNECTED
Enter fullscreen mode Exit fullscreen mode

Seven refused connections after the container was declared healthy. The probe log shows the mechanics, and it is worth reading slowly because it is the whole post in three lines:

08:13:52.4  exit 0  /var/run/postgresql:5432 - accepting connections
08:13:54.5  exit 0  /var/run/postgresql:5432 - accepting connections
08:13:56.5  exit 2  /var/run/postgresql:5432 - no response
Enter fullscreen mode Exit fullscreen mode

The first two probes passed against the temporary initialization server, on the socket. Then the entrypoint shut that server down to finish init work, and the third probe failed, no response, while the real server was still seconds away. The status even flipped to unhealthy for a moment during the handoff. If the failure had landed one probe later, after the container was already marked healthy, the streak would have needed three consecutive failures to matter, and by then the app had long since started and crashed.

This is the failure mode that makes people conclude the healthcheck pattern "doesn't work" and delete it. The gate worked. The probe answered a different question than the one the app asks.

What does the fixed file look like?

Three edits to the generated file, and my run 2 output after them:

 Container racefix-db-1  Started
 Container racefix-db-1  Waiting
 Container racefix-db-1  Healthy
 Container racefix-app-1 Starting
 Container racefix-app-1 Started
app-1  | ATTEMPT 1: CONNECTED
Enter fullscreen mode Exit fullscreen mode

The app is not created until db reports Healthy, and its very first connection lands. The full file:

services:
  app:
    image: alpine:3.20
    depends_on:
      db:
        condition: service_healthy
        restart: true
    command: >
      sh -c 'i=0;
      while [ $$i -lt 30 ]; do
        i=$$((i+1));
        if nc -z -w 1 db 5432 2>/dev/null; then
          echo "ATTEMPT $$i: CONNECTED";
          exit 0;
        else
          echo "ATTEMPT $$i: CONNECTION-REFUSED";
        fi;
        sleep 0.5;
      done;
      echo "GAVE UP"'

  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_PASSWORD: example
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -h 127.0.0.1 -U postgres"]
      interval: 2s
      timeout: 3s
      retries: 10
      start_period: 10s

volumes:
  pgdata:
Enter fullscreen mode Exit fullscreen mode

The three edits, one line each. condition: service_healthy makes Compose wait for the healthcheck verdict instead of container start, the gate. restart: true re-runs that gate when you explicitly restart the database, so the app is not left pointing at stale connections after a docker compose restart db. And -h 127.0.0.1 forces the probe onto TCP, where the temporary initialization server cannot answer it, the honest probe. The timing knobs around it matter too, start_period especially: they give a slow first boot room to finish before failures start counting. On a cold volume my database needed about seven seconds, so 10 seconds of grace is the floor I would ship, and CI on shared runners gets more.

Keep the application-side retry anyway. The gate protects the startup sequence only. If Postgres restarts two hours later, nothing re-runs the gate for you, a limit I pushed on in the exit-0 OOM post from the other angle: a green healthcheck reports its own probe, not your app's reality.

How do you review agent YAML in 60 seconds?

You do not need to run the stack to catch this class of bug. Read the diff against the current spec, the same way you would review a human's PR.

Check the dependency wiring first. A bare depends_on: - db list means no waiting; it needs the long form with condition: service_healthy to gate startup. Check the probe next, because this is the part everyone trusts. pg_isready with no -h flag is a socket probe; on the Postgres image that can pass during initialization. It needs -h 127.0.0.1 (or the service name, if you want the probe to mirror app traffic) to test the path your app actually uses. Check the timing budget after that: no start_period means a slow first boot spends its failure budget before the database had a chance. CI runners are slower than your laptop, so give the budget headroom there. And check what happens after startup: no restart: true on the dependency means a deliberate database restart leaves the app on stale connections.

Then fix the input, not just the output. The prompt that produces current YAML is a prompt with current references in it. Paste the startup-order doc and the services reference links into the request, and say "gate the app on service_healthy with a TCP probe" instead of "write me a compose file with Postgres". The model still carries the old corpus, and it will still try the 2021 shape when you let it guess. But it does not argue with a pasted spec. The measured version of this instinct is the ReCode result: outdated API knowledge persists "even with access to current documentation" (arXiv:2506.20495). The documentation you paste is doing real work, and the review pass you run after is doing the rest.

One more habit that pays for itself: cold-boot the result before you merge it. On a scratch project, docker compose down -v && docker compose up --wait is a 30-second test. It reproduces exactly the run that fails in CI, and --wait turns the healthcheck into the pass/fail signal instead of a grepped log line.

FAQ

Doesn't restart: unless-stopped already handle this?
It hides it. The app crash-loops until the database happens to be ready, then looks healthy. The race is still there; you just stopped being able to see it.

Does the long depends_on form without condition wait for readiness?
No. Without condition: service_healthy it is equivalent to the short list: ordering only.

Is docker compose up --wait enough for CI?
Yes, and it is the right tool: it blocks until healthchecked services report healthy. It does not remove the need for the gate on the app service itself, or for an honest probe.

Do I still need app-level retries if the gate works?
Yes. The gate runs at startup only. A connection that drops an hour later is the application's problem, and the same retry-with-backoff loop you already write for production networks covers it.

Where does this leave the agent?

The agent is not wrong that the file works. It worked on the machine it had, with the volume it had, and the corpus it was trained on says that file is what a compose file looks like. The spec moved, the tutorials didn't, and the average of all that 2021 YAML is what comes out when you ask for 2026 infrastructure. The three-line diff between the inherited shape and the current one is small, it is mechanical, and it is exactly the kind of review you were going to do anyway on a file nobody typed by hand.

What is the oldest line of config an agent has handed you that you only caught because it failed somewhere strange? I am collecting these for a follow-up, and the comments are where I will read them.

Verified against Docker Engine 29.1.3 and Docker Compose v5.1.4 on 2026-10-29, with three cold-start runs on fresh volumes; every timestamp and probe result above is from docker inspect or the Postgres log of those runs. Claims about model behavior are cited to the linked papers, and anything I could not reproduce myself is not in this post.

Sources

Every claim above traces to these primary sources. Where I could not verify something hands-on, the post says so.

About the author

Kunal Das writes operations engineering notes as he learns them, always against primary sources. Contact: GitHub and Dev.to. Follow the Docker tag for the next posts in this series.

Top comments (0)