How to Deploy Pangolin on a VPS
Updated Jul 2026
verified on Ubuntu 26.04 · Jul 2026Self-host Pangolin on your own VPS — a tunneled reverse proxy with built-in identity and automatic SSL that publishes private services to the internet over WireGuard, no Cloudflare account required.
- A VPS with 1 vCPU / 1–2 GB RAM and a public IPv4 address
- A fresh Ubuntu 26.04 server with root/sudo SSH access, Docker + Compose installed
- A domain with a wildcard DNS record (a single `*.example.com` A record) pointed at the server — every resource you publish gets its own subdomain
What Pangolin is
Pangolin is a self-hosted, tunneled reverse proxy — think of it as a self-hosted Cloudflare Tunnel. You run Pangolin on a VPS with a public IP, and it publishes services that don't have a public IP of their own (a home server, a lab box behind NAT, a container on an internal network) to the internet over an encrypted WireGuard tunnel. Nothing has to open a port on the private side; the private machine dials out to Pangolin, and Pangolin exposes it as a proper HTTPS subdomain.
That combination — reverse proxy plus tunnel — is bundled with two things most
self-hosted proxies leave to you: identity and SSL are built in. Every
published "Resource" can sit behind Pangolin's own auth (password, OAuth, or
just an invite-only login) before traffic ever reaches the app, and TLS
certificates are issued and renewed automatically. The stack is three
containers working together: a TypeScript dashboard/API (pangolin) that owns
configuration and auth, a small Go data-plane binary called Gerbil that
terminates the WireGuard tunnels and the public ports, and Traefik as the
actual reverse proxy, driven live by Pangolin's API via a badger auth-forwarding
plugin. It's AGPL-3.0 (Community Edition — there's a paid Enterprise tier for
larger orgs, not needed here).
This guide deploys the Pangolin server itself on your VPS — the piece that sits on the public internet. Once it's up, private machines connect to it with a lightweight client (Newt) to register resources; that side is out of scope here, but everything below gets the server itself running and reachable.
Server sizing — why 1–2 GB is enough
Pangolin's job is proxying and tunnel termination, not compute — the three containers (a Node dashboard, a small Go binary, and Traefik) are all lightweight, and none of them does anything CPU-heavy under normal use. 1 vCPU and 1–2 GB RAM is comfortable for a personal or small-team instance with a handful of published resources. The default datastore is SQLite, stored on a small volume, so disk needs are modest too — 10–20 GB leaves plenty of room.
Where it gets heavier is scale: a lot of concurrent tunnel clients or high proxied traffic volume benefits from more RAM and a dedicated Postgres datastore instead of SQLite, but that's a later problem. A Hetzner CX22 (2 vCPU / 4 GB) has generous headroom for a first deploy.
Prepare the server
This guide assumes you've already worked through
Docker & Compose on Ubuntu — a fresh
Ubuntu 26.04 box with Docker Engine, the Compose plugin, and ufw allowing SSH
plus 80 and 443. Pangolin needs two more ports open: 51820/udp (the main
WireGuard tunnel) and 21820/udp (a secondary port Gerbil uses for client
hole-punching). Unlike the reverse-proxy guides where everything binds to
localhost behind a proxy, Pangolin's Traefik container shares Gerbil's network
namespace and binds 80/443 directly — so the base guide's existing 80/443
rules already cover the web ports, and you only need to add the two UDP ports.
ufw allow 51820/udp
ufw allow 21820/udp
mkdir -p ~/pangolin/config/db ~/pangolin/config/letsencrypt ~/pangolin/config/traefik/logs
cd ~/pangolin
If your VPS provider has its own cloud firewall panel in addition to ufw
(Hetzner Cloud Firewall, DigitalOcean Cloud Firewalls, etc.), open the same
four ports there too — ufw alone won't help if the provider's firewall
drops the traffic first.
Install Pangolin
Pangolin ships an interactive one-command installer
(curl -fsSL https://static.pangolin.net/get-installer.sh | bash then
sudo ./installer) that walks you through the setup with prompts. This guide
uses Pangolin's other officially documented install path instead — manual
Docker Compose — because every value is supplied up front in config files,
which makes the whole install scriptable and reproducible rather than
answered by hand.
First generate a random secret Pangolin uses to sign sessions — treat it like a password:
export PANGOLIN_SECRET=$(openssl rand -hex 32)
echo "$PANGOLIN_SECRET" > ~/pangolin/.pangolin-secret
chmod 600 ~/pangolin/.pangolin-secret
Create the Compose file:
cat > docker-compose.yml <<'EOF'
name: pangolin
services:
pangolin:
image: docker.io/fosrl/pangolin:latest
container_name: pangolin
restart: unless-stopped
volumes:
- ./config:/app/config
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3001/api/v1/"]
interval: "10s"
timeout: "10s"
retries: 15
gerbil:
image: docker.io/fosrl/gerbil:latest
container_name: gerbil
restart: unless-stopped
depends_on:
pangolin:
condition: service_healthy
command:
- --reachableAt=http://gerbil:3004
- --generateAndSaveKeyTo=/var/config/key
- --remoteConfig=http://pangolin:3001/api/v1/
volumes:
- ./config/:/var/config
cap_add:
- NET_ADMIN
- SYS_MODULE
ports:
- 51820:51820/udp
- 21820:21820/udp
- 443:443
- 80:80
traefik:
image: docker.io/traefik:v3.6
container_name: traefik
restart: unless-stopped
network_mode: service:gerbil
depends_on:
pangolin:
condition: service_healthy
command:
- --configFile=/etc/traefik/traefik_config.yml
volumes:
- ./config/traefik:/etc/traefik:ro
- ./config/letsencrypt:/letsencrypt
- ./config/traefik/logs:/var/log/traefik
networks:
default:
driver: bridge
name: pangolin
EOF
Traefik's static config — this is where the Let's Encrypt email lives:
cat > config/traefik/traefik_config.yml <<'EOF'
api:
insecure: true
dashboard: true
providers:
http:
endpoint: "http://pangolin:3001/api/v1/traefik-config"
pollInterval: "5s"
file:
filename: "/etc/traefik/dynamic_config.yml"
experimental:
plugins:
badger:
moduleName: "github.com/fosrl/badger"
version: "v1.4.0"
log:
level: "INFO"
format: "common"
maxSize: 100
maxBackups: 3
maxAge: 3
compress: true
certificatesResolvers:
letsencrypt:
acme:
httpChallenge:
entryPoint: web
email: "admin@example.com"
storage: "/letsencrypt/acme.json"
caServer: "https://acme-v02.api.letsencrypt.org/directory"
entryPoints:
web:
address: ":80"
websecure:
address: ":443"
transport:
respondingTimeouts:
readTimeout: "30m"
http:
tls:
certResolver: "letsencrypt"
encodedCharacters:
allowEncodedSlash: true
allowEncodedQuestionMark: true
serversTransport:
insecureSkipVerify: true
ping:
entryPoint: "web"
EOF
Traefik's dynamic config — the routers that get the dashboard itself online
(every other route, for resources you publish later, Pangolin pushes to
Traefik live via the http provider above):
cat > config/traefik/dynamic_config.yml <<'EOF'
http:
middlewares:
badger:
plugin:
badger:
disableForwardAuth: true
redirect-to-https:
redirectScheme:
scheme: https
routers:
main-app-router-redirect:
rule: "Host(`pangolin.example.com`)"
service: next-service
entryPoints:
- web
middlewares:
- redirect-to-https
- badger
next-router:
rule: "Host(`pangolin.example.com`) && !PathPrefix(`/api/v1`)"
service: next-service
entryPoints:
- websecure
middlewares:
- badger
tls:
certResolver: letsencrypt
api-router:
rule: "Host(`pangolin.example.com`) && PathPrefix(`/api/v1`)"
service: api-service
entryPoints:
- websecure
middlewares:
- badger
tls:
certResolver: letsencrypt
ws-router:
rule: "Host(`pangolin.example.com`)"
service: api-service
entryPoints:
- websecure
middlewares:
- badger
tls:
certResolver: letsencrypt
services:
next-service:
loadBalancer:
servers:
- url: "http://pangolin:3002"
api-service:
loadBalancer:
servers:
- url: "http://pangolin:3000"
tcp:
serversTransports:
pp-transport-v1:
proxyProtocol:
version: 1
pp-transport-v2:
proxyProtocol:
version: 2
EOF
And Pangolin's own config — the base domain, dashboard hostname, and the secret you generated:
cat > config/config.yml <<EOF
gerbil:
start_port: 51820
base_endpoint: "pangolin.example.com"
app:
dashboard_url: "https://pangolin.example.com"
log_level: "info"
telemetry:
anonymous_usage: true
domains:
domain1:
base_domain: "example.com"
server:
secret: "${PANGOLIN_SECRET}"
cors:
origins: ["https://pangolin.example.com"]
methods: ["GET", "POST", "PUT", "DELETE", "PATCH"]
allowed_headers: ["X-CSRF-Token", "Content-Type"]
credentials: false
flags:
require_email_verification: false
disable_signup_without_invite: true
disable_user_create_org: false
allow_raw_resources: true
EOF
Before starting, replace every example.com / pangolin.example.com /
admin@example.com placeholder across the three config files with your
real base domain, dashboard hostname, and email (see the wildcard DNS section
below for what to use if you don't have a domain yet). Then bring the stack
up:
docker compose up -d
docker compose logs --tail 60 pangolin traefik gerbil
Pangolin starts first, Gerbil waits for it to report healthy, and Traefik waits on Pangolin too since it needs the dashboard's live routing config. First boot takes a little longer while images are pulled and Gerbil generates its WireGuard keypair.
Domain + wildcard DNS
Pangolin gives every resource you publish its own subdomain under your base
domain — app1.example.com, app2.example.com, and so on — generated on
demand as you create them in the dashboard. Rather than adding a DNS record
per resource, point a single wildcard A record at your server once:
Type A, Name *, Value <your-server-ip>. From then on, every new subdomain
resolves automatically and Traefik's HTTP-01 challenge (configured above)
issues it a certificate the first time it's requested — no DNS-provider API
keys or DNS-01 setup needed.
No domain yet? Use <your-server-ip>.sslip.io as a throwaway wildcard.
sslip.io resolves any subdomain of <ip>.sslip.io to that IP with zero
setup — pangolin.203.0.113.10.sslip.io, app1.203.0.113.10.sslip.io, and
anything else you make up all resolve correctly, which is exactly the
wildcard behavior Pangolin needs. Set your base domain to
<your-server-ip>.sslip.io and your dashboard hostname to
pangolin.<your-server-ip>.sslip.io in the config files above, and
everything else works unchanged. It's fine for testing and evaluation; for
anything real, register an actual domain and add the wildcard A record — a
sslip.io address is public and guessable, which isn't what you want for
production auth-gated resources.
First-run setup
On first boot Pangolin has no admin account yet. It prints a one-time setup token to its logs:
docker compose logs pangolin 2>&1 | grep -i "setup token" || true
Visit https://<your-dashboard-domain>/auth/initial-setup in a browser, paste
the token, and set a strong password — this becomes the owner account with
full access to every organization and resource. Like most self-hosted admin
panels, that setup page is open to whoever gets there first, so do this
immediately after the stack comes up.
Once you're in, create your first Site (a tunnel endpoint) and a test Resource pointing at a simple internal service to confirm routing works end to end — the dashboard walks you through installing the Newt client on the machine you're publishing from.
Backups
Everything that matters lives under ~/pangolin/config: the SQLite database
(config/db/db.sqlite), the Let's Encrypt certificates
(config/letsencrypt/acme.json), Gerbil's WireGuard key (config/key), and
the config files themselves — including the secret in config/config.yml,
which signs every session token. Back up the whole directory:
tar -czf ~/pangolin-backup-$(date +%F).tar.gz -C ~/pangolin config
Copy that archive off the box on a schedule — S3-compatible storage or another machine — and periodically restore it into a throwaway stack to confirm it actually works. A backup you haven't restored is a guess. If you're running more than a handful of resources, consider migrating the datastore to Postgres for better durability under load; that's a config change documented in Pangolin's docs, not something this guide's SQLite default handles for you.
Upgrades
docker compose pull
docker compose up -d
Read the release notes before upgrading, more than with most self-hosted
tools — Pangolin's config schema has changed across versions in ways that can
break an unattended pull && up -d if you skip straight over a breaking
release. Take the backup above first, then upgrade one version at a time
rather than jumping several releases, and pin specific image tags
(fosrl/pangolin:1.x.x instead of :latest) once you're past initial setup
so an upgrade is always a deliberate, single-version step.
Troubleshooting
Certificate never issues / dashboard shows a self-signed or invalid cert.
Almost always DNS. Confirm your wildcard A record (or sslip.io hostname)
actually resolves to the server's IP from outside (dig pangolin.example.com from your laptop), and that ports 80 and 443 are open
both in ufw and any provider-level cloud firewall — the HTTP-01 challenge
needs port 80 reachable from the internet at the exact moment the cert is
requested.
502 / Bad Gateway from Traefik. Check docker compose ps — Traefik
depends on Pangolin being healthy, and if Pangolin is still starting or
crash-looping, Traefik has nowhere to send the request. Check
docker compose logs pangolin for the actual error.
Tunnel clients (Newt) can't connect. WireGuard traffic on 51820/udp or
21820/udp is being dropped somewhere between the client and the server —
check the provider's cloud firewall in addition to ufw, since many VPS
panels ship with UDP blocked by default even when ufw allows it.
Setup page 404s or the dashboard is unreachable at your domain. The
Host() rules in dynamic_config.yml and dashboard_url in config.yml
must exactly match the hostname you're actually visiting — if you edited one
placeholder and missed another, Traefik has no route for the request.
Verification + next steps
You're done when all three containers show healthy in docker compose ps,
the dashboard loads over HTTPS at your real domain with a valid certificate,
you've created the owner account, and a test Resource routes traffic from a
connected Newt client through to an internal service. From here, invite
teammates as users on specific organizations, and layer in an identity
provider if you want SSO rather than Pangolin-native logins. A Hetzner CX22
comfortably runs Pangolin alongside whatever it's tunneling to; see
Best VPS for self-hosting for the ranked picks.