Docker

Run your services in Docker Compose? Add one container to your project and your services become reachable from the internet on a dedicated public IPv4 address, without changing those services at all. 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: inbound traffic arrives over the tunnel rather than through a published port.

Before you start

  • A GetPublicIP IP address and its API key — see Create an API Key
  • Linux: Docker Engine and Compose v2, meaning docker compose rather than the older docker-compose script
  • 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 run wsl --update if 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 that 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 not boilerplate. It is how the agent recovers from the faults it cannot fix in place, as described in Connection drops.

A complete working example

Nothing to add it to yet? This is a whole setup in one file: 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.

Two things are worth noticing about that file. web has no ports: entry, so nginx is reachable from the internet while not being published on the machine running it. And both services join appnet, because the agent resolves web by service name and can only do that on a network the two of them share. Compose would put both on the project's default network if you left the networks: blocks out entirely, and that 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]
PartDefaultNotes
prototcptcp or udp; comma-separate for both
stackipv4ipv4 or ipv6; comma-separate for dual-stack
serviceNonethe destination, resolved at runtime: a compose service name, any resolvable hostname, or a literal IPv4 address
internalPortsame as publicPortthe port on the destination
ExampleMeaning
8080/tcp->web:80TCP 8080 on your public IP goes to web on port 80
443/tcp,udp->web:443TCP and UDP 443 go to web on port 443
53/udp->dns:53UDP 53 goes to dns on port 53
9000/tcp/ipv4,ipv6->apidual-stack; the internal port defaults to 9000

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.

Note that you refer to your containers by service name, never by IP address. Compose assigns container IPs dynamically, so the agent resolves those names itself and re-checks them every 30 seconds. When a container restarts on a different IP, the forwarding rules are updated automatically.

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.

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 leaves everything above that to you, and encryption is one of the things above it. So if you serve HTTPS, your own service terminates the TLS connection. Map 443 to the container that already holds your certificate, and nothing about your TLS setup changes:

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 layer of encryption between our network and your machine, but it is the outer layer. What travels inside it is the same TLS ciphertext your visitor sent. Your certificates and private keys never leave your machine, there is nothing to upload to us, and your traffic is never decrypted and re-encrypted on the way through. We do not decrypt TLS traffic, and we 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 one, exactly as it would on any other public host.

  • Point a domain at your public IP. Add an A record for your IPv4 address, and an AAAA record 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, automated, and well suited to this. 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 quietly 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, terminate TLS.

Forwarding to a service on the host

Already running something on the box and just want it public? Point a mapping at host.docker.internal, the address you would reach for anyway:

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

Two things to check, because they produce the same "it just doesn't work" symptom:

  • The host service must listen on 0.0.0.0, not 127.0.0.1. A service bound to loopback refuses the connection from the container. Confirm with ss -lntp.
  • A host firewall will drop it silently unless you allow the bridge subnet inbound on docker0 or the br-* interface. This is the one case on this page where a firewall rule is needed, and it covers the container-to-host hop, not inbound traffic from the internet.

Host and container destinations can be mixed freely in a single GETPUBLICIP_MAPPINGS. The one exception is Seeing the real client IP address, which host destinations cannot work with at all.

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. Nothing to do, it works as-is.
  • Several networks. List getpublicip on 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 an interval.

This is what a healthy tunnel looks like in the log. It is worth knowing now, so you have something to compare against later:

tunnel status up=true interfaces=1 peers=1 healthy=true handshakeAge=31s rxDelta=0

healthy=true is the field that matters. Do not read up=true on its own as good news, because it only means the interface exists, and it stays true even on a tunnel that has quietly 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 the platform applies them, and requests made during that window fail. Two or three failures followed by success is the normal sequence, not a fault.

Test from a network that is not your own. Mobile data is the easiest option:

curl http://{YOUR IP ADDRESS}

Connection drops

Nothing to restart and nothing to configure. The tunnel re-establishes itself after a dropped connection, a router reboot, or your ISP handing you a new address.

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. 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 rather than trying to repair the tunnel in place, and Docker restarts it into a clean, 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. That is the recovery working, not an error.

