Skip to content

Pangolin + CrowdSec + Authentik

If you run services on your homelab — Jellyfin, Grafana, Vaultwarden, whatever — you eventually want to access them from outside your home by typing grafana.yourdomain.com instead of 192.168.1.50:3000. That requires a reverse proxy: something that receives HTTPS traffic from the internet and routes it to the right container on your LAN.

This guide deploys a reverse proxy stack with three layers of security that most homelab guides skip:

ComponentWhat it doesWhy you want it
Pangolin + GerbilRoute management dashboard + WireGuard tunnelManage all your reverse proxy routes from a web UI instead of editing YAML files. The WireGuard tunnel means you don’t need to forward dozens of ports on your router — only one UDP port.
Traefik v3The actual reverse proxy / load balancerHandles TLS termination, certificate management, and request routing. It’s the engine under the hood — Pangolin is the dashboard that configures it.
CrowdSecWeb Application Firewall (WAF) + crowd-sourced threat intelligenceBlocks malicious requests (SQL injection, XSS, path traversal, brute force) before they reach your services. Shares threat data with 200k+ other CrowdSec users.
AuthentikSingle Sign-On (SSO) via OpenID ConnectOne login for all your services. Role-based access control lets you give friends access to Jellyfin but not Grafana.

How is this different from a standard Traefik setup?

Section titled “How is this different from a standard Traefik setup?”

In a traditional Traefik homelab setup, you add Docker labels to each container to define routes. This works but has drawbacks:

  • Every route change requires editing compose files and redeploying containers
  • Labels are scattered across dozens of compose files — hard to see the full picture
  • Adding SSO requires a separate forward-auth container and complex label chains
  • No built-in WireGuard tunnel — you need Cloudflare Tunnel or manual port forwarding

With Pangolin, you add routes from a web dashboard. Pangolin pushes the config to Traefik via an HTTP API — no labels, no container restarts. SSO is built in. The WireGuard tunnel (Gerbil + Newt) handles connectivity.

Gerbil is the WireGuard server. It runs alongside Traefik and accepts incoming tunnel connections. It binds ports 80, 443, and 51820/udp on your host.

Newt is the WireGuard client. It runs on the same Docker network as your services and creates a tunnel back to Gerbil. When Traefik needs to reach a service like http://grafana:3000, the request travels through this tunnel. This is how services on your LAN become reachable from the internet without port-forwarding each one individually.

If you’re running everything on a single server (not a VPS + homelab split), Newt still serves a purpose: it registers your local services with Pangolin so they appear in the dashboard and can be managed centrally.

Internet (your public IP or VPS)
│ :80 (HTTP redirect)
│ :443 (HTTPS)
│ :51820/udp (WireGuard)
┌─────────────────────────────────────────────────────────┐
│ Gerbil (WireGuard server, binds all external ports) │
│ │
│ ┌───────────────────────────────────────────────────┐ │
│ │ Traefik v3.4 (network_mode: service:gerbil) │ │
│ │ │ │
│ │ Entrypoints: │ │
│ │ :443 (websecure) ─── TLS termination │ │
│ │ :8080 (traefik) ─── Dashboard (LAN only) │ │
│ │ │ │
│ │ Providers: │ │
│ │ HTTP ← polls Pangolin API every 5s for routes │ │
│ │ File ← dynamic_config.yml (middleware + manual) │ │
│ │ │ │
│ │ Plugins: │ │
│ │ badger v1.3.1 ─── Pangolin auth middleware │ │
│ │ bouncer v1.5.0 ─── CrowdSec WAF integration │ │
│ └───────────────────────────────────────────────────┘ │
└────────────────────┬────────────────────────────────────┘
┌──────────┴──────────┐
▼ ▼
┌──────────────────┐ ┌────────────────────┐
│ Pangolin │ │ CrowdSec │
│ API :3001 │ │ LAPI :8080 │
│ UI :3002 │ │ AppSec :7422 │
│ (route manager) │ │ Metrics :6060 │
│ │ │ (WAF + ban engine) │
│ Stores routes │ │ │
│ in SQLite DB │ │ Reads Traefik logs │
│ Pushes to │ │ + inspects requests │
│ Traefik via API │ │ inline via plugin │
└──────────────────┘ └────────────────────┘
┌──────────────────┐ ┌──────────────────────────────┐
│ Newt │ │ Your services (proxy network) │
│ WireGuard client│───▶│ │
│ Registers local │ │ grafana:3000 │
│ services with │ │ audiobookshelf:80 │
│ Pangolin │ │ vaultwarden:80 │
└──────────────────┘ │ jellyseerr:5055 │
│ ... │
│ └──────────────────────────────┘
┌──────────────────────────┐
│ Authentik (SSO) │
│ Server :9000 (HTTP) │
│ Server :9443 (HTTPS) │
│ + PostgreSQL (local vol)│
│ + Redis │
│ │
│ Handles OIDC login flow │
│ Returns identity claims │
│ to Pangolin │
└──────────────────────────┘

Here’s what happens when someone visits https://grafana.yourdomain.com:

  1. DNS resolves grafana.yourdomain.com to your public IP (or VPS IP). Cloudflare handles this via a wildcard CNAME or individual A records.
  2. Gerbil receives the connection on port 443 and passes it to Traefik (they share a network namespace).
  3. Traefik matches the hostname against its router rules. The route for grafana.yourdomain.com was pushed by Pangolin’s HTTP provider.
  4. CrowdSec bouncer plugin runs first (it’s a middleware). It checks the client IP against the ban list and sends the request body to the AppSec engine for WAF inspection. If malicious → 403 Forbidden.
  5. Pangolin’s SSO middleware (badger) checks if the route requires authentication. If yes, it checks for a valid session cookie. If no cookie → redirect to Authentik login.
  6. Authentik handles login: username/password, MFA, etc. On success, it redirects back to Pangolin with an OIDC token containing the user’s email and role.
  7. Pangolin validates the token, creates a session cookie, checks if the user’s role has access to this resource, and forwards the request to the backend service.
  8. Traefik proxies the request to http://grafana:3000 via the Docker network (or WireGuard tunnel if on a different host).

