Chapter 7: Networking
Topic 42

Publishing Ports

PortsExposure

A container's ports are reachable from other containers on its network, but not from the host or the outside world until you publish them. -p 8080:80 tells the daemon to install an iptables DNAT rule that forwards a host port to a container port. That is the whole mechanism: a forwarding rule the kernel applies on the way in.

What -p 8080:80 does
client hits host:8080
iptables DNAT
forwarded to container:80

Publishing is a deliberate decision about attack surface, not a checkbox you tick on every container. proxy publishes 80 and 443 because the world must reach it; web and db publish nothing, because only sibling containers on driftwood-net need them. Every published port is a door, and the fewer doors the better.

The -p hostPort:containerPort Syntax

-p 80:80 maps host port 80 to the container's port 80 by way of a DNAT rule. The left side is the host port a client connects to; the right side is the port the process is listening on inside the container. They need not match — -p 8080:80 lets a client connect to host port 8080 while the container's server listens on 80, which is how you run several containers' port-80 services on one host by mapping each to a different host port.

Publish vs Expose

EXPOSE in a Dockerfile — and the --expose flag — is documentation only. It records which ports the image listens on and changes nothing about reachability. Only -p/--publish (or Compose's ports:) actually opens a host port. Conflating the two is the single most common port complaint: "I exposed 8000 in the Dockerfile but I can't reach it." EXPOSE never published anything; without a matching -p there is no host port at all.

The Bind Address: 0.0.0.0 vs 127.0.0.1

-p 80:80 binds to 0.0.0.0 by default — every host interface — so the service is reachable from anywhere that can route to the host. On a cloud VM with a public IP, that means the public internet, immediately, the moment the container starts. Prefixing the host with a loopback address — -p 127.0.0.1:5432:5432 — binds only to the host's loopback, so the port is reachable from the host itself but not from the network. The difference between those two forms is the difference between a local-only database and one exposed to the entire internet.

Publishing decisions for the Driftwood stack
# proxy: the single public entry point — publish 80 and 443 on all interfaces
docker run -d --name proxy --network driftwood-net -p 80:80 -p 443:443 nginx

# web and db: reached only over driftwood-net by siblings — publish nothing
docker run -d --name web --network driftwood-net driftwood:latest
docker run -d --name db  --network driftwood-net postgres:16

# a host-only admin port, never exposed to the network
docker run -d --name adminer --network driftwood-net -p 127.0.0.1:8081:8080 adminer

The stack's exposure is one container wide. proxy takes 80 and 443 on 0.0.0.0; web and db take no host ports and are reachable only by name over driftwood-net; the admin tool binds 127.0.0.1 so it answers the host's own browser and nothing on the LAN.

Why Publish Only proxy

proxy is the single entry point, so it is the only container that publishes anything — 80 and 443. web (gunicorn on :8000) and db (postgres on :5432) are reached only over driftwood-net by their siblings, by name, and publish nothing to the host. The result is that the application's entire externally reachable attack surface is the reverse proxy. Anything trying to reach the database has to first get through proxy and then through web — there is no direct door.

Host Port Conflicts and Ranges

Two containers cannot publish the same host port at once — the second fails to bind, because a host port is a single resource. When you do not care which host port is used, -p 80 with no host port lets the daemon assign a random high one, and docker port web shows what it chose. That is handy for ephemeral test containers where you just need a port, not a specific one, and want to avoid collisions across many short-lived runs.

Common Mistakes
  • Publishing the database with -p 5432:5432 on a cloud host — that binds PostgreSQL to 0.0.0.0 and exposes it to the internet; anyone scanning the host's IP can reach it. db should publish nothing and be reached only over driftwood-net.
  • Believing EXPOSE 8000 in the Dockerfile makes the port reachable from the host — it is documentation; without a matching -p nothing is published and the connection is refused.
  • Binding to 0.0.0.0 when only the host needs access — a port meant for a local tool or a host-side process should bind 127.0.0.1: so it is never reachable from the network.
  • Getting the two sides of -p backwards — -p 8080:80 means connect to host 8080 and the container listens on 80; reversing it forwards host 80 to a container port nothing is listening on, and the connection refuses.
Best Practices
  • Publish only the ports the outside world genuinely needs — for Driftwood that is proxy's 80 and 443, and nothing else — so the attack surface is one container.
  • Bind host-only services to 127.0.0.1: explicitly so a development database or admin endpoint is never reachable from the network or internet.
  • Reach internal services container-to-container over a user-defined network by name and port, never by publishing them to the host.
  • Use EXPOSE in Dockerfiles as documentation of listening ports, and -p or Compose ports: as the actual, reviewed exposure decision.
Comparable tools Podman identical -p semantics; rootless, it publishes via a userspace proxy (slirp4netns/pasta) rather than host iptables Kubernetes replaces ad-hoc publishing with Service types (NodePort, LoadBalancer) and Ingress iptables DNAT · docker-proxy the rule plus userspace helper that publishing installs under the hood

Knowledge Check

In -p 8080:80, what does each side mean?

  • The host port a client connects to is 8080; the container's process listens on 80
  • The container's process listens on 8080 and the host forwards inbound traffic from port 80
  • 8080 is the HTTP protocol version and 80 is the actual TCP port number
  • Both numbers must be identical or Docker rejects the mapping at start

Why can you reach a port you "exposed" in the Dockerfile only after also publishing it?

  • Because EXPOSE is documentation only — only -p actually opens a host port
  • Because EXPOSE opens the port but only for TCP, and you needed UDP
  • Because EXPOSE binds the port to 127.0.0.1 and -p moves it to 0.0.0.0
  • Because EXPOSE opens the port but a separate firewall rule blocks it until -p

What is the security difference between -p 5432:5432 and -p 127.0.0.1:5432:5432 on a cloud VM?

  • The first binds all interfaces and exposes the port to the internet; the second binds loopback only
  • The first forwards traffic to the container while the second instead forwards to the host's own local PostgreSQL
  • The second form encrypts the connection with TLS while the first sends every byte in plaintext
  • The first opens a single port and the second opens a whole contiguous range of ports

Why does only proxy publish ports in the Driftwood stack?

  • It is the single entry point, so publishing only it keeps the attack surface to one container
  • web and db are technically unable to publish any of their ports to the host
  • Without publishing their own ports, web and db could not talk to each other or to proxy at all
  • Docker permits only a single container per host to publish ports to the outside

You got correct