Gluetun Port Forwarding for Torrent Apps
Downloads work but nothing ever connects in. Set up gluetun port forwarding, push the new port to your torrent client on every reconnect, and verify it.
Why nothing connects in without a forwarded port
Gluetun port forwarding asks your VPN provider to map one public port on its exit address back to your container, which is the only way another peer can start a connection to your torrent client. Without that mapping the tunnel is healthy, downloads run, and nothing ever arrives on its own. Every working connection is one your client opened first.
The mechanism is NAT (network address translation). Your container shares the provider's exit address with many other customers. When your client opens a connection outward, the provider records that flow and sends the replies back down your tunnel. A stranger's peer connecting inward matches no recorded flow, so the packet reaches the exit address and is dropped there. Your client still reaches every peer that is itself connectable, so downloads finish and the problem stays invisible. Seeding is where it shows, because a seeder is a machine that other people connect to.
An open inbound port changes two things. You join a swarm faster, because peers who cannot accept connections themselves can now reach you, and you can upload to those peers at all.
Why most VPN providers do not offer a forwarded port
A forwarded port is a scarce resource on a shared address. The provider reserves one port number on one exit IP for one customer, and then answers for whatever that customer does with it. Several large providers removed the feature and named abuse handling as the reason. Treat support as a category question rather than a checkbox: ask whether the provider offers port forwarding today, on your plan, and on servers you can actually select.
Where forwarding exists, the port is dynamic. It belongs to the VPN session and not to your account, so it can be a different number after every reconnect. Private Internet Access issues a signed port that gluetun refreshes, and the upstream documentation says you keep the same port for 60 days as long as you bind mount the /gluetun directory so that state survives a restart. ProtonVPN assigns a random port over NAT-PMP (NAT port mapping protocol) on a short lease that has to be renewed continuously. This is why setting the port once in the client never keeps working.
Which providers gluetun can ask for a port
As of gluetun v3.41.3, released 30 July 2026, the native integration validates against four provider names: Private Internet Access, ProtonVPN, Perfect Privacy and PrivateVPN. Enable it with VPN_PORT_FORWARDING=on, which is off by default. Older guides use PORT_FORWARDING or PRIVATE_INTERNET_ACCESS_VPN_PORT_FORWARDING. Both still work in this version as retro-compatible names, and both are on their way out.
Two provider details decide whether the request can succeed at all. ProtonVPN needs a paid plan, and NAT-PMP has to be switched on: enable NAT-PMP (Port Forwarding) under the VPN options when you generate the WireGuard configuration, or append +pmp to your username when you use OpenVPN. Private Internet Access on OpenVPN has PORT_FORWARD_ONLY, which restricts server selection to servers that support forwarding, so you do not land on one that never had it. WireGuard and OpenVPN differ in how the port is requested, so read your provider's page before you choose.
When gluetun runs a custom configuration instead of a built-in provider, VPN_PORT_FORWARDING_PROVIDER names the API gluetun should call. The upstream Private Internet Access page pairs that variable with VPN_PORT_FORWARDING_USERNAME and VPN_PORT_FORWARDING_PASSWORD, which carry the account credentials the port request needs.
Turn on gluetun port forwarding in docker compose
This assumes the tunnel already works. If it does not, start by routing Docker container traffic through gluetun and come back once downloads run.
services:
gluetun:
image: qmcgaw/gluetun:v3.41.3
container_name: gluetun
cap_add:
- NET_ADMIN
devices:
- /dev/net/tun:/dev/net/tun
ports:
- 8080:8080/tcp
- 8000:8000/tcp
volumes:
- ./gluetun:/gluetun
environment:
- VPN_SERVICE_PROVIDER=protonvpn
- VPN_TYPE=wireguard
- WIREGUARD_PRIVATE_KEY=${WIREGUARD_PRIVATE_KEY}
- VPN_PORT_FORWARDING=on
- TZ=Etc/UTC
restart: unless-stopped
qbittorrent:
image: lscr.io/linuxserver/qbittorrent:5.2.3
container_name: qbittorrent
network_mode: "service:gluetun"
environment:
- PUID=1000
- PGID=1000
- TZ=Etc/UTC
- WEBUI_PORT=8080
volumes:
- ./qbittorrent:/config
- ./downloads:/downloads
depends_on:
- gluetun
restart: unless-stoppedPin the tag. qmcgaw/gluetun:latest follows the master branch, where the port forwarding internals are changing for v4, so an unpinned image can change behaviour at the next docker compose pull. Keep the private key out of the compose file with an env file for compose secrets.
Where gluetun writes the forwarded port
Gluetun exposes the port in three places, and all of them carry the same value.
It logs the port once per acquisition. The line reads port forwarded is 45678, and no port forwarded when the request produced nothing.
docker logs gluetun 2>&1 | grep -i "port forwarded"It writes the number to the file named by VPN_PORT_FORWARDING_STATUS_FILE, which defaults to /tmp/gluetun/forwarded_port. The file holds one port per line, is written with mode 0644, and is chowned to the container's PUID and PGID. When forwarding stops, gluetun clears the file instead of deleting it, so a consumer can read an empty file rather than hit a missing one.
docker exec gluetun cat /tmp/gluetun/forwarded_portIt serves the value on the control server, which listens on :8000 by default and is set by HTTP_CONTROL_SERVER_ADDRESS.
curl -s http://127.0.0.1:8000/v1/portforward{"port":45678,"ports":[45678]}Gluetun also opens that port in its own firewall on the VPN interface, so FIREWALL_VPN_INPUT_PORTS is not needed while the native integration is doing the work. That variable covers the other case: a provider gluetun cannot query, where you were issued a static port out of band and have to allow it by hand.
One of these three is durable and two are not. The upstream documentation marks the status file as deprecated in v4.0.0, and GET /v1/openvpn/portforwarded already answers with 301 Moved Permanently pointing at /v1/portforward. New work should read the control server.
Why the client must be told the port on every reconnect
A torrent client stores its listening port in its own configuration and keeps that number across restarts. The forwarded port is a property of the VPN session. After a reconnect the two numbers disagree, so the provider maps a port nothing listens on, and the client listens on a port nothing maps. Reconnects are not rare: a container restart, a server change, a dropped tunnel that gluetun's health check restarts, or a lease that could not be renewed. The result is a setup that was reachable yesterday and is quietly unreachable today, with no error in either log.
So the port has to be applied at the moment gluetun acquires it. There are two ways to wire that, and they differ in which process does the work.
Option 1: gluetun pushes the port with an up command
VPN_PORT_FORWARDING_UP_COMMAND runs when port forwarding comes up, and VPN_PORT_FORWARDING_DOWN_COMMAND runs when it goes down. Gluetun substitutes {{PORT}} (the first port), {{PORTS}} (all of them, comma separated) and {{VPN_INTERFACE}} (the tunnel interface name, tun0 by default) before running the command. Shell syntax needs an explicit /bin/sh -c wrapper. This is the upstream qBittorrent example, written as two compose environment entries:
- VPN_PORT_FORWARDING_UP_COMMAND=/bin/sh -c 'wget -O- -nv --retry-connrefused --post-data "json={\"listen_port\":{{PORT}},\"current_network_interface\":\"{{VPN_INTERFACE}}\",\"random_port\":false,\"upnp\":false}" http://127.0.0.1:8080/api/v2/app/setPreferences'
- VPN_PORT_FORWARDING_DOWN_COMMAND=/bin/sh -c 'wget -O- -nv --retry-connrefused --post-data "json={\"listen_port\":0,\"current_network_interface\":\"lo\"}" http://127.0.0.1:8080/api/v2/app/setPreferences'Each field in that call has a job. listen_port is the new port. current_network_interface binds qBittorrent to the tunnel. random_port set to false stops qBittorrent picking its own port at the next start. upnp set to false stops it trying to map a port through a router that is not there.
Two requirements come with this approach. qBittorrent's web UI must answer on 127.0.0.1:8080 from inside the gluetun container, which is automatic when the client shares gluetun's network namespace. And Bypass authentication for clients on localhost (bypass_local_auth) must be enabled, because the command sends no credentials. The down command is there because qBittorrent does not always re-establish the port after a disconnect.
The command runs inside the gluetun container, which is built on Alpine and ships wget. There is no curl in that image. A command naming a binary the image does not have fails every single time forwarding comes up.
Option 2: a process outside gluetun reads the port
The other pattern runs a small process beside gluetun that fetches the port and pushes it into the client through the client's own API. Read it from the control server:
port=$(curl -s http://127.0.0.1:8000/v1/portforward | jq -r .port)Or read the file, if the process can see it. /tmp/gluetun/forwarded_port lives inside the gluetun container, so a sidecar needs a shared volume mounted at /tmp/gluetun in both containers, or you point VPN_PORT_FORWARDING_STATUS_FILE at a path under a volume you already mount.
Authentication matters here. In v3.41.3 the route GET /v1/portforward belongs to a default role named public with auth = "none", so it answers without credentials, and gluetun logs a warning starting with route GET /v1/portforward is unprotected by default, please set up authentication. Upstream is closing that door in a later release. Define a role now, in the file bind mounted at /gluetun/auth/config.toml:
roles = [
{ name = "qbittorrent", routes = ["GET /v1/portforward"], auth = "apikey", apikey = "myapikey" }
]Generate a key with docker run --rm qmcgaw/gluetun:v3.41.3 genkey and send it in the X-API-Key header. HTTP_CONTROL_SERVER_AUTH_DEFAULT_ROLE does the same job as one JSON-encoded environment variable when you would rather not mount a file. Port 8000 published without a role gives anyone who can reach it control over the VPN state, so decide deliberately how far it travels when you work out how to reach gluetun from the host and other containers.
Pick the up command when the client exposes an API that one wget call can drive, because it fires exactly once per event and adds nothing to keep running. Pick an external process when the client needs a login flow, a config file rewrite, or a restart. In an arr stack behind one gluetun container this usually ends as one small poller, since only the torrent client cares about the port.
The trap: sharing the namespace does not set the listening port
This failure wastes the most time. network_mode: "service:gluetun" puts the client in gluetun's network namespace, so it has the VPN address, the tunnel routes and gluetun's firewall rules. None of that sets the client's listening port. Gluetun opens the forwarded port on the VPN interface, packets for it arrive in the namespace, and if the client listens on a different port the kernel has nothing to deliver them to. The connection is refused or times out while every outbound check looks healthy. The forwarded port and the client's listening port are two separate numbers, and keeping them equal is the whole job.
Compare them instead of guessing. Both commands run against the same namespace:
docker exec gluetun cat /tmp/gluetun/forwarded_port
docker exec gluetun wget -qO- http://127.0.0.1:8080/api/v2/app/preferences | grep -o '"listen_port":[0-9]*'One more setting sends people in the wrong direction. VPN_PORT_FORWARDING_LISTENING_PORT redirects inbound traffic from the forwarded port to a fixed local port using iptables. Upstream tells you not to use it with torrent clients, because the client announces its own listening port to trackers and peers, so the swarm learns the wrong number.
How to prove the forwarded port is reachable
The client's own connection indicator reflects outbound tracker connections, so it can look green while nothing can reach you. Test with a listener you control, from a network outside the tunnel. Upstream publishes a small tool for this. Stop the torrent client first, because two processes cannot bind the same port.
docker stop qbittorrent
docker exec -it gluetun /bin/shInside the container, change amd64 for your CPU architecture and 4567 for your forwarded port:
wget -qO port-checker https://github.com/qdm12/port-checker/releases/download/v0.4.0/port-checker_0.4.0_linux_amd64
chmod +x port-checker
./port-checker --listening-address=":4567"Now find the exit address gluetun is using. The response is JSON and the address is in the public_ip field.
curl -s http://127.0.0.1:8000/v1/publicip/ipOpen http://<that address>:4567 from a device that is not on the same VPN. A phone on mobile data works. A page showing your browser's IP address and user agent, with a matching request logged by port-checker, means inbound TCP reaches the namespace. A timeout means it does not, and the cause sits above the client. Stop the tool with CTRL+C, leave the shell with exit, and start the client again. This check exercises TCP only. DHT (distributed hash table) and uTP traffic use UDP on the same port number, which this test does not cover.
Failure modes and the strings you will see
No port line in the log at all. Nothing asked for a port. Confirm the variable actually reached the container with docker exec gluetun printenv | grep PORT_FORWARDING, since a variable set in the wrong compose service is a common cause.
Gluetun refuses to start and complains about the provider. VPN_PORT_FORWARDING_PROVIDER is validated against the four supported names, so a typo stops the container rather than running quietly without forwarding.
The log says no port forwarded. Gluetun asked and the provider gave nothing back. On ProtonVPN that usually means NAT-PMP was not enabled on the configuration you generated, or the plan does not include forwarding. On Private Internet Access it usually means the selected server does not offer it.
A port arrives, but nothing connects in. Compare the forwarded port with the client's listening port using the two commands above. If they match, check that the client is bound to the tunnel interface and that its random-port option is off, because that option rewrites the listening port at every start.
The up command seems to do nothing. Run the exact command inside the container to see the error: docker exec gluetun /bin/sh -c '<your command>'. curl: not found is the usual result, because the image ships wget only.
401 Unauthorized from the control server. You defined an auth config and the role does not list the route you are calling. Routes are matched as method plus path, so a role listing /v1/portforward alone does not cover GET /v1/portforward.
A different port on Private Internet Access after every restart. Bind mount /gluetun so the saved port state survives the restart. Without that volume gluetun requests a new port each time.
FAQ
Why do my torrents download but never get incoming connections?
Without a forwarded port the VPN provider has no NAT rule that sends inbound packets on any port to your tunnel, so connections you did not start are dropped at the exit address. Downloads still work because your client opens those connections itself, and it can reach any peer that is connectable. Seeding and swarm joins suffer, since both depend on other people reaching you. The fix is a provider that offers port forwarding, VPN_PORT_FORWARDING=on in gluetun, and the resulting port applied to the client's listening port.
Does gluetun work with any VPN provider's port forwarding?
No. Gluetun v3.41.3 has native integration for four providers: Private Internet Access, ProtonVPN, Perfect Privacy and PrivateVPN. Anything outside that list fails validation for VPN_PORT_FORWARDING_PROVIDER, and the container stops at startup. If your provider issues a static port through its own control panel, gluetun cannot request it for you, but FIREWALL_VPN_INPUT_PORTS will allow that fixed port through gluetun's firewall. Provider policies change, so check the current provider page before you buy a plan for this.
Do I have to update the port after every reconnect?
Yes, and that update should be automatic. The forwarded port belongs to the VPN session, so a container restart, a server change or a failed lease renewal can produce a new number, while the client keeps the port stored in its own configuration. Either let gluetun push it with VPN_PORT_FORWARDING_UP_COMMAND, which runs the moment forwarding comes up, or run a small process that reads GET /v1/portforward from the control server and writes the value into the client through its API.
How do I check the forwarded port is really open?
Run a listener on that exact port inside gluetun's network namespace and connect to it from outside the VPN. Stop the torrent client first so the port is free, then run the upstream port-checker binary inside the gluetun container with --listening-address=":<port>". Get the exit address from curl -s http://127.0.0.1:8000/v1/publicip/ip and open http://<address>:<port> from a phone on mobile data. A request appearing in the port-checker log proves inbound TCP arrives. A timeout means it does not, whatever the client's own status icon shows.