All of this happens in milliseconds. The user just sees a login page (once), then Grafana.

  • A Linux host with Docker Engine and Docker Compose v2 installed. Any Debian/Ubuntu-based distro works. Minimum 2 GB RAM.
  • A domain name — e.g., yourdomain.com. This guide assumes DNS is managed by Cloudflare (free tier is fine).
  • A public IP that can receive traffic on ports 80, 443, and 51820/udp. If your ISP uses CGNAT, you’ll need a cheap VPS (Hetzner, Oracle free tier) as a tunnel endpoint instead.
  • A free CrowdSec account for console enrollment and crowd-sourced blocklists.

Open these ports on your router/firewall and forward them to your Docker host:

PortProtocolPurpose
80TCPHTTP → HTTPS redirect (Gerbil)
443TCPHTTPS traffic (Gerbil → Traefik)
51820UDPWireGuard tunnel (Gerbil)

If you use ufw on the Docker host:

Terminal window
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 51820/udp

Traefik needs this to automatically create TLS certificates via Let’s Encrypt DNS challenge.

  1. Log into dash.cloudflare.com
  2. Go to My Profile → API Tokens → Create Token
  3. Use the “Edit zone DNS” template
  4. Under Zone Resources, select Include → Specific zone → yourdomain.com
  5. Click Continue to summary → Create Token
  6. Copy the token — you’ll need it for the .env file later. This token can only edit DNS records for your domain, nothing else.

In Cloudflare, create DNS records for your domain. You have two options:

TypeNameContentProxy
A@Your public IPDNS only (grey cloud)
CNAME*yourdomain.comDNS only (grey cloud)

This routes all subdomains (grafana.yourdomain.com, auth.yourdomain.com, etc.) to your server. New services work immediately without adding DNS records.

Create an A record for each subdomain you want to expose. More control, but you need to add a record every time you add a service.

3.5 Create the Docker network and directories

Section titled “3.5 Create the Docker network and directories”
Terminal window
# Create the shared network all containers will use
docker network create proxy
# Compose files
mkdir -p /opt/stacks/pangolin
mkdir -p /opt/stacks/authentik
# Persistent config directories
mkdir -p /srv/docker/pangolin/config/traefik
mkdir -p /srv/docker/crowdsec/{config,data}
mkdir -p /srv/docker/traefik/{letsencrypt,logs}
mkdir -p /srv/docker/authentik/{media,templates}
  1. Go to app.crowdsec.net and create a free account
  2. Go to Security Engines → Engines → Add Security Engine
  3. Copy the enrollment key — it looks like cmd4uxk7n...
  4. You’ll paste this into the .env file. When CrowdSec starts, it uses this key to register with the console, which gives you a web dashboard to see alerts, decisions, and crowd-sourced blocklists.

4. Understanding Traefik v3 (Crash Course)

Section titled “4. Understanding Traefik v3 (Crash Course)”

Traefik is the engine that actually handles incoming requests. Pangolin configures it, but understanding these core concepts will save you hours of debugging.

An entrypoint is a port that Traefik listens on. Think of it as a door into your reverse proxy.

entryPoints:
websecure: # Name (referenced by routers)
address: ":443" # Port to listen on
traefik:
address: ":8080" # Dashboard port

In our setup, we only define two entrypoints: websecure (443) for all HTTPS traffic, and traefik (8080) for the dashboard. We do not define a web (port 80) entrypoint because Gerbil handles port 80 and redirects HTTP to HTTPS before Traefik even sees it.

A router matches incoming requests to backend services based on rules. The most common rule is hostname matching:

routers:
my-grafana:
rule: "Host(`grafana.yourdomain.com`)" # Match this hostname
entryPoints: [websecure] # Only listen on :443
middlewares: [security-chain] # Apply these middlewares first
service: grafana-svc # Then forward to this service
tls:
certResolver: letsencrypt # Use this cert resolver for TLS

You can also match on paths (PathPrefix(/api/)), headers, or combinations. The priority field controls which router wins when multiple rules match — higher number wins.

A service is the backend your request gets forwarded to. In Docker setups, this is typically http://container-name:port:

services:
grafana-svc:
loadBalancer:
servers:
- url: "http://grafana:3000"

Traefik resolves grafana via Docker’s internal DNS because all containers are on the same proxy network.

Middlewares modify requests before they reach the backend. They’re applied in order. Our stack uses three, chained together:

MiddlewareWhat it does
crowdsec-bouncerChecks the client IP against CrowdSec’s ban list. Sends the request body to the AppSec WAF engine for SQL injection, XSS, and other attack detection. Blocked requests get a 403.
secure-headersAdds security headers to responses: HSTS (force HTTPS), X-Frame-Options (prevent clickjacking), X-Content-Type-Options (prevent MIME sniffing), CSP, etc.
rate-limitLimits each client to 50 requests/second with a burst of 100. Prevents abuse without affecting normal browsing.

These three are combined into a chain middleware called security-chain so you can apply all three with a single reference:

security-chain:
chain:
middlewares:
- crowdsec-bouncer
- secure-headers
- rate-limit

Providers are how Traefik learns about routes. In a traditional setup, the Docker provider watches for containers with labels. Our setup uses two different providers:

