# Docker port isolation for parallel dev environments

Give each project its own host ports, and make them an environment variable with an offset default: Postgres on 5433 or 5434, not 5432. On my machine another project's 20 containers hold 5432 and 6379, and a worker that fell back to 6379 crash-looped against the wrong Redis. Compose isolates volumes per directory, not ports, so git worktrees of one project still collide.

Published: 2026-10-21
Canonical: https://umar.codes/docker-port-isolation

## Revisions

- 2026-10-21 — 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.
- 2026-10-21 — Published two days after its Oct 19 slot; backfilled by the publishing run because no draft existed for the row.

---

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:

```yaml
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=5433` and the port
  inside `DATABASE_URL` are 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 `.env`
  gives 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](/sqlite-in-production) and publishes no database
port.
