Detail chart · Infrastructure

Docker Port Mapping

Pattern ◆◇◇◇◇

Map container ports to host ports to expose services while maintaining network isolation between containers.

Summary

Containers run services on their default internal ports. Docker’s port-mapping flag (-p host:container) exposes each container to a unique port on the host, preventing conflicts when running multiple instances of the same service on one machine.

Problem

Running two PostgreSQL instances on the same host fails immediately — both try to bind to port 5432. The same applies to any duplicated service (Redis on 6379, MySQL on 3306, etc.).

Solution

Map each container’s internal port to a unique host port. The service inside the container stays on its default port; only the host-facing port changes.

# docker-compose.yml
services:
  project_a_db:
    image: postgres:16
    ports:
      - "5432:5432"   # host:container
    volumes:
      - project_a_data:/var/lib/postgresql/data
    environment:
      POSTGRES_DB: project_a
      POSTGRES_USER: admin
      POSTGRES_PASSWORD: ${PROJECT_A_DB_PASSWORD}

  project_b_db:
    image: postgres:15
    ports:
      - "5433:5432"   # different host port, same internal port
    volumes:
      - project_b_data:/var/lib/postgresql/data
    environment:
      POSTGRES_DB: project_b
      POSTGRES_USER: admin
      POSTGRES_PASSWORD: ${PROJECT_B_DB_PASSWORD}

volumes:
  project_a_data:
  project_b_data:

Each container listens on 5432 internally — the standard PostgreSQL port. Docker maps that to a distinct host port. Clients connect to the host port:

# Connect to project A
psql -h localhost -p 5432 -U admin -d project_a

# Connect to project B
psql -h localhost -p 5433 -U admin -d project_b

Document your port assignments to avoid collisions as the fleet grows:

ProjectHost PortInternal PortImage
Project A54325432postgres:16
Project B54335432postgres:15
Project C54345432postgres:16

As the fleet grows, a manually-maintained table drifts. A pre-flight check script catches collisions before docker compose up fails halfway through starting a stack:

#!/usr/bin/env bash
# check-port-conflicts.sh — scan a compose file's declared host ports
# against ports already bound on this machine.
set -euo pipefail

compose_file="${1:-docker-compose.yml}"

# Extract host-side ports from "host:container" mappings
host_ports=$(grep -oP '"\K[0-9]+(?=:[0-9]+")' "$compose_file" | sort -u)

conflicts=0
for port in $host_ports; do
  if lsof -iTCP:"$port" -sTCP:LISTEN -t >/dev/null 2>&1; then
    echo "CONFLICT: host port $port is already in use"
    conflicts=$((conflicts + 1))
  fi
done

if [ "$conflicts" -gt 0 ]; then
  echo "Found $conflicts port conflict(s) — resolve before starting the stack."
  exit 1
fi

echo "No port conflicts detected across $(echo "$host_ports" | wc -l) mapped port(s)."

Run it as a pre-flight gate: ./check-port-conflicts.sh docker-compose.yml && docker compose up -d. This turns a runtime failure (a container that silently fails to bind) into a fast, explicit pre-flight error.

Live Playground

Experiment with the pattern below. The before version assigns every service the same hardcoded host port and only discovers the collision when the second container tries to bind. The after version tracks assigned host ports and allocates the next free one, catching the conflict before it ever reaches Docker.

When to Use

Avoid when:

Trade-offs

BenefitCost
Containers stay on service defaults — no internal reconfigurationHost port assignments must be tracked manually
Simple, well-understood Docker primitivePort sprawl as the number of services grows
Each container is independently startable and stoppableNamed volumes require cleanup discipline on teardown