ProviderSourceWhat it provides
HTTP providerPolls http://pangolin:3001/api/v1/traefik-config every 5 secondsAll routes managed by Pangolin (the majority of your services). When you add a route in the Pangolin dashboard, Traefik picks it up within 5 seconds.
File providerWatches dynamic_config.yml on diskMiddleware definitions (CrowdSec, headers, rate-limit) and manual routes that Pangolin doesn’t manage (like the Pangolin dashboard itself).

We do not use the Docker provider at all. No labels needed on any container.

A certificate resolver handles TLS certificate issuance from Let’s Encrypt (or another ACME provider). Our config uses DNS challenge via Cloudflare:

certificatesResolvers:
letsencrypt:
acme:
email: you@example.com # Let's Encrypt registration email
storage: /letsencrypt/acme.json # Where certs are stored
dnsChallenge:
provider: cloudflare # Use Cloudflare DNS API
delayBeforeCheck: 30 # Wait 30s for DNS propagation

When Traefik sees a request for a hostname it doesn’t have a cert for, it automatically requests one from Let’s Encrypt by creating a temporary DNS TXT record via the Cloudflare API. Certs auto-renew before expiry. All certs are stored in acme.json.

Traefik v3 supports plugins loaded at startup. Our stack uses two:

PluginPurpose
badger (v1.3.1)Pangolin’s authentication middleware. When a route has SSO enabled, badger intercepts the request, checks for a valid Pangolin session, and redirects to the OIDC provider if needed. This is how Pangolin enforces SSO without a separate forward-auth container.
bouncer (v1.5.0)CrowdSec’s Traefik plugin. Checks every request against CrowdSec’s local API (ban list) and optionally forwards request bodies to the AppSec WAF engine for deep inspection.

Plugins are declared in the experimental.plugins section of the static config. Traefik downloads them on first start.

This is a Docker networking feature that makes Traefik share Gerbil’s network namespace. In plain English: Traefik doesn’t get its own IP address or port bindings. Instead, it uses Gerbil’s. When Traefik listens on :443, that’s actually Gerbil’s port 443.

This is necessary because Gerbil is the WireGuard server that receives all incoming traffic. Traefik needs to be “inside” Gerbil’s network to process that traffic. The alternative would be complex iptables rules to forward traffic between containers.

Consequence: Traefik has no ports: section in the compose file. All port bindings are on the gerbil service. If Traefik tried to bind port 80 while Gerbil is already binding port 80, one of them would fail silently.

Create a .env file with your secrets. These values are referenced by ${VARIABLE} in the compose file.

Terminal window
# System
GID=1000 # Your user's group ID (run: id -g)
TZ=Europe/Copenhagen # Your timezone
# Cloudflare API token (created in step 3.3)
CF_API_KEY=your-cloudflare-api-token
# CrowdSec console enrollment (created in step 3.6)
CROWDSEC_ENROLL_KEY=your-crowdsec-enroll-key
ENROLL_INSTANCE_NAME=homelab # Friendly name shown in CrowdSec console
# Gerbil — this tells Gerbil how to reach itself internally
GERBIL_REACHABLE_AT=http://gerbil:80
# Newt credentials — leave blank for now, fill in after first boot (step 5.7)
NEWT_ID=
NEWT_SECRET=

This is the main Pangolin configuration. It defines your domain, admin credentials, and how Gerbil’s WireGuard tunnel works.

app:
dashboard_url: "https://pangolin.yourdomain.com"
log_level: "info"
server:
external_port: 443 # The port clients connect to (HTTPS)
internal_port: 3001 # Pangolin API (internal, not exposed publicly)
next_port: 3002 # Pangolin Next.js UI (internal)
secret: "generate-me" # Run: openssl rand -hex 32
domains:
domain1:
base_domain: "yourdomain.com"
cert_resolver: "letsencrypt" # Must match the resolver name in Traefik config
traefik:
cert_resolver: "letsencrypt"
http_entrypoint: "web" # Name of the HTTP entrypoint
https_entrypoint: "websecure" # Name of the HTTPS entrypoint
gerbil:
base_endpoint: "gerbil.yourdomain.com" # PUBLIC hostname for WireGuard
start_port: 51820 # First WireGuard port
block_size: 24 # Subnet size for tunnel IPs
subnet_group: "100.90.128.0/24" # Internal tunnel IP range
flags:
require_email_verification: false # Skip email verification (homelab)
disable_signup_without_invite: true # No public registration
allow_raw_resources: true # Allow IP:port targets (not just hostnames)
allow_local_sites: true # Allow sites pointing to local IPs
users:
server_admin:
email: "you@example.com"
password: "change-me" # Initial admin password

This file is loaded once when Traefik starts. It defines entrypoints, providers, cert resolvers, and plugins. Changes require a Traefik restart.

