How would you design a CI pipeline step that runs `docker compose up` for integration tests without leaking containers, networks, or ports between concurrent pipeline runs?
Quick Answer
Give every pipeline run a unique Compose project name (via -p or COMPOSE_PROJECT_NAME) so its containers, networks, and volumes never collide with a concurrent run on the same CI host, and always pair `up` with a guaranteed `down -v` teardown even on test failure. Binding to ephemeral host ports instead of fixed ones avoids port collisions when multiple pipeline jobs run in parallel on shared runners.
Detailed Answer
Picture a hotel that reuses the same room number for every guest without checking anyone out first — the moment two guests are assigned room 204 at overlapping times, someone's luggage gets mixed up with someone else's. That's exactly what happens when a CI system runs docker compose up for two concurrent pipeline jobs on the same runner without namespacing them: by default, Compose derives its project name from the current directory name, so two checkouts of the same repo running in parallel both try to create a network and containers named after the same project, and the second job either fails outright or, worse, silently reuses the first job's still-running containers and gets contaminated test data.
Compose's project-name mechanism exists specifically to let multiple independent instances of the same stack run side-by-side without any manual coordination — but it only helps if you actually set a unique name per run instead of relying on the default directory-derived one, which is identical across every checkout of the same repo. CI systems are the textbook case where you want deliberately unique namespacing every single run, because the whole point of CI is parallel, disposable, repeatable execution, and 'repeatable' breaks the instant one run's leftover state can bleed into the next.
Internally, when you pass -p <unique-name> or set COMPOSE_PROJECT_NAME, every resource Compose creates — the bridge network, named volumes, and container names — gets prefixed with that project name instead of the directory name. This means job A's <project-a>_default network and job B's <project-b>_default network are two entirely separate Docker networks even though both were built from the identical docker-compose.yml file, and their containers, despite having the same service names internally (both have a service called 'postgres-db'), get different actual container names like project-a_postgres-db_1 and project-b_postgres-db_1. This is what makes true parallel execution on a shared runner safe.
At CI scale, the two things that still bite teams are host port publishing and leftover volumes. If your compose file hardcodes a fixed host port mapping like 5432:5432, two parallel jobs will still collide on the host's port 5432 regardless of project name, because published ports are a Docker-daemon-wide resource, not scoped per project — the fix is binding the host side to 0 (Docker picks a free ephemeral port) and having your test suite read the actual assigned port via docker compose port postgres-db 5432 rather than assuming 5432. The other recurring issue is teardown: a test failure that causes the pipeline step to exit early without running docker compose down -v leaves orphaned containers and volumes accumulating on the runner until disk fills up, so the teardown must run in a finally-equivalent (a post-step or trap) regardless of whether the tests passed.
The gotcha: docker compose down without -v deletes containers and networks but leaves named volumes behind, so a 'clean' teardown that forgets -v still accumulates orphaned Postgres data volumes run after run, and after enough CI runs the runner's disk fills up in a way that looks like a random, unrelated Docker daemon failure rather than an obvious volume leak — engineers often burn hours looking at the wrong layer before realizing docker system df shows dozens of forgotten anonymous volumes.