This is the one place the compose file has to be right: recovery depends on your restart policy, so keep restart: unless-stopped on the service. Without it the container stops and stays stopped.

What this does not do is keep your service reachable while your internet is down. If the connection to your machine is gone, so is your service. The tunnel simply picks itself back up as soon as there is a connection to use.

Seeing the real client IP address

By default your containers do not see who is connecting to them. Inbound traffic is forwarded to your container and its source address is rewritten by the GetPublicIP service container, so every visitor appears in your access logs as the same address. Geo-location, per-client rate limiting, abuse blocking and fail2ban all see one caller.

GETPUBLICIP_PRESERVE_CLIENT_IP turns the rewriting off, and your containers then see the real client address. Read what it costs before you turn it on. The rewriting is what makes replies come back through the GetPublicIP service container. Without it, each of your services has to send its replies back through the service container itself, and because visitors arrive from arbitrary addresses all over the internet that means changing the service's default route. Set the variable on its own and your forwarding will stop working.

It is also all or nothing. The setting applies to the whole docker compose network rather than to one mapping, so it affects every service you forward to.

Turning it on

Two things, together: the variable, and a default route into each service you forward to. Docker will not let a container act as a network gateway, so the route has to be written inside each target's own network namespace. A tiny sidecar container that shares that namespace does it without touching your service or its image:

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
      net.ipv6.conf.all.forwarding: 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: ["ip", "route", "replace", "default", "via", "172.29.7.2"]
    depends_on:
      - web
      - getpublicip

networks:
  appnet:
    driver: bridge
    ipam:
      config:
        - subnet: 172.29.7.0/24
          gateway: 172.29.7.1

Repeat the sidecar for each service you forward to, and check the four things below. Each of them produces a service that hangs rather than 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 a separate thing 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. Run ip route replace default via <agent IPv4> and ip -6 route replace default via <agent IPv6> in the sidecar.

The route does not survive a restart of your service. Docker rebuilds the network namespace with a clean routing table, so the route is gone and inbound traffic stops until the sidecar runs again: docker compose up -d web-route. For anything you depend on, arrange for the sidecar to be re-run whenever the service it targets restarts.

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. If you need both, run a second agent, with its own API key and IP address, for the host mappings and leave this setting off there. The agent names the destinations that cannot work in its log rather than letting them time out silently.

When it does not work

Getting the route wrong looks like nothing at all. Connections simply hang: the tunnel stays up, healthy=true keeps appearing, 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. The replies are leaving your container by the wrong path and never coming back to be translated.

Check the routing table inside the service itself, which is where the problem actually is:

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

Container permissions

SettingWhy it is needed
cap_add: NET_ADMINConfigure the WireGuard interface and the forwarding rules
devices: /dev/net/tunThe tunnel device itself
net.ipv4.ip_forward=1Forward traffic from the tunnel to your target container
net.ipv4.conf.all.src_valid_mark=1Required by wg-quick's routing setup
net.ipv6.conf.all.forwarding=1Only 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 simply 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 it is worth confirming the device node actually exists before blaming 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 seeWhat it means
GETPUBLICIP_API_KEY is requiredThe variable is not set, or your .env file is not being picked up
tunnel up: … 401The API key is not valid
waiting for destinations to resolveThe 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 errorsNET_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/tunThe device node does not exist on the host. On Docker Desktop, check your WSL2 kernel version
Traffic arrives but replies hangThe forwarding sysctls are not set
A host mapping is refusedThe 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 brokenThe connection dropped. The agent is already dealing with it. See Connection drops
tunnel health cannot be determinedThe 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 restartedNormal 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 onThe 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 IPsA 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
no destination resolved in the family its mapping asked forAn ipv6 mapping pointing at a service with no IPv6 address, usually a compose network without enable_ipv6: true. See Forwarding IPv6
preserveClientIp and masqueradeOutbound are mutually exclusiveGETPUBLICIP_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 quietly leave the setting off

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

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 additionally 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.

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 actually down your service is unreachable, and no tunnel changes that, but it picks itself 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.


New to GetPublicIP? Start with Getting Started for how the service works. Running your services directly on a host instead of in containers? See the Linux and Windows guides.