Terminal window
global:
checkNewVersion: false
sendAnonymousUsage: false
# Enable the Traefik dashboard on :8080 (only accessible from LAN)
api:
insecure: true
dashboard: true
# Application log — useful for debugging startup issues and cert problems
log:
level: INFO
filePath: "/app/logs/traefik.log"
# Access log — records every HTTP request. CrowdSec parses this.
# MUST be JSON format for CrowdSec's Traefik parser to work.
accessLog:
filePath: "/app/logs/access.log"
format: json
# Prometheus metrics — useful if you have a Grafana/Prometheus monitoring stack
metrics:
prometheus:
addEntryPointsLabels: true
addServicesLabels: true
addRoutersLabels: true
# Entrypoints — the ports Traefik listens on
entryPoints:
websecure:
address: ":443"
transport:
respondingTimeouts:
readTimeout: "30m" # Long timeout for WebSocket connections
http:
tls:
certResolver: letsencrypt # Auto-TLS for all routes on this entrypoint
traefik:
address: ":8080" # Dashboard — do NOT expose publicly
# Certificate resolver — auto-issues Let's Encrypt certs via Cloudflare DNS
certificatesResolvers:
letsencrypt:
acme:
email: you@example.com
storage: /letsencrypt/acme.json
dnsChallenge:
provider: cloudflare
delayBeforeCheck: 30 # Seconds to wait for DNS propagation
# Trust self-signed certs from backend services (e.g., Pangolin's internal API)
serversTransport:
insecureSkipVerify: true
# Plugins — loaded on startup
experimental:
plugins:
badger:
moduleName: "github.com/fosrl/badger"
version: "v1.3.1" # Pangolin's auth/SSO middleware
bouncer:
moduleName: "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
version: "v1.5.0" # CrowdSec WAF integration
# Providers — how Traefik discovers routes
providers:
http:
endpoint: "http://pangolin:3001/api/v1/traefik-config"
pollInterval: "5s" # Check Pangolin for route changes every 5s
file:
filename: "/etc/traefik/dynamic_config.yml"
watch: true # Hot-reload when file changes

This file is hot-reloaded whenever it changes (no restart needed). It defines middlewares and manual routes that aren’t managed by Pangolin.

http:
middlewares:
# CrowdSec bouncer — checks every request against the ban list
# and sends request bodies to the AppSec WAF engine
crowdsec-bouncer:
plugin:
bouncer:
enabled: true
crowdsecMode: "live" # Check bans in real-time
crowdsecLapiUrl: "http://crowdsec:8080" # CrowdSec's Local API
crowdsecLapiKey: "REPLACE_AFTER_STEP_6.3" # Bouncer API key
crowdsecAppsecEnabled: true # Enable WAF inspection
crowdsecAppsecUrl: "http://crowdsec:7422" # AppSec engine endpoint
crowdsecAppsecFailureBlock: true # Block if AppSec is unreachable
updateIntervalSeconds: 60 # Cache ban list for 60s
forwardedHeadersCustomName: "X-Forwarded-For" # Read real client IP from header
# Security headers — added to every response
secure-headers:
headers:
referrerPolicy: "same-origin"
customResponseHeaders:
X-Robots-Tag: "noindex, nofollow" # Tell search engines not to index
X-Content-Type-Options: "nosniff" # Prevent MIME type sniffing
forceSTSHeader: true
stsSeconds: 31536000 # HSTS: force HTTPS for 1 year
stsIncludeSubdomains: true
frameDeny: true # Prevent clickjacking (X-Frame-Options)
contentTypeNosniff: true
browserXssFilter: true # Legacy XSS protection header
# Rate limiter — prevent request flooding
rate-limit:
rateLimit:
average: 50 # Requests per second (sustained)
burst: 100 # Allowed burst above average
# Chain all three into a single reusable middleware
security-chain:
chain:
middlewares:
- crowdsec-bouncer
- secure-headers
- rate-limit
# ----- Manual routes (not managed by Pangolin) -----
# Pangolin can't manage its own routes (chicken-and-egg), so we define them here.
routers:
# API + WebSocket traffic → Pangolin's API port (443 internally)
pangolin-api:
rule: "Host(`pangolin.yourdomain.com`) && PathPrefix(`/api/`)"
entryPoints: [websecure]
service: pangolin-api
priority: 100 # Higher priority = matched first
tls: { certResolver: letsencrypt }
pangolin-websocket:
rule: "Host(`pangolin.yourdomain.com`) && PathPrefix(`/ws`)"
entryPoints: [websecure]
service: pangolin-api
priority: 100
tls: { certResolver: letsencrypt }
# Everything else → Pangolin's Next.js UI (port 3002)
pangolin-ui:
rule: "Host(`pangolin.yourdomain.com`)"
entryPoints: [websecure]
service: pangolin-ui
priority: 1 # Lowest priority = catch-all
tls: { certResolver: letsencrypt }
services:
pangolin-api:
loadBalancer:
servers:
- url: "http://pangolin:443" # Pangolin's internal API
pangolin-ui:
loadBalancer:
servers:
- url: "http://pangolin:3002" # Pangolin's Next.js UI
services:
pangolin:
image: fosrl/pangolin:latest
container_name: pangolin
restart: unless-stopped
volumes:
- /srv/docker/pangolin/config:/app/config
ports:
- "3003:3001" # API — optional, for direct LAN access during setup
- "3004:3002" # UI — optional, for direct LAN access during setup
networks:
- proxy
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3001/api/v1/"]
interval: 3s
timeout: 5s
retries: 15
gerbil:
image: fosrl/gerbil:latest
container_name: gerbil
restart: unless-stopped
depends_on:
pangolin:
condition: service_healthy # Wait for Pangolin to be ready
command:
- --reachableAt=${GERBIL_REACHABLE_AT}
- --generateAndSaveKeyTo=/var/config/wg.key
- --remoteConfig=http://pangolin:3001/api/v1/
volumes:
- /srv/docker/pangolin/config:/var/config
cap_add:
- NET_ADMIN # Required for WireGuard (creates network interfaces)
- SYS_MODULE # Required for loading kernel modules
ports:
- "51820:51820/udp" # WireGuard tunnel
- "443:443" # HTTPS (Traefik listens here via network_mode)
- "80:80" # HTTP redirect
- "8080:8080" # Traefik dashboard (LAN only)
networks:
- proxy
traefik:
image: traefik:v3.4
container_name: traefik
restart: unless-stopped
network_mode: service:gerbil # Share Gerbil's network — no ports needed here
depends_on:
pangolin:
condition: service_healthy
command:
- --configFile=/etc/traefik/traefik_config.yml
environment:
- CLOUDFLARE_DNS_API_TOKEN=${CF_API_KEY} # Used by ACME DNS challenge
volumes:
- /srv/docker/pangolin/config/traefik/traefik_config.yml:/etc/traefik/traefik_config.yml:ro
- /srv/docker/pangolin/config/traefik/dynamic_config.yml:/etc/traefik/dynamic_config.yml:ro
- /srv/docker/traefik/letsencrypt:/letsencrypt:rw
- /srv/docker/traefik/logs:/app/logs:rw
newt:
image: fosrl/newt:latest
container_name: newt
restart: unless-stopped
environment:
- PANGOLIN_ENDPOINT=https://pangolin.yourdomain.com
- NEWT_ID=${NEWT_ID}
- NEWT_SECRET=${NEWT_SECRET}
networks:
- proxy
depends_on:
pangolin:
condition: service_healthy
crowdsec:
image: crowdsecurity/crowdsec:latest
container_name: crowdsec
restart: unless-stopped
networks:
- proxy
ports:
- "6060:6060" # Prometheus metrics endpoint
environment:
- GID=${GID}
# Collections to install on first boot (space-separated)
- COLLECTIONS=crowdsecurity/traefik crowdsecurity/http-cve crowdsecurity/whitelist-good-actors crowdsecurity/appsec-virtual-patching crowdsecurity/appsec-generic-rules crowdsecurity/base-http-scenarios crowdsecurity/sshd crowdsecurity/linux
- ENROLL_KEY=${CROWDSEC_ENROLL_KEY}
- ENROLL_INSTANCE_NAME=${ENROLL_INSTANCE_NAME}
- TZ=${TZ}
- USE_WAL=1 # Write-ahead logging for better SQLite performance
volumes:
- /srv/docker/crowdsec/config:/etc/crowdsec:rw
- /srv/docker/crowdsec/data:/var/lib/crowdsec/data:rw
- /srv/docker/traefik/logs:/traefik-logs:ro # Read Traefik access logs
- /var/log:/var/log:ro # Read system logs (sshd, etc.)
security_opt:
- no-new-privileges:true
networks:
proxy:
external: true
Terminal window
cd /opt/stacks/pangolin
docker compose up -d

