Skip to main content

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 80 in Dockerfile = metadata only. Records that the image listens on port 80. No firewall hole, no NAT rule.
  • -p HOST:CONTAINER on docker run = actually maps the port. Adds the iptables NAT rule.
  • -P (uppercase) on docker 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 -p to use them from your laptop.

Quick example

dockerfile
FROM nginx:1.27-alpine EXPOSE 80
bash
# 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> # ← works

EXPOSE 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:

bash
$ docker inspect mysite --format '{{json .Config.ExposedPorts}}' {"80/tcp":{}}

That's it. The metadata exists for two reasons:

  1. Documentation: when someone runs docker inspect, they see what ports the image expects. Useful for tool authors and humans reading the image.
  2. -P flag: docker run -P (capital P) publishes every EXPOSEd port to a random host port. Without EXPOSE, -P has 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:

  1. Picks a host port (8080) and a container port (80).
  2. Adds an iptables DNAT rule that forwards host:8080 -> container:80.
  3. (On Linux) starts a docker-proxy userland 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

bash
-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 mapping

The full form is [HOST_IP:]HOST_PORT:CONTAINER_PORT[/PROTOCOL].

-P (capital): publish-all

bash
$ docker run -d -P nginx $ docker port <container> 80/tcp -> 0.0.0.0:32768

Docker 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

dockerfile
EXPOSE 8080
bash
$ docker run -d mysite $ curl localhost:8080 # Connection refused

EXPOSE alone is not enough. You still need -p or -P at run time.

Forgetting EXPOSE and wondering why -P does nothing

dockerfile
# Dockerfile has no EXPOSE FROM alpine:3.21 CMD ["nc", "-l", "-p", "8080"]
bash
$ 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

bash
# 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 nginx

Classic 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:

yaml
# compose.yaml services: api: image: myapp # no ports: published db: image: postgres:16 # no ports: published

From 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:443 on the reverse proxy / web server.
  • Internal services (DB, cache, queue): no -p at all in production. Other containers reach them via Docker DNS by service name.
  • CI tests: -P to grab whatever host port is free, then docker port to 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

dockerfile
FROM node:22-alpine WORKDIR /app COPY . . RUN npm ci --omit=dev EXPOSE 3000 CMD ["node", "server.js"]
bash
# 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 # OK

Compose with internal-only and published

yaml
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:5432

Three 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 ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Comments

No comments yet