Ch. 13 · Docker

Docker Exposed Ports Versus Published Ports

EXPOSE documents a port; -p opens one on the host. Bind addresses, -P, the 127.0.0.1 trap inside containers and the ufw bypass on Linux.

~5 min readintermediateupdated Oct 7, 2026

EXPOSE 80 in a Dockerfile and -p 8080:80 on the command line look related, and they are, loosely. But only one of them makes the container reachable from your browser. Mixing them up leads to the two most common questions in Docker support channels: “why can’t I reach my container?” and, less often asked but more dangerous, “why can the whole internet reach my database?”

The examples below use nginx:alpine and a tiny Node server, run on Docker Desktop with curl from the host.

EXPOSE is documentation

The nginx image declares port 80:

$ docker image inspect nginx:alpine --format '{{json .Config.ExposedPorts}}'
{"80/tcp":{}}
Text

Run it without any -p flag and nothing is published:

$ docker run -d --name p0 nginx:alpine
$ docker port p0
$ docker ps --format '{{.Names}}  {{.Ports}}'
p0  80/tcp
Text

docker port prints nothing, and the 80/tcp in docker ps has no arrow, meaning “the image says it listens here” and nothing more. The container is reachable from other containers on the same network (it always was, EXPOSE or not), but not from the host.

So what is EXPOSE for? It tells humans and tools which port the image’s process listens on. docker run -P uses it, Compose and some platforms display it, and it’s a reasonable place to document the contract. It doesn’t open a firewall, it doesn’t make the port reachable, and -p 8080:1234 works fine for a port that was never exposed. The Dockerfile reference describes it as a kind of documentation between image author and user, which is the right mental model.

-p and the address you bind to

Publishing creates a mapping from a host address and port to a container port:

$ docker run -d --name p1 -p 127.0.0.1:18080:80 nginx:alpine
$ docker run -d --name p2 -P nginx:alpine
$ docker ps --format '{{.Names}}  {{.Ports}}'
p2  0.0.0.0:55000->80/tcp
p1  127.0.0.1:18080->80/tcp
$ curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:18080/
200
Text

Read -p as [host-ip:]host-port:container-port[/protocol]. Three things are worth knowing in an interview:

  • The default host IP is all interfaces. -p 8080:80 is 0.0.0.0:8080 (and [::]:8080 where IPv6 is enabled). On a laptop on café Wi-Fi or a cloud VM with a public IP, that’s everyone. For anything only you need (a dev database, an admin UI), write 127.0.0.1: in front. Docker Engine also lets you change the default with the daemon’s ip setting.
  • -P (capital) publishes every exposed port to a random high host port. Here it picked 55000. Handy for tests that run in parallel; useless when something needs a fixed address. docker port <container> tells you what you got.
  • UDP needs saying. -p 5353:53/udp. Without the suffix it’s TCP only.

A published host port can only be bound once per address. Start a second container on the same 0.0.0.0:8080 and Docker refuses with Bind for 0.0.0.0:8080 failed: port is already allocated; the port already allocated guide covers finding the culprit.

The app listening on 127.0.0.1 inside the container

Here’s a Node server that binds to loopback, the default for many dev servers (Vite, Flask’s app.run(), Rails in development):

docker run -d --name p3 -p 127.0.0.1:18081:3000 node:22-bookworm \
  node -e 'require("http").createServer((q,s)=>s.end("hi\n")).listen(3000,"127.0.0.1")'
Terminal
$ curl -sS http://127.0.0.1:18081/
curl: (52) Empty reply from server
$ docker exec p3 node -e 'require("http").get("http://127.0.0.1:3000",r=>r.pipe(process.stdout))'
hi
Text

The server is healthy, and reachable from inside its own container, but the published port gets nothing. Traffic that arrives through a published port is forwarded to the container’s network interface (its eth0 address, something like 172.17.0.x), not to its loopback. A process bound to 127.0.0.1 inside the container isn’t listening on that interface. Depending on the platform you’ll see an empty reply, a connection reset or a refused connection, which sends people off debugging firewalls.

The fix is in the app: listen on 0.0.0.0 (or ::). For Vite, --host 0.0.0.0; for Flask, --host=0.0.0.0; for most production servers it’s already the default. When you’re unsure what a container is bound to, run ss -lnt or netstat -lnt inside it, or check the app’s start-up log for the address.

Container-to-container traffic doesn’t use published ports

If web and api are on the same user-defined network, web calls http://api:3000, using the container port, with no -p on api at all. Publishing is only for traffic entering from outside Docker’s networks. Two mistakes follow from forgetting this:

  1. Publishing every service’s port “so they can talk”, which exposes internal services to the host network for no reason.
  2. Calling http://api:8080 because the compose file says 8080:3000. The host port means nothing to other containers; inside the network it’s api:3000.

The networks and DNS note explains how api resolves in the first place.

Firewalls and the Linux surprise

On a Linux host, Docker implements publishing with its own iptables (or nftables) rules in the nat and filter tables, inserted ahead of rules managed by tools like ufw or firewalld’s default zone. The consequence catches people out every year: you run ufw deny 5432, publish Postgres with -p 5432:5432, and it’s still reachable from the internet, because Docker’s forwarding rules handle the traffic before ufw’s input rules see it. Docker’s docs describe the DOCKER-USER chain as the place to add your own filtering for published ports.

The simplest defence is the one already mentioned: don’t publish what doesn’t need it, and bind what does to 127.0.0.1 unless it really must be public. Behind a reverse proxy, only the proxy should publish 80 and 443; everything else talks over a Docker network.

Gotcha

Docker Desktop on macOS and Windows behaves differently: published ports are forwarded by Desktop’s own backend process on the host, so the Linux iptables behaviour described here doesn’t apply. Don’t assume a test on your laptop proves how a Linux server will behave.

Interview practice

You deploy a Compose stack on a Linux VM with a public IP: proxy (publishes 80:80 and 443:443), app (publishes 3000:3000 “for debugging”), and redis (publishes 6379:6379). ufw allows only 22, 80 and 443. A week later Redis has been wiped and contains a ransom note key. Explain how that happened and what the configuration should look like.

Answer

6379:6379 binds Redis on all host interfaces, including the public one. Docker’s own iptables rules forward that traffic before ufw’s input rules are evaluated, so the “only 22, 80, 443” policy never applied to published container ports. An unauthenticated Redis reachable from the internet gets found by scanners within hours, and wiping it is a common automated attack. app on 3000 had the same exposure.

The fix: remove the ports: entries from redis and app entirely. proxy reaches app as http://app:3000 and app reaches redis as redis:6379 over the Compose network, so neither needs publishing. If someone needs to debug from the VM itself, publish on loopback (127.0.0.1:3000:3000) or use docker compose exec. Add filtering in the DOCKER-USER chain if published ports must be restricted by source, set a Redis password anyway, and since the data was exposed, treat anything stored in Redis as compromised.

Keep going

More in Docker

read ✓Docker · mid

Docker Networks and Container DNS

Why containers can't resolve each other on the default bridge, how the 127.0.0.11 resolver works, and why nginx keeps a stale upstream IP.

~5 min readread →
esc