Skip to content

Command Palette

Search for a command to run...

How to Deploy Pangolin on a VPS

Updated Jul 2026

verified on Ubuntu 26.04 · Jul 2026

Self-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.

Before you start
  • 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.

Next steps

We use analytics cookies (Google Analytics, PostHog) to see which guides are useful. No ad networks, no cross-site tracking. See our privacy policy.