Skip to main content

Port Publishing and Binding

By default, containers are isolated -- nothing from the outside can reach them. Port publishing maps a port on the host to a port inside the container, creating an entry point for traffic.

How Port Publishing Works

flowchart LR
subgraph Internet
Client["Client"]
end
subgraph Host["Docker Host"]
HP["Host Port 8080"]
subgraph Container["Container"]
CP["App on Port 80"]
end
end
Client -->|"Request to :8080"| HP
HP -->|"-p 8080:80"| CP

style HP fill:#fff3e0,stroke:#ef6c00
style CP fill:#e3f2fd,stroke:#1565c0

Publish Syntax

The general form is:

-p [host_ip:]host_port:container_port[/protocol]
SyntaxWhat It DoesAccessible From
-p 8080:80Map host 8080 → container 80All network interfaces (0.0.0.0)
-p 127.0.0.1:8080:80Map host 8080 → container 80, localhost onlyHost machine only
-p 0.0.0.0:443:8443/tcpExplicit all-interfaces binding with protocolAll interfaces, TCP only
-PMap all EXPOSEd ports to random high portsAll interfaces

The Security Default

When you write -p 8080:80 without specifying an IP, Docker binds to all interfaces (0.0.0.0). This means the port is accessible from the network, not just locally.

Bind Services Intentionally

For services that should only be reached from the host itself (admin panels, debugging tools), always bind to 127.0.0.1:

docker run -d -p 127.0.0.1:9000:9000 admin-tool

Common Patterns

Web Server (Public)

docker run -d \
--name web \
-p 80:80 \
-p 443:443 \
nginx:alpine

Database (Internal Only -- No Publishing)

# No -p flag. Only reachable from other containers on the same network.
docker run -d \
--name db \
--network app-net \
postgres:16

Admin Tool (Localhost Only)

docker run -d \
--name pgadmin \
-p 127.0.0.1:5050:80 \
dpage/pgadmin4

Compose Port Mapping

services:
web:
image: nginx:alpine
ports:
- "80:80" # Public
- "443:443" # Public

api:
image: my-api:1.0.0
# No ports -- internal only, reached via Docker DNS

admin:
image: admin-ui:1.0.0
ports:
- "127.0.0.1:9000:9000" # Localhost only

EXPOSE vs -p (Publish)

ConceptWhat It Does
EXPOSE 80 in DockerfileDocumentation only -- declares what port the app listens on. Does not publish anything
-p 8080:80 at runtimeActually creates the port mapping on the host

EXPOSE is a hint. Only -p (or ports: in Compose) opens the door.

Verifying Published Ports

# See all containers and their port mappings
docker ps

# See ports for a specific container
docker port my-container

# Detailed port info
docker inspect -f '{{json .NetworkSettings.Ports}}' my-container

# Check what's actually listening on the host
ss -tulpen | grep 8080

Troubleshooting Port Conflicts

If a container fails to start with "port already in use":

# Find what's using the port on the host
ss -tulpen | grep :8080

# Check if another container has it
docker ps --format '{{.Names}}\t{{.Ports}}' | grep 8080

Solutions: stop the conflicting service, change the host port, or remove the old container.

The Reverse Proxy Pattern

In production, the most common setup publishes only the reverse proxy (ports 80 and 443) and keeps all other services internal:

flowchart LR
Internet -->|"443"| RP["Reverse Proxy<br/>(published)"]
RP -->|"Docker DNS"| API["API<br/>(internal)"]
RP -->|"Docker DNS"| Web["Web App<br/>(internal)"]
API -->|"Docker DNS"| DB["Database<br/>(internal)"]

style RP fill:#fff3e0,stroke:#ef6c00
style DB fill:#e8f5e9,stroke:#2e7d32

This minimizes the attack surface -- only one service is exposed to the internet.

Key Takeaways

  • Use -p host:container to publish ports. Without -p, the container is internal only.
  • Bind to 127.0.0.1 for admin tools and services that should not be network-accessible.
  • EXPOSE in a Dockerfile is documentation only -- it does not publish ports.
  • In production, publish only the reverse proxy and keep everything else on internal Docker networks.
  • Always verify published ports with docker port or ss -tulpen after deploying.

What's Next