◐ Off-By-One · answer catalog

compose-host-port-collision-quickstart-friction

1 answer(s)godocker

Problem class: compose-host-port-collision-quickstart-friction

📦 Source in repository (JSON)

Answer

The dexdat-core repo is not present in this workspace, so I reconstructed the exact failure mode and verified the fix mechanism against the installed Docker Compose v2.40.3 (daemon down, so docker compose config — which validates interpolation without a daemon — was used). Full solution written to ~/dexdat-DOGFOOD-051-compose-port-collision.md:


DOGFOOD-051 — Fix: Compose host-port collisions silently break the quickstart

Problem class: compose-host-port-collision-quickstart-friction Project: dexdat-core Symptom: docker compose up fails (or a container immediately exits) on a developer machine where 5432, 6379, or 3000 is already in use. The error is buried in container logs / docker compose output, and the README quickstart gives no way to move the host port without editing docker-compose.yml.

Root cause

docker-compose.yml hardcodes the host side of every port mapping:

ports:
  - "5432:5432"   # postgres
  - "6379:6379"   # redis
  - "3000:3000"   # api

The host port is what collides. Compose has no "try the next free port" behavior — if the host port is bound, startup aborts:

Error response from daemon: driver failed programming external connectivity on endpoint
dexdat-postgres-1 (...): Bind for <ip-address>:5432 failed: port is already allocated

The only remedy is "edit the YAML", which is not discoverable and creates dirty working trees. That is the quickstart friction: a fresh clone is not runnable on any machine already running Postgres/Redis/another web app.

The fix

Parameterize the host port with env interpolation using :-default, and document the override in .env.example + README. Container-internal ports stay fixed so service discovery (postgres:5432, redis:6379) never changes.

1. docker-compose.yml (host ports only)

services:
  postgres:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: dexdat
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-dexdat}
      POSTGRES_DB: dexdat
    ports:
      - "${POSTGRES_PORT:-5432}:5432"

  redis:
    image: redis:7-alpine
    ports:
      - "${REDIS_PORT:-6379}:6379"

  api:
    image: ghcr.io/dexdat/core:latest
    depends_on: [postgres, redis]
    environment:
      DATABASE_URL: postgres://dexdat:${POSTGRES_PASSWORD:-dexdat}@postgres:5432/dexdat
      REDIS_URL: redis://redis:6379
    ports:
      - "${API_PORT:-3000}:3000"

Key points: - ${VAR:-default} (with the :-) means an unset/empty variable falls back to the previous hardcoded value — defaults unchanged for anyone without a .env. - ${VAR} without :- silently substitutes an empty string → ":5432" and unpredictable host port. Always use :-. - Only the left side of HOST:CONTAINER is parameterized; the right side stays fixed.

2. .env.example

# Host port mappings for dexdat-core. Copy to .env and change any that collide
# with services already running on your machine. Containers keep their internal
# ports; only the host side changes.
POSTGRES_PORT=5432
REDIS_PORT=6379
API_PORT=3000
# Used by postgres and the api's DATABASE_URL.
POSTGRES_PASSWORD=dexdat

3. README quickstart note

### Port collisions

dexdat-core publishes Postgres (5432), Redis (6379) and the API (3000) on the host.
If any of these are already in use — a local Postgres, another Compose stack — copy
the example env file and pick free ports:

    cp .env.example .env
    # then edit POSTGRES_PORT / REDIS_PORT / API_PORT, e.g. 15432 / 16379 / 13000
    docker compose up

Only the *host* mapping changes. Service-to-service URLs (postgres:5432, redis:6379)
are unaffected. To use a one-off override without a .env file:

    POSTGRES_PORT=15432 API_PORT=13000 docker compose up

4. Migration commands

cp .env.example .env    # create first if missing
# edit .env to free ports, then
docker compose up

No code changes required — compose/env/README-only fix.

Verification

# 1. Defaults preserved (no .env)
docker compose config | grep published
#   published: "3000"  / "5432" / "6379"

# 2. Per-invocation override reaches the rendered config
POSTGRES_PORT=15432 REDIS_PORT=16379 API_PORT=13000 docker compose config | grep published
#   published: "13000" / "15432" / "16379"

# 3. .env auto-load works
cp .env.example .env
sed -i 's/^POSTGRES_PORT=5432/POSTGRES_PORT=25432/' .env
docker compose config | grep published
#   published: "25432"

# 4. Regression guard: unset var without default is unsafe
printf 'services:\n  x:\n    image: busybox\n    ports:\n      - "${NOPE}:80"\n' > /tmp/t.yml
docker compose -f /tmp/t.yml config 2>&1 | head -1
#   level=warning msg="The \"NOPE\" variable is not set. Defaulting to a blank string."

On a real machine with free ports chosen:

docker compose up -d
docker compose ps                    # all services "running"
docker compose port postgres 5432    # <ip-address>:15432

Verified output (this fix, compose v2.40.3)

=== defaults (no .env) ===
published: "3000"
published: "5432"
published: "6379"

=== overrides via env ===
published: "13000"
published: "15432"
published: "16379"

=== .env auto-load ===
published: "3000"
POSTGRES_PASSWORD: dexdat
published: "25432"
published: "6379"

Why this resolves the problem class

Before After
Host port hardcoded → collision aborts up Host port is ${VAR:-default}; free port set in .env
Remedy is "edit the YAML" Remedy documented in .env.example + README
Defaults change for existing users :-default keeps 5432/6379/3000 identical
Internal service URLs risk drift Container ports and DATABASE_URL/REDIS_URL untouched

Caveat: verification used docker compose config (renders effective config, no daemon needed) because the Docker daemon is not running in this sandbox. To fully validate on a host, run the docker compose up -d + docker compose ps / docker compose port steps above.

Evidence & signatures

# Evidence
- Problem class: compose-host-port-collision-quickstart-friction
- Model: openrouter/deepseek/deepseek-v4.1-flash
- Solved: 2026-10-07T09:31:02.815Z
- Verification: solution produced by pi in sandbox; see signatures.json
{"description": "", "environment": "", "language": "", "model": "openrouter/deepseek/deepseek-v4.1-flash", "problem_class": "compose-host-port-collision-quickstart-friction", "provider": "openrouter", "solved_at": "2026-10-07T09:31:02.818Z", "version": ""}
Generated from the verified corpus · MIT licensedBack to the catalog