Multi-location OpenVPN HTTP proxy gateway — Docker Compose stack with a web control plane, Redis-backed assignment state, and on-demand per-port VPN workers.
| Repository | github.com/cipher-x-sudo/portico-proxy |
| Deployment | Docker Compose (Linux, Windows Docker Desktop, macOS Docker Desktop) |
| License | Add a LICENSE to your fork when redistributing; this README does not grant rights beyond upstream components. |
- Portico
Portico exposes one TCP listener per logical location (configurable count). Until a port is activated, connections are rejected. After activation, the gateway starts a worker container running OpenVPN plus an HTTP proxy, forwards client traffic when the proxy is ready, and tears workers down after idle periods (no proxy traffic; activity resets the timer). A dashboard drives assignments, activation, and diagnostics; state persists in Redis and/or a JSON assignments file.
Design goals
- No OpenVPN or TAP installation on the host — workers carry the VPN data plane.
- Explicit activation and proxy authentication — avoid accidental open proxies.
- Observable stack: container logs, control API, dashboard status.
Services are defined in docker-compose.yml and communicate on the internal proxynet bridge.
flowchart TB
subgraph clients["Clients"]
C["curl / apps / emulator"]
end
subgraph compose["Docker Compose"]
FE["portico-frontend :8080"]
GW["portico-gateway + Docker socket"]
R[("portico-redis AOF")]
W["portico-worker instances proxy-PORT"]
end
C -->|HTTP| FE
FE -->|/api/*| GW
GW --> R
GW -->|orchestrate| W
| Service | Image / name | Role |
|---|---|---|
| Frontend | portico-frontend |
Serves the SPA; reverse-proxies /api/* to the gateway. |
| Gateway | portico-gateway |
Config, listeners, control API, starts/stops workers via Docker API. |
| Redis | portico-redis |
Optional persistence for assignments and activePorts (see env). |
| Worker | portico-worker (build) |
Long-running image; runtime instances are proxy-<listenerPort>. |
| Item | Notes |
|---|---|
| Docker | Engine 24+ recommended; Compose V2 (docker compose). |
| OS | Linux x86_64/arm64 (as supported by base images), or Docker Desktop on Windows 10/11 / macOS. |
| Resources | RAM and CPU scale with concurrent workers (maxSlots). Many listeners may require higher nofile ulimit (see docker-compose.yml). |
| Privileges | Gateway mounts /var/run/docker.sock to orchestrate workers — treat the host as trusted. |
| Compliance | You are responsible for VPN provider terms of service and local law; this software is infrastructure only. |
On a fresh VPS or workstation, install Docker and Compose v2 (the docker compose plugin). Do not rely on the legacy docker-compose Python package (v1.29.x): it breaks against current Docker Engine with KeyError: 'ContainerConfig' when recreating containers.
sudo apt update
sudo apt install -y docker.io docker-compose-v2
sudo usermod -aG docker $USER
newgrp dockerVerify Compose v2: docker compose version. Run this project with docker compose (space), never docker-compose (hyphen), unless you know you are on a patched v1.
If docker-compose-v2 is not in your distro’s repositories, install Docker Engine from Docker’s Linux install docs so you get the docker-compose-plugin package, then use docker compose.
After usermod, you may need to log out and back in instead of newgrp docker for the group change to apply in all sessions.
| Path | Purpose |
|---|---|
docker-compose.yml |
Stack definition, published ports, env wiring. |
backend/ |
Gateway (gateway.py), Docker helpers, worker image context, example JSON configs. |
frontend/ |
Vite/React dashboard; production image serves static build via Nginx. |
ovpn/ |
Example tree layout; production .ovpn content usually supplied via OVPN_HOST_PATH. |
.env.example |
Template for secrets and paths (copy to .env, never commit). |
-
Clone and enter the repository root (directory containing
docker-compose.yml). -
Provision local files from tracked templates:
cp backend/openvpn-proxy-config.example.json backend/openvpn-proxy-config.json cp backend/openvpn-proxy-assignments.example.json backend/openvpn-proxy-assignments.json cp .env.example .env
Windows (PowerShell):
Copy-Item backend\openvpn-proxy-config.example.json backend\openvpn-proxy-config.json Copy-Item backend\openvpn-proxy-assignments.example.json backend\openvpn-proxy-assignments.json Copy-Item .env.example .env
-
Edit
.env— set at minimum:OVPN_HOST_PATH— absolute path to your.ovpndirectory if./ovpnis incorrect (Windows: forward slashes, e.g.E:/vpn/profiles).OPENVPN_USERNAME/OPENVPN_PASSWORDif you are not using per-providerauth.txtfiles underovpn/.
-
Build and start:
docker compose build docker compose up -d
-
Verify — open the dashboard and confirm Status loads without HTTP 502. Inspect services:
docker compose psanddocker compose logs -f gateway.
Runtime JSON is mounted at /config/openvpn-proxy-config.json inside the gateway. OpenVPN runs inside workers; you typically omit host-only keys such as openvpnPath and forceBindIPPath for Docker.
| Key / group | Purpose |
|---|---|
locationSpec |
Preferred template: count, defaultOvpn (path under the ovpn mount), labelPrefix, randomAccessFirstN. count must not exceed the published TCP span in Compose. If USE_DOCKER and DOCKER_PROXY_CONTAINER_PORT_* are set, count may be smaller than that span: the gateway pads extra listener slots at runtime (defaults from defaultOvpn or the first row). If count is larger than the span, extra JSON rows are ignored. |
portBase |
First listener port inside the gateway network namespace (default 50000). |
proxyUsername / proxyPassword |
HTTP proxy authentication presented to clients (optional; gateway may apply defaults — see dashboard). |
clientProxyHost |
Hostname or IP shown in the dashboard for HTTP proxy URLs. When empty and proxyListenHost binds all interfaces (0.0.0.0), the gateway auto-detects your public IPv4 (cached HTTP checks to ifconfig.me / ipify / icanhazip) unless autoDetectClientProxyHost is false. Set clientProxyHost explicitly for a DNS name, LAN IP, or to disable any outbound probe while still controlling the displayed host. |
autoDetectClientProxyHost |
Default true. When clientProxyHost is empty and listeners are all-interfaces, fetch and show egress IPv4 for proxy URLs. Set false to keep 127.0.0.1 hints (e.g. strict no-egress policy). |
proxyListenHost |
Bind address inside the gateway container (127.0.0.1 vs 0.0.0.0). Widen only with auth and host firewall awareness. |
internalPortBase |
Internal pproxy port range for slots (default 51000). |
maxSlots |
Upper bound on concurrent worker containers. |
idleTimeoutMinutes |
Idle eviction when no bytes traverse the proxy for a slot. |
autoActivateOnStartup |
Whether persisted activePorts are started after gateway restart. |
useDocker / dockerImage / dockerNetwork / dockerOvpnVolume |
Docker backend (USE_DOCKER=1 in Compose). Defaults align with portico-worker and proxynet. |
randomizeCountry |
Restricts random profile selection (random or ISO country code); see backend/ovpn_filter.py. |
Legacy keys (openvpnPath, forceBindIPPath, pythonPath, maxLocations) exist for non-container runs of gateway.py and are out of scope for this deployment guide.
| Exposure | Default bind | Description |
|---|---|---|
| Dashboard | 0.0.0.0:8080 (default in Compose) |
Static UI; /api/* proxied to gateway. Use http://YOUR_IP:8080 from another machine. For local-only, set the publish bind to 127.0.0.1:8080:80. |
| Control API | 127.0.0.1:49999 |
JSON REST used by the UI (/api/status, /api/activate, …). Always localhost on the host. |
| HTTP proxies | 0.0.0.0:58000+ (host, default) |
Mapped from container portBase+index. PUBLISHED_PROXY_PORT_BASE (default 58000) should equal DOCKER_PROXY_HOST_PORT_FIRST. DOCKER_PROXY_CONTAINER_PORT_* must span the same number of ports as the host range; portBase in JSON must equal DOCKER_PROXY_CONTAINER_PORT_FIRST. See Ubuntu VPS: scaling proxy port count below. |
Clients on the same machine use 127.0.0.1 and the published host port. Android emulator uses host alias 10.0.2.2 (e.g. 10.0.2.2:58000). For LAN clients behind NAT, set clientProxyHost to the hostname or IP those clients use. On a VPS, leaving clientProxyHost empty usually suffices because the gateway auto-detects the public IPv4 for dashboard URLs (override with clientProxyHost when you need a stable DNS name). Allow the matching TCP ports in the firewall.
- Compose defaults publish the dashboard and proxy ports on
0.0.0.0so they are reachable on the server’s public IP (the control API49999stays127.0.0.1only). clientProxyHostcan stay empty: the gateway fills in your public IPv4 for/api/statusand the dashboard. Set it to a hostname if you prefer DNS in proxy URLs, or setautoDetectClientProxyHosttofalseand setclientProxyHostmanually if outbound IP discovery must be disabled.- Open the host firewall (example UFW):
sudo ufw allow 8080/tcpand a TCP range covering every published proxy port you use. Example:sudo ufw allow 58000:58127/tcpwhen you publish only host ports 58000–58127 (that is 128 ports, e.g.locationSpec.count: 128). The default Compose mapping is 58000–58515 (516 ports);58127in older docs was an example, not a platform cap. End port formula:PUBLISHED_PROXY_PORT_BASE + location_count - 1, butlocation_countmust not exceedDOCKER_PROXY_HOST_PORT_LAST - DOCKER_PROXY_HOST_PORT_FIRST + 1. Ifufwis enabled and these ports are closed, browsers will time out even though Docker is listening. - Hardening: use strong
proxyUsername/proxyPassword; do not expose49999publicly; consider TLS or SSH tunneling for8080on untrusted networks.
On Linux there is no Windows select(512) listener cap; the gateway uses selectors.DefaultSelector() for accept loops. You are limited mainly by Docker’s published port range, portBase + count ≤ 65535, host firewall, and file descriptor limits.
- Set
DOCKER_PROXY_HOST_PORT_FIRST/DOCKER_PROXY_HOST_PORT_LASTandDOCKER_PROXY_CONTAINER_PORT_FIRST/DOCKER_PROXY_CONTAINER_PORT_LASTin.envso both sides span the same number of TCP ports (see.env.example). - Set
PUBLISHED_PROXY_PORT_BASEto the same value asDOCKER_PROXY_HOST_PORT_FIRSTso dashboard URLs match the map. - Set
portBaseinopenvpn-proxy-config.jsontoDOCKER_PROXY_CONTAINER_PORT_FIRST. WithUSE_DOCKERandDOCKER_PROXY_CONTAINER_PORT_FIRST/LASTset, the gateway opens that full span of TCP listeners. IflocationSpec.count(orlocations.length) is smaller, extra slots are padded at runtime (synthetic labels; defaults fromlocationSpec.defaultOvpnor the first row’sovpn); use the Dashboard to assign any.ovpnper port. If JSON defines more rows than the Docker span, only the first N rows are used and a trim warning is logged. docker compose up -dafter edits. Very large mappings (thousands of rules) can make Compose and iptables updates slower; that is expected.- Raise gateway
ulimits.nofileindocker-compose.ymlif you run many locations and heavy concurrency (for example 131072 soft/hard) and align the host daemon limits if the kernel still returns “too many open files”.
The gateway logs a warning on startup when counts or bases disagree with these env vars; GET /api/status includes publishMismatch, publishMismatchHint, and dockerPublishedHostPortFirst/Last / dockerPublishedPortSpan for the Configuration page banner.
- Inactive ports — TCP connections are refused until an
.ovpnis assigned and the port is activated (UI orPOST /api/activate?port=<port>). - Activation — Gateway validates profile paths against the
ovpnvolume, schedules a worker, waits for HTTP proxy readiness, then forwards traffic. - Concurrency — At most
maxSlotsworkers; additional activations queue or fail per implementation and logs. - Idle shutdown — Workers stop after
idleTimeoutMinuteswithout proxy traffic; timer resets on payload bytes. - Random-access rows —
POST /api/randomize-port,POST /api/refresh-port,POST /api/extend-port(see gateway and UI). - Shutdown —
docker compose downsignals the gateway; it stops dynamicproxy-<port>containers.stop_grace_period: 60sallows orderly teardown.
docker compose pull # if you later publish images to a registry
docker compose build --no-cache
docker compose up -dredis_datavolume: AOF persistence for Redis state. Snapshot or replicate per your DR policy.backend/openvpn-proxy-assignments.json: file-based mirror of picks andactivePortswhen not using Redis, or whenREDIS_ASSIGNMENTS_MIRROR_FILE=1.- Config: keep
openvpn-proxy-config.jsonand.envin a secrets manager or encrypted backup — not in Git.
docker compose logs -f gateway— control plane and worker spawn errors.docker compose logs -f portico-frontend— Nginx access/errors (upstream 502 indicates gateway unreachable).docker logs proxy-<port>— per-worker OpenVPN/pproxy output when the container still exists.
Hyper-V may reserve TCP ranges in the 51xxx band. This project defaults to 58000–58515 on the host. Override with DOCKER_PROXY_HOST_PORT_FIRST, DOCKER_PROXY_HOST_LAST, and PUBLISHED_PROXY_PORT_BASE in .env if your environment conflicts.
Gateway env OPENVPN_PROXY_ASSIGNMENTS_PATH overrides the default mount target if you use a custom compose layout.
- Docker socket — The gateway can start arbitrary worker containers; isolate the daemon and restrict who can access the compose project directory.
- Bind addresses — Default Compose publishes the UI and proxy host ports on
0.0.0.0(reachable on the LAN/public IP); the control API stays on 127.0.0.1 only. Treat8080and58000+as sensitive surfaces: mandatory proxy authentication, host firewall, and TLS or SSH tunneling on untrusted networks. - Secrets — Provider credentials belong in
.envorovpn/**/auth.txt, not in tracked JSON. The Config page can now edit provider credentials and writes directly to each providerauth.txt(for exampleovpn/NC/auth.txt,ovpn/EX/auth.txt). Rotate credentials if a workstation or volume was compromised. - Control API — Equivalent to administrative access; do not expose
49999to untrusted networks without TLS termination and authentication in front (not included by default).
| Symptom | Likely cause | Action |
|---|---|---|
KeyError: 'ContainerConfig' when running docker-compose up |
Legacy Compose v1 (docker-compose 1.29.x) vs modern Docker Engine. |
Install Compose v2 (see Linux host: install Docker), then use docker compose up -d. Optionally sudo apt remove docker-compose so the old binary is not used by mistake. |
Conflict. The container name "...portico-gateway" is already in use |
A leftover gateway container from an earlier Compose run (often v1), with a name like <hex>_portico-gateway, was not removed before docker compose up. |
From the repo root: docker compose down. Run docker ps -a, find any stray *portico-gateway* row, then docker rm -f <CONTAINER_ID> (use the full ID from the error if given). Bring the stack up again: docker compose up -d. |
502 on every /api/* call |
Nginx in frontend cannot reach gateway:49999 because portico-gateway is down or Restarting. | docker compose logs portico-gateway --tail 120. Fix the first error (e.g. Failed to bind, Config path is a directory, Invalid JSON, Invalid locationSpec). Confirm docker compose ps shows gateway Up. Rebuild if needed: docker compose build gateway --no-cache && docker compose up -d. |
portico-gateway restarting |
Bad config mount, bind error, or missing files. | Ensure backend/openvpn-proxy-config.json exists on the host before the first up (otherwise Docker creates a directory at the mount path and the gateway exits). Same for openvpn-proxy-assignments.json. Read logs for Failed to bind or Config path is a directory. |
| Cannot open dashboard from public IP | Firewall or bind address. | With default Compose, use http://PUBLIC_IP:8080. Allow 8080/tcp (and proxy ports) in ufw/cloud security group. For local-only, set 127.0.0.1:8080:80 in docker-compose.yml. |
Rejecting connection on inactive port |
Port not activated. | Assign .ovpn, activate in UI, wait for active state. |
| Wrong port from host | Using container port instead of published port. | Use host map (e.g. 58000), not 50000, unless you intentionally publish 50000. |
files: [] from /api/ovpn-files |
Empty or wrong ovpn_data mount. |
docker exec portico-gateway ls -la /ovpn; fix OVPN_HOST_PATH, recreate ovpn_data if needed (docker compose down then docker volume rm ovpn_data — data loss). |
exec /entrypoint.sh: no such file or directory |
CRLF in worker entrypoint.sh. |
docker compose build worker --no-cache; ensure .gitattributes keeps *.sh as LF. |
| Worker start timeout | Bad profile, auth, or provider blocking. | Read docker logs proxy-<port> and gateway stderr for OpenVPN errors. |
Optional scripts (run on the host, outside containers):
| Script | Purpose |
|---|---|
scripts/align-location-ovpn-to-folder.py |
Bulk-align locations[].ovpn with files on disk. |
scripts/scan_ovpn_providers.py |
Summarize .ovpn counts per top-level folder. |
scripts/free-docker-proxy-ports.ps1 |
Windows: address Hyper-V excluded TCP ranges (elevated PowerShell; see script header). |
Portico is infrastructure software. You are solely responsible for compliance with your VPN provider’s terms, applicable export and cryptography rules, and local regulations. The authors and maintainers do not endorse unlawful use. No warranty is implied; operate production stacks under your organization’s change management, monitoring, and backup policies.