Suggest an editImprove this articleRefine the answer for “EXPOSE in Dockerfile vs port publishing: what is the difference?”. Your changes go to moderation before they’re published.Approval requiredContentWhat you’re changing🇺🇸EN🇺🇦UAPreviewTitle (EN)Short answer (EN)**`EXPOSE`** in a Dockerfile is documentation only — it tells humans (and `docker run -P`) which port the image listens on. It does NOT publish anything. **`-p` / `--publish`** on `docker run` is what actually maps a host port to a container port. ```dockerfile EXPOSE 80 # documentation; container is NOT reachable from host yet ``` ```bash docker run nginx # EXPOSE 80, but NOT reachable from host docker run -p 8080:80 nginx # NOW reachable at http://localhost:8080 docker run -P nginx # publish all EXPOSEd ports on random host ports ``` **Key:** EXPOSE is a hint, not an action. The container's port is always reachable from other containers on the same network; what `-p` adds is reachability from the host machine.Shown above the full answer for quick recall.Answer (EN)Image**`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.For the reviewerNote to the moderator (optional)Visible only to the moderator. Helps review go faster.