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:

FunctionExplanation
DocumentationPeople reading the Dockerfile know which ports the app uses
Tooling hintDocker Compose, Kubernetes, or other tools can auto-detect
Image metadatadocker 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}}'
EXPOSE without -p = the port isn’t accessible from the host. This is the most common misconception. Many people think EXPOSE is 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 --> U

Port 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>
FormatExampleNotes
-p 8080:80Binds to 0.0.0.0:8080Default, all interfaces
-p 127.0.0.1:8080:80Binds to localhostSafe, not exposed to LAN/internet
-p 192.168.1.10:8080:80Binds to a LAN IPOnly reachable from the LAN
-p 80:80Host port 80Needs root or CAP_NET_BIND_SERVICE
-p 8080:80/tcpTCP onlyDefault
-p 8080:80/udpUDPFor DNS, video streaming, etc.
-p 80Random host portDocker picks a free port
-p 8000-8100:80RangeMaps 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 #

AspectEXPOSE-p / --publish
Where declaredDockerfileCLI / 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 practiceYes, for documentationOnly 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 user

What 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
host vs ingress mode: For single-host (development), mode: host publishes directly on the host. For Swarm, mode: ingress publishes 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 api to 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| A
Principle: minimize ports mapping. Every -p is 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:

  • Service type ClusterIP = internal only, no external port.
  • Service type NodePort = published on every node IP in the 30000-32767 range.
  • Service type 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 + Ingress is 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.

SituationUseExample
Image port documentationEXPOSEEXPOSE 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 productionService + IngressClusterIP 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 -p binds to 0.0.0.0 — meaning every interface, including a public IP. For internal services, always bind to 127.0.0.1 or 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 ports key at the service level. host mode (default) or ingress mode (Swarm).
  • Between containers, port mapping isn’t needed. Same network + internal DNS is enough. -p is 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 EXPOSE is enough without -p, forgetting to bind to localhost for internal services.
  • In Kubernetes, the EXPOSE concept remains useful for documentation, but public access is handled by Service + Ingress.
  • Important commands: docker port, docker inspect, iptables -t nat -L DOCKER, ss -tlnp for debugging.
  • The main principle: minimize port mapping. Every -p is an additional attack surface. Open only what’s needed.

← Previous: None Network   Next: Internal DNS →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact