Skip to main content
DevOps Insightsguides & answers

Docker · commonly confused

EXPOSE vs -p / --publish

Short answer

EXPOSE is documentation. -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 --expose does the same at run time.
publish (-p, --publish)
A docker run option 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 EXPOSE can change what a docker run does.
bind address
The IP address a program listens on. 0.0.0.0 means every IPv4 address the machine has; 127.0.0.1 means 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 -p with 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 version printing both a Client and a Server section. Note the Server version: Engine 28.0.0 changed how ports published to 127.0.0.1 are 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:3000 fails 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, -p publishes 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:3000 while 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.1 does not.
  • curl or 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/tcp with no arrow is the container that was only exposed. Docker recorded the port and forwards nothing to it. An image's EXPOSE line on its own gives you exactly this.
  • 0.0.0.0:32768->80/tcp is 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=80 named only the container port, so Docker picked an ephemeral host port.
  • 0.0.0.0 includes the host's public addresses. A current Engine publishes to 0.0.0.0 and [::] 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=80 filter matched only the published container. To check one container, docker port <container> lists its mappings in the form 80/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.0 inside the container and a request from the host, such as curl 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 use in 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 as Error starting userland proxy:. On Linux, sudo ss -ltnp shows which process holds the port. Stop it or choose another host port.
docker port <container> 3000 prints no 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 -p is the port the application really uses, and that it listens on 0.0.0.0 rather than 127.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 the DOCKER-USER chain.

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 ps show an arrow in the PORTS column?
0.0.0.0:8080->3000/tcp means a mapping exists. A bare 3000/tcp means 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 (or netstat -tlnp) and look at the Local Address column: 127.0.0.1:3000 is the problem, 0.0.0.0:3000 is correct.
Does it work with docker exec but 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:5432 with 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. Add 127.0.0.1: when only the host needs the port.
  • On Linux, ufw does not protect published ports: Docker routes the traffic in the nat table before ufw's rules apply, so a ufw deny on the port changes nothing. Limit the publish address, or, with the default iptables backend, filter in the DOCKER-USER chain.
  • Publishing to 127.0.0.1 is 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). Check docker version before relying on it.
  • -P publishes 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 with docker image inspect --format '{{json .Config.ExposedPorts}}' <image>.
  • EXPOSE restricts 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 interview

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.

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