Wait about 30 seconds for Pangolin to initialize its database and generate configs. You can watch the logs:

Terminal window
docker compose logs -f pangolin

Once you see Server started, open the Pangolin dashboard:

  • Via HTTPS (if DNS is set up): https://pangolin.yourdomain.com
  • Via LAN (always works): http://your-server-ip:3004

Log in with the email and password from config.yml.

5.7 Create a Newt site and configure credentials

Section titled “5.7 Create a Newt site and configure credentials”
  1. In the Pangolin dashboard, go to Sites → Add Site
  2. Give it a name (e.g., “homelab”) and choose Newt as the connection method
  3. Pangolin generates a Site ID and Secret. Copy both.
  4. Edit your .env file and fill in NEWT_ID and NEWT_SECRET
  5. Restart Newt to pick up the credentials:
Terminal window
docker compose restart newt

Check that Newt connects successfully:

Terminal window
docker compose logs newt

You should see Connected to Pangolin. In the dashboard, the site status should show green/online.

CrowdSec is already running from the compose file above. It auto-installed the collections from the COLLECTIONS environment variable on first boot. Now we need to configure what it monitors, generate a bouncer key, and verify everything works.

CollectionWhat it detects
crowdsecurity/traefikParses Traefik JSON access logs — detects brute force, path scanning, bad user agents
crowdsecurity/http-cveDetects probing for known CVEs (Log4Shell, Spring4Shell, etc.)
crowdsecurity/whitelist-good-actorsWhitelists known-good IPs (search engine bots, monitoring services) to avoid false positives
crowdsecurity/appsec-virtual-patchingWAF rules that block exploitation of known vulnerabilities in real-time
crowdsecurity/appsec-generic-rulesGeneric WAF rules: SQL injection, XSS, path traversal, command injection, etc.
crowdsecurity/base-http-scenariosBaseline HTTP attack detection (directory enumeration, credential stuffing patterns)
crowdsecurity/sshdParses /var/log/auth.log for SSH brute force attempts
crowdsecurity/linuxGeneral Linux system log analysis

This tells CrowdSec what log files to read and which parsers to use.

Terminal window
# Parse Traefik access logs (JSON format)
filenames:
- /traefik-logs/access.log
labels:
type: traefik
---
# AppSec WAF engine — inspects request bodies inline
# Traefik's bouncer plugin sends requests here before forwarding them
listen_addr: 0.0.0.0:7422
appsec_config: crowdsecurity/appsec-default
name: appsec
source: appsec
labels:
type: appsec

The Traefik bouncer plugin needs an API key to talk to CrowdSec’s Local API (LAPI). Generate one:

Terminal window
docker exec crowdsec cscli bouncers add traefik-bouncer

This outputs a key like 86d1vOdL1ZlPmKBL/KgMi8JvfeGT70GY7Cv4W3OnTyM. Copy it and paste it into your dynamic_config.yml:

Terminal window
# In dynamic_config.yml, replace:
crowdsecLapiKey: "REPLACE_AFTER_STEP_6.3"
# With your actual key:
crowdsecLapiKey: "86d1vOdL1ZlPmKBL/KgMi8JvfeGT70GY7Cv4W3OnTyM"

Since the file provider has watch: true, Traefik picks up the change automatically (no restart needed). But if Traefik was failing to start because of the placeholder key, restart it:

