By default, nothing outside the Docker host can reach a port inside a container. To make a port reachable, you publish it when you start the container:
shell
1docker run -d --name web -p 8080:80 nginxNow port 8080 on the host forwards to port 80 in the container, and http://localhost:8080 opens the NGINX welcome page. The first number is the host port, the second is the port the app listens on inside the container.
One thing trips up almost everyone: EXPOSE in a Dockerfile doesn't do this. It only documents the port. You still need -p (or -P) when you run the container.
The commands were tested with Docker Engine 29 on Docker Desktop for Mac and on Linux.
EXPOSE vs -p: exposing is not publishing
Docker has three port options, and they do very different things:
EXPOSEin a Dockerfile (or--exposeindocker run) records which port the app listens on. It's metadata for whoever runs the image. It opens nothing on the host.-p/--publishmaps a host port to a container port. This is what makes the container reachable from outside.-P/--publish-allpublishes every port listed inEXPOSEto a random free port on the host.
Here's a quick way to see the difference. This Dockerfile exposes port 80:
dockerfile
1FROM nginx:1.29-alpine2EXPOSE 80Build it and run it without -p:
shell
1docker build -t expose-demo .2docker run -d --name expose-only expose-demo3docker port expose-only4curl http://localhost:80docker port prints nothing, because no port is published, and curl fails with Failed to connect. The PORTS column in docker ps shows 80/tcp without a host side. That's how an exposed but unpublished port looks.
So why write EXPOSE at all? It tells the next person (and tools like -P) which port the app uses. Keep it in your Dockerfiles, just don't expect it to open anything.
EXPOSE also doesn't change the port the app listens on. That comes from the app's own config, like the listen directive in NGINX or a PORT environment variable in a Node app.
Containers on the same network don't need published ports
Publishing is only for traffic from outside Docker. Containers attached to the same user-defined network reach each other by container name on any port the app listens on:
shell
1docker network create app-net2docker run -d --name api --network app-net nginx3docker run --rm --network app-net curlimages/curl -s -o /dev/null -w "%{http_code}\n" http://api:80This returns 200 without any -p. If your database only talks to your app container, don't publish its port at all. Every published port is one more thing reachable from outside.
Publish a port with docker run -p
The full syntax is:
shell
1-p [HOST_IP:]HOST_PORT:CONTAINER_PORT[/PROTOCOL]Only the container port is required. Everything else has a default: all host interfaces, TCP, and a random host port if you leave it out.
Map one port
shell
1docker run -d --name web -p 8080:80 nginxCheck the result with docker ps:
shell
1CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES2295932e5b083 nginx "/docker-entrypoint.…" 5 seconds ago Up 4 seconds 0.0.0.0:8080->80/tcp, [::]:8080->80/tcp web0.0.0.0 and [::] mean the port is open on every IPv4 and IPv6 address of the host. In our test, the container answered not only on localhost but also on the machine's LAN address. Keep that in mind on laptops in shared networks and on any server with a public IP.
Publish several ports
Add one -p per port:
shell
1docker run -d -p 8080:80 -p 8443:443 nginxFor a block of ports, use a range on both sides. The ranges must be the same size:
shell
1docker run -d -p 8100-8101:8100-8101 my-appLet Docker pick the host port
Give only the container port, and Docker picks a free host port:
shell
1docker run -d -p 80 nginxYou can also give a host range for one container port. Docker takes the first free port in the range:
shell
1docker run -d -p 8090-8092:80 nginxIn our test, the first container got 8090 and a second one with the same command got 8091. It's handy for running several copies of the same service. Check which port each one got with docker port.
Publish a UDP port
TCP is the default. For UDP, add /udp, and if the service uses both protocols, publish the port twice:
shell
1docker run -d -p 5300:53/udp -p 5300:53/tcp my-dnsBind to localhost only
To make a port reachable from the Docker host but not from the network, put 127.0.0.1 in front of the host port:
shell
1docker run -d -p 127.0.0.1:8081:80 nginxIn our test, curl to 127.0.0.1 on port 8081 returned 200, and the same port on the machine's LAN address refused the connection. This is the right default for databases, admin panels, and anything that sits behind a reverse proxy on the same host.
For IPv6 localhost, wrap the address in square brackets:
shell
1docker run -d -p "[::1]:8083:80" nginxPublish all exposed ports with -P
-P (capital P, long form --publish-all) is a run-time flag. It publishes every port the image declares with EXPOSE (or that you add with --expose) to a random free port on the host:
shell
1docker run -d --name web-random -P nginx2docker port web-randomshell
180/tcp -> 0.0.0.0:55000The host port comes from the ephemeral range (/proc/sys/net/ipv4/ip_local_port_range on Linux), so it changes every time you recreate the container.
Two things to know about -P:
- It only publishes ports from
EXPOSE. On an image with noEXPOSE, likealpine,-Ppublishes nothing. - It publishes on all interfaces, like
-pwithout an IP. Use it for quick local tests, not on servers.
Check which ports are published
docker port lists the mappings of one container:
shell
1docker port webshell
180/tcp -> 0.0.0.0:8080280/tcp -> [::]:8080Add the container port to get just one mapping, for example docker port web 80/tcp. docker ps shows the same data in the PORTS column for all running containers.
For the raw data, use docker inspect:
shell
1docker inspect web --format '{{json .NetworkSettings.Ports}}'shell
1{"80/tcp":[{"HostIp":"0.0.0.0","HostPort":"8080"},{"HostIp":"::","HostPort":"8080"}]}Ports in Docker Compose: ports vs expose
Compose follows the same split. ports publishes to the host, like -p. expose only documents the port for other services, like EXPOSE:
yaml
1services:2 web:3 image: nginx:1.29-alpine4 ports:5 - "127.0.0.1:8080:80"6 api:7 image: nginx:1.29-alpine8 expose:9 - "80"After docker compose up -d, docker compose ps shows 127.0.0.1:8080->80/tcp for web and only 80/tcp for api. The web container still reaches api by its service name on port 80, because Compose puts both services on one network.
The ports list accepts the same forms as -p:
yaml
1ports:2 - "8080:80"3 - "127.0.0.1:8001:8001"4 - "8100-8101:8100-8101"5 - "5300:53/udp"6 - "3000"Quote the mappings, as in the examples above, so YAML always reads them as strings.
Add a port to a running container
You can't. Docker sets up port publishing when it creates the container, and docker update has no option for ports. The fix is to recreate the container with the new -p flag:
shell
1docker stop web2docker rm web3docker run -d --name web -p 8080:80 -p 8443:443 nginxData in volumes survives this. Data written only inside the container's filesystem doesn't, so check for that before you remove it.
With Compose it's simpler. Add the port to ports in the Compose file and run docker compose up -d again. Compose sees the changed config and recreates only that service. In our test, the log showed Container ... Recreated and the new port answered right after.
Published ports and the firewall (ufw)
This is the part that bites people in production. On Linux, published ports bypass ufw and firewalld rules. Docker's own docs say so in the Docker Engine install guide for Ubuntu and on the packet filtering and firewalls page.
Why ufw doesn't block published ports
Traffic to a published port is redirected to the container in the nat table, before ufw's rules on incoming traffic ever see it. The packet is then forwarded to the container through Docker's own chains, which come before ufw's.
We checked this on Docker Engine 29 on Linux. With ufw enabled and default deny incoming, a container published with -p 8080:80 still answered from another machine with 200. Even an explicit rule didn't help:
shell
1sudo ufw deny 8080/tcpThe port stayed open. Meanwhile, ufw did drop traffic to the host's other ports, as expected.
So if your server relies on ufw and you run -p 5432:5432 for PostgreSQL, the database is open to the internet.
Bind to 127.0.0.1 behind a reverse proxy
The simplest fix: publish internal ports only on localhost and put a reverse proxy (NGINX, Caddy, Traefik) in front for anything that should be public:
shell
1docker run -d --name app -p 127.0.0.1:3000:3000 my-appThe proxy listens on 80 and 443 and forwards to port 3000 on 127.0.0.1. In our ufw test, the port bound to 127.0.0.1 wasn't reachable from outside, while the same app published without an IP was.
If the proxy also runs in Docker, you don't need to publish the app port at all. Put both containers on one network and publish only the proxy's 80 and 443.
Filter in the DOCKER-USER chain
When a port has to stay published on a public interface, but only some clients should reach it, add rules to the DOCKER-USER chain. Docker creates it for your own rules and processes it before its own.
This example allows only one source IP to reach published ports through the external interface eth0:
shell
1sudo iptables -I DOCKER-USER -i eth0 ! -s 203.0.113.10 -j DROP2sudo iptables -I DOCKER-USER -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPTThe second rule is not optional. -I inserts it at the top, so it runs first and lets replies to the containers' own outgoing connections back in. In our test, without it, containers couldn't even resolve DNS. With both rules, the allowed IP got 200 and everyone else timed out.
To close one published port to the outside instead, match the original port. By the time packets reach DOCKER-USER, the destination is already rewritten to the container port, so use conntrack:
shell
1sudo iptables -I DOCKER-USER -i eth0 -p tcp -m conntrack --ctorigdstport 8080 -j DROPReplace eth0 with your server's external interface. Rules added with iptables are lost on reboot, so re-apply them at boot with your usual tooling, like a small systemd unit or your configuration management. More examples are in Docker's iptables guide.
Docker Engine 29 also has an experimental nftables backend. It has no DOCKER-USER chain: you add your own nftables table with a forward chain instead. If you haven't switched the backend on purpose, you're on iptables.
On a cloud server, a security group or cloud firewall in front of the machine is another solid layer. It filters traffic before it reaches the host, so Docker's rules don't matter there.
Docker Desktop vs Docker Engine on Linux
Ports work the same way from the command line, but the plumbing is different:
- Docker Engine on Linux publishes ports with iptables or nftables rules on the host itself. The host can also reach containers on bridge networks by their container IP, without publishing. That's why the ufw issue above is a Linux issue.
- Docker Desktop (Mac, Windows) runs containers in a small VM. For a published port, the Docker Desktop backend process listens on the host port and forwards connections into the VM. In our test on a Mac,
curlto the container's IP timed out, so from the host you go through a published port.
Docker Desktop also binds to all interfaces by default. In our test on a Mac, a port published with -p 8080:80 was reachable from the local network. The Docker Desktop network settings have an option to bind to localhost by default. To filter traffic with the host firewall, write rules for the Docker Desktop backend process, as described in the Docker Desktop networking docs.
Common errors
Bind for 0.0.0.0:8080 failed: port is already allocated. Another container already uses host port 8080. Find it with docker ps, stop it, or choose another host port. On Docker Desktop, a port held by a non-Docker app gives ports are not available: ... bind: address already in use. On a Mac, for example, UDP port 5353 is taken by the system's mDNS service.
curl: (56) Recv failure: Connection reset by peer. The port is published, but nothing listens on the container side. Usually the second number in -p is wrong, like -p 8085:81 for NGINX, which listens on 80. Compare it with the port in the app's logs or in EXPOSE.
The same error when the port is right. The app inside the container listens on 127.0.0.1 instead of 0.0.0.0. Published traffic comes in through the container's network interface, so an app bound to the container's own localhost never sees it. Set the app to listen on all interfaces, for example with --host 0.0.0.0 for most dev servers.
Failed to connect with no mapping in docker port. The port is only exposed, not published. Recreate the container with -p.
Wrapping up
EXPOSE documents a port, -p publishes it, and -P publishes every exposed port to a random host port. Check the result with docker port, use ports in Compose, and recreate the container to add a port.
If you're just getting started, our guide on how to run a Docker container on your local machine walks through building an image and running it with a published port. Need the opposite direction, from a container to a service on the host? See connecting to the Docker host from a Docker container. For more everyday commands, here's our list of Docker commands every Docker expert must know.
On a server, the default is the risky part: -p without an IP opens the port to the whole internet, and ufw won't stop it. If you're not sure which ports your servers expose right now, our cloud infrastructure review checks that, along with the rest of your setup.
