Dash0 Raises $110M Series B at $1B Valuation

  • 9 min read

How to Expose Multiple Ports in Docker

A single container often needs more than one port reachable from the host: HTTP and HTTPS, a metrics endpoint next to the main service, DNS over both TCP and UDP. Docker offers three mechanisms for this: EXPOSE in your Dockerfile, the -p flag at runtime, and the ports directive in Compose. They don't all do the same thing. Most of the confusion in this area comes from treating them as interchangeable when they aren't.

Use multiple -p flags with docker run

If you just need a container reachable on several host ports, repeat the -p flag for each mapping:

bash
12345
docker run -d --name myapp \
-p 8080:80 \
-p 8443:443 \
-p 9090:9090 \
nginx:latest

That forwards host port 8080 to container port 80, 8443 to 443, and 9090 to 9090. The order is always host:container, and getting that backwards is one of the most common sources of "connection refused" errors when starting Docker containers.

Verify the mappings with docker ps:

bash
1
docker ps
text
12
CONTAINER ID IMAGE PORTS NAMES
a4f8c9e12345 nginx:latest 0.0.0.0:8080->80/tcp, 0.0.0.0:8443->443/tcp, 0.0.0.0:9090->9090/tcp myapp

Each -p is an independent forwarding rule, so you can mix protocols, bind specific host IPs, and use ranges in the same command.

Document ports with EXPOSE in your Dockerfile

EXPOSE does not publish ports. It is metadata only. People expect it to open the port; it doesn't.

It does two specific things. It documents the ports the image is designed to use, so anyone reading the Dockerfile knows what to publish. And it lets docker run -P (uppercase, often missed) auto-publish those ports to random host ports. That's it. Without a corresponding -p at runtime, an exposed port stays unreachable from outside Docker.

You can declare multiple ports in either of two equivalent ways:

dockerfile
1234567
# Multiple instructions
EXPOSE 80
EXPOSE 443
EXPOSE 9090
# Or a single instruction
EXPOSE 80 443 9090

Both produce identical results in the resulting image. Stick to one style for readability; multi-line is easier to grep and diff.

Multiple ports in Docker Compose

For anything more involved than a single docker run, Compose is cleaner. The ports directive takes a list:

yaml
1234567
services:
api:
image: my-app:latest
ports:
- "8080:80"
- "8443:443"
- "9090:9090"

Each list item is one published port mapping, semantically equivalent to a -p flag. Same order, same behavior.

For finer control over the host IP, protocol, or Swarm mode behavior, use the long syntax:

yaml
12345678910111213141516
services:
api:
image: my-app:latest
ports:
- target: 80
published: 8080
protocol: tcp
mode: host
- target: 53
published: 53
protocol: udp
mode: host
- target: 9090
host_ip: 127.0.0.1
published: 9090
protocol: tcp

target is the container port (required), published is the host port, protocol defaults to TCP, and host_ip binds to a specific interface. Binding admin or debug ports to 127.0.0.1 keeps them off the public network without relying on a firewall.

Port ranges

When a service uses a contiguous block of ports, like a game server with RTC channels or a SIP gateway, you can publish the whole range without listing each port:

bash
1
docker run -d -p 8000-8005:8000-8005 my-service

In Compose short syntax:

yaml
123
ports:
- "8000-8005:8000-8005"
- "9090-9091:8080-8081" # mismatched ranges of equal size also work

The host range and container range must be the same size. Long syntax also supports ranges — both target and published accept range strings:

yaml
1234567
services:
game:
image: my-game-server
ports:
- target: 10000-10100
published: 10000-10100
protocol: udp

If published is a range but target is a single port, Compose picks any free host port from the range — useful when you don't care which host port you land on, only that it falls in a specific window:

yaml
1234
ports:
- target: 8080
published: "8080-8090"
protocol: tcp

UDP and mixed-protocol exposure

Every Docker port mapping defaults to TCP. For UDP, append /udp to the container port. This matters for DNS, DHCP, WireGuard, QUIC, syslog, SIP, and most game servers.

With docker run:

bash
1234
docker run -d --name dns \
-p 53:53/tcp \
-p 53:53/udp \
my-dns-server

DNS is the canonical reason you often want both. Queries normally go over UDP, but zone transfers and any response larger than 512 bytes fall back to TCP. A DNS server that only publishes UDP will work fine until the first response that overflows, which usually happens at the worst possible time.

In a Dockerfile, the same /udp suffix applies:

dockerfile
12
EXPOSE 53/tcp
EXPOSE 53/udp

In Compose short syntax:

yaml
1234
ports:
- "53:53/tcp"
- "53:53/udp"
- "6060:6060/udp"

In Compose long syntax, set protocol: udp on the relevant entry, as shown earlier.

Common pitfalls

The biggest misconception is that EXPOSE controls access. It doesn't. Omitting it doesn't prevent anyone from publishing the port with -p, and including it doesn't block traffic. If your application listens on 8080, docker run -p 8080:8080 works whether the Dockerfile mentions 8080 or not. Treat EXPOSE as documentation. Use firewalls, network policies, and authentication for actual access control.

Published ports bind to 0.0.0.0 by default, which on a host with a public IP means the world. docker run -p 5432:5432 postgres on a cloud VM has just exposed Postgres to the internet. Bind to localhost for anything that should only be local: -p 127.0.0.1:5432:5432. The same prefix works in Compose short syntax, or set host_ip in long syntax.

Compose's expose: and ports: directives are different things, despite the naming. expose: mirrors the Dockerfile's EXPOSE instruction; it makes the port reachable to other services on the same Compose network but does not publish to the host. If you wrote expose: ["8080"] and can't reach the service from your browser, that's why. You wanted ports:.

Host port conflicts will trip you up eventually. Only one process on the host can bind a given port, so publishing -p 8080:80 while something else already holds 8080 fails with bind: address already in use. Run ss -tlnp | grep 8080 or lsof -i :8080 to find what's holding it before you blame Docker.

One more thing worth knowing about -P (uppercase): it publishes everything to random ports, which is fine for a quick test but breaks any URL you've bookmarked or any script that hardcodes a port. Reach for lowercase -p whenever you'll connect to the container more than once.

Final thoughts

The four rules worth keeping in your head: -p (or ports: in Compose) is what actually publishes a port; EXPOSE only documents; /udp flips the protocol when you need it; and bind to 127.0.0.1 for anything that shouldn't escape the host, since Docker defaults to 0.0.0.0. Most of the surprises in this area come from forgetting one of these.

Once you're publishing several ports across several services, the next problem is operational: knowing which port belongs to which workload, and whether traffic is actually flowing on each one. Per-port traffic patterns are usually the first thing to check when a service starts misbehaving in a way that doesn't show up in CPU or memory graphs.

Dash0's infrastructure monitoring tracks container network activity alongside real-time logs and distributed traces, so you can see what's reaching each published port and which service is responding, without SSH-ing into individual hosts. Start a free trial to see your containers, networks, and traffic in one view. No credit card required.