Skip to content
Latchkey LogoLatchkey home

Docker Conflict. The container name is already in use in CI

Docker Conflict. The container name is already in use means a container already holds the name you passed to --name, and names are unique per daemon whether or not the container holding one is running. The daemon is refusing to create a second container under a name it has already assigned, and it tells you the id of the container that has it.

Diagram of the daemon name table outliving a stopped container across two CI jobs
The name belongs to the daemon, not to the run. A stopped container keeps holding it until something removes it.

What this error means

A docker run --name step fails on the second run of a workflow and not the first, or fails in one job while an identical job elsewhere passes. The name in the message is quoted and carries a leading slash that you did not write, which is the daemon printing its own stored form of the name. The container blocking you is often stopped rather than running, so it does not appear in a plain docker ps and the name looks free when it is not.

Reconstructed from nameConflictError in moby daemon/errors.go at v28.5.2, not a recorded run
docker: Error response from daemon: Conflict. The container name "/postgres-test" is already in use by container "9c4a1f6e2b7d". You have to remove (or rename) that container to be able to reuse that name.

Why the name has a slash in front of it

The leading slash is the first thing people notice and the last thing anyone explains. The daemon stores container names in a namespace whose paths begin at the root, so the name you passed as postgres-test is held as /postgres-test, and the message prints the stored form. The quotes around it are not yours either: the message is built in moby daemon/errors.go, where nameConflictError renders both the name and the blocking container id as quoted Go strings.

That is useful rather than trivia, because it means you can trust the message completely. The name in the quotes is exactly what the daemon has recorded, so a mismatch between it and what you think you passed is itself the bug, and the id in the second pair of quotes is a container you can inspect right now.

Why the name is still takenWhere the container holding it is
A previous job on the same machine did not clean upStopped, on a reused self-hosted runner. Invisible to plain docker ps
An earlier step in this job started it and moved onRunning, in the same workflow. Visible in docker ps
The workflow was rerun after failing mid-wayStopped, from the failed attempt, because cleanup never ran
Two matrix legs share one daemon and one fixed nameRunning, started by the sibling leg moments earlier

Common causes

A container from an earlier job is still registered

The dominant cause on any runner that serves more than one job. The previous job failed, or was cancelled, before whatever would have removed the container ran, and the daemon has held the name ever since. The container is usually stopped, which is why it does not show up in the first place people look.

An earlier step in the same job started it

A setup step starts a database with a fixed name, a later step tries to start it again because it cannot tell whether the first one ran, and the second attempt collides with the first. This is the version that also happens on hosted runners, and it is the only one that does.

Two matrix legs share a daemon and a hardcoded name

Matrix legs that land on the same self-hosted machine run against the same daemon, so a name that is fixed in the workflow file is fixed for all of them. In our experience this presents as intermittent, because it depends on which legs are scheduled together.

An action you call picks the name for you

Some actions start service containers under names of their own choosing. When one of those is left behind, the conflict is between two runs of the action rather than anything in your workflow, and the only fix available to you is to remove the container before the action runs.

How to fix it

Remove the blocking container, using the id in the message

  1. Look for stopped containers too, with docker ps -a, not just docker ps.
  2. Remove by the id the error printed, which is unambiguous even if names repeat.
  3. Then start the container again, and keep the removal in the job so the next run is not manual.
Terminal
docker ps -a --filter name=postgres-test --format '{{.ID}} {{.Names}} {{.Status}}'
docker rm -f postgres-test

Make the name unique per run

Suffix the name with the run id, and the job index when a matrix is involved, so no two runs and no two legs can ever ask for the same one. This is the fix that keeps working when the workflow grows, because it does not depend on cleanup having succeeded.

Terminal
NAME="pg-${GITHUB_RUN_ID}-${GITHUB_JOB}"
docker run -d --name "$NAME" postgres:17
echo "NAME=$NAME" >> "$GITHUB_ENV"

Stop naming containers that nothing addresses by name

If the only reason for --name is that an example had one, drop it and keep the container id instead. The daemon will generate a unique name, nothing can collide, and the id is a better handle for scripts anyway because it is guaranteed unique.

