DEV Community

Cover image for Your coding agent is bad at Docker, and it isn't the agent's fault
Alexandre Oliveira
Alexandre Oliveira

Posted on Originally published at linkedin.com

Your coding agent is bad at Docker, and it isn't the agent's fault

I let a coding agent run my local stack for a few months. It writes the compose file, brings the services up, reads the logs, fixes the bug, and checks the fix worked.

It's good at most of that. Docker is where it struggles every time, and after a while I stopped blaming the model. The problem is Docker's interface, which was designed for a person sitting at a terminal. That person can open a browser and has some context about the machine. They also notice when a log repeats itself. An agent gets stdout once the process exits and pays for every byte of it.

Here are the five places where it breaks, with the workaround I use for each using plain docker.

1. "port is already allocated" names no culprit

Error response from daemon: driver failed programming external connectivity:
Bind for 0.0.0.0:8080 failed: port is already allocated
Enter fullscreen mode Exit fullscreen mode

The daemon knows the bind failed but has no idea who holds the port. The operating system knows. An agent that sees this error usually guesses: it changes the port in the compose file, or restarts the engine, or both. Then you end up with a stack on 8081 and nobody remembers why.

On Windows the answer takes two lines:

$c = Get-NetTCPConnection -LocalPort 8080 -State Listen
Get-Process -Id $c.OwningProcess | Select-Object Id, ProcessName, Path
Enter fullscreen mode Exit fullscreen mode

On Linux or macOS, lsof -iTCP:8080 -sTCP:LISTEN does the same job.

The fix is to put this in the agent's instructions as the first step after a bind failure, before it is allowed to edit any port. "Find out who holds it" is a rule the agent can follow. "Use judgment" isn't.

2. docker ps and docker rm -f -v look the same to an allowlist

Agent tools ask permission before running shell commands, and an allowlist removes the prompt for the safe ones. The trouble is that Docker doesn't split commands into reads and writes. Every verb hangs off the same binary, so you have two bad options:

  • allow docker *, which also allows docker volume rm and docker system prune -a --volumes
  • approve every call by hand, and after the fortieth docker ps you stop reading the prompts

You can allowlist subcommand by subcommand (docker ps, docker logs, docker inspect, docker images), and that's what I do. It's still a list you maintain, though, and there are holes in it. docker exec can be a read or a write depending on what it runs. docker compose mixes ps and down -v under one prefix. A global flag before the subcommand (docker --context prod rm ...) gets around a naive prefix rule.

My workaround: allowlist only the pure reads, and accept that every write gets a prompt. That's the place where you want the prompt anyway.

3. Logs are the biggest token sink in the session

A container in a crash loop writes the same stack trace on every restart. Eight restarts means eight copies, and docker logs api hands the agent all eight. Most of what it reads in a debugging session is repetition.

--tail cuts the size but also cuts the part you needed, because the first error is usually at the top. uniq doesn't help, since repeated traces aren't adjacent lines.

What works best for me is to keep the log out of the context entirely:

docker logs api > .agent/api.log 2>&1
grep -n -i -E "error|exception|fatal" .agent/api.log | head -40
Enter fullscreen mode Exit fullscreen mode

A file on disk costs nothing. The agent pays only for the lines grep returns, and it can go back for context around a specific line number. Put this in the instructions as the default way to read logs, and keep docker logs without redirection for short-lived containers.

4. "running" is not "answering"

docker ps says Up 2 minutes. The agent concludes the fix worked. Then you open the browser and get connection refused, because the process inside bound 127.0.0.1 instead of 0.0.0.0, or crashed after startup, or is still running migrations.

Up to that point, the expensive step in the loop was me: open the browser, look, report back. The agent can't close that gap if all it checks is the container state.

Give it an actual readiness check with a deadline:

timeout 60 bash -c 'until curl -sf http://localhost:8080/health; do sleep 2; done' \
  && echo READY || echo "NOT ANSWERING after 60s"
Enter fullscreen mode Exit fullscreen mode

And when it fails, read what the healthcheck itself said instead of guessing:

docker inspect --format '{{json .State.Health}}' api
Enter fullscreen mode Exit fullscreen mode

The healthcheck log usually says ECONNREFUSED 127.0.0.1:8080 in plain words, and that's the whole diagnosis.

5. Cleanup can't tell your work from the agent's

After an afternoon of reproducing a bug, the agent has created six containers, two networks and a volume. When you want them gone, the only tool on offer is prune, which works on the whole machine. It can't tell the agent's scaffolding from the Postgres volume you've been filling since March. So nobody delegates prune, and the scaffolding piles up.

Labels fix this, provided they're applied at creation time:

docker run --label agent.session=repro-17 ...
# compose: add `labels: { agent.session: repro-17 }` to each service
Enter fullscreen mode Exit fullscreen mode

Then the cleanup is scoped:

docker ps -aq --filter label=agent.session=repro-17 | xargs -r docker rm -f
Enter fullscreen mode Exit fullscreen mode

Leave volumes out of that line on purpose. A container comes back from its image, but a deleted volume is gone for good.

The catch is that the agent has to remember to add the label every time, and the one it forgets is the one that bites you later.

The pattern behind all five

Each of these is a join Docker doesn't do: the daemon's view and the OS's view (ports), the command and its intent (allowlists), the raw log and what's new in it (dedup), the container state and the service's behavior (readiness), the object and who created it (labels).

A person does those joins in their head without noticing. An agent does them with extra tool calls, which you pay for, or it skips them and guesses.

None of the workarounds above need anything beyond plain docker and a few lines in your agent's instructions file. Start with #3 and #4, since those two gave me the biggest drop in wasted turns.

I wrote these wrappers in too many projects, and on Windows I ended up putting them into a small CLI with a read/do split that an allowlist can actually express. It's part of FreeWilly, if you're on Windows and want them prebuilt. The patterns work the same without it.

What's the Docker failure your agent hits most? I'd like to know which ones I missed.

Top comments (0)