Docker · commonly confused
EXPOSE vs -p / --publish
Short answer
-p is what actually makes a port reachable through your machine's own addresses, such as localhost, and from other machines. Neither one changes what address your application binds to inside the container.First published
On this page · 10 sections
In simple terms
There are three separate things going on here, and the confusion comes from assuming they are one thing.
First, your application decides which address and port to listen on. That is a decision your code or config makes, and Docker has no say in it. Second, EXPOSE in a Dockerfile writes a note in the image metadata saying “this container expects traffic on port 8080”. It is a label for humans and for tools. It opens nothing. Third, -p 8080:8080 at run time creates the actual mapping from a port on your host to a port inside the container.
So if you cannot reach your container, the question is not “did I EXPOSE it”. The question is which of those three things is missing.
Words used on this page
- port
- A number that, together with an IP address, identifies one listening program on a machine.
- EXPOSE
- A Dockerfile instruction that records in the image's configuration which ports the application is expected to listen on. It opens and forwards nothing.
docker run --exposedoes the same at run time. - publish (-p, --publish)
- A
docker runoption that creates a forwarding rule from a port on the host to a port in the container. It is set when the container is created. - host port and container port
- In
-p 8080:3000, 8080 is the port on the host that you connect to and 3000 is the port the application listens on inside the container. - -P, --publish-all
- Publishes every exposed port to a random high port on the host. This is how
EXPOSEcan change what adocker rundoes. - bind address
- The IP address a program listens on.
0.0.0.0means every IPv4 address the machine has;127.0.0.1means the machine itself only. - loopback (127.0.0.1, localhost)
- The address a machine uses to talk to itself. Inside a container it is the container's own loopback, separate from the host's.
- NAT
- Network address translation: rewriting a packet's address on the way through. Docker's firewall rules use it to send traffic for a host port to the container's own address and port.
- userland proxy (docker-proxy)
- A small Docker process that listens on the published host port and relays connections to the container. It runs alongside the firewall rules and is on by default.
- ephemeral port
- A high port number handed out on request.
-P, and-pwith only a container port, use one for the host side.
What actually happens
EXPOSE adds an entry to the image's Config.ExposedPorts. Nothing in the network path reads that entry in order to route traffic. Its practical effects are indirect: docker run -P (capital P) publishes every exposed port to a random host port, some orchestrators use it as a default, and it tells the next person which port the image expects.
-p 8080:3000 creates a rule that forwards traffic arriving on host port 8080 to port 3000 in the container's network namespace. On Linux this is implemented with firewall rules (iptables by default, nftables as an experimental option from Engine 29.0.0) and a userland proxy that is on by default; the details vary, but the important part is that it is a run-time mapping, not an image property.
The third piece does not look like a networking setting at all. Traffic arriving inside the container goes to the container's network interface, so your application has to be listening on an address that includes it. A process bound to 127.0.0.1 is bound to the container's own loopback, which the mapping never reaches. It must bind 0.0.0.0 (or the specific interface) to receive forwarded traffic.
All three pieces, made explicit
# Dockerfile — the note, for whoever reads this next EXPOSE 3000 # The application must bind an address the mapping can reach. # 127.0.0.1 here is the container's OWN loopback and will not work. CMD ["node", "server.js"] # server listens on 0.0.0.0:3000 # Run time — this is the line that actually opens the door docker run -p 8080:3000 myapp # │ └── container port, where the app listens # └─────── host port, what you type in the browser
Remove the EXPOSE line and this still works. Remove the -p and it does not. That is the whole distinction in one experiment, and it is worth actually running.
Try it yourself
Before you start
- Docker Engine, or Docker Desktop, with
docker versionprinting both a Client and a Server section. Note the Server version: Engine 28.0.0 changed how ports published to127.0.0.1are protected, and the error for a host port that is already taken is worded differently in 27, 28 and 29. - A host port nothing else is using.
-p 8080:3000fails if another container or program already holds 8080 on the host; choose a different host port rather than stopping something you do not own. - A safe place to experiment. Without a host address,
-ppublishes on every interface of the host, so on a cloud VM or a laptop on shared Wi-Fi, use-p 127.0.0.1:8080:3000while you test. - The port and the address your application listens on inside the container, from its config or its start-up log. The port goes on the right-hand side of
-p. The address must include the container's network interface:0.0.0.0(all interfaces) works,127.0.0.1does not. curlor a browser on the host, to request the published port and see what actually answers.
One exposed port and one published port, side by side
Quoted from the publish and expose filter example in Docker's docker container ls reference, not reproduced: Docker is not installed on the machine this page was checked on, and the reference does not say which Docker version produced the output. It also leaves out the container ID that each docker run -d prints. --expose is the run-time equivalent of a Dockerfile EXPOSE line. Both containers run top, which serves nothing, so the output shows what Docker set up, not whether anything would answer.
Command
docker run -d --publish=80 busybox top docker run -d --expose=8080 busybox top docker ps -a docker ps --filter publish=80
Output · as shown in Docker docs: docker container ls (publish and expose filters)
$ docker run -d --publish=80 busybox top $ docker run -d --expose=8080 busybox top $ docker ps -a CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES 9833437217a5 busybox "top" 5 seconds ago Up 4 seconds 8080/tcp dreamy_mccarthy fc7e477723b7 busybox "top" 50 seconds ago Up 50 seconds 0.0.0.0:32768->80/tcp admiring_roentgen $ docker ps --filter publish=80 CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES fc7e477723b7 busybox "top" About a minute ago Up About a minute 0.0.0.0:32768->80/tcp admiring_roentgen
How to read it
8080/tcpwith no arrow is the container that was only exposed. Docker recorded the port and forwards nothing to it. An image'sEXPOSEline on its own gives you exactly this.0.0.0.0:32768->80/tcpis the published container. Read it right to left: container port 80 is reachable through host port 32768 on every IPv4 address of the host.--publish=80named only the container port, so Docker picked an ephemeral host port.0.0.0.0includes the host's public addresses. A current Engine publishes to0.0.0.0and[::]by default, and since 27.0.1 gives both the same ephemeral port, so the PORTS column can list a second, bracketed[::]entry for the same mapping.- The
publish=80filter matched only the published container. To check one container,docker port <container>lists its mappings in the form80/tcp -> 0.0.0.0:32768, and prints nothing for ports that were only exposed. - To prove a request gets through, you need a real server listening on
0.0.0.0inside the container and a request from the host, such ascurl http://localhost:<host port>.
The mistake this causes
Adding EXPOSE and expecting the port to be reachable from the host.
Why people do it The name is genuinely misleading. “Expose” sounds like it exposes something. In every other context that word means “make reachable”, and here it means “write down”.
What you see docker ps shows the container running and healthy. The port appears in the PORTS column with no -> arrow in front of it. curl localhost:3000 returns connection refused, and the container logs show no incoming request at all — because none arrived.
If it goes wrong
- The container will not start:
Bind for <address>:<port> failed: port is already allocated - Docker has already given that host port to another container. Find it with
docker ps --filter publish=<port>, then stop it or publish on a different host port, for example-p 8081:3000. - The container will not start, with
address already in usein the error - A program outside Docker is listening on that host port. From Engine 28 Docker binds the port itself and the error contains
failed to bind host port; Engine 27 and earlier, with the default userland proxy, report it asError starting userland proxy:. On Linux,sudo ss -ltnpshows which process holds the port. Stop it or choose another host port. docker port <container> 3000printsno public port '3000' published for <container>(older clients:Error: No public port ...)- That container port is at most exposed, not published. Ports are published when the container is created, so remove the container and run it again with
-p <host port>:3000. - The PORTS column shows the arrow, yet requests to the host port are refused, reset, or get an empty reply
- The mapping exists, so look inside the container. Check that the right-hand side of
-pis the port the application really uses, and that it listens on0.0.0.0rather than127.0.0.1. Flask's development server, for example, is reachable only from its own machine until started with--host=0.0.0.0. - A port you meant to keep private is reachable from other machines, even though ufw denies it
- On Linux, Docker's NAT rules divert published traffic before it reaches the INPUT and OUTPUT chains ufw uses, so ufw never sees it. Publish with a host address instead,
-p 127.0.0.1:8080:3000, or, with the default iptables backend, add filtering rules to theDOCKER-USERchain.
How it shows up in production
A service works on a developer's machine and fails in a container, and the cause can sit in any of these three layers rather than in the application, so check all three first.
The third one is hard to see: the mapping is correct, the port is published, docker ps shows 0.0.0.0:8080->3000/tcp, and it still refuses connections — because the framework binds to localhost when no host is configured. Flask's development server is one: it is reachable only from its own machine until you start it with --host=0.0.0.0. The fix is a one-line config change and it looks nothing like a networking problem.
How to tell which one you are hitting
- Does
docker psshow an arrow in the PORTS column? 0.0.0.0:8080->3000/tcpmeans a mapping exists. A bare3000/tcpmeans the port is only documented — you are missing-p.- Is the mapping there but the connection still refused?
- Then the application is probably bound to loopback inside the container. Run
docker exec <container> ss -tlnp(ornetstat -tlnp) and look at the Local Address column:127.0.0.1:3000is the problem,0.0.0.0:3000is correct. - Does it work with
docker execbut not from the host? - That confirms the application is running and answering — the gap is the mapping or the bind address, not the app.
Security considerations
-p 5432:5432with no host address publishes on every interface of the host, so a database published that way on a cloud VM or a laptop is open to anyone who can reach the machine. Docker's documentation calls publishing insecure by default. Add127.0.0.1:when only the host needs the port.- On Linux, ufw does not protect published ports: Docker routes the traffic in the
nattable before ufw's rules apply, so aufw denyon the port changes nothing. Limit the publish address, or, with the default iptables backend, filter in theDOCKER-USERchain. - Publishing to
127.0.0.1is only host-only on Engine 28.0.0 and later. Before that, hosts on the same network segment, such as the same switch, could reach those ports, and 28.3.3 fixed a case where a firewalld reload made them reachable again (CVE-2025-54388). Checkdocker versionbefore relying on it. -Ppublishes every port the image exposes, including any its author exposed for debugging or administration. Publish the ports you mean with-p, and read what an image exposes first withdocker image inspect --format '{{json .Config.ExposedPorts}}' <image>.EXPOSErestricts nothing. Every container on the same bridge network, and the Docker host itself, can reach every port your application listens on, exposed or not. Keep services that should not talk to each other on separate networks.
In an interview
How this gets asked in an interviewpreparation
Usually phrased as “What does EXPOSE do in a Dockerfile?”
What a strong answer contains A complete answer names all three layers and keeps them separate: EXPOSE is metadata, -p creates the host mapping, and the application's bind address determines whether forwarded traffic is accepted. Mentioning that docker run -P uses the exposed ports shows you know why EXPOSE is not simply useless.
The follow-up Expect “so why would you ever write EXPOSE?” The honest answer is documentation and tooling defaults, not function — and being comfortable saying a feature is mostly a convention is a better signal than inventing a purpose for it.
Sources
The behaviour this page describes, as documented by the projects themselves.
- Docker docs: docker container port
- Docker docs: docker container run, publish and publish-all
- Docker docs: Packet filtering and firewalls (Docker and ufw)
- Docker docs: Docker with iptables (DOCKER-USER chain)
- Docker docs: Docker with nftables
- Docker Engine 28 release notes (port publishing changes in 28.0.0, CVE-2025-54388 in 28.3.3)
- Docker Engine 27 release notes (same ephemeral port on IPv4 and IPv6 in 27.0.1)
- ss(8) Linux manual page
- Flask quickstart: Externally Visible Server
- Docker — Dockerfile reference
- Docker — Port publishing and mapping
- Docker — Publishing and exposing ports
- Docker docs: docker container ls (publish and expose filters)
Did this get you to an answer?
No text box on purpose — please do not paste production logs anywhere
Something wrong or out of date? Report it