Per-device Private Internet Access (PIA) region control for devices that already use Tailscale as their only VPN.
This solves the Android "one VPN slot" problem: keep Tailscale connected for home services and Pi-hole DNS, then route only your device's internet traffic through PIA when you need it — without disconnecting Tailscale.
Phone (Tailscale only)
├─ Normal: home LAN + Pi-hole DNS, direct internet
└─ PIA on: Tailscale exit node → home Docker host → Gluetun (PIA) → internet
Each device picks its own region (e.g. Mexico, Australia). Other devices are unaffected. Regional Docker stacks start on demand and stop after idle timeout.
| Component | Purpose |
|---|---|
| vpn-controller | REST API + web dashboard — device registration, per-device on/off, on-demand regional stacks |
| Gluetun + Tailscale exit nodes | One Docker stack per region (gluetun-<region> + tailscale-exit-<region>) |
| Android app | Region picker, VPN toggle, Tailscale exit node automation, QR pairing, background sync |
| Windows client | PowerShell/Python CLI + tailscale set --exit-node |
- Linux Docker host on your LAN (same machine as other containers is fine)
- Tailscale tailnet with subnet routing to home
- Pi-hole DNS configured in Tailscale admin (optional but typical)
- PIA account
- Tailscale reusable auth key with
tag:pia-exitfor exit nodes — create here - Docker + Docker Compose v2 on the host
git clone https://github.com/AKASGaming/tailscale-pia-controller.git
cd tailscale-pia-controller
cp .env.example .envEdit .env:
PIA_USER=your_pia_username
PIA_PASS=your_pia_password
TS_AUTHKEY=tskey-auth-xxxxxxxx?tags=tag:pia-exit
LAN_CIDR=192.168.1.0/24
CONTROLLER_SECRET=choose-a-long-random-string
CONTROLLER_PORT=8090
IDLE_SHUTDOWN_MINUTES=30
HOST_RUNTIME_DIR=/absolute/path/to/tailscale-pia-controller/runtime
DOCKER_PROJECT_NAME=tailscale-pia-controller
TS_EXIT_NODE_TAG=tag:pia-exit| Variable | Description |
|---|---|
PIA_USER / PIA_PASS |
PIA credentials used by Gluetun |
TS_AUTHKEY |
Reusable Tailscale key with tag:pia-exit (see Tailscale setup below) |
LAN_CIDR |
Your home LAN — allowed through Gluetun firewall |
CONTROLLER_SECRET |
Protects device registration and admin actions. Leave empty for open LAN registration |
HOST_RUNTIME_DIR |
Absolute host path to runtime/ if auto-detection fails |
DOCKER_PROJECT_NAME |
Labels regional containers for Portainer grouping |
IDLE_SHUTDOWN_MINUTES |
Stop unused regional stacks after this many minutes |
docker compose up -d --buildVerify:
curl http://localhost:8090/health
curl http://localhost:8090/regionsOpen http://your-docker-host:8090/ in a browser.
After code or config changes:
docker compose up -d --build vpn-controllerNote: On controller shutdown/restart, VPN is disabled for all registered devices and regional stacks are stopped. Android clients clear their exit node on the next refresh.
- Open Tailscale admin → Access controls and merge in
docs/tailscale-acl.example.json:
{
"tagOwners": {
"tag:pia-exit": ["your-email@example.com"]
},
"autoApprovers": {
"exitNode": ["tag:pia-exit"]
}
}-
Create a new auth key with tag
tag:pia-exitand set it asTS_AUTHKEYin.env. -
Regional exit nodes (
pia-mexico,pia-au, etc.) join the tailnet already approved.
If a node was created before autoApprovers were configured, delete its Tailscale state (
runtime/data/<region>/tailscale/) and re-enable the region.
Confirm Tailscale DNS still points to Pi-hole (Admin → DNS) if you use it.
Default regions are in regions/regions.yaml. The server_region value must match Gluetun PIA naming exactly (city-level names like CA Toronto, not Canada).
| Region ID | Display name | Exit node hostname | PIA server region |
|---|---|---|---|
mexico |
Mexico | pia-mexico |
Mexico |
us-east |
US East | pia-us-east |
US East |
us-west |
US West | pia-us-west |
US West |
uk |
UK London | pia-uk |
UK London |
netherlands |
Netherlands | pia-nl |
Netherlands |
canada |
Canada (Toronto) | pia-ca |
CA Toronto |
japan |
Japan (Tokyo) | pia-jp |
JP Tokyo |
australia |
Australia (Sydney) | pia-au |
AU Sydney |
The dashboard at http://your-docker-host:8090/ provides:
- Live status — server status (
Idle/Active), device VPN state, and stats auto-refresh every 5 seconds - Stack status — shows
idlewhen a regional stack is running but no devices are connected - Idle shutdown — live countdown per region, plus a Stop now button to shut down idle stacks early
- Pair a device — QR code + 6-character pairing code (when
CONTROLLER_SECRETis set) - Per-device controls — enable/disable VPN, change region, remove devices
- Region overview — PIA server region names, stack status, and idle shutdown state
- In-app feedback — admin actions show confirmation banners without reloading the page
If CONTROLLER_SECRET is empty, registration is open and the QR code only encodes the controller URL.
Requirements: Tailscale for Android installed and connected before enabling PIA.
Option A — Download APK (recommended)
Download the latest APK from GitHub Releases (currently pia-control-v1.0.17-debug.apk from v1.0.23).
Enable "Install unknown apps" for your browser or files app if prompted.
Option B — Build from source
- Open
apps/androidin Android Studio - Build → Build APK(s)
- Install on your phone
- Open the PIA Control app
- Enter your controller URL (e.g.
http://192.168.68.53:8090) - Either:
- Tap Scan pairing QR code on the web dashboard, or
- Tap Check connection, then enter the 6-character pairing code shown on the dashboard
- Enter a device name → tap Register device
- Allow notifications when prompted (Android 13+) — needed for alerts when the WebUI changes VPN settings while the app is in the background
- Tailscale must stay connected (always)
- Open PIA Control
- Select a region from the dropdown
- Toggle Route traffic through PIA
- Wait for the stack to start (15–45 seconds on first use per region). The app applies the Tailscale exit node automatically when ready
- Browse — your IP reflects the PIA region; home LAN and Pi-hole DNS still work via Tailscale
- To switch regions: pick a new region from the dropdown while VPN is on
- Toggle off when done — exit node clears, Tailscale stays connected
| Feature | Description |
|---|---|
| Remote sync | Polls the controller every 3 seconds while registered — WebUI changes apply even when the app is in the background |
| Background notifications | Alerts when VPN is enabled, disabled, or region-changed from the WebUI while the app is not open |
| Exit node automation | Applies and clears the Tailscale exit node automatically; clears it when VPN is disabled remotely |
| Device settings | Edit device name, re-register, or reset all app data from the setup screen |
| Refresh status | Reloads VPN state and region list from the controller |
| Open Tailscale | Launch Tailscale to manually pick an exit node if needed |
| Test IP & location | Check your public IP through the active route |
| Export logs | Save a diagnostic log to Downloads and share it for troubleshooting |
Tips:
- Enable Allow direct access to local network in the Tailscale app when using an exit node
- If the exit node doesn't apply automatically, tap Refresh status or select it manually in Tailscale (
pia-mexico,pia-au, etc.) - After a controller update/restart, open the app once to clear a stale exit node
Requirements: Tailscale for Windows with CLI enabled.
cd apps/windows
# Register once (use pairing secret from .env, or pairing code from dashboard)
./vpn-control.ps1 register -ControllerUrl http://192.168.1.10:8090 -Name "My PC" -PairingSecret "your-secret"
# List regions
./vpn-control.ps1 regions
# Enable Mexico
./vpn-control.ps1 enable -Region mexico
# Check status
./vpn-control.ps1 status
# Disable
./vpn-control.ps1 disableCross-platform Python CLI:
python apps/windows/vpn-control.py register --controller-url http://192.168.1.10:8090 --name "My PC" --pairing-secret your-secret
python apps/windows/vpn-control.py enable --region mexico
python apps/windows/vpn-control.py disable| Method | Endpoint | Auth | Description |
|---|---|---|---|
GET |
/ |
No | Web dashboard |
GET |
/health |
No | Service health |
GET |
/regions |
No | List available regions |
GET |
/pairing |
No | Pairing requirements + 6-char code |
GET |
/dashboard/state |
No | Live dashboard JSON |
POST |
/devices/register |
Pairing code or secret | Register device, returns API token |
PATCH |
/devices/me |
Bearer token | Update device name |
GET |
/devices/me/vpn |
Bearer token | Current device VPN state |
PUT |
/devices/me/vpn |
Bearer token | { "enabled": true, "region": "mexico" } |
Example:
# Register with pairing code from dashboard
curl -X POST http://localhost:8090/devices/register \
-H "Content-Type: application/json" \
-d '{"name":"Test Phone","platform":"curl","pairing_code":"ABC123"}'
# Enable VPN
curl -X PUT http://localhost:8090/devices/me/vpn \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"enabled":true,"region":"mexico"}'Full interactive docs: http://your-docker-host:8090/docs
flowchart LR
Phone[Android App]
Web[Web Dashboard]
API[vpn-controller]
MX[gluetun_mexico + tailscale_exit]
AU[gluetun_australia + tailscale_exit]
PiHole[Pi-hole via Tailscale DNS]
Phone -->|register / enable| API
Web -->|live status| API
API -->|start on demand| MX
API -->|start on demand| AU
Phone -->|Tailscale exit node| MX
Phone -->|DNS| PiHole
- Regional stacks start on demand when a device enables a region
- Stacks stop after
IDLE_SHUTDOWN_MINUTESwith no active users (checked every 60 seconds) - The WebUI shows a live countdown per region when a stack is idle and waiting to shut down
- Each Gluetun container uses one PIA connection
| Issue | Fix |
|---|---|
| Exit node not in Tailscale app | Approve it in Tailscale admin, or set up ACL autoApprovers; wait for pia-* machine to appear |
Stack stuck on starting |
docker logs gluetun-<region> — check PIA credentials and server_region in regions.yaml |
| Gluetun "region not valid" | Use city-level PIA names (e.g. CA Toronto, not Canada) — see regions/regions.yaml |
| Home LAN unreachable with exit on | Enable Allow LAN access in Tailscale app |
| Pi-hole blocking stops | Verify Tailscale DNS still points to Pi-hole in admin console |
| App says "Connect Tailscale first" | Open Tailscale and ensure VPN is connected before enabling PIA |
| Stale exit node after controller restart | Open PIA Control and tap Refresh status — exit node clears automatically |
| WebUI change not reflected on phone | Ensure the app is registered and notifications are allowed; the app polls every 3 seconds in the background |
| Regional stack won't start | Check controller logs for Resolved host runtime directory and Docker socket access |
| Portainer grouping wrong | Set DOCKER_PROJECT_NAME in .env to match your stack name |
Controller logs:
docker logs -f vpn-controllerRegional stack logs:
docker logs gluetun-mexico
docker logs tailscale-exit-mexicoAndroid diagnostic logs: Tap Export logs in the app → share the file from Downloads.
Manual idle cleanup:
curl -X POST http://localhost:8090/admin/cleanup-idle- Set
CONTROLLER_SECRETbefore exposing the API beyond your LAN - Prefer accessing the controller over Tailscale (
http://your-docker-host:8090) - Do not commit
.env— it contains PIA credentials - Pairing codes expire after 30 minutes; the full
CONTROLLER_SECRETis never shown on the dashboard - Restrict exit node ACLs in Tailscale to trusted devices
tailscale-pia-controller/
├── docker-compose.yml # Controller service
├── regions/regions.yaml # Region definitions
├── controller/ # FastAPI API + web dashboard
│ └── app/static/ # Dashboard CSS, JS, and app icon
├── apps/android/ # Android control app
├── apps/windows/ # PowerShell + Python CLI
├── releases/ # Prebuilt Android APKs
└── docs/ # Tailscale ACL example
MIT