EXPOSE in Dockerfile vs port publishing: what is the difference?
EXPOSE and port publishing look related but do completely different things. EXPOSE is documentation; publishing (-p) is what makes a port actually reachable from the host. Confusing them is one of the most common Docker bugs in dev environments.
Theory
TL;DR
EXPOSE 80in Dockerfile = metadata only. Records that the image listens on port 80. No firewall hole, no NAT rule.-p HOST:CONTAINERondocker run= actually maps the port. Adds the iptables NAT rule.-P(uppercase) ondocker run= publish every EXPOSEd port on random host ports. EXPOSE matters only because of this flag.- Without
-p, the container's port is reachable from other containers on the same Docker network, but NOT from the host or the outside world. - Most images (
nginx,postgres) have EXPOSE for documentation. You still need-pto use them from your laptop.
Quick example
FROM nginx:1.27-alpine
EXPOSE 80# Build and run WITHOUT -p
$ docker build -t mysite .
$ docker run -d --name web mysite
$ curl http://localhost:80
curl: (7) Failed to connect to localhost port 80
# Container's port 80 is up internally; nothing maps it to the host.
# Now WITH -p
$ docker rm -f web
$ docker run -d --name web -p 8080:80 mysite
$ curl http://localhost:8080
<html>...</html> # ← worksEXPOSE was identical in both runs. Only -p made the host see port 80.
What EXPOSE actually does
EXPOSE writes one piece of metadata into the image's config:
$ docker inspect mysite --format '{{json .Config.ExposedPorts}}'
{"80/tcp":{}}That's it. The metadata exists for two reasons:
- Documentation: when someone runs
docker inspect, they see what ports the image expects. Useful for tool authors and humans reading the image. -Pflag:docker run -P(capital P) publishes every EXPOSEd port to a random host port. Without EXPOSE,-Phas nothing to publish.
It does NOT:
- Open any host port
- Configure iptables / NAT
- Make the container reachable from outside the Docker network
- Affect inter-container traffic on Docker networks (containers can always talk to each other on any port if on the same network)
What -p actually does
When you docker run -p 8080:80 nginx, the daemon:
- Picks a host port (8080) and a container port (80).
- Adds an iptables DNAT rule that forwards
host:8080 -> container:80. - (On Linux) starts a
docker-proxyuserland process as backup forwarder for IPv6 and edge cases.
The container's port 80 is reachable from anywhere on the host's network now.
-p syntax variations
-p 8080:80 # host 8080 → container 80, all interfaces
-p 127.0.0.1:8080:80 # only loopback (localhost-only)
-p 80 # random host port → container 80
-p 8080:80/udp # UDP instead of TCP
-p 8080-8090:80-90 # range mappingThe full form is [HOST_IP:]HOST_PORT:CONTAINER_PORT[/PROTOCOL].
-P (capital): publish-all
$ docker run -d -P nginx
$ docker port <container>
80/tcp -> 0.0.0.0:32768Docker picks random high-numbered host ports and maps every EXPOSEd port. Useful in CI when you do not care which host port; just need some port. Combined with docker port to find what was assigned.
Common mistakes
Adding EXPOSE 8080 and expecting the host to see it
EXPOSE 8080$ docker run -d mysite
$ curl localhost:8080 # Connection refusedEXPOSE alone is not enough. You still need -p or -P at run time.
Forgetting EXPOSE and wondering why -P does nothing
# Dockerfile has no EXPOSE
FROM alpine:3.21
CMD ["nc", "-l", "-p", "8080"]$ docker run -d -P myimg
$ docker port <container>
# (empty — nothing to publish)-P only knows about EXPOSEd ports. Without EXPOSE 8080, it has nothing to map.
Reversing the -p direction
# WRONG: thinks 80 is the host, 8080 is container
$ docker run -p 80:8080 nginx
# Container listens on 80 (its real port). Nothing on 8080.
# Host:80 returns connection refused.
# RIGHT: HOST_PORT:CONTAINER_PORT
$ docker run -p 80:80 nginxClassic gotcha. The order is host first, container second.
Using EXPOSE for security
EXPOSE does not restrict anything. A container's actual listening ports come from what the app inside binds to, not from EXPOSE. An app that binds to port 22 inside a container with EXPOSE 80 is still listening on 22 — and other containers on the same network can reach it.
Inter-container communication does not need EXPOSE or -p
This is the part that surprises people:
# compose.yaml
services:
api:
image: myapp
# no ports: published
db:
image: postgres:16
# no ports: publishedFrom inside the api container, db:5432 works perfectly. The Compose-created bridge network lets containers talk to each other on any port the destination is listening on. EXPOSE and -p are about HOST visibility only.
Good security practice: do NOT publish DB ports to the host in production. Only publish what the outside world should reach (web, API), and let internal services talk via the Docker network.
Real-world usage
- Public-facing services:
-p 80:80 -p 443:443on the reverse proxy / web server. - Internal services (DB, cache, queue): no
-pat all in production. Other containers reach them via Docker DNS by service name. - CI tests:
-Pto grab whatever host port is free, thendocker portto find it. Useful when running parallel test instances that all use the same image but cannot share host ports. - EXPOSE in Dockerfiles: keep it for documentation. Anyone reading the Dockerfile knows the app's port without running it.
Follow-up questions
Q: What is the difference between EXPOSE in Dockerfile and expose: in Compose?
A: Same idea, slightly different scope. Dockerfile EXPOSE becomes part of the image. Compose expose: only applies at run time and to other Compose services on the same network — still no host publishing. Both are documentation/metadata. Use ports: in Compose to actually publish.
Q: Why does docker port show two lines for one port?
A: IPv4 and IPv6: 0.0.0.0:8080 and [::]:8080. Two address families, one logical mapping.
Q: Can I publish a port AFTER the container is running?
A: No (with the standard CLI). You have to stop, recreate with -p, and start again. Compose makes this less painful — docker compose up -d notices the change and recreates only that service.
Q: What is the difference between binding to 0.0.0.0 and 127.0.0.1?
A: 0.0.0.0 = all host interfaces (publicly reachable on whatever network the host is on). 127.0.0.1 = loopback only (just this machine). For dev tools you want only locally accessible, use 127.0.0.1:8080:80.
Q: (Senior) How do you secure a publicly-published port in Docker?
A: Containers and -p produce iptables DNAT rules that bypass UFW/firewalld by default — Docker writes its rules in the DOCKER chain that runs before INPUT. To restrict, either bind to 127.0.0.1:8080:80 (only locally), or use iptables-save rules in the DOCKER-USER chain to filter, or front the container with a real reverse proxy + cloud firewall instead of trusting -p for security.
Examples
Image with EXPOSE, run two ways
FROM node:22-alpine
WORKDIR /app
COPY . .
RUN npm ci --omit=dev
EXPOSE 3000
CMD ["node", "server.js"]# Without -p: app runs but unreachable from host
$ docker run -d --name app myapp
$ curl localhost:3000 # connection refused
# With explicit -p: reachable
$ docker run -d -p 3000:3000 myapp
$ curl localhost:3000 # OK
# Or with -P (random host port)
$ docker run -d -P myapp
$ docker port <id>
3000/tcp -> 0.0.0.0:32789
$ curl localhost:32789 # OKCompose with internal-only and published
services:
web:
image: nginx
ports:
- "80:80" # public
api:
image: myapp
expose:
- "3000" # internal only — web reaches api:3000, host cannot
db:
image: postgres:16
# no ports / no expose — only api can reach db:5432Three services, only one (web) is reachable from the host. The other two are isolated inside the project's Docker network. This is the production-shape pattern.
Short Answer
Interview readyA concise answer to help you respond confidently on this topic during an interview.
Comments
No comments yet