I remember spending a genuinely embarrassing amount of time early on trying to figure out why I could curl localhost:8080 and get nothing back, even though my container logs clearly showed the app was “listening on port 8080.” The issue was simple in hindsight: I’d used EXPOSE in my Dockerfile and assumed that alone made the port reachable from my host. It doesn’t. In this guide, I’ll walk through exactly how Docker’s port publishing works, the difference between EXPOSE and -p, every syntax variant you’ll actually use, and how to debug it when it doesn’t work.
EXPOSE vs. Publish: The Distinction That Trips Everyone Up
There are two completely different mechanisms at play, and conflating them is the root of most confusion:
EXPOSE(in a Dockerfile) is purely documentation and metadata. It tells anyone reading the Dockerfile — and tools likedocker network connectinter-container communication defaults — which ports the application inside the container is expected to listen on. It does not open any port on the host.- Publishing (
-p/--publishondocker run, orports:in Compose) actually creates the mapping between a port on the host and a port inside the container’s network namespace, using Docker’s iptablesDNATrules (or a userland proxy, depending on configuration).
A Dockerfile might contain:
FROM node:20-alpine
WORKDIR /app
COPY . .
RUN npm install
EXPOSE 3000
CMD ["node", "server.js"]
If I run this with no -p flag:
docker build -t mynodeapp .
docker run -d --name node_app mynodeapp
The app inside the container is listening on port 3000, and other containers on the same user-defined network can reach it on 3000 — but from the host, curl localhost:3000 will fail, because nothing has been published to the host yet.
Basic Port Publishing with -p
To actually make port 3000 reachable from my host machine, I add -p:
docker run -d --name node_app -p 3000:3000 mynodeapp
The syntax is -p <host_port>:<container_port>. Verify:
curl -I http://localhost:3000
Expected output:
HTTP/1.1 200 OK
Content-Type: text/html; charset=UTF-8
Mapping to a Different Host Port
You don’t need the host and container ports to match. This is useful when running multiple instances of the same image, or avoiding conflicts with other services already using that port:
docker run -d --name node_app_alt -p 8081:3000 mynodeapp
Now the app is reachable at http://localhost:8081, while it still listens on 3000 internally.
Letting Docker Choose an Ephemeral Host Port
If you don’t care which host port is used — common for scaled-out test containers — omit the host port:
docker run -d --name node_app_random -p 3000 mynodeapp
Find out what Docker assigned:
docker port node_app_random
Expected output:
3000/tcp -> 0.0.0.0:32768
Binding to a Specific Host Interface
By default, -p 3000:3000 binds to 0.0.0.0, meaning the port is reachable on every network interface on the host — including any public-facing ones, which can be a real security issue on cloud servers. To bind only to localhost:
docker run -d --name node_app_local -p 127.0.0.1:3000:3000 mynodeapp
Now the container is only reachable from the host itself, not from other machines on the network. I use this pattern constantly for internal services that should only be accessed through a reverse proxy running on the same host.
To bind to a specific network interface (e.g., an internal VPN interface with IP 10.8.0.1):
docker run -d --name node_app_vpn -p 10.8.0.1:3000:3000 mynodeapp
Publishing UDP Ports
By default, -p publishes TCP. For UDP services (DNS servers, some game servers, syslog), specify the protocol explicitly:
docker run -d --name dns_container -p 53:53/udp -p 53:53/tcp coredns/coredns
Publishing All Exposed Ports at Once
If a Dockerfile declares multiple EXPOSE directives, -P (capital, publish all) maps each of them to a random ephemeral host port:
docker run -d --name multi_port_app -P mynodeapp
docker port multi_port_app
Expected output (example):
3000/tcp -> 0.0.0.0:32771
9229/tcp -> 0.0.0.0:32770
I use -P mostly for quick local testing of images with multiple services, rarely in production, where I want explicit, predictable port mappings.
Publishing Ports in Docker Compose
The ports: key in a Compose file mirrors -p syntax:
services:
web:
image: mynodeapp
ports:
- "3000:3000"
- "127.0.0.1:9229:9229" # debug port, localhost only
expose:
- "3000"
Bring it up:
docker compose up -d
docker compose port web 3000
Expected output:
0.0.0.0:3000
Note that Compose’s expose: key mirrors the Dockerfile’s EXPOSE — it’s for inter-container visibility on Compose-managed networks, not host publishing.
How Port Publishing Actually Works Internally
When you run -p 3000:3000, Docker does two things, depending on your daemon configuration:
1. Sets up an iptables DNAT rule in the DOCKER chain of the nat table, redirecting traffic arriving on the host’s port 3000 to the container’s internal IP on port 3000.
Inspect it directly:
sudo iptables -t nat -L DOCKER -n -v
Expected output (abbreviated):
Chain DOCKER (2 references)
target prot opt in out source destination
DNAT tcp -- !docker0 * 0.0.0.0/0 0.0.0.0/0 tcp dpt:3000 to:172.17.0.2:3000
2. Optionally runs docker-proxy, a small userland process that also listens on the host port and forwards traffic to the container. This existed historically to handle certain edge cases (like localhost-bound traffic that doesn’t traverse the iptables FORWARD chain in some configurations) and IPv6 support before it matured in the NAT rules.
You can see it running:
ps aux | grep docker-proxy
Expected output:
root 4821 0.0 0.0 /usr/bin/docker-proxy -proto tcp -host-ip 0.0.0.0 -host-port 3000 -container-ip 172.17.0.2 -container-port 3000
If you want to disable the userland proxy and rely purely on iptables DNAT (slightly better performance, one less process per published port), set this in /etc/docker/daemon.json:
{
"userland-proxy": false
}
Restart Docker:
sudo systemctl restart docker
Verifying What’s Actually Published
A command I run constantly during debugging:
docker ps --format "table {{.Names}}\t{{.Ports}}"
Expected output:
NAMES PORTS
node_app 0.0.0.0:3000->3000/tcp, :::3000->3000/tcp
Or, more detailed:
docker port node_app
And from the host’s own networking view:
sudo ss -tulpn | grep 3000
Expected output:
tcp LISTEN 0 4096 0.0.0.0:3000 0.0.0.0:* users:(("docker-proxy",pid=4821,fd=4))
Common Errors and Fixes
“Bind for 0.0.0.0:3000 failed: port is already allocated”
Something else — another container, or a host process — already owns that port.
sudo ss -tulpn | grep 3000
docker ps --filter "publish=3000"
Either stop the conflicting process/container, or map to a different host port.
“connection refused” even though the container is running
Usually means the application inside the container isn’t actually listening on 0.0.0.0, only on 127.0.0.1 inside its own namespace. A container’s 127.0.0.1 is not reachable from the host, even with port publishing, because the container has its own loopback interface. Fix the app’s bind address (e.g., Node’s app.listen(3000, '0.0.0.0') instead of the default localhost), and confirm:
docker exec node_app netstat -tulpn
You want to see 0.0.0.0:3000, not 127.0.0.1:3000.
Port works with curl from the host, but not from another machine on the network
Check the host’s own firewall (separate from Docker’s iptables rules):
sudo ufw status
sudo firewall-cmd --list-all
And confirm the port was published to 0.0.0.0, not 127.0.0.1:
docker port node_app
A Realistic Multi-Service Example
Here’s a docker-compose.yml I’ve used for a typical web stack, showing a mix of publicly published, localhost-only, and purely internal (unpublished) ports:
services:
nginx:
image: nginx:alpine
ports:
- "80:80"
- "443:443"
depends_on:
- api
api:
build: ./api
ports:
- "127.0.0.1:8080:8080" # only reachable from the host, behind nginx
expose:
- "8080"
db:
image: postgres:16
# no ports published at all — only reachable by other containers
# on the same Compose network, never from the host or outside
environment:
POSTGRES_PASSWORD: examplepass
Here, only nginx is reachable from outside the host (ports 80/443). The api service is reachable only from the host itself (for direct debugging), and db isn’t published at all — it’s only reachable container-to-container, which is the correct security posture for a database that should never be directly internet-facing.
Kubernetes Equivalent: Exposing a Pod’s Port
The direct conceptual equivalent of docker run -p in Kubernetes is a Service, typically of type NodePort or LoadBalancer for external exposure, or ClusterIP (the default) for internal-only exposure:
apiVersion: v1
kind: Service
metadata:
name: node-app-service
spec:
type: NodePort
selector:
app: node-app
ports:
- port: 3000
targetPort: 3000
nodePort: 30080
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: node-app
spec:
replicas: 2
selector:
matchLabels:
app: node-app
template:
metadata:
labels:
app: node-app
spec:
containers:
- name: node-app
image: mynodeapp
ports:
- containerPort: 3000
containerPort here plays the same documentation-only role as EXPOSE in a Dockerfile, and the Service’s nodePort is what actually makes the port reachable on the cluster’s nodes — directly analogous to Docker’s -p publishing.
Best Practices
- Never assume
EXPOSEpublishes a port — always use-por Compose’sports:for anything you need reachable from the host or outside world. - Bind sensitive services to
127.0.0.1rather than0.0.0.0unless you specifically need external reachability — this alone prevents a huge class of “why is my database exposed to the internet” incidents. - Don’t publish ports you don’t need to. If two containers only need to talk to each other, keep them on the same Docker network and skip publishing entirely.
- Make sure your application binds to
0.0.0.0inside the container, not127.0.0.1— this is one of the most common “it works locally but not in Docker” bugs. - Use explicit host:container mappings in production, avoiding
-Por unmapped-p <port>(random port) outside of testing scenarios, so your infrastructure is predictable. - Audit published ports regularly with
docker psandss -tulpnon production hosts, especially after onboarding new services.
Summary
Publishing a container’s port is a distinct step from exposing it — EXPOSE in a Dockerfile is metadata, while -p/--publish (or Compose’s ports:) is what actually creates the iptables DNAT rule (and optionally a docker-proxy process) that lets traffic from the host, or the wider network, reach into a container’s isolated network namespace. Understanding the syntax variations — host-only binding, UDP, random ports, and interface-specific binding — along with how to verify and debug the mapping, is essential for running containers securely and predictably, whether standalone, via Compose, or eventually through Kubernetes Services.
References
- Docker Documentation: Publish and expose ports
- Docker Documentation: Dockerfile reference — EXPOSE
- Docker Documentation: Docker Compose ports
- Kubernetes Documentation: Service
- CNCF: Cloud Native Networking Landscape
