How to expose port in Docker container

Alex KondratievAlex Kondratiev
Maksym YemleninovMaksym Yemleninov

11 min read

Learn how to make a port inside a Docker container reachable from your machine or your network. The guide explains what EXPOSE in a Dockerfile does and doesn't do, how to publish ports with docker run -p and -P, how to check port mappings, how ports and expose work in Docker Compose, and why published ports skip ufw on Linux.


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 nginx

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

  • EXPOSE in a Dockerfile (or --expose in docker run) records which port the app listens on. It's metadata for whoever runs the image. It opens nothing on the host.
  • -p / --publish maps a host port to a container port. This is what makes the container reachable from outside.
  • -P / --publish-all publishes every port listed in EXPOSE to 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-alpine
2EXPOSE 80

Build it and run it without -p:

shell

1docker build -t expose-demo .
2docker run -d --name expose-only expose-demo
3docker port expose-only
4curl http://localhost:80

docker 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-net
2docker run -d --name api --network app-net nginx
3docker run --rm --network app-net curlimages/curl -s -o /dev/null -w "%{http_code}\n" http://api:80

This 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 nginx

Check the result with docker ps:

shell

1CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
2295932e5b083 nginx "/docker-entrypoint.…" 5 seconds ago Up 4 seconds 0.0.0.0:8080->80/tcp, [::]:8080->80/tcp web

0.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 nginx

For 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-app

Let Docker pick the host port

Give only the container port, and Docker picks a free host port:

shell

1docker run -d -p 80 nginx

You 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 nginx

In 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-dns

Bind 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 nginx

In 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" nginx

Publish 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 nginx
2docker port web-random

shell

180/tcp -> 0.0.0.0:55000

The 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 no EXPOSE, like alpine, -P publishes nothing.
  • It publishes on all interfaces, like -p without 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 web

shell

180/tcp -> 0.0.0.0:8080
280/tcp -> [::]:8080

Add 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-alpine
4 ports:
5 - "127.0.0.1:8080:80"
6 api:
7 image: nginx:1.29-alpine
8 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 web
2docker rm web
3docker run -d --name web -p 8080:80 -p 8443:443 nginx

Data 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/tcp

The 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-app

The 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 DROP
2sudo iptables -I DOCKER-USER -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT

The 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 DROP

Replace 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, curl to 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.

Alex Kondratiev

Alex Kondratiev

Founder of ITsyndicate. DevOps Enthusiast with 15+ years of experience in cloud, Infrastructure as Code, Kubernetes, and automation. Specialized in architecting secure, scalable, and resilient systems.

An Infrastructure Review Should Give You a Plan, Not a Dependency

Why we want the review to be useful even if your own team does all the work afterward. Of course we want to help with the implementation. We are an engineering company. We like solving infrastructure problems, and we want relationships where we can take responsibility for the work over time. So why offer an Infrastructure Review that a client can take back to their own team? Because I want the first engagement to earn its fee on its own, the client should leave with a better understanding of their situation, clear priorities, and a practical next step. They can then decide whether they want us to carry out the changes. That is the principle behind how we are building our Infrastructure Review. It shapes what we agree to investigate, how we explain our findings, and what the client can do with them afterward.

Your Infrastructure Can Be Expensive Even When Nothing Is Broken

A quiet production environment can still hide rising costs, wasted engineering capacity, and risks that appear only during growth or recovery. A familiar weekly update sounds like this: production is stable, the latest release went out, and there were no serious customer complaints. The cloud bill is higher again, but there is probably a reasonable explanation. One engineer spent half a day helping with the release, another fixed a monitoring issue, and the database may need attention next quarter. Nothing is broken badly enough to become a leadership problem. That is exactly why the underlying problem can survive for so long. I have seen this pattern more than once. Production looks stable, while senior engineers quietly spend much of their time keeping it that way. The platform works, but the company is paying for that stability through cloud spend, manual effort, slower releases, and assumptions about growth or recovery that nobody has tested recently. Infrastructure doesn’t need to cause an outage to become expensive. Outages are obvious. Inefficiency is quieter, and it can keep working for years while charging you for the privilege.

How to Run a Docker Container on Your Local Machine

This article is a quick, hands-on guide to running Docker locally. It walks through essential CLI commands for managing images and containers (build, pull, run, ps, start/stop, rm/rmi) and shows how to automate image creation with a Dockerfile. You’ll build an image, run a container with port mapping, verify it’s running, and learn tips for environment setup - everything you need to spin up and manage local containers efficiently.

Stay online.
Stay in command.