Terminal
CID=$(docker run -d postgres:17)
docker logs "$CID"
docker rm -f "$CID"

Clean up in a step that runs even when the job fails

Use if: always() so teardown happens after a failed test run as well as a passing one. This is the difference between one bad job and every subsequent job on that machine failing the same way until somebody logs in.

.github/workflows/ci.yml
- name: Tear down
  if: always()
  run: docker rm -f postgres-test || true

Hosted runners hide this and self-hosted runners do not

On a GitHub-hosted runner every job gets a fresh virtual machine, so the daemon has no history and a fixed name cannot collide across jobs. That is why a workflow can use hardcoded names for months and then break the week it moves to self-hosted runners, where the same daemon serves job after job and remembers everything that was not removed.

This also explains the reports that involve container jobs and actions that start services. When an action runs docker run --name with a name it chose, and a previous run of that action left the container behind, the collision is between two runs of somebody else's code and there is nothing in your workflow to change. Removing the leftover before the action runs is the only lever you have.

.github/workflows/ci.yml
- name: Clear anything a previous job left behind
  run: docker rm -f postgres-test 2>/dev/null || true

- name: Start it fresh
  run: docker run -d --rm --name postgres-test postgres:17

Unique names beat cleanup, and cleanup beats hope

There are two durable answers and they suit different jobs. If nothing needs to address the container by name, do not give it one: let the daemon generate a name and capture the id, and a collision becomes unreachable rather than handled. If something does need a stable name, such as another container resolving it over a network alias, make the name unique per run by suffixing it with the run id.

Then add the cleanup anyway, with an always-run step, because the case that bites you is the job that failed before its own teardown. --rm covers a container that exits on its own and does not cover a job that was cancelled while the container was still up.

.github/workflows/ci.yml
- name: Start with a name nothing else can hold
  run: |
    docker run -d --name "pg-${{ github.run_id }}-${{ strategy.job-index }}" postgres:17

- name: Always clean up
  if: always()
  run: docker rm -f "pg-${{ github.run_id }}-${{ strategy.job-index }}" || true

What the runner does about it

No repair, and no recorded run. The reason here is that the failure is made of state that a hosted runner does not have: it needs a container left over from a previous job on the same daemon, and our reproduction harness starts each job on a clean machine. To record it we would have to keep a machine alive across two jobs specifically so the first could litter for the second, which reproduces self-hosted runner reuse rather than anything about our runners.

The useful consequence is worth stating the other way around. If your workflow hits this on Latchkey runners, it is not leftover state from yesterday, because there is none; it is an earlier step in the same job that started the container and did not stop it. That narrows the search to the workflow file in front of you.

How to prevent it

  • Prefer generated names and container ids over fixed names in CI.
  • When a stable name is required, make it unique per run and per matrix leg.
  • Put teardown in an if: always() step, not at the end of the happy path.
  • On self-hosted runners, prune stopped containers on a schedule, not by hand.

Frequently asked questions

Why does the container name in the error have a slash in front of it?
Because that is how the daemon stores it. Names live in a namespace rooted at a slash, so postgres-test is recorded as /postgres-test, and the conflict message prints the stored form inside quotes. You do not need to include the slash when you pass the name.
Why does docker ps show nothing when the name is taken?
Because docker ps lists running containers only, and a stopped container keeps its name until it is removed. Use docker ps -a to see it. This is the single most common reason people conclude the error is wrong about the name being in use.
Why did this start happening only after we moved to self-hosted runners?
Because hosted runners give each job a fresh machine with an empty daemon, so a fixed name cannot survive from one job to the next. A self-hosted runner reuses the same daemon, which remembers every container that was not removed, so the same workflow suddenly collides with its own history.
Does --rm prevent this on its own?
Only partly. It removes the container when it exits normally, which covers the ordinary path and not the one that causes the trouble: a job cancelled or failed while the container is still running leaves it behind with the name held. Pair --rm with an if: always() cleanup step.

Related guides

References

Stopped containers do not survive a runner that does not. Latchkey gives every job a fresh one. Start free → 30-day trial · No credit card