Terminal window
docker compose restart traefik
Section titled “6.4 Configure Prometheus metrics (optional but recommended)”

After first boot, CrowdSec generates its main config at /srv/docker/crowdsec/config/config.yaml. Find the prometheus section and ensure level is set to full:

prometheus:
enabled: true
level: full # "full" exposes cs_info, cs_lapi_*, cs_active_decisions
listen_addr: 0.0.0.0
listen_port: 6060
Terminal window
# Check that CrowdSec is parsing Traefik logs
docker exec crowdsec cscli metrics
# You should see lines parsed under "traefik" and "appsec"
# Check the bouncer is registered and connected
docker exec crowdsec cscli bouncers list
# Status should show "validated" (not "pending")
# Check installed collections
docker exec crowdsec cscli collections list
# Check for any active decisions (bans)
docker exec crowdsec cscli decisions list

6.6 How the two detection layers work together

Section titled “6.6 How the two detection layers work together”
LayerDetection methodSpeedWhat it catches
AppSec engine(port 7422)Inline request inspection — the bouncer plugin sends each request to the AppSec engine before forwarding it to the backendReal-time (per-request)SQL injection, XSS, path traversal, command injection, known CVE exploitation patterns. Blocks the individual request with 403.
Log parser(reads access.log)Behavioral analysis — CrowdSec reads Traefik access logs and looks for patterns over timeNear real-time (log processing delay)Brute force, credential stuffing, directory enumeration, aggressive scanning. Bans the IP for a configured duration (default: 4 hours).

Additionally, CrowdSec’s crowd-sourced blocklists (via console enrollment) pre-ban known-bad IPs before they even send a request. This is the “crowd” in CrowdSec — 200k+ users sharing threat intelligence.

Terminal window
# View real-time alerts
docker exec crowdsec cscli alerts list
# Manually ban an IP for 24 hours
docker exec crowdsec cscli decisions add --ip 1.2.3.4 --duration 24h --reason "manual ban"
# Unban an IP
docker exec crowdsec cscli decisions delete --ip 1.2.3.4
# View all active bans
docker exec crowdsec cscli decisions list
# Check CrowdSec console enrollment status
docker exec crowdsec cscli console status

Authentik is a self-hosted identity provider. In this stack, it handles login for all your SSO-protected services. Users authenticate once via Authentik and get access to everything their role allows.

Why Authentik when Pangolin already has SSO?

Section titled “Why Authentik when Pangolin already has SSO?”

Pangolin has built-in user management — you can create accounts in the dashboard and protect routes with login. For a solo homelab, that works. I added Authentik because I needed more:

  • MFA — Pangolin’s built-in auth is username/password only. Authentik supports TOTP, WebAuthn/FIDO2 hardware keys, and more. If your homelab is internet-facing, a password alone isn’t enough.
  • Shared access with friends and family — I share some services (Audiobookshelf, Seerr) with people who shouldn’t see my admin tools (Grafana, Dozzle). Authentik’s group system feeds into Pangolin’s role-based access, so I can separate “Admin” from “Member” based on Authentik group membership.
  • One login for everything — services like Grafana, Portainer, and Gitea support OIDC natively. With Authentik, they can all use the same identity provider directly — not just the ones routed through Pangolin.
  • Self-service — users can reset their own passwords and manage their MFA devices without messaging me.
  • Audit trail — Authentik logs every login, failed attempt, and session. Useful when you’re exposing services to the internet.

If you’re the only user and just want password-protected routes, skip Authentik entirely — Pangolin’s built-in SSO is enough. Add Authentik when you need MFA, multiple users with different access levels, or a centralized identity provider.

Terminal window
# Generate strong secrets — do NOT use these example values
PG_PASS=generate-me # openssl rand -base64 18
AUTHENTIK_SECRET_KEY=generate-me # openssl rand -base64 36
services:
authentik-postgres:
image: postgres:16-alpine
container_name: authentik-postgres
restart: unless-stopped
volumes:
- authentik_postgres:/var/lib/postgresql/data # LOCAL volume, not NFS!
environment:
POSTGRES_DB: authentik
POSTGRES_USER: authentik
POSTGRES_PASSWORD: ${PG_PASS}
healthcheck:
test: ["CMD-SHELL", "pg_isready -d authentik -U authentik"]
interval: 5s
timeout: 5s
retries: 10
networks:
- proxy
authentik-redis:
image: redis:7-alpine
container_name: authentik-redis
restart: unless-stopped
command: --save 60 1 --loglevel warning
volumes:
- authentik_redis:/data
healthcheck:
test: ["CMD-SHELL", "redis-cli ping | grep PONG"]
interval: 5s
timeout: 5s
retries: 10
networks:
- proxy
authentik-server:
image: ghcr.io/goauthentik/server:2025.2
container_name: authentik-server
restart: unless-stopped
command: server
environment:
AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY}
AUTHENTIK_POSTGRESQL__HOST: authentik-postgres
AUTHENTIK_POSTGRESQL__USER: authentik
AUTHENTIK_POSTGRESQL__NAME: authentik
AUTHENTIK_POSTGRESQL__PASSWORD: ${PG_PASS}
AUTHENTIK_REDIS__HOST: authentik-redis
AUTHENTIK_ERROR_REPORTING__ENABLED: "false"
volumes:
- /srv/docker/authentik/media:/media
- /srv/docker/authentik/templates:/templates
ports:
- "9000:9000" # HTTP
- "9443:9443" # HTTPS
depends_on:
authentik-postgres:
condition: service_healthy
authentik-redis:
condition: service_healthy
networks:
- proxy
authentik-worker:
image: ghcr.io/goauthentik/server:2025.2
container_name: authentik-worker
restart: unless-stopped
command: worker
environment:
AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY}
AUTHENTIK_POSTGRESQL__HOST: authentik-postgres
AUTHENTIK_POSTGRESQL__USER: authentik
AUTHENTIK_POSTGRESQL__NAME: authentik
AUTHENTIK_POSTGRESQL__PASSWORD: ${PG_PASS}
AUTHENTIK_REDIS__HOST: authentik-redis
AUTHENTIK_ERROR_REPORTING__ENABLED: "false"
volumes:
- /srv/docker/authentik/media:/media
- /srv/docker/authentik/templates:/templates
depends_on:
authentik-postgres:
condition: service_healthy
authentik-redis:
condition: service_healthy
networks:
- proxy
volumes:
authentik_postgres:
driver: local # MUST be local — NFS causes data corruption
authentik_redis:
driver: local
networks:
proxy:
external: true
Terminal window
cd /opt/stacks/authentik
# Authentik runs as a non-root user internally.
# The media directory needs wide permissions, especially on NFS.
chmod 777 /srv/docker/authentik/media
docker compose up -d

