How to Expose Docker Containers to the Internet
The short version. A ports: entry publishes a container to your host machine and no further. Four things get you past that: a port-forwarding rule on your router, which needs a real public address from your ISP; a Cloudflare Tunnel, for HTTP and HTTPS; Tailscale, for reaching your own devices privately; or a dedicated public IP routed your Docker project, which carries any protocol and needs nothing opened anywhere.
Want a public IP for your compose stack?
A dedicated public IPv4 and IPv6 routed to your Docker host over an encrypted tunnel. No published ports, no router changes, works behind CGNAT.
Your Docker Compose stack works, you run docker compose up -d, open http://localhost:8080, and the application is there. Now you want to get your project online so you can share it with other.
Working out how to expose Docker containers to the internet is where things get difficult, because at that point it has stopped being a Docker problem. Your ports: entries have already done everything they can, and what stands between your stack and the internet is your ISP's network.
This guide covers what is in the way, the four ways around it, and what a dedicated public IP changes about a compose project. The full configuration reference lives in the Docker setup documentation.
What ports: actually does
When you write ports: "8080:80", Docker asks the host's kernel to forward traffic arriving on host port 8080 to port 80 inside the container. The service becomes reachable at the host's own address: localhost:8080 on the machine itself, and something like 192.168.1.50:8080 from a laptop on the same network.
That is the whole extent of it. A published port reaches the host, and through the host it reaches your local network. It does not reach past your router, and no compose setting changes that, because the obstacle is not inside Docker.
Private addresses, and the translation that hides them
Look at the address your machine holds on your network. If it begins 192.168., 10., or 172.16. through 172.31., it is a private address: a range set aside for internal use, which no router on the public internet will carry. Every device on your network has one, and so does every container.
Your router holds the single public address your ISP gave you and lends it to everything behind it, using network address translation, or NAT. When your laptop connects outwards, the router rewrites the packet's source address to its own, notes what it did, and rewrites the replies so they reach the machine that asked.
That works in one direction only. An outbound connection creates its own entry in the router's table as it leaves; an inbound connection from a stranger arrives with no matching entry and nowhere to be sent, so it is discarded. Port forwarding is how you create one of those entries by hand, in advance.
If you are unsure what your connection gives you, the guide to how to get a public IP address covers how to check.
CGNAT, where port forwarding stops being an option
There is a second wall behind the first, and a growing number of people are behind it.
Many ISPs have run out of IPv4 addresses, so they perform the same translation a second time, one level up. Your router's WAN address is then private as well, usually in the 100.64.0.0/10 range, and the real public address is shared between you and many other customers. This is carrier-grade NAT, or CGNAT.
A forwarding rule on your own router now points at traffic that never arrives, because where an inbound connection goes is decided upstream, on equipment you have no access to. You can confirm which situation you are in, and see which ISPs do this, in the guide to self-hosting behind carrier-grade NAT.
Four approaches are worth considering for a compose stack, and everything that survives CGNAT does so for one reason: it is built on a connection your machine makes outwards.
The four compared
| Method | Works behind CGNAT | Protocols | Who terminates TLS | Router change | Address you are reached on |
|---|---|---|---|---|---|
| Router port forwarding | No | Any TCP or UDP | Your own service | Yes | Your ISP's, usually changing |
| Cloudflare Tunnel | Yes | HTTP and HTTPS | Cloudflare | No | Cloudflare's shared space |
| Tailscale | Yes | Any, but private only | End to end within your tailnet | No | A private tailnet address |
| Dedicated public IP | Yes | Any TCP or UDP | Your own service | No | A public IPv4 that is yours |
The first two columns decide most cases. With a real public address and an unblocked port, forwarding is free and hard to beat; if everything you run speaks HTTP, Cloudflare's tunnel is free and very good at that job. A longer treatment is in GetPublicIP vs Cloudflare Tunnel vs Tailscale.
The four in detail
Router port forwarding
Any protocol, if your ISP gives you a real public address.
Give the Docker host a fixed address, publish the container port with a ports: entry, then add a rule on the router sending inbound traffic on a chosen port to that host. It costs nothing, provided your ISP has not put you behind CGNAT and does not block the port you need, 25 for mail and 80 for HTTP are the usual casualties. Residential addresses also change periodically. If a rule is not behaving, the port forwarding troubleshooting guide works through the usual causes in order.
Cloudflare Tunnel
HTTP and HTTPS, with Cloudflare terminating your TLS.
A small daemon, which can run as another container in the same compose project, makes an outbound connection to Cloudflare's network and holds it open. Because that connection is outbound, CGNAT is not a problem and your router needs nothing. It is free, well documented, and for a self-hosted web application a genuinely good answer. The limits matter before you commit: a game server, mail server, DNS resolver or anything speaking raw TCP or UDP is out of scope, Cloudflare decrypts your traffic at its edge and re-encrypts it on the way to you, and you are reached through shared address space, so there is no reverse DNS record to set.
Tailscale and other mesh VPNs
Your own devices, from anywhere. Not public hosting.
Tailscale builds a private network out of your own devices. Install it on the Docker host and on your laptop and phone, sign in with the same account on each, and they reach one another regardless of NAT or CGNAT. For getting at your own services while away from home this is the least work of anything here. Only devices you have enrolled can connect, though, which is the point of the design: a visitor who has never heard of you cannot reach your tailnet, so websites, game servers and anything else meant for other people fall outside what it sets out to do.
A dedicated public IP address
Any protocol, on an address that belongs to you.
Instead of reaching your stack through somebody else's infrastructure, you are given a public IPv4 address of your own, routed to your machine. The tunnel is an outbound connection, so CGNAT and router configuration stop mattering. What arrives at the far end is ordinary packets on an ordinary public address: whatever protocol they are, on whatever port you chose to forward, with nothing inspecting or rewriting them on the way.
Which one fits what you are running
A web application and nothing else. Cloudflare Tunnel, unless you would like some privacy and have proper end-to-end encryption.
Only your own devices need access. Tailscale. Nothing public needs to exist at all.
Anything that is not HTTP, or CGNAT plus more than web traffic. Port forwarding if your connection allows it, a dedicated public IP otherwise.
If your stack is not in Docker, the same four apply and are covered in how to expose a home server to the internet.
What this looks like in a compose file
The agent is one more service alongside what you already run. It joins the same network as your other containers, holds the tunnel open, and forwards the ports you list to the services you name.
Two ports on your public address, sent to the `web` service by name. The mapping syntax also handles UDP, IPv6, dual-stack and destinations outside the compose project; that reference, with a complete example, is on the Docker setup page.
Your own services are untouched. `web` above is whatever you were already running, with no new environment variables, no labels, and no awareness that any of this is happening.
services:
web:
image: nginx:alpine
restart: unless-stopped
getpublicip:
image: ghcr.io/getpublicip/getpublicip:latest
restart: unless-stopped
environment:
GETPUBLICIP_API_KEY: "${GETPUBLICIP_API_KEY}"
GETPUBLICIP_MAPPINGS: "80/tcp->web:80; 443/tcp->web:443"
cap_add:
- NET_ADMIN
devices:
- /dev/net/tun
sysctls:
net.ipv4.ip_forward: 1
net.ipv4.conf.all.src_valid_mark: 1
net.ipv6.conf.all.forwarding: 1
Why there are no ports: entries
Look again at web in that file. It publishes nothing. For a guide about exposing containers, that is the part worth slowing down for.
Traffic for your public address arrives inside the agent container, on the tunnel interface. The agent then applies a destination NAT rule: the same class of rule your router applies when you forward a port, running inside the container rather than at the edge of your network. It rewrites the packet's destination from the public address to the container's address on the compose network, and passes it along.
Nothing on that path involves a published host port. The port is open on your public IPv4 address, not on your host and not on your router, so there is nothing new listening for a scan of your home connection to find. Adding a ports: entry to web would only publish it to your local network as well, usually the opposite of what you were arranging.
Destinations are written as service names rather than addresses, because Compose assigns container addresses as it sees fit. The agent re-checks those names every 30 seconds by default, so a container that restarts on a different address keeps working.
What the container needs, and what it does not
Three things, all visible above: the NET_ADMIN capability, so it can configure the tunnel interface and the forwarding rules; the /dev/net/tun device, which is the tunnel itself; and the forwarding sysctls, so traffic arriving on the tunnel can be passed to another container.
It does not need privileged: true. If you find a setup asking for it, something is being worked around rather than configured. It needs no WireGuard kernel module either: the image uses one when the kernel offers it and otherwise falls back to the bundled userspace wireguard-go.
What you get that the tunnels do not
Any protocol, not only HTTP. Forwarding happens at the packet level, so what travels over it is your business. A Minecraft server on UDP, a mail server on 25 and 587, SSH: each is the same kind of mapping as a web server.
An address that is yours. Not a hostname on shared infrastructure. It stays yours if you move the stack to another machine, ISP or country, so DNS records pointing at it survive the move.
Reverse DNS. Because the address is allocated to you, a PTR record can be set for it. Mail deliverability depends on that absolutely, which is what puts mail servers out of reach of the other approaches.
Your TLS stays yours. GetPublicIP forwards packets and never terminates TLS, so no certificates or keys are held on our side. The session is negotiated between your visitor and whichever of your containers terminates it, and is decrypted for the first time there. That service needs a valid certificate of its own, as on any public host.
The things that catch people out
One API key drives one tunnel
A WireGuard key belongs to a single endpoint, so two containers started with the same API key knock each other offline, whichever handshake most recently winning. It presents as intermittent flakiness rather than a clean error, with one reliable sign: both containers restart repeatedly, because each keeps concluding it is the broken one. Issue a separate key per tunnel from the API key page.
restart: unless-stopped is load-bearing
Most interruptions repair themselves. For the rarer faults a reconnection cannot fix, the agent deliberately exits rather than repairing a half-broken tunnel in place, and relies on Docker to start it again. Without a restart policy it stops and stays stopped.
IPv6 needs enabling on the network
Docker bridge networks carry IPv4 only unless told otherwise, so a container on a default network has no IPv6 address to forward to. Set enable_ipv6: true and an IPv6 subnet before adding IPv6 mappings.
Windows needs a WSL2 6.6 kernel
Docker Desktop runs containers in a WSL2 virtual machine, and the tunnel needs a netfilter module that kernel 5.15 does not include. wsl --version reports which you have and wsl --update moves you forward.
Your containers see the agent, not the visitor
By default the source address of inbound traffic is rewritten to the agent's own, so every visitor appears in your logs as the same local address. That matters for geolocation, rate limiting and fail2ban. GETPUBLICIP_PRESERVE_CLIENT_IP turns the rewriting off, and needs a route change in each target service to go with it: once replies are addressed to real internet clients, it is the service's default route that decides where they go rather than its own subnet. The Docker documentation covers it; the setting alone leaves you with a service that hangs while the logs still look healthy.
Plain HTTP is still plain
The tunnel encrypts the leg between our edge and your machine. It does nothing for the leg between your visitor and the public address, which is the internet. Serve HTTPS from your own container.
Frequently Asked Questions
How do I expose a Docker container to the internet?
There are four practical routes. Port forwarding works if your ISP gives you a real public address and does not block the port. Cloudflare Tunnel publishes HTTP and HTTPS over an outbound connection. Tailscale connects your own devices privately. A dedicated public IP routes a real IPv4 address to your stack over a WireGuard tunnel, carrying any protocol with nothing opened on your router.
Can a Docker container have its own public IP address?
Not from your ISP's address, which your router holds on behalf of everything behind it. What you can do is have a public IPv4 address of your own routed to the machine over a WireGuard tunnel, then forward individual ports from it to containers by service name. The address belongs to you rather than to any one container.
Can I expose a Docker Compose service without port forwarding?
Yes. A tunnel reaches your stack over a connection your machine makes outwards, so there is no inbound port to forward and nothing to change on the router. With GetPublicIP the exposed services need no published ports either, because traffic arrives on the tunnel interface rather than a port published on the host.
How do I expose Docker containers if my ISP uses CGNAT?
Carrier-grade NAT gives your router a private address rather than a public one, so no rule you add at home can help. Anything that works is built on an outbound connection from your machine: Cloudflare Tunnel for HTTP and HTTPS, Tailscale for private access between your own devices, a dedicated public IP for every protocol.
Does the GetPublicIP container need privileged mode?
No. It needs the NET_ADMIN capability, the /dev/net/tun device and the forwarding sysctls, and nothing more. It needs no WireGuard kernel module on the host either, because the image falls back to the userspace wireguard-go implementation when the kernel module is not available.
Does this work on Windows and macOS?
Yes, with Docker Desktop, and the compose file is the same on all three platforms. Your containers run inside a Linux VM on macOS and Windows, so the tunnel device comes from that VM. On Windows, check the WSL2 kernel version: 5.15 is missing a netfilter module the tunnel needs and 6.6 has it.
Where to go next
If a dedicated public IP suits what you are running, the Docker setup documentation has the complete reference: the full mapping syntax, a working example, IPv6, forwarding to a service on the Docker host, and the per-platform notes.
