Your Personal ProtonVPN Gateway for Docker.
181
A production-ready Dockerized ProtonVPN gateway featuring a hot-swappable OpenVPN tunnel, an embedded Web Control Panel, and an independent verification Proof Page with real-time external IP and geolocation telemetry.
The image eaeoz/protonvpn-docker:1 is an All-in-One container: it bundles both the OpenVPN tunnel and the Web UI.
docker compose)If you want the VPN gateway along with the verification proof page, create a docker-compose.yml file:
name: protonvpn-docker
services:
# ------------------------------------------------------------------
# The OpenVPN tunnel gateway
# ------------------------------------------------------------------
vpn:
image: eaeoz/protonvpn-docker:latest
container_name: protonvpn
cap_add:
- NET_ADMIN
devices:
- /dev/net/tun:/dev/net/tun
volumes:
- ./config:/site/config
- ./ovpn:/site/ovpn
- ./state:/site/state
environment:
DEFAULT_OVPN: ${DEFAULT_OVPN:-}
dns:
- 8.8.8.8
- 1.1.1.1
ports:
- "8080:80" # Proof page (web service attaches to vpn netns)
healthcheck:
test: ["CMD-SHELL", "test -f /run/tunnel-up || test -f /site/state/tunnel-stopped"]
interval: 30s
timeout: 5s
start_period: 60s
retries: 3
restart: unless-stopped
# ------------------------------------------------------------------
# Control panel: web UI (profile picker + tunnel status + reconnect)
# ------------------------------------------------------------------
panel:
image: eaeoz/protonvpn-docker-panel:latest
container_name: protonvpn-panel
depends_on:
- vpn
environment:
DEFAULT_OVPN: ${DEFAULT_OVPN:-}
ports:
- "8000:8000"
volumes:
- ./ovpn:/app/ovpn
- ./state:/app/state
- ./www:/app/www:ro
- ./config:/app/config
- /var/run/docker.sock:/var/run/docker.sock
restart: unless-stopped
# ------------------------------------------------------------------
# Proof container: shares VPN network to verify exit IP.
# Fully self-contained image (page + updater baked in).
# ------------------------------------------------------------------
web:
image: eaeoz/protonvpn-docker-proof:1
depends_on:
- vpn
network_mode: "service:vpn"
volumes:
- ./state:/site/state # optional: live "current profile" name
restart: unless-stopped
Then run:
docker compose up -d
👉 Now open http://localhost:8000 in your browser!
.ovpn files to upload new servers.Routing Other Containers: To route another container through this VPN tunnel, add
--net=container:protonvpnto that container'sdocker runcommand.
Containers living in a separate compose project (different folder, e.g. D:\Compose\deemix) cannot use service:vpn or depends_on — both only work within one compose file. Instead, reference the gateway by its container name with network_mode: "container:protonvpn", and publish the container's UI port through the vpn service.
Step 1 — add the app's port to the vpn service of the main protonvpn-docker compose (guest ports are only reachable through the vpn container's published ports):
vpn:
image: eaeoz/protonvpn-docker:latest
# ... (cap_add, devices, volumes, dns, env as above) ...
ports:
- "8080:80" # Proof page (web service attaches to vpn netns)
- "6595:6595" # deemix UI (deemix shares this netns)
Step 2 — create a standalone compose file in the app's own folder:
# D:\Compose\deemix\docker-compose.yml
services:
deemix:
image: registry.gitlab.com/bockiii/deemix-docker:latest
container_name: deemix
network_mode: "container:protonvpn"
# NOTE: depends_on cannot span separate compose projects -
# the vpn stack must simply be started first.
environment:
- PUID=1000
- PGID=1000
- UMASK_SET=022
- DEEMIX_SINGLE_USER=true
volumes:
- ./deemix/config:/config
- ./deemix/downloads:/downloads
restart: unless-stopped
Step 3 — start in this order, from each folder:
cd /path/to/protonvpn-docker && docker compose up -d # gateway first
cd /path/to/deemix && docker compose up -d # then the app
protonvpn container is ever recreated (image update, up after config change), re-attach the app once:cd /path/to/deemix && docker compose up -d --force-recreate
Why
--force-recreate? A recreated vpn container has a fresh network namespace. The old pointer (network_mode: container:...) becomes stale, so the attached container must be recreated too - a plaindocker restartfails withjoining network namespace of container: No such container.
This pattern works for any tunnel-attached app (qBittorrent, SABnzbd, radarr, ...): mount nothing extra, just replace the port number in the
ports:entry of thevpnservice with the app's own UI port.
Test your exit IP from any folder, without touching the tunnel. Ships as
docker-compose.proof.yml - it attaches to an
already-running protonvpn container and shares its network namespace:
docker compose -f docker-compose.proof.yml up -d
# -> open http://localhost:8080
Or as a single docker run:
docker run -d --name protonvpn-proof --network container:protonvpn --restart unless-stopped eaeoz/protonvpn-docker-proof:1
Note: run EITHER this standalone proof container OR the
web/proofservice of the main stack - both listen on port 80 inside the same shared network namespace, so running both at once causes a bind conflict (the VPN tunnel and the control panel keep working either way). If no profile is loaded yet, the proof page shows a "No OpenVPN profile loaded" message and hides IP data instead of stale values.
config/login.conf automatically applied to all .ovpn profiles.network_mode: "service:vpn") maintain their network namespace. The tunnel always runs on a pinned tun0 device (the profile's own dev directive is replaced), and every switch fully tears down the previous OpenVPN session first (no stacked processes or stale routes), so the exit IP always matches the selected profile..ovpn profile uploader and multi-profile batch manager.AUTH_FAILED) and TLS handshake issues with previous-session notice banners.The panel buttons communicate with the gateway through small marker files in the shared state/ directory (also consumed by the embedded panel inside the vpn container):
| File | Written by | Effect |
|---|---|---|
selected.ovpn | panel ("Use as default") | hot-switches the tunnel to that profile |
reload-request | Restart button / credentials save | reconnects the tunnel (also revives a stopped one) |
tunnel-stop-request | Stop Tunnel | tears the tunnel down and persists the stop |
tunnel-start-request | Start Tunnel | brings the tunnel back up |
tunnel-stopped | gateway (after a stop) | persists "off" state; blocks auto-restart & healthcheck restart until removed |
current-profile, ipinfo.json | gateway / proof page | active profile name and live exit-IP telemetry |
These persist across container restarts, so a deliberate stop survives docker restart and Docker Desktop restarts.
Important
You **cannot** use your Proton account email and login password for OpenVPN. Proton generates dedicated OpenVPN credentials for security.
username+p2p or a long alphanumeric string).ovpn)GNU/LinuxUDP (recommended for speed) or TCP.ovpn files (e.g. ca-free-7.protonvpn.udp.ovpn, mx-free-4.protonvpn.udp.ovpn).If you cloned the source code repository:
├── config/
│ └── login.conf # OpenVPN credentials (username on line 1, password on line 2)
├── ovpn/ # Place your downloaded *.ovpn files here
├── state/ # Runtime tracking (active profile, connection state, error logs,
│ # tunnel stop/start requests, exit-IP telemetry)
├── panel/ # Web control panel code & templates
├── vpn/ # All-in-one Gateway container (Alpine + OpenVPN + Embedded Panel)
├── www/ # Verification proof web server (Nginx)
├── docker-compose.yml # Local development compose
└── README.md
docker compose up -d --build
To route another service (such as a torrent client, scraper, or curl script) through ProtonVPN, attach it to the vpn network namespace:
services:
vpn:
image: eaeoz/protonvpn-docker:1
container_name: protonvpn
cap_add:
- NET_ADMIN
devices:
- /dev/net/tun:/dev/net/tun
ports:
- "8000:8000" # Web UI
- "8080:80" # Forward any ports needed by attached containers
volumes:
- ./config:/site/config
- ./ovpn:/site/ovpn
- ./state:/site/state
my_app:
image: alpine:latest
network_mode: "service:vpn"
depends_on:
- vpn
command: sh -c "while true; do wget -q -O- https://api.ipify.org; sleep 60; done"
Images are versioned with the integer tag 1 and always pushed together with latest:
# 1. Build all-in-one image as tag "1"
docker build -t eaeoz/protonvpn-docker:1 -f vpn/Dockerfile .
# 2. Build standalone control panel image
docker build -t eaeoz/protonvpn-docker-panel:1 ./panel
# 3. Build self-contained proof page image
docker build -t eaeoz/protonvpn-docker-proof:1 -f www/Dockerfile ./www
# 4. Also tag everything as latest
docker tag eaeoz/protonvpn-docker:1 eaeoz/protonvpn-docker:latest
docker tag eaeoz/protonvpn-docker-panel:1 eaeoz/protonvpn-docker-panel:latest
docker tag eaeoz/protonvpn-docker-proof:1 eaeoz/protonvpn-docker-proof:latest
# 5. Push all tags to Docker Hub
docker push eaeoz/protonvpn-docker:1
docker push eaeoz/protonvpn-docker:latest
docker push eaeoz/protonvpn-docker-panel:1
docker push eaeoz/protonvpn-docker-panel:latest
docker push eaeoz/protonvpn-docker-proof:1
docker push eaeoz/protonvpn-docker-proof:latest
Clean republish: when the remote tags are wiped first (all old tags removed via the Docker Hub API before pushing), only
1andlatestremain as the published tags for all three images (protonvpn-docker,protonvpn-docker-panelandprotonvpn-docker-proof).
When starting the stack on Windows, Docker may refuse to launch the protonvpn container with:
Cannot start Docker Compose application. Reason: compose [start] exit status 1.
Container protonvpn Starting
Error response from daemon: ports are not available: exposing port TCP 0.0.0.0:8080 -> 127.0.0.1:0:
listen tcp 0.0.0.0:8080: bind: An attempt was made to access a socket in a way forbidden by its access permissions.
Even with nothing listening on 8080 (netstat -ano | findstr ":8080" shows no output), the bind is rejected. The culprit is the Windows NAT Driver (WinNAT): it dynamically reserves large blocks of ports at boot (typically whenever Hyper-V / WSL2 / Docker Desktop re-enumerates its NAT), and any port in such a block cannot be bound by Docker - even with administrator rights.
Check which ranges are reserved (run in any shell):
netsh interface ipv4 show excludedportrange protocol=tcp
Example output where 8080 is swallowed by a reserved range:
Protocol tcp Port Exclusion Ranges
Start Port End Port
---------- --------
7997 7996
7997 8096 <-- 8080 is in here
10100 10199
...
If 8080 falls inside one of these ranges, that is the problem.
Open PowerShell as Administrator and run:
# 1. Stop WinNAT so it releases its dynamic port exclusions (incl. the range around 8080)
net stop winnat
# 2. Permanently reserve port 8080 for Docker (survives reboots)
netsh int ipv4 add excludedportrange protocol=tcp startport=8080 numberofports=1 store=persistent
# 3. Restart WinNAT
net start winnat
What each step does:
net stop winnat - releases all dynamic exclusions WinNAT created at boot. The reserved block that swallowed 8080 disappears (this is safe: WinNAT re-creates its own ranges on start).
Add excludedportrange with store=persistent - pins port 8080 as an administered exclusion. Dynamic ranges can never claim it again, but nothing is actually listening on it, so Docker is free to bind it. The * marker in the exclusion list denotes such permanent entries:
Start Port End Port
---------- --------
8080 8080 * <-- now permanently reserved for us
net start winnat - restarts the NAT driver, which re-reserves its usual ranges around the pinned port.
Then docker compose start (or up -d) works immediately. The reservation persists across reboots, so this is a one-time fix per port. The same recipe works for any other port — it just replaces startport=8080 (and, for several consecutive ports, changes numberofports=).
No admin rights available? The alternative is changing the host side of the port mapping in
docker-compose.ymlto a port outside every reserved range (e.g.- "8180:80"), which needs no elevation.
config/login.conf and real .ovpn files are excluded by default in .gitignore.Content type
Image
Digest
sha256:3acd26b8b…
Size
29.5 MB
Last updated
4 days ago
docker pull eaeoz/protonvpn-docker