Erwan Leboucher

Senior Software Engineer · Paris · he/him

Checking Systems
← Back

Ditching Cloudflare Tunnels: Self-hosting with Pangolin on a VPS

How to make my homelab more sovereign.

As part of my effort to reduce US dependencies in my day-to-day life, one key tool I relied on was Cloudflare Tunnels. I currently use it on my Kubernetes homelab to expose services to friends and family from anywhere. It’s easy to set up and free to use.

However, Cloudflare is a US-based company, and with the current geopolitical climate it has become increasingly difficult to rely on American infrastructure. Cloudflare has already threatened to pull its servers from certain markets rather than comply with local regulations, a move that highlights how dependent we are on their goodwill, even if the regulations themselves are debatable.

Sadly, free non-US alternatives to Cloudflare Tunnels don’t exist. One alternative was to self-host an open source alternative called Pangolin on a VPS, which allows you to have a similar setup without relying on a third party.

Choosing a VPS was pretty straightforward to me. It was either Hetzner or OVH. Since I was also hosting my domain DNS on Cloudflare, I chose to go with OVH so I can host my domain DNS on OVH as well, and have the VPS on the same provider. I took the VPS-1 which is 5 euro per month and the cheapest option (still pretty overkill for my needs). (Disclosure: I joined OVH as a software engineer in March 2026, so take that recommendation with the appropriate grain of salt.)

How it works

An incoming request hits the VPS, where Traefik handles TLS termination and routing. On the cluster side, Newt runs as a Deployment and handles the WireGuard tunnel back to the VPS. Traffic is forwarded through that tunnel to your in-cluster gateway (kgateway in my case), which routes it to the appropriate service. Pangolin acts as the control plane, managing sites, resources, and access rules across that tunnel. Gerbil runs alongside Pangolin on the VPS and handles the network-level plumbing between Traefik and the tunnel.

internet -> VPS (Traefik + Gerbil) -> WireGuard tunnel (Newt) -> cluster (kgateway -> service)

The Setup

Pangolin setup is just a Docker Compose file that runs Pangolin, Traefik, and Gerbil (with CrowdSec as an optional dependency). docker-compose.yml

To help a fast deployment I also created an Ansible playbook that automates the setup of the VPS and the deployment of Pangolin, with security hardening and gitops in place with doco-cd. playbook.yml My Ansible will set up doco-cd, deploy Pangolin, apply security rules, and push secrets generated by Pangolin to 1Password.

Now that we have Pangolin set up and running, we need to configure the link between Pangolin and our cluster. For that, the Pangolin team developed Newt, which is based on WireGuard.

Newt can push configuration directly to Pangolin, letting you manage exposed services from the cluster using Blueprints:

newt-blueprint.yaml
jellyfin:
name: 'Jellyfin'
protocol: http
ssl: true
full-domain: 'jellyfin.erwanleboucher.dev'
tls-server-name: jellyfin.erwanleboucher.dev
targets:
- site: glistening-desert-rosy-boa
hostname: kgateway-external.network.svc.cluster.local
method: https
port: 443

This defines what is called a resource: the link between the external world and the cluster. The site id is a unique identifier that is generated by Newt when you create a site.

If you are experienced with Cloudflare Tunnels, you might think about having a wildcard domain to route all the services that are available in your gateway (here kgateway). Sadly this is not possible with Newt/Wireguard. Therefore, either you define each of them manually or you can use the sidecar service I created to automatically discover and configure services from your cluster.

Newt-Sidecar

home-operations/newt-sidecar

Newt-sidecar watches all HTTPRoute resources in your cluster and configures them in Pangolin automatically. This runs as a sidecar container alongside the Newt deployment.

helmrelease.yaml
newt-sidecar:
image:
repository: ghcr.io/home-operations/newt-sidecar
tag: latest
args:
- --gateway-name=kgateway-external
- --site-id=glistening-desert-rosy-boa
- --target-hostname=kgateway-external.network.svc.cluster.local
- --deny-countries=RU,CN,KP,IR,BY,IL # GeoIP blocking via Badger, see Security section

TLS With OVH DNS challenge

As my DNS is hosted on OVH, we need to set up the OVH DNS challenge to automatically configure TLS certificates.

Traefik has a built-in OVH DNS challenge provider, which makes it easier than cert-manager which needs an external webhook (which I will write about soon).

The OVH API key is a bit tricky to get. You need to provide the endpoint path to the OVH API, which is not the most obvious process, but you can follow this guide to get your credentials (which is also the guide for the OVH cert-manager webhook).

Once you have your credentials (ApplicationKey, ApplicationSecret and ConsumerKey), make them available as environment variables in your deployment:

