Managing ports
Publishing, interfaces, ranges, and exposure.
A container's network is a room with no door. Nothing outside the host can reach a process inside it until you cut one, and -p (short for publish) is how you cut it. The short form reads -p HOST:CONTAINER. It tells Docker to write a NAT (Network Address Translation) rule, a piece of kernel bookkeeping that forwards traffic landing on a port of the host to a port inside the container. The container port is where your app actually listens. The host port is the number the outside world dials. Get them the wrong way round and nothing connects. EXPOSE in a Dockerfile is where people trip, because it opens nothing at all. It is a sticky note in the image metadata saying which port the app uses, and Docker acts on it only if you pass -P (capital P), which publishes every EXPOSEd port onto a random high-numbered host port.
$ docker run -d -p 8080:80 --name web nginx:1.27c3f9a1e77b20e4d5a9c1f0b8d7e6a5c4b3a2f1e0d9c8b7a6f5e4d3c2b1a09876$ docker port web80/tcp -> 0.0.0.0:8080$ curl -s localhost:8080 | grep -o '<title>[^<]*</title>'<title>Welcome to nginx!</title>
What that one flag actually builds
Two things happen behind that single flag. Docker adds a DNAT (Destination Network Address Translation) rule to iptables, the kernel's packet-handling table. The rule rewrites the address on packets arriving at the host port so they head for the container's private IP (Internet Protocol) address and port instead. Docker also starts a small helper process called docker-proxy that listens on the host port and catches the cases the NAT path misses, such as a connection from the host to its own published port over loopback (the machine talking to itself at 127.0.0.1). That helper is why the host port turns up in ss (socket statistics, the tool that lists open sockets) owned by docker-proxy. Hold on to one detail here. The DNAT rule lives in chains Docker manages by itself, not the ones a host firewall like ufw (Uncomplicated Firewall) writes into, and that gap becomes an ugly surprise further down the page.
$ docker run -d -P --name rnd nginx:1.27a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90$ docker port rnd80/tcp -> 0.0.0.0:32768$ curl -s -o /dev/null -w '%{http_code}\n' localhost:32768200
Deciding which interface the door opens onto
The short form hides a field. Written out in full it is -p [HOST_IP:]HOST:CONTAINER[/proto], and that host IP decides who gets to knock. Leave it off and Docker binds 0.0.0.0, meaning every network interface the host owns, including the public one on a cloud VM (Virtual Machine, a rented server). Put 127.0.0.1: in front and the port answers only to the host itself. Same phone, very different reach: one number is unlisted, the other is printed in the directory. The /proto suffix picks the protocol. It defaults to TCP (Transmission Control Protocol, the connection-based one most apps speak). Add /udp for UDP (User Datagram Protocol) services such as DNS (Domain Name System) resolvers or syslog. Need the same container port on both protocols? Pass two -p flags, one per protocol.
$ docker run -d -p 127.0.0.1:9090:80 --name onlyme nginx:1.27$ docker run -d -p 9091:80 --name anyone nginx:1.27$ docker port onlyme80/tcp -> 127.0.0.1:9090$ docker port anyone80/tcp -> 0.0.0.0:9091$ sudo ss -tlnp | grep docker-proxyLISTEN 0 4096 127.0.0.1:9090 0.0.0.0:* users:(("docker-proxy",pid=8812,fd=4))LISTEN 0 4096 0.0.0.0:9091 0.0.0.0:* users:(("docker-proxy",pid=8840,fd=4))
When the host port is already spoken for
A host IP and port pair backs exactly one published container. Ask for one that is already in use and Docker will not quietly pick another; it fails the container at start with port is already allocated. This bites hardest when you run two copies of the same image, because both default to the same host port. The fix is to give each copy its own host port and leave the container port alone. The app inside has no idea it is being reached on 8081 rather than 8080. One cleanup detail catches people out: the container that failed to bind still exists, parked in Created state, so remove it before you retry or the name collides as well. If a service listens on a whole block of ports at once, publish the range in a single flag instead of typing out every port by hand.
$ docker run -d -p 8080:80 --name web1 nginx:1.275d41402abc4b2a76b9719d911017c592a1e0d9c8b7a6f5e4d3c2b1a098765432$ docker run -d -p 8080:80 --name web2 nginx:1.27docker: Error response from daemon: driver failed programming externalconnectivity on endpoint web2 (a7f3...): Bind for 0.0.0.0:8080 failed:port is already allocated.
$ docker rm web2 # clear the parked container firstweb2$ docker run -d -p 8081:80 --name web2 nginx:1.27 # same app port, new host portb6d767d2f8ed5d21a44b0e5886680cb9c1f0b8d7e6a5c4b3a2f1e0d9c8b7a6f5e$ docker port web280/tcp -> 0.0.0.0:8081$ docker run -d -p 30000-30009:30000-30009 --name pasv myftp:1.0 # a passive-data port block7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d$ docker port pasv | sort -V | head -230000/tcp -> 0.0.0.0:3000030001/tcp -> 0.0.0.0:30001
Before you reach for -p at all, ask whether you need it. Containers sharing a user-defined network already find each other by name, with nothing published anywhere. Your web container talks to db:5432 straight across that network; only the browser out on the internet needs the web port opened. Every published port is one more thing somebody has to defend, so open what genuinely has to be reached from outside the host and leave the rest sealed.
-p 6379:6379 on a cloud host answers the internet even while ufw is set to deny 6379. The firewall you were counting on never sees the packet. Bind 127.0.0.1 for anything that is not meant to be public, so the interface never opens in the first place, and control real public exposure at the cloud provider's security group, or with a rule in the DOCKER-USER chain, which is the one hook Docker leaves for you.EXPOSE is documentation; -p is the door
EXPOSE in a Dockerfile publishes nothing. -p HOST:CONTAINER or --publish-all does. Binding to 127.0.0.1:8080:80 keeps a service off the office LAN (Local Area Network); 0.0.0.0 puts it on every interface the host owns. Ranges are fine, stacking several -p flags is fine, and a clash on the host port fails immediately instead of silently. Under Swarm, published ports run through the routing mesh; on a single host, iptables or nftables NAT rules do all the work.
Databases and admin UIs (user interfaces) rarely need publishing at all when a shared network does the job. When you must publish one, pair it with a firewall rule and real authentication. Picking a high host port over a privileged one below 1024 trades a little operational friction for a little tradition, and on Linux, binding a low port can need an extra capability granted to the process. Write the host-to-container mapping into the runbook. A reverse proxy should be the normal public front door.
docker port and ss -lntp on the host tell you what is actually listening. If curl to localhost works but a remote client times out, check the bind address and the host firewall before you blame the application.
When you change a published port on a real host, note the old mapping, the new one, which host you ran the command on, and the exact command that puts it back. A port change is easy to reverse and easy to forget about, and a container left parked in Created state will bite whoever runs the same command next. Paste the docker port output from before and after into the ticket, along with the curl status line you saw from loopback and from a remote box. If a teammate cannot replay the change from that ticket alone, the runbook is not finished yet.
Try this
Run this on a lab engine; Docker 24 or newer is fine. Read the sample output first, so you know what a healthy result looks like before you lean on the command anywhere that matters.
$ docker run -d --name web -p 127.0.0.1:8080:80 nginx:1.27-alpine$ docker port web80/tcp -> 127.0.0.1:8080$ curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8080/200# STATUS: published only on loopback — not on the LAN
Takeaway
Publishing cuts the door; EXPOSE only labels it. Default to loopback or an internal interface, keep data stores off the host's public side entirely, and confirm the result with docker port plus a real HTTP request rather than trusting that the flag did what you meant.
docker run -d -p 6379:6379 redis:7, and count on the host's ufw rules to keep it private. Scanners find it within the hour. What went wrong?-p 8080:80 sets up two separate things on the host. Which pair?docker run -d -p 8080:80 --name web1 nginx:1.27 works. The same command with --name web2 dies with port is already allocated. You decide web2 should take host port 8081 instead. What has to happen before you re-run it, and why?