Wait for all containers to be healthy:

Terminal window
docker compose ps
  1. Open http://your-server-ip:9000/if/flow/initial-setup/ in your browser
  2. Create your admin account. Choose a strong password — this is the superuser for your entire identity system.
  3. After setup, you’ll land on the Authentik admin dashboard. Bookmark it.

So that Authentik is accessible at https://auth.yourdomain.com, create a route in the Pangolin dashboard:

  1. In Pangolin, go to Resources → Add Resource
  2. Set the domain to auth.yourdomain.com
  3. Set the target to http://authentik-server:9000
  4. Disable SSO on this route (set it to public/no authentication)

8. Part 4 — Connecting Authentik to Pangolin (OIDC)

Section titled “8. Part 4 — Connecting Authentik to Pangolin (OIDC)”

OIDC (OpenID Connect) is the protocol that lets Pangolin delegate authentication to Authentik. When a user hits an SSO-protected route, Pangolin says “go prove your identity to Authentik” and Authentik sends back a signed token with the user’s email and role.

FieldValue
NamePangolin
Authorization flowSelect the default implicit-consent flow (or explicit-consent if you want users to approve scopes)
Redirect URIshttps://pangolin.yourdomain.com/api/v1/auth/idp/oidc/callback
Signing KeyLeave empty
  1. In Authentik, go to Applications → Providers in the left sidebar
  2. Click Create at the top
  3. Select OAuth2/OpenID Connect Provider
  4. Fill in:
  5. Click Finish
  6. On the provider detail page, copy the Client ID and Client Secret. You’ll need them in step 8.3.
FieldValue
NamePangolin
Slugpangolin (auto-generated from name)
ProviderSelect “Pangolin” (the provider you just created)
Launch URLhttps://pangolin.yourdomain.com
  1. Go to Applications → Applications in the left sidebar
  2. Click Create
  3. Fill in:
  4. Click Create