docker-compose.yml
container_name: traefik
restart: unless-stopped
environment:
- OVH_ENDPOINT=ovh-eu
- OVH_APPLICATION_KEY=${OVH_APPLICATION_KEY}
- OVH_APPLICATION_SECRET=${OVH_APPLICATION_SECRET}
- OVH_CONSUMER_KEY=${OVH_CONSUMER_KEY}
- CROWDSEC_API_KEY=${CROWDSEC_API_KEY}

And in traefik_config.yaml:

traefik_config.yaml
letsencrypt:
acme:
dnsChallenge:
provider: ovh
email: 'erwanleboucher@gmail.com'
storage: '/letsencrypt/acme.json'
caServer: 'https://acme-v02.api.letsencrypt.org/directory'

This will allow you to generate certificates for your domains using the OVH DNS challenge and protect your services with HTTPS.

Security

As this opens a door into our homelab cluster, we should be careful to protect our environment as much as possible.

In my case, I added ufw rules to restrict the VPS access to only the services I need.

ufw-rules.yaml
port: 22
- name: HTTP
port: 80
- name: HTTPS
port: 443
- name: Wireguard
port: 51820
protocol: udp
- name: Gerbil
port: 21820
protocol: udp

Pangolin also gives you two ways to secure your cluster:

  • Pangolin’s own security features with the Badger Traefik middleware
  • CrowdSec which is an open-source security platform that provides threat detection and prevention capabilities.

I decided to use both, as Badger also adds monitoring to the Pangolin dashboard. Badger also offers authentication with SSO capabilities, shareable links, and more. I also use it for GeoIP blocking, as seen in the newt-sidecar config above.

Badger

Pangolin uses the MaxMind GeoIP database to block access from countries you don’t want reaching your services, and you can easily configure it with Docker Compose.

docker-compose.yml
container_name: geoipupdate
image: ghcr.io/maxmind/geoipupdate:v7.1.1
restart: unless-stopped
environment:
- GEOIPUPDATE_ACCOUNT_ID=${MAXMIND_ACCOUNT_ID}
- GEOIPUPDATE_LICENSE_KEY=${MAXMIND_LICENSE_KEY}
- GEOIPUPDATE_EDITION_IDS=GeoLite2-Country GeoLite2-ASN
- GEOIPUPDATE_FREQUENCY=72
volumes:
- './config/GeoLite2:/usr/share/GeoIP'

And in the Pangolin config:

pangolin_config.yaml
maxmind_asn_path: './config/GeoLite2/GeoLite2-ASN.mmdb'

This will automatically download and update the GeoIP database every 72 hours. To learn more and get the license key.

CrowdSec

I haven’t dug too deeply into CrowdSec yet, but to make it work with Pangolin, you need to configure it with Docker Compose like:

docker-compose.yml
environment:
COLLECTIONS: crowdsecurity/traefik crowdsecurity/appsec-virtual-patching crowdsecurity/appsec-generic-rules
ENROLL_INSTANCE_NAME: pangolin-crowdsec
ENROLL_TAGS: docker
GID: '1000'
PARSERS: crowdsecurity/whitelists
healthcheck:
interval: 10s
retries: 3
start_period: 30s
test:
- CMD
- cscli
- lapi
- status
timeout: 5s
image: docker.io/crowdsecurity/crowdsec:v1.7.6
labels:
- traefik.enable=false
ports:
- 6060:6060
restart: unless-stopped
volumes:
- /opt/crowdsec/config:/etc/crowdsec
- /opt/crowdsec/data:/var/lib/crowdsec/data
- ./config/traefik/logs:/var/log/traefik
configs:
- source: crowdsec_acquis
target: /etc/crowdsec/acquis.yaml
config:
crowdsec_acquis:
content: |
source: file
filenames:
- /var/log/traefik/access.log
labels:
type: traefik
---
source: appsec
listen_addr: 0.0.0.0:7422
path: /
appsec_config: crowdsecurity/virtual-patching
labels:
type: appsec

Then create a bouncer API key for Traefik. If you want CrowdSec to auto-generate one:

terminal
cscli bouncers add traefik-bouncer

Copy the output key and set it as CROWDSEC_API_KEY in your environment. If you already have a key (e.g. from an Ansible vault), you can pass it directly:

terminal
cscli bouncers add traefik-bouncer --key "$CROWDSEC_API_KEY"

Migrating away from Cloudflare Tunnels took an afternoon and has been rock solid since. The setup is more involved than a single cloudflared token and some wildcard configuration, but you gain full control over your traffic, no vendor lock-in, and no dependency on US infrastructure. If you’re already running a Kubernetes homelab, the extra complexity is well worth it. Questions? Come find me on the home-operations Discord.

Everything explained above is in my own homelab repository here.