Docker port isolation for parallel dev environments
Give each project its own host ports, set by an environment variable with an offset default: Postgres on 5433 or 5434, Redis on 6380, never the standard 5432 and 6379. On 2026-10-21, another project’s Compose stack of 20 containers held both standard ports on my machine. Docker Compose isolates volumes and networks per directory, but not ports.
Two projects, two offsets, one machine
I have two client projects that run Postgres in Docker for local development. Each one moved off the standard port for a different reason.
The first is a time-tracking app that syncs with Jira. Its Compose file has two services, Postgres and Redis. On 2026-06-02 I added the file and set Postgres to host port 5433. Later that same day I added Redis on 6380. Both use a default that a variable can override:
ports:
- "${DATABASE_HOST_PORT:-5433}:5432"
The second is a Shopify app. On 2026-08-26 its design spec said 5432:5432.
The commit that added the Compose file, on the same day, used 5434. The README
gives the reason: “OrbStack on this machine already holds 5432 and 5433”. The
first project had taken 5433 twelve weeks earlier.
So the registry of which project owns which port is my memory, plus one line in each README. Nothing checks it. This holds for two projects. At five projects, I would write the ports down in one place.
A fallback port connects to the wrong service
A port clash does not always fail with “address already in use”. The worse case is a successful connection to a different project’s service.
The time-tracking app’s worker reads REDIS_URL. At first it did not load the
repository’s .env file, so the variable was undefined. The Redis client then
used its default, redis://localhost:6379. On my machine, a different
project’s Redis answered on that port and required a password. The worker
crash-looped with NOAUTH Authentication required. The message points at
credentials, not at the wrong server.
I checked the ports again on 2026-10-21. One Compose project with 20 running
containers holds 5432 and 6379. Any code in any project that falls back to a
default port reaches that stack. The fix had two parts: load .env before
reading REDIS_URL, and move this project’s Redis to 6380. The fallback to
redis://localhost:6379 is still in the worker’s code. If the variable goes
missing again, the worker connects to the same wrong Redis.
CI is different. The GitHub Actions job runs its own Postgres and Redis as service containers on 5432 and 6379, because nothing else runs on that runner. The variable lets one code path use 5433 locally and 5432 in CI.
Compose isolates volumes per directory, not ports
Docker Compose names a project after the directory that holds the Compose
file. I copied the same file into two directories and ran docker compose config in each. Every network and volume name took the directory name as a
prefix, so each directory gets its own network and its own empty volumes. Both
copies still published host ports 5433 and 6380.
That matters for git worktrees. The time-tracking app runs coding agents in an
agent loop that gives each story its own worktree. It copies .env into every
worktree, so each one gets the same two ports. In one run on 2026-08-04, the
loop ran 5 stories, with 11 agent session logs. An agent that runs pnpm db:up
inside its worktree gets a new Compose project, which asks for ports that the
main checkout’s containers already hold. In the logs, one agent removed the
network its worktree had created, and every agent ended up on the main
checkout’s containers. One reviewer session wrote: “The main repo’s containers
already serve Postgres on 5433 and Redis on 6380 — exactly what the isolation
suite expects.”
This did not cause damage, because the loop runs one unit at a time. Its
config says max_parallel = 1 and clamps values above 1. Two agents in two
worktrees at the same time would share one database and one queue.
What a port offset does not cover
The offset solves clashes between projects. It does not solve these four problems:
- Two variables that must agree.
DATABASE_HOST_PORT=5433and the port insideDATABASE_URLare separate lines in.env. The template says to change them together. Nothing fails if they differ. - Worktrees of the same project. Each worktree needs its own port and its
own
COMPOSE_PROJECT_NAME, or it must share the main stack on purpose, with a separate database name and queue prefix for each worktree. Copying.envgives neither. - A fresh worktree has an empty database. A per-worktree stack starts with new volumes, so it needs migrations and seed data before any test runs.
- A default port in code. A fallback such as
?? "redis://localhost:6379"turns a missing variable into a connection to whatever holds that port. A missing variable should stop the process with an error.
For a single checkout per project, the offset default was enough: two lines in
the Compose file and two in .env. For parallel agents, the port is the easy
part. The database each agent writes to is the real design question. In a
different app, I avoided the host port question entirely: that app keeps
SQLite on a Docker volume and publishes no database
port.
Revisions
- Created from two client repositories' Compose files, environment templates and commit history (2026-06-02 and 2026-08-26), an agent loop's run logs (2026-08-04), and the ports and containers listening on my machine on 2026-10-21.
- Published two days after its Oct 19 slot; backfilled by the publishing run because no draft existed for the row.