Skip to main content

Docker

EXPOSE vs -p / --publish

Short answer

EXPOSE is documentation. `-p` is what actually makes a port reachable from your machine. Neither one changes what address your application binds to inside the container.

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.

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 iptables rules and a userland proxy; the details vary, but the important part is that it is a run-time mapping, not an image property.

The third piece catches more people than either of those. 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.

◑ 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.

How it shows up in production

A service works on a developer's machine and fails in a container, and the difference is almost always one of these three layers rather than anything to do with the application.

The version that wastes the most time is the third one: 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 defaults to binding localhost when it does not see a host configured. Many frameworks do. 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.
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.

What comes next

Did this get you to an answer?

No text box on purpose — please do not paste production logs anywhere