Updated Sep 23, 2026
Docker
If your services run in Docker Compose, add our container to the project and they become
reachable from the internet on your dedicated public IPv4 address, with no changes to the
services themselves. Inbound traffic arrives over the tunnel instead of through a published
port, so you do not add ports: entries, you do not need a routable address from your ISP,
and there is nothing to open on your router or your host firewall.
Before you start
- A GetPublicIP IP address and its API key. See Create an API Key
- Linux: Docker Engine and Compose v2, meaning
docker composerather than the olderdocker-composescript - macOS: Docker Desktop, on either Intel or Apple Silicon
- Windows: Docker Desktop in Linux containers mode, on a WSL2 6.6 kernel. Check
with
wsl --version, and runwsl --updateif it reports anything older, because kernels before 6.6 are missing a netfilter module the tunnel needs
Everything on this page works unchanged on Windows and macOS with Docker Desktop, with nothing to install beyond Docker Desktop itself. It is free for personal use, education, non-commercial open source, and small businesses with fewer than 250 employees and under $10M in annual revenue; larger organisations need a paid subscription from Docker. See macOS and Windows for the detail on each.
The image is published for linux/amd64 and linux/arm64 at
ghcr.io/getpublicip/getpublicip.
Put your API key in a .env file
Create a .env file next to your compose file. See
Create an API Key for more information.
GETPUBLICIP_API_KEY=your-api-key-from-the-dashboard
Compose reads the .env file automatically, which is why the compose files below refer to
${GETPUBLICIP_API_KEY} rather than containing the key itself. Add .env to your
.gitignore.
Add to your Docker compose file
Add this service alongside the containers you already run:
services:
getpublicip:
image: ghcr.io/getpublicip/getpublicip:latest
restart: unless-stopped
environment:
GETPUBLICIP_API_KEY: "${GETPUBLICIP_API_KEY}"
GETPUBLICIP_MAPPINGS: "8080/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
networks:
- appnet
Replace appnet with the network your own services are already on, and web with your own
service names. Everything else can stay as it is.
Keep restart: unless-stopped. It is how the agent recovers from the faults it cannot fix in place, as described in
Connection drops.
A complete working example
If you do not have a compose project yet, this file is a complete setup: a web server on your
own public IP address, reachable from anywhere. Save it as compose.yaml:
services:
web:
image: nginx:alpine
restart: unless-stopped
networks:
- appnet
getpublicip:
image: ghcr.io/getpublicip/getpublicip:latest
restart: unless-stopped
environment:
GETPUBLICIP_API_KEY: "${GETPUBLICIP_API_KEY}"
GETPUBLICIP_MAPPINGS: "80/tcp->web:80"
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
networks:
- appnet
networks:
appnet:
Then start it:
docker compose up -d
Give it about 30 to 60 seconds and open http://{YOUR IP ADDRESS} from any network, or from
your phone on mobile data. The nginx welcome page means a request from the internet reached
your container.
web has no ports: entry. The agent reaches nginx over the internal appnet network, and
both services join it because the agent resolves web by service name, which only works on a
network the two of them share. If you left the networks: blocks out, Compose would put both
on the project's default network, which works just as well, but naming the network makes the
requirement visible.
Map your ports
GETPUBLICIP_MAPPINGS decides which ports on your public IP go to which of your containers.
Separate entries with ; or newlines:
publicPort[/proto[,proto]][/stack[,stack]]->service[:internalPort]
| Part | Default | Notes |
|---|---|---|
publicPort | None | a single port, or a range such as 60000-60099 |
proto | tcp | tcp or udp; comma-separate for both |
stack | ipv4 | ipv4 or ipv6; comma-separate for dual-stack |
service | None | the destination, resolved at runtime: a compose service name, any resolvable hostname, or a literal IPv4 address |
internalPort | same as publicPort | the port on the destination; for a range, leave it out or repeat the same range |
| Example | Meaning |
|---|---|
8080/tcp->web:80 | TCP 8080 on your public IP goes to web on port 80 |
443/tcp,udp->web:443 | TCP and UDP 443 go to web on port 443 |
53/udp->dns:53 | UDP 53 goes to dns on port 53 |
9000/tcp/ipv4,ipv6->api | dual-stack; the internal port defaults to 9000 |
60000-60099/udp->turn | UDP 60000 to 60099 go to turn on the same ports |
The segments after the port are matched by keyword, so their order does not matter.
8080/tcp/ipv4 and 8080/ipv4/tcp mean the same thing.
Refer to your containers by service name, never by IP address. Compose assigns container IPs dynamically, so the agent resolves the names itself and re-checks them every 30 seconds. When a container restarts on a different IP, the agent updates the forwarding rules.
A destination can also be any hostname that resolves, or a literal IPv4 address, and all three behave identically. A literal IPv6 address is not accepted; use a hostname with an AAAA record instead.
Port ranges
Some services need a whole block of ports rather than one: a TURN server for voice and video calls, FTP in passive mode, or anything carrying RTP media. Write the public port as a range and every port in it is forwarded to the same port on your container:
GETPUBLICIP_MAPPINGS: "3478/tcp,udp->turn; 60000-60099/udp->turn"
Port 60042 on your public IP always arrives at port 60042 on turn. These services
tell the other side which port to use, so a port that changed on the way in would
break the connection without any error.
For the same reason, a range can only go to the same range. You can leave the
internal port out, as above, or repeat the range exactly
(60000-60099/udp->turn:60000-60099). Sending it to different ports, such as
->turn:61000-61099, is refused when the container starts, with
a port range must forward to the same range. A range also cannot include port
51820, which the tunnel itself uses.
Open only as many ports as your service is set up to use. For a TURN server that
means the relay range it is configured to allocate from, not everything above
49152. This needs image 1.3.1 or later.
Forwarding IPv6
Mappings are IPv4 unless you say otherwise, so add the ipv6 stack to forward your public
IPv6 address, or ipv4,ipv6 to forward both. Two things have to line up for it to work.
Your compose network needs IPv6 enabled. Docker bridge networks are IPv4-only by default,
so a container on one has no IPv6 address for the agent to forward to. Give the network
enable_ipv6: true and an IPv6 subnet:
networks:
appnet:
enable_ipv6: true
ipam:
config:
- subnet: 172.29.7.0/24
- subnet: fd00:dead:beef::/64
The stack applies to the destination as well as the public port. The forwarding rules
cannot translate between the two families, so an IPv6 request has to reach your container over
IPv6. 8080/tcp/ipv4,ipv6->web:80 therefore needs web to hold both an IPv4 and an IPv6
address, which the network above gives it.
Keep net.ipv6.conf.all.forwarding: 1 in the agent's sysctls block. It is in the examples on
this page already, and it is only needed when you forward IPv6.
If something is missing the agent tells you which destination and which family failed, with
no destination resolved in the family its mapping asked for. An otherwise healthy setup that
produces that line for an ipv6 mapping is almost always a network without enable_ipv6.
SSL / TLS
GetPublicIP works at the packet level. It forwards TCP and UDP to your containers, and encryption and certificates stay with you: if you serve HTTPS, your own service terminates the TLS connection. Map 443 to the container that already holds your certificate, and your TLS setup stays as it is:
GETPUBLICIP_MAPPINGS: "443/tcp->web:443; 80/tcp->web:80"
We do not decrypt your traffic
Your public IP is not a TLS proxy. It holds none of your certificates or private keys, so it has nothing to decrypt with. The TLS session is negotiated directly between your visitor's browser and your own service, and it stays encrypted the whole way: out of the browser, across the internet to your public IP, through the WireGuard tunnel, and into your container, where it is decrypted for the first time.
The tunnel adds a second, outer layer of encryption between our network and your machine, and inside it travels the same TLS ciphertext your visitor sent. Your certificates and private keys never leave your machine, and your traffic is never decrypted and re-encrypted on the way through. We do not decrypt TLS traffic and have no plans to.
Your service needs a valid certificate
Because the certificate lives on whichever service terminates the connection, that service needs a valid certificate, exactly as it would on any other public host.
- Point a domain at your public IP. Add an
Arecord for your IPv4 address, and anAAAArecord if you serve IPv6 as well. Certificate authorities issue for domain names, so a bare IP address is not enough. - Get the certificate from Let's Encrypt. It is free and automated. Caddy and Traefik request and renew for you with no more configuration than your domain name; nginx and Apache pair with certbot.
- If you use an HTTP-01 challenge, map port 80 as well as 443 to the same service. Let's Encrypt connects to port 80 on your public IP to answer the challenge, and it fails if only 443 is mapped. A DNS-01 challenge does not need port 80.
- Leave those ports mapped. Renewal repeats the same challenge every 60 days or so, and it fails without telling you if the mapping has gone.
Plain HTTP is still plain
A mapping to port 80 serving unencrypted HTTP is unencrypted between your visitor and your public IP, exactly as it would be anywhere else on the internet. The WireGuard tunnel covers only the hop between our network and your machine. If the content matters, enable TLS.
Forwarding to a service on the host
To make a service that runs directly on the Docker host public, point a mapping at
host.docker.internal:
services:
getpublicip:
environment:
GETPUBLICIP_MAPPINGS: "8081/tcp->host.docker.internal:8081"
extra_hosts:
- "host.docker.internal:host-gateway" # needed on Linux; built in on Docker Desktop
- The host service must listen on
0.0.0.0, not127.0.0.1. A service bound to loopback refuses the connection from the container. Confirm withss -lntp. - A host firewall will drop it silently unless you allow the bridge subnet inbound on
docker0or thebr-*interface.
Host and container destinations can be mixed freely in a single GETPUBLICIP_MAPPINGS, except
with
Seeing the real client IP address,
which host destinations cannot work with.
Docker Compose networks
Because targets are resolved by service name, the getpublicip container has to be on the
same Docker network as the containers it forwards to.
- One default network. It works as it is.
- Several networks. List
getpublicipon every network that holds a service it forwards to. - Targets in a different compose project. Both projects need to join a shared external
network:
docker network create shared
Then in each compose file:services: getpublicip: networks: - shared networks: shared: external: true
If this is wrong, the logs repeat waiting for destinations to resolve and the agent keeps
retrying rather than failing outright.
Check the logs
docker compose up -d
docker compose logs -f getpublicip
A healthy start brings the tunnel up on interface pi0, resolves your destinations,
installs the forwarding rules, and pushes the mappings to GetPublicIP. The agent then keeps
running, re-checking the destinations and the tunnel itself on a 30 second interval.
A healthy tunnel logs a line like this one. Keep it in mind as something to compare against if the tunnel misbehaves later:
tunnel status up=true interfaces=1 peers=1 healthy=true handshakeAge=31s rxDelta=0
Look at healthy=true. up=true only means the interface exists, and it stays true even on
a tunnel that has stopped passing traffic.
Testing
Give it about 30 to 60 seconds before you test. Pushing new mappings puts your IP address into
an updating state while we apply them, and requests made during that window will fail. Two
or three failures followed by success is normal.
Test from a network that is not your own. Mobile data is the easiest option:
curl http://{YOUR IP ADDRESS}
Connection drops
The tunnel re-establishes itself after a dropped connection, a router reboot, or your ISP handing you a new address, with nothing for you to restart or configure.
Most of the time it repairs itself without the agent having to do anything. The tunnel is an outbound connection held open by a keepalive every 25 seconds, and our server re-learns your address from any packet the agent sends, so when your router drops the mapping or moves you to a new port, the next keepalive puts it back.
For the rarer faults a keepalive cannot fix, the agent stops rather than trying to repair the
tunnel in place, and Docker restarts it with a fresh connection. In the log you will see
tunnel appears broken when it first notices, and then
exiting so the container is restarted if the problem persists across several checks. Both
lines are normal recovery.
Recovery depends on your restart policy, so keep restart: unless-stopped on the service.
Without it the container stops and stays stopped.
While your host's internet connection is down, your services are unreachable too. The tunnel comes back up as soon as there is a connection to use.
Seeing the real client IP address
By default, your containers do not see the visitor's IP address. The GetPublicIP container
rewrites the source address of inbound traffic as it forwards it, so every visitor appears in
your access logs as the same local IP address. Geolocation, per-client rate limiting, abuse
blocking and fail2ban all see one IP address.
GETPUBLICIP_PRESERVE_CLIENT_IP turns the rewriting off, and your containers then see the
real client IP address.
It needs a change to every service you forward to, so read this before you turn it on.
While the source address is rewritten to one on the compose network, your services reply
through the GetPublicIP container automatically. With GETPUBLICIP_PRESERVE_CLIENT_IP on, each
service's default route has to point at the GetPublicIP container, so that its replies go back
to the client through your public IP address. Without that route, replies leave through your
ISP connection and the client drops them.
This needs image 1.1.0 or later.
The setting applies to the whole agent, not to a single mapping, so it affects every service that agent forwards to.
Why the default route has to change
Everything else in this section follows from what happens to one request coming in and its reply going back out:
By default
visitor 203.0.113.9 → your public IP → tunnel → agent
agent rewrites the source to its own address, 172.29.7.2
web replies to 172.29.7.2, which is on its own subnet
agent puts the real address back, reply leaves through the tunnel
With client IP preservation on
visitor 203.0.113.9 → your public IP → tunnel → agent
web sees 203.0.113.9 and then send replies to 203.0.113.9
the web container reply will follow the default route
A container decides where to send a reply by looking at its own subnet first and its default route second. While the source address is being rewritten, every reply is addressed to something on the local subnet, and the default route never comes into it. With client IP preservation on, every reply is addressed to somewhere out on the internet, so the default route decides all of them. The fix is therefore a route, set inside each service, and no setting on the agent can replace it.
Docker has no built-in way to do this, so the examples below set the default route on each service container with a small sidecar container.
Example Project
There are two versions of the same project. Start with the IPv4-only one unless you also forward your public IPv6 address. Every IPv6 line below is one more thing to get right, and a half-finished IPv6 setup gives you a service that works over IPv4 and hangs over IPv6.
IPv4 only
services:
getpublicip:
image: ghcr.io/getpublicip/getpublicip:latest
restart: unless-stopped
environment:
GETPUBLICIP_API_KEY: "${GETPUBLICIP_API_KEY}"
GETPUBLICIP_MAPPINGS: "80/tcp->web:80"
GETPUBLICIP_PRESERVE_CLIENT_IP: "true"
cap_add:
- NET_ADMIN
devices:
- /dev/net/tun
sysctls:
net.ipv4.ip_forward: 1
net.ipv4.conf.all.src_valid_mark: 1
networks:
appnet:
ipv4_address: 172.29.7.2 # fixed, because the sidecar routes to it
web:
image: nginx:alpine
restart: unless-stopped
networks:
appnet:
ipv4_address: 172.29.7.3 # give every service a fixed address, not just the agent
# Points web's default route at the agent, from inside web's own network
# namespace. Runs once and exits.
web-route:
image: alpine:3.20
network_mode: "service:web"
cap_add:
- NET_ADMIN
restart: "no"
command: sh -c 'ip route replace default via 172.29.7.2'
depends_on:
web:
condition: service_started
restart: true # re-run this sidecar whenever web restarts
getpublicip:
condition: service_started
networks:
appnet:
driver: bridge
ipam:
config:
- subnet: 172.29.7.0/24
gateway: 172.29.7.1
IPv4 and IPv6
The same project with the IPv6 half added. Four things change: the network gains
enable_ipv6: true and an IPv6 subnet, every service gains a fixed ipv6_address, the agent
gains the net.ipv6.conf.all.forwarding sysctl, and the sidecar sets a second default route.
services:
getpublicip:
image: ghcr.io/getpublicip/getpublicip:latest
restart: unless-stopped
environment:
GETPUBLICIP_API_KEY: "${GETPUBLICIP_API_KEY}"
GETPUBLICIP_MAPPINGS: "80/tcp/ipv4,ipv6->web:80"
GETPUBLICIP_PRESERVE_CLIENT_IP: "true"
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
networks:
appnet:
ipv4_address: 172.29.7.2 # fixed, because the sidecar routes to it
ipv6_address: fd00:c0de:7::2
web:
image: nginx:alpine
restart: unless-stopped
networks:
appnet:
ipv4_address: 172.29.7.3 # give every service a fixed address, not just the agent
ipv6_address: fd00:c0de:7::3
# Points web's default routes at the agent, from inside web's own network
# namespace. Runs once and exits. Both families need their own route.
web-route:
image: alpine:3.20
network_mode: "service:web"
cap_add:
- NET_ADMIN
restart: "no"
command: >
sh -c 'ip route replace default via 172.29.7.2;
ip -6 route replace default via fd00:c0de:7::2'
depends_on:
web:
condition: service_started
restart: true # re-run this sidecar whenever web restarts
getpublicip:
condition: service_started
networks:
appnet:
driver: bridge
enable_ipv6: true
ipam:
config:
- subnet: 172.29.7.0/24
gateway: 172.29.7.1
- subnet: fd00:c0de:7::/64
gateway: fd00:c0de:7::1
Whichever you start from, repeat the sidecar for each service you forward to, and check the four points below. Getting any of them wrong leaves a service that hangs without reporting an error.
Give every service on the network a fixed address, not just the agent. An unpinned service
takes the lowest free address, which is the one you reserved for the agent, and depends_on
means it often starts first and takes it. The agent then fails to start at all with
Address already in use.
If you forward IPv6, set both routes. The IPv6 default route is separate from the IPv4 one,
so a sidecar that only sets the IPv4 route leaves IPv6 replies going out through the bridge, and
those connections hang while IPv4 works. The second ip -6 route replace line in the dual-stack
example sets it. Leaving it out is the most common way that version goes wrong, because
everything you test over IPv4 works.
The route does not survive a restart of your service. Docker builds a new network namespace
with a clean routing table, so the route is gone and inbound traffic stops until the sidecar runs
again. The depends_on above handles this: restart: true tells Compose to re-run the sidecar
whenever web restarts, so docker compose restart web and docker compose up -d both repair
the route on their own.
It does not cover Docker restarting your service without you. If web crashes, or is killed by
the out-of-memory killer, its own restart: policy brings it back without Compose being
involved, and nothing re-runs the sidecar. The route is missing, inbound traffic stops, and
nothing reports a problem. Put it back with:
docker compose up -d web-route
We recommend adding the default route in the service's Dockerfile so it survives these restarts.
Otherwise, check the routes with docker exec web ip route and set the default route again
whenever it has gone.
Host destinations cannot work in this mode. Forwarding to a service on the Docker host needs the reply to come back through the agent as well, and that would mean changing the default route of the host itself.
Check it worked
Run a request against your IP address:
curl http://{YOUR IP ADDRESS} # one request through your public IP
docker compose logs --tail 10 web # what your container recorded
The log should show the real client IP address. If it shows 172.29.7.2, client IP
preservation is not on and you are seeing the default behaviour.
Testing from the Docker host itself is fine. The request still goes out to your public IP, reaches our network and comes back through the tunnel, so your container records your ISP connection's public address, which is easy to tell apart from the bridge address the rewriting would have put there.
If you forward IPv6, check it separately with curl -6. The two families have separate default
routes, so one can work while the other is broken.
When it does not work
A wrong default route produces no error. Connections hang, the tunnel stays up and keeps
logging healthy=true, the forwarding rules stay correct, and nothing in
docker compose logs getpublicip reports a problem, because from the agent's side there isn't
one. Your container's replies are leaving by the wrong path instead of through your public IP
address.
Check the routing table inside the service:
docker exec web ip route # default must be via the agent's address
docker exec web ip -6 route # and this one too, if you forward IPv6
Sending outbound traffic through your IP
The sections above cover traffic coming in. GETPUBLICIP_OUTBOUND covers the
other direction: your containers reach the internet from your public IP instead of
your ISP's address. That helps when something you connect to only allows a known
address, or when you want your outbound traffic to come from the same address as
your inbound.
It needs the same default-route change as client IP preservation, for the same reason: the reply has to come back through the agent. If you have already set up the sidecar for that, you only need to add the setting.
services:
getpublicip:
image: ghcr.io/getpublicip/getpublicip:latest
restart: unless-stopped
environment:
GETPUBLICIP_API_KEY: "${GETPUBLICIP_API_KEY}"
GETPUBLICIP_MAPPINGS: "80/tcp->web:80"
GETPUBLICIP_OUTBOUND: "true"
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
networks:
appnet:
ipv4_address: 172.29.7.2
Then point each container that should use it at the agent, exactly as in the Example Project above. A container whose route you leave alone carries on using the normal Docker bridge, so you can move services over one at a time.
Check it from inside a container you have routed:
docker exec web curl -s https://api.getpublicip.com/ip
It should report your GetPublicIP address rather than your home or office one.
Outbound only, with no ports forwarded
If all you want is the outbound direction, GETPUBLICIP_MAPPINGS becomes
optional:
environment:
GETPUBLICIP_API_KEY: "${GETPUBLICIP_API_KEY}"
GETPUBLICIP_OUTBOUND: "true"
An agent started this way leaves the port mappings on your IP address as they are, including the ones you set up in the dashboard.
What to know before you turn it on
Your containers share one address. Everything behind the agent leaves from the same public IPv4 and the same public IPv6 address, so the far end cannot tell one container from another.
It fails closed. A container routed through the agent has no internet at all while the tunnel is down, where before it would have used your normal connection. This is deliberate, so that your traffic never falls back to your ISP address without you knowing, but think about it before you route something you depend on through the agent.
If a container cannot reach the internet, the problem is almost always its route rather than the agent. Check inside the container itself:
docker exec web ip route # default must be via the agent's address
docker exec web ip -6 route # and this one too, if you use IPv6
Container permissions
| Setting | Why it is needed |
|---|---|
cap_add: NET_ADMIN | Configure the WireGuard interface and the forwarding rules |
devices: /dev/net/tun | The tunnel device itself |
net.ipv4.ip_forward=1 | Forward traffic from the tunnel to your target container |
net.ipv4.conf.all.src_valid_mark=1 | Required by wg-quick's routing setup |
net.ipv6.conf.all.forwarding=1 | Only needed if you forward IPv6 |
macOS
Use Docker Desktop and the compose file above works unchanged, on both Intel and Apple Silicon.
Other Docker runtimes for macOS (Colima, OrbStack, Rancher Desktop) run different kernels and have not been verified, so we cannot say whether the tunnel comes up on them.
Windows
The compose file above needs no changes on Windows using Docker Desktop.
Docker Desktop needs 64-bit Windows 10 or 11, on a version still supported by Microsoft, and the WSL 2 backend, which is the default. There are then two things to check before you start.
1. Docker Desktop must be in Linux containers mode. The Windows containers engine cannot run this image, and it is not available on the WSL 2 backend in any case.
2. Your WSL2 kernel must be 6.6.x. Older kernels, 5.15 among them, are missing a
netfilter module the tunnel needs, and there is no way to work around it from inside the
container. The tunnel fails to come up with unknown option '--save-mark'. Check and update
from PowerShell:
wsl --version # need "Kernel version: 6.6.x"
wsl --update # if it reports anything older
6.6 only became the settled default around WSL 2.4 to 2.7, so a machine that has not been updated in a while can still be on 5.15. The same requirement applies if you run Docker CE inside a WSL2 distribution rather than Docker Desktop, because it shares the same kernel.
On 6.6, tun is a module rather than built in, so check that the device node exists before
you look for a problem in the agent. This command fails at container creation if it is
missing:
docker run --rm --device /dev/net/tun alpine ls -l /dev/net/tun
No Windows Firewall rule or port forward is needed. Inbound traffic arrives over the tunnel, so neither Windows Firewall nor WSL's NAT is in the path.
Laptops and sleep
Sleep drops the tunnel. On macOS and Windows the Linux VM suspends when your machine does, and your public IP is unreachable until it wakes, at which point the tunnel re-establishes itself automatically. Try it on your laptop by all means, but run it on a server: a machine that sleeps is a poor host for an always-on public IP.
One API key, one tunnel
A WireGuard key belongs to a single endpoint. If two containers run with the same API key, they will knock each other offline. Whichever one handshaked most recently wins, and the other silently drops traffic until its next keepalive. It looks like random flakiness rather than a clear error. The usual cause is testing on a laptop while a server is already connected with the same key.
This one is self-diagnosing: both containers restart over and over, because each keeps deciding it is the broken one. A container that is restart-looping on a machine whose internet is fine almost always means a second agent is using the same key.
Use one API key per running tunnel.
Troubleshooting
docker compose logs -f getpublicip is the place to start.
| What you see | What it means |
|---|---|
GETPUBLICIP_API_KEY is required | The variable is not set, or your .env file is not being picked up |
tunnel up: … 401 | The API key is not valid |
waiting for destinations to resolve | The target is not up yet, the service name is wrong, or the agent is not on a shared network with it |
wg-quick or permission errors | NET_ADMIN or /dev/net/tun is missing |
iptables-restore … unknown option '--save-mark' | Windows only: a WSL2 kernel older than 6.6. Run wsl --update. See Windows |
error gathering device information … /dev/net/tun | The device node does not exist on the host. On Docker Desktop, check your WSL2 kernel version |
| Traffic arrives but replies hang | The forwarding sysctls are not set |
| A host mapping is refused | The host service is bound to 127.0.0.1 rather than 0.0.0.0, or a host firewall is dropping the bridge subnet |
tunnel appears broken | The connection dropped. The agent is already dealing with it. See Connection drops |
tunnel health cannot be determined | The agent could not read the tunnel's status this time round, which is not the same as the tunnel being down. It is given longer to clear on its own before the container is restarted |
exiting so the container is restarted | Normal recovery, not an error. The container comes back with a fresh connection. If it repeats for a long time, either the machine has no internet or two agents are sharing one API key |
Nothing in the log looks wrong, but inbound traffic hangs, and GETPUBLICIP_PRESERVE_CLIENT_IP is on | The target is not sending its replies back through the agent. Run docker exec <target> ip route: the default route has to point at the agent, and ip -6 route as well if you forward IPv6. See Seeing the real client IP address |
these destinations cannot work while preserving client IPs | A host destination is mapped while client IP preservation is on, which cannot work. Move those mappings to a second agent that leaves the setting off |
a port range must forward to the same range | A range mapping points at different ports on the destination, or a range on one side and a single port on the other. Leave the internal port out, or repeat the same range. See Port ranges |
no destination resolved in the family its mapping asked for | An ipv6 mapping pointing at a service with no IPv6 address, usually a compose network without enable_ipv6: true. See Forwarding IPv6 |
A container you routed at the agent has no internet, and GETPUBLICIP_OUTBOUND is on | Either the tunnel is down (this mode fails closed on purpose) or the container's default route is not pointing at the agent. Run docker exec <target> ip route, and ip -6 route as well if you use IPv6. See Sending outbound traffic through your IP |
GETPUBLICIP_MAPPINGS is required (or set GETPUBLICIP_OUTBOUND=true for egress with no inbound ports) | You started the agent with no port mappings. That is only allowed for an outbound-only setup, which needs GETPUBLICIP_OUTBOUND=true |
skipping API push: no mappings configured (egress-only) | Not an error. An outbound-only agent leaves the port mappings on your IP address untouched rather than clearing them |
preserveClientIp and masqueradeOutbound are mutually exclusive | GETPUBLICIP_PRESERVE_CLIENT_IP and GETPUBLICIP_MASQUERADE were both set on. Remove GETPUBLICIP_MASQUERADE |
is not a boolean (use true or false) | A setting like GETPUBLICIP_PRESERVE_CLIENT_IP was given something other than true or false. yes and on are refused deliberately, so that a typo cannot leave the setting off without you noticing |
To check a configuration without bringing anything up or contacting the API, run it in dry run mode. It prints the rules it would apply and exits:
docker run --rm \
-e GETPUBLICIP_API_KEY=x -e GETPUBLICIP_DRY_RUN=true \
-e GETPUBLICIP_ISOLATED_INTERFACE=eth0 \
-e GETPUBLICIP_MAPPINGS="8080/tcp->web:80" \
ghcr.io/getpublicip/getpublicip:latest
Frequently Asked Questions
Do I need to publish ports on my containers to use GetPublicIP?
No. Traffic arrives through the WireGuard tunnel, not through a published host port, so the services you expose need no ports: entries and no changes at all. Adding ports: would also expose the service on your LAN, which is usually not what you want.
Does the GetPublicIP container need to run as privileged?
No. The container needs the NET_ADMIN capability, the /dev/net/tun device, and the forwarding sysctls, and that is all.
Do I need the WireGuard kernel module installed on the host?
No. The image uses the host's kernel module when it is available and otherwise falls back to the bundled userspace wireguard-go. There is nothing to install on the host either way, on any platform.
Does this work on macOS and Windows?
Yes, and the compose file needs no changes on either. Your container's host is a Linux VM rather than the desktop operating system, so the tunnel device comes from that VM and there is nothing to install on macOS or Windows itself. macOS works unmodified on Intel and Apple Silicon with Docker Desktop. On Windows, Docker Desktop must be in Linux containers mode and your WSL2 kernel must be 6.6.x. Check with wsl --version and run wsl --update if it reports anything older, because kernels before 6.6 are missing a netfilter module the tunnel requires.
Does GetPublicIP decrypt my HTTPS traffic?
No. GetPublicIP forwards packets and never terminates TLS, so it holds none of your certificates or private keys and has nothing to decrypt with. The TLS session is negotiated directly between your visitor's browser and whichever of your own services terminates it, and it is decrypted for the first time when it gets there. That service needs a valid certificate of its own, exactly as it would on any other public host. Let's Encrypt is free and automated and suits this well, and if you use an HTTP-01 challenge remember to map port 80 alongside 443.
Can I forward a port to a service running on the Docker host rather than a container?
Yes. Point the mapping at host.docker.internal, adding extra_hosts: - "host.docker.internal:host-gateway" on Linux. It is built in on Docker Desktop. A literal IP works too. The host service must also listen on 0.0.0.0 rather than 127.0.0.1, and a host firewall needs to allow the bridge subnet inbound. Host destinations are the one thing that cannot be combined with GETPUBLICIP_PRESERVE_CLIENT_IP.
Can my containers see the real visitor's IP address?
Yes, with GETPUBLICIP_PRESERVE_CLIENT_IP=true, and it is off by default because it needs work at your end. Normally the agent rewrites the source address of inbound traffic to its own so replies come back through it, which is why your access logs show one address for every visitor. Turning that off means every service you forward to has to send its replies back through the agent instead, which means changing that service's default route. It applies to your whole compose file rather than one mapping, and forwarding to a service on the Docker host cannot work while it is on. Set it up together with the route, never on its own: the variable by itself leaves you with a service that hangs while the logs still look healthy.
Can I use X-Forwarded-For instead of GETPUBLICIP_PRESERVE_CLIENT_IP?
Not by itself. X-Forwarded-For only helps when whatever adds the header can see the real client address to begin with. The source address is rewritten at the packet level, before anything reads an HTTP request, so a reverse proxy inside your compose file receives packets that already claim to come from the GetPublicIP container and has nothing truthful to put in the header. Adding one there just records the wrong address in a second place. There is one case where the header does work and you need none of this: if your traffic reaches a CDN or an external proxy such as Cloudflare before it ever arrives at your public IP, that service sees the real client and sets the header itself, and your containers should read it in the normal way.
Can my containers use my public IP for outgoing traffic too?
Yes, with GETPUBLICIP_OUTBOUND=true. By default only inbound traffic uses the tunnel and your containers still reach the internet through your normal connection. Turning this on sends their outgoing traffic through the tunnel as well, so it leaves from your GetPublicIP address. It needs the same change as seeing the real client IP does (each container's default route has to point at the GetPublicIP container), so if you have set that up already there is nothing more to do than add the setting. Every container behind it shares the one address. It also fails closed on purpose: while the tunnel is down those containers have no internet at all, instead of falling back to your ISP's address.
What happens if my internet connection drops?
The tunnel re-establishes itself, and there is nothing to restart and nothing to configure. Most drops are repaired by the keepalive that holds the connection open, without the agent having to intervene; on a real tunnel we measured traffic flowing again about 17 seconds after connectivity returned. For the rarer faults a keepalive cannot fix, the agent stops the container rather than trying to repair the tunnel in place, and your restart policy brings it straight back up with a fresh connection, which is why the compose snippet sets restart: unless-stopped. While your internet is down your service is unreachable, and the tunnel comes back up as soon as there is a connection to use.
What happens when one of my containers restarts with a new IP address?
The agent re-resolves every destination service name on an interval (GETPUBLICIP_RESOLVE_INTERVAL, 30 seconds by default) and re-applies the forwarding rules if a container has moved. You do not need to restart anything. A single dropped request right after a re-sync is expected while connection tracking settles.
Can I run the same API key in two places at once?
No. A WireGuard key belongs to a single endpoint, so two containers using the same API key will knock each other offline. Whichever one handshakes most recently wins. It presents as random, intermittent failures rather than a clean error, though there is one clear signal: both containers restart over and over, because each keeps deciding it is the broken one. Use one API key per running tunnel.
Getting Started explains how the service works. To run your services directly on a host instead of in containers, see the Linux and Windows guides.