FieldValueNotes
NameAuthentikDisplay name on the login button
Issuer URLhttps://auth.yourdomain.com/application/o/pangolin/Must include trailing slash
Client ID(from step 8.1)
Client Secret(from step 8.1)
Identifier PathemailWhich token claim identifies the user
Scopesopenid email profileSpace-separated
  1. In the Pangolin dashboard, go to Settings → Identity Providers
  2. Click Add OIDC Provider
  3. Fill in:
  4. Click Save
  1. In Pangolin, create a test resource (or use an existing one) with SSO enabled
  2. Open the resource’s URL (e.g., https://grafana.yourdomain.com) in a private/incognito browser window
  3. You should be redirected to auth.yourdomain.com with an Authentik login page
  4. Log in with your Authentik credentials
  5. You should be redirected back to the service and see its content

If you get stuck in a redirect loop, check the troubleshooting section.

By default, SSO is binary: either you’re logged in or you’re not. RBAC lets you create roles (e.g., Admin and Member) and restrict certain services to certain roles. This requires three pieces:

  1. Groups in Authentik (to categorize users)
  2. A custom scope mapping (to include the role in the OIDC token)
  3. Role mapping config in Pangolin (to read the role from the token)
  1. Go to Directory → Groups in the left sidebar
  2. Click Create
  3. Create a group called homelab-admins
  4. Click into the group → Users tab → Add existing user → add yourself
  5. Create another group called homelab-members (for friends/family you want to share services with)

This tells Authentik to include a role field in the OIDC token based on group membership.

FieldValue
NamePangolin Role
Scope namepangolin_role
Expression(see below)
  1. Go to Customization → Property Mappings
  2. Click Create → Scope Mapping
  3. Fill in:

Paste this Python expression:

Terminal window
# Returns "Admin" for homelab-admins members, "Member" for everyone else
if ak_is_group_member(request.user, name="homelab-admins"):
return {"role": "Admin"}
return {"role": "Member"}

Step C: Add the scope to the OIDC provider

Section titled “Step C: Add the scope to the OIDC provider”
  1. Go to Applications → Providers → Pangolin (the provider you created earlier)
  2. Click Edit
  3. In the Scopes section, add Pangolin Role to the selected scopes
  4. Click Update
  1. In the Pangolin dashboard, go to Settings → Identity Providers → Authentik
  2. Update the Scopes field to: openid email profile pangolin_role
  3. Set the Organization role mapping for your org to: role
  4. Click Save

Now when a user logs in, the OIDC token includes "role": "Admin" or "role": "Member", and Pangolin uses this to determine which resources they can access.

In Pangolin, go to each resource and set which roles can access it:

Access levelServices (examples)Configuration
Admin onlyGrafana, Tautulli, Dozzle, WazuhSSO enabled, only “Admin” role assigned
Admin + MemberAudiobookshelf, Homepage, SeerrSSO enabled, both “Admin” and “Member” roles assigned
Public (no SSO)Authentik, VaultwardenSSO disabled — these services handle their own auth

This is the normal way to add services. Example: exposing Grafana.

networks:
- proxy
networks:
proxy:
external: true
  1. Make sure Grafana’s container is on the proxy Docker network. In Grafana’s compose file, add:
  2. In the Pangolin dashboard, go to Resources → Add Resource
  3. Set domain: grafana.yourdomain.com
  4. Set target: http://grafana:3000 (container name and port)
  5. Enable SSO if you want Authentik login required
  6. Click Create

Within 5 seconds, Pangolin pushes the route to Traefik. Open https://grafana.yourdomain.com and it should work.

9.2 Via Traefik file provider (manual routes)

Section titled “9.2 Via Traefik file provider (manual routes)”

Use this for services that need custom middleware, special routing rules, or services that Pangolin can’t manage (like Pangolin itself). Add to dynamic_config.yml:

routers:
my-service:
rule: "Host(`myapp.yourdomain.com`)"
entryPoints: [websecure]
middlewares: [security-chain] # Apply CrowdSec + headers + rate-limit
service: my-service
tls: { certResolver: letsencrypt }
services:
my-service:
loadBalancer:
servers:
- url: "http://container-name:port"

The file is hot-reloaded — no restart needed.

  • Pangolin-managed routes: In the Pangolin UI, you can attach middlewares under the resource’s advanced settings. Reference security-chain@file (the @file suffix tells Traefik the middleware is defined in the file provider, not the HTTP provider).
  • File provider routes: Add middlewares: [security-chain] directly to the router definition as shown above.
ServiceTargetSSO?Notes
Grafanahttp://grafana:3000YesAdmin-only recommended
Audiobookshelfhttp://audiobookshelf:80YesHas its own auth too
Vaultwardenhttp://vaultwarden:80NoHas its own auth; SSO would break browser extensions
Jellyseerr/Seerrhttp://seerr:5055NoHas its own user system
Tautullihttp://tautulli:8181YesAdmin-only
Dozzlehttp://dozzle:8080YesShows Docker logs — admin-only

This stack has several stateful components. Losing any of them means reconfiguring from scratch. Here’s what to back up and how.

ComponentData locationWhat it containsPriority
Pangolin/srv/docker/pangolin/config/config.yml, SQLite database (all routes, sites, users), WireGuard keysCritical
Traefik/srv/docker/traefik/letsencrypt/acme.json (all TLS certificates)High (certs can be re-issued, but it takes time and hits rate limits)
Traefik config/srv/docker/pangolin/config/traefik/Static + dynamic config, middleware definitionsHigh
CrowdSec/srv/docker/crowdsec/config/ + data/Config, acquis.yaml, decision database, bouncer keysMedium (can be rebuilt, but you lose ban history)
Authentik DBDocker volume authentik_postgresAll users, groups, OIDC configs, flows, policiesCritical
Authentik media/srv/docker/authentik/media/Uploaded logos, custom CSSLow
Compose files/opt/stacks/pangolin/ + /opt/stacks/authentik/compose.yaml + .env filesCritical

Since Authentik’s PostgreSQL uses a local Docker volume (not a bind mount), you need to dump it:

Terminal window
# Create a SQL dump
docker exec authentik-postgres pg_dump -U authentik authentik > authentik_backup.sql
# To restore (after recreating the container with an empty volume):
cat authentik_backup.sql | docker exec -i authentik-postgres psql -U authentik authentik

10.3 Backing up Pangolin’s SQLite database

Section titled “10.3 Backing up Pangolin’s SQLite database”
Terminal window
# Copy the database (it's a single file)
cp /srv/docker/pangolin/config/db/db.sqlite /path/to/backup/
# To restore: stop Pangolin, copy the file back, start Pangolin
docker compose -f /opt/stacks/pangolin/compose.yaml stop pangolin
cp /path/to/backup/db.sqlite /srv/docker/pangolin/config/db/
docker compose -f /opt/stacks/pangolin/compose.yaml start pangolin
#!/bin/bash
# backup-proxy-stack.sh — run daily via cron
BACKUP_DIR="/path/to/backups/proxy-stack/$(date +%Y-%m-%d)"
mkdir -p "$BACKUP_DIR"
# Pangolin config + SQLite DB
cp -r /srv/docker/pangolin/config/ "$BACKUP_DIR/pangolin-config/"
# Traefik config + certs
cp -r /srv/docker/pangolin/config/traefik/ "$BACKUP_DIR/traefik-config/"
cp -r /srv/docker/traefik/letsencrypt/ "$BACKUP_DIR/traefik-certs/"
# CrowdSec config
cp -r /srv/docker/crowdsec/config/ "$BACKUP_DIR/crowdsec-config/"
# Authentik DB dump
docker exec authentik-postgres pg_dump -U authentik authentik > "$BACKUP_DIR/authentik.sql"
# Compose files + env
cp /opt/stacks/pangolin/compose.yaml "$BACKUP_DIR/"
cp /opt/stacks/pangolin/.env "$BACKUP_DIR/pangolin.env"
cp /opt/stacks/authentik/compose.yaml "$BACKUP_DIR/authentik-compose.yaml"
cp /opt/stacks/authentik/.env "$BACKUP_DIR/authentik.env"
# Retain last 30 days
find "$(dirname "$BACKUP_DIR")" -maxdepth 1 -type d -mtime +30 -exec rm -rf {} +
echo "Backup complete: $BACKUP_DIR"