Port Mapping & Exposure #
One of the most frequently used but also most misunderstood concepts in Docker is port mapping and port exposure. Many developers can run a container with -p 8080:80, but don’t truly understand how it differs from EXPOSE 80 in a Dockerfile, or what actually happens at the iptables level when port mapping is active.
Common mistakes: -p 8080:80 accidentally exposing a database to the public. Or thinking EXPOSE is enough to make a port accessible. Or using -p 80 without realizing it binds to 0.0.0.0 (all interfaces). All of these stem from a half-baked understanding of two concepts that are actually different.
This article covers port mapping and exposure in depth: precise definitions, the difference between EXPOSE and -p, full syntax, what happens at the kernel level, and correct security patterns. After reading it, you’ll know exactly when to use EXPOSE, when to use -p, and how to avoid the common traps that often become the source of security bugs.
What Is Port Exposure? #
Port exposure is how a container declares the ports used by its application. It’s metadata — not a mechanism that actually opens access.
# Dockerfile
FROM nginx:alpine
EXPOSE 80
EXPOSE 80 means: “the application inside this image listens on port 80”. But it does NOT:
- Open port 80 to the host.
- Make port 80 accessible from outside the container.
- Change iptables or firewall configuration.
EXPOSE is a metadata contract between the image and the user (or tools). Its functions:
| Function | Explanation |
|---|---|
| Documentation | People reading the Dockerfile know which ports the app uses |
| Tooling hint | Docker Compose, Kubernetes, or other tools can auto-detect |
| Image metadata | docker inspect shows the exposed ports |
# See the EXPOSE from an image
docker inspect nginx --format '{{json .Config.ExposedPorts}}'
# {"80/tcp":{}}
# See running containers - which ones are exposed
docker ps --format '{{.Names}}: {{.Ports}}'
EXPOSEwithout-p= the port isn’t accessible from the host. This is the most common misconception. Many people thinkEXPOSEis enough. In fact, without-p(or--publish), an exposed port can only be accessed from other containers on the same network — not from the host or the internet.
What Is Port Mapping? #
Port mapping (or port publishing) is the mechanism that actually opens host-to-container access. Docker uses iptables DNAT to forward traffic from a host port to a container port.
docker run -d --name web -p 8080:80 nginx
Meaning: all traffic entering host port 8080 will be forwarded to container port 80.
flowchart LR
U[User / Client] -->|localhost:8080| H[Host: 0.0.0.0:8080]
H -->|iptables DNAT| IPT[iptables NAT]
IPT -->|172.17.0.2:80| CT[Container: 80]
CT -->|Response| IPT
IPT -->|SNAT| H
H --> UPort mapping characteristics:
- Actually opens access — not just metadata.
- Uses iptables DNAT — TCP packets entering the host port get their destination rewritten to the container IP.
- Can bind to a specific IP — doesn’t have to be
0.0.0.0. - Supports multiple protocols — TCP, UDP, SCTP.
- Random ports — the host port can be auto-chosen by Docker.
Full Syntax #
-p <host_ip>:<host_port>:<container_port>/<protocol>
| Format | Example | Notes |
|---|---|---|
-p 8080:80 | Binds to 0.0.0.0:8080 | Default, all interfaces |
-p 127.0.0.1:8080:80 | Binds to localhost | Safe, not exposed to LAN/internet |
-p 192.168.1.10:8080:80 | Binds to a LAN IP | Only reachable from the LAN |
-p 80:80 | Host port 80 | Needs root or CAP_NET_BIND_SERVICE |
-p 8080:80/tcp | TCP only | Default |
-p 8080:80/udp | UDP | For DNS, video streaming, etc. |
-p 80 | Random host port | Docker picks a free port |
-p 8000-8100:80 | Range | Maps a port range to one container port |
# Random port - Docker picks an available host port
docker run -d -p 80 nginx
docker ps
# PORTS
# 0.0.0.0:32768->80/tcp # host port 32768 (example)
# See which port was picked
docker port <container>
-p 80(without:container_port) is NOT port mapping to container port 80. It means: “bind container port 80 to a random host port”. To map to a specific container port, the full syntax is:-p <host_port>:<container_port>.
EXPOSE vs -p Comparison #
| Aspect | EXPOSE | -p / --publish |
|---|---|---|
| Where declared | Dockerfile | CLI / docker-compose |
| Opens the port to the host | ❌ No | ✅ Yes |
| Changes iptables | ❌ No | ✅ Yes (DNAT) |
| Required for host access | ❌ No | ✅ Yes |
| Required for inter-container access | ❌ No (same network is enough) | ❌ No |
| Can be overridden at runtime | ✅ (default behavior) | ✅ |
Visible in docker inspect | ✅ | ✅ |
| Best practice | Yes, for documentation | Only if genuinely needed |
A Simple Experiment #
# Container with EXPOSE but no -p
docker run -d --name exp-only -P nginx
docker ps
# PORTS: 80/tcp <- exposed only, not published
# (Port 80/tcp appears but isn't published to the host)
curl http://localhost:80
# Connection refused
# Container with -p
docker run -d --name pub -p 8080:80 nginx
docker ps
# PORTS: 0.0.0.0:8080->80/tcp <- published
curl http://localhost:8080
# Works! Welcome to nginx.
-P (capital) in the CLI is auto-publish all EXPOSEd ports to random host ports. Useful for quick testing.
How Port Mapping Works at the Kernel Level #
When you run docker run -p 8080:80 nginx, Docker adds an iptables DNAT rule. Let’s see the details.
# View the DNAT rule for our container
sudo iptables -t nat -L -n -v | grep 8080
# -A DOCKER ! -i docker0 -p tcp -m tcp --dport 8080 -j DNAT --to-destination 172.17.0.2:80
sequenceDiagram
participant U as User
participant H as Host NIC
participant K as Kernel
participant I as iptables PREROUTING
participant B as Bridge
participant C as Container
U->>H: TCP to host:8080
H->>K: Packet arrives
K->>I: Evaluate DNAT rules
I->>I: dst becomes 172.17.0.2:80
I->>B: Forward to bridge
B->>C: Reaches container eth0
C-->>B: HTTP response (src: 172.17.0.2:80)
B-->>K: Response returns
K->>K: Conntrack: src becomes host IP
K->>H: Exits via host eth0
H-->>U: HTTP response reaches the userWhat you should underline:
- DNAT changes the packet’s destination on entry.
- Conntrack (connection tracking) automatically reverse-NATs replies.
- The source port seen in the container is an ephemeral port (e.g.
8080 -> 32768, not 8080 -> 80). The container doesn’t realize it’s accessed via host port 8080.
Conntrack is the magic that makes two-way NAT work. It records connection state (src IP, src port, dst IP, dst port) so replies can be correctly reverse-NAT-ed. Without conntrack, you’d have to write separate rules for each direction.
Port Mapping in Docker Compose #
In Docker Compose, port mapping is declared with the ports key at the service level.
version: "3.9"
services:
web:
image: nginx
ports:
- "8080:80" # host:container
- "127.0.0.1:8443:443" # bind to localhost
- "9000:9000/udp" # UDP
- "80" # random host port
The "8080:80" string format is the most common. The long format (mapping) also works:
ports:
- target: 80
published: 8080
protocol: tcp
mode: host # or "ingress" for Swarm
hostvsingressmode: For single-host (development),mode: hostpublishes directly on the host. For Swarm,mode: ingresspublishes via the Swarm routing mesh (whoever receives a request on port 8080 forwards it to an available container).
Port Range Mapping #
For applications needing many ports (game servers, video streaming, multi-port services), Docker supports range mapping.
# Map a host port range to a container port range
docker run -p 7000-7010:7000-7010 my-server
# Docker Compose
services:
game-server:
image: my-game
ports:
- "7000-7010:7000-7010/udp"
The container listens on ports 7000-7010 (inside the container), and Docker maps each port to the same-numbered host port. Fits:
- Minecraft servers and their mods
- WebRTC / video streaming
- Multi-port services (HTTP + WebSocket + custom protocols)
- Real-time multiplayer games
Port Exposure Without Port Mapping (Inter-Container) #
It’s important to understand: containers can talk to each other without port mapping. Being on the same network is enough.
# docker-compose.yml
services:
api:
image: my-api
# No ports exposed
web:
image: my-web
# No ports exposed
web can access api via http://api:8080 because:
- Both are on the Compose default network.
- Docker’s internal DNS resolves
apito the container IP.
No -p or EXPOSE needed. Port mapping is only required for access from outside the container network (host, internet, containers on different networks).
flowchart LR
subgraph NET["docker-compose network"]
A[api: 8080]
B[web: 80]
end
A <-.->|internal: api:8080| B
EXT[External Client] -.->|needs -p| APrinciple: minimizeportsmapping. Every-pis an additional attack surface. Internal services (databases, caches, message brokers) must not have port mappings to the host. Only frontend services (web, API gateways) may.
Port Mapping Security Risks #
Port mapping opens access to containers. Without careful configuration, databases or internal services can be exposed to the public.
1. Default Binding to 0.0.0.0 #
# ✗ Anti-pattern: -p without an IP = bind to 0.0.0.0
docker run -d -p 3306:3306 mysql
# If the host has a public IP, MySQL is exposed to the internet!
# ✓ Solution: bind to localhost
docker run -d -p 127.0.0.1:3306:3306 mysql
# Or even without -p, if host access isn't needed
2. Databases Exposed in Production #
# ✗ Anti-pattern: postgres with -p in production
services:
postgres:
image: postgres
ports:
- "5432:5432" # <-- dangerous in production!
# ✓ Solution: postgres without ports
services:
postgres:
image: postgres
# No 'ports' - only containers on the Compose network can access it
api:
image: my-api
depends_on:
- postgres
3. Images Listening on Many Ports #
# An image listening on 10+ ports (e.g. a Kubernetes dashboard)
docker run -p 8001:8001 -p 8443:8443 -p 10250:10250 ...
# Every port is an attack surface
Production rule of thumb:
- Must have port mapping: web servers (80/443), API gateways, reverse proxies.
- Must NOT have port mapping: databases (Postgres, MySQL, Mongo, Redis), internal caches, message brokers (Kafka, RabbitMQ), admin tools (pgAdmin, RedisInsight, Kibana).
- Be careful: monitoring agents, log shippers — only if host access is genuinely needed.
Port Mapping & Exposure Best Practices #
1. Always Include EXPOSE in the Dockerfile #
# Every image with an application port
EXPOSE 8080
This helps:
- People reading the Dockerfile know which ports are used.
- Auto-detect tools (Compose, K8s) work.
- Image registries (Docker Hub) show port info.
2. Minimize Port Mapping in Production #
# Production - only the ports needed
services:
frontend:
ports:
- "443:443" # public HTTPS
api:
# No ports - accessed via the internal network
postgres:
# No ports
3. Bind to a Specific IP for Sensitive Services #
# Database - bind to localhost only
docker run -p 127.0.0.1:5432:5432 postgres
# Admin tool - bind to a specific LAN IP
docker run -p 192.168.1.100:8080:8080 adminer
4. Use Random Ports for Testing #
# CI/CD testing - random ports to avoid parallel conflicts
docker run -d -P my-test-image
docker port <container> 80
# Will return the random port being used
5. Combine with a Reverse Proxy for TLS #
# Traefik on host networking for HTTPS termination
services:
traefik:
image: traefik
network_mode: host
# ... TLS configuration
# Backend services without ports - accessed via Traefik
app:
image: my-app
# No ports
Useful Port Mapping Commands #
# View all of a container's port mappings
docker port <container>
# See the ports EXPOSEd in an image
docker inspect <image> --format '{{json .Config.ExposedPorts}}'
# See port mappings of running containers
docker ps --format "table {{.Names}}\t{{.Ports}}"
# Check whether a specific port is bound on the host
ss -tlnp | grep 8080
# or
netstat -tlnp | grep 8080
# Test connectivity from the host
curl -v http://localhost:8080
# Test connectivity from another container on the same network
docker exec <other-container> curl http://<target>:80
# View the iptables rules Docker added
sudo iptables -t nat -L DOCKER -n -v
Port Mapping and K8s / Production #
In production Kubernetes, port mapping works differently:
Servicetype ClusterIP = internal only, no external port.Servicetype NodePort = published on every node IP in the30000-32767range.Servicetype LoadBalancer = a cloud load balancer in front.Ingress= HTTP routing layer (more flexible than LoadBalancer).
The EXPOSE concept in Dockerfiles still applies — K8s reads it for documentation, but doesn’t automatically create a Service. You must still declare a Service or Ingress manually.
The Docker vs Kubernetes port mapping principle: In Docker Compose,ports:is the main way. In Kubernetes,Service+Ingressis the main way. Both work similarly (forwarding traffic), but K8s has extra layers (load balancing, TLS termination, path routing) usually handled by the Ingress controller.
Anti-Patterns: Signs You’re Using Port Mapping Wrong #
✗ Databases (Postgres, MySQL, Mongo, Redis) have -p in production
✗ Containers have many -p flags without a clear reason
✗ Port mapping without a host IP (binding to 0.0.0.0) for internal services
✗ EXPOSE 80 without -p, then confused about why it's unreachable
✗ -p 80 (without a container port) for a specific port
✗ Using a 1-65535 port range to "simplify"
✗ Hardcoded ports that conflict between services
Summary Table: Choose -p or EXPOSE
#
For quick decisions, use this table.
| Situation | Use | Example |
|---|---|---|
| Image port documentation | EXPOSE | EXPOSE 8080 in the Dockerfile |
| Public web server | -p | -p 80:80 or -p 443:443 |
| Production database | (not needed) | Don’t add ports: in Compose |
| Containers talking to each other | (not needed) | Same network is enough |
| Admin UI accessed from the host | -p 127.0.0.1: | -p 127.0.0.1:8080:8080 |
| Services on an internal LAN | -p <lan_ip>: | -p 192.168.1.10:8080:8080 |
| Parallel testing (CI/CD) | -p with random | -P (auto) or -p 80 |
| Multi-port game servers | -p with a range | -p 7000-7010:7000-7010/udp |
| K8s production | Service + Ingress | ClusterIP for internal |
Summary #
- Port exposure (
EXPOSE) = metadata. Declares the ports the application uses. Does NOT open access.- Port mapping (
-p/--publish) = opens access. Creates iptables DNAT rules forwarding traffic from the host to the container.- Default
-pbinds to0.0.0.0— meaning every interface, including a public IP. For internal services, always bind to127.0.0.1or a specific IP.- Full syntax:
-p <host_ip>:<host_port>:<container_port>/<protocol>. Can be TCP, UDP, a range, or a random port.- Docker Compose uses the
portskey at the service level.hostmode (default) oringressmode (Swarm).- Between containers, port mapping isn’t needed. Same network + internal DNS is enough.
-pis only for host access or outside the network.- Production best practice: only ports that should be publicly reachable (web servers) get published. Databases, caches, brokers, admin tools = must not be published.
- Anti-patterns: publishing databases, random images with many ports, thinking
EXPOSEis enough without-p, forgetting to bind to localhost for internal services.- In Kubernetes, the
EXPOSEconcept remains useful for documentation, but public access is handled by Service + Ingress.- Important commands:
docker port,docker inspect,iptables -t nat -L DOCKER,ss -tlnpfor debugging.- The main principle: minimize port mapping. Every
-pis an additional attack surface. Open only what’s needed.