A production-ready, minimal, and fully environment-driven Docker Compose stack for self-hosting Headscale β the open-source, self-hosted implementation of the Tailscale control server β secured by CrowdSec and reverse-proxied through Caddy with automatic TLS, Cloudflare Dynamic DNS, MaxMind GeoIP filtering, and OIDC authentication.
- Headscale β Self-hosted Tailscale control plane with embedded DERP and STUN server
- Caddy β Automatic HTTPS (Let's Encrypt), Cloudflare DNS challenge, HTTP/3 (QUIC)
- CrowdSec β Active intrusion prevention via Caddy bouncer plugin
- MaxMind GeoIP β Native country-based IP filtering before requests even reach Headscale
- Push Notifications β Real-time CrowdSec ban alerts via NTFY, Gotify, or any HTTP webhook
- Cloudflare DDNS β Automatic public IP update via caddy-dynamicdns module
- OIDC Authentication β Support for Google, Authentik, Keycloak, etc., with granular Access Control
- Version Pinning β Define exactly which image tags to pull via
.env - Git-safe β
.gitignoreis pre-configured to exclude all secrets and runtime data - Automated init script β Safely creates files, generates secrets, and downloads the GeoIP DB
| Service | Image | Role |
|---|---|---|
| Headscale | headscale/headscale:${VERSION} |
VPN Control Plane |
| Caddy | ghcr.io/olife97/dhi-caddy-cloudflare |
Reverse Proxy + TLS + DDNS |
| CrowdSec | crowdsecurity/crowdsec:${VERSION} |
IPS / Threat Intelligence |
- Docker Engine v24+ and Docker Compose v2+
- A domain name with Cloudflare DNS management
- A Cloudflare API Token with
Zone:DNS:Editpermissions - DNS Record: You must manually create the initial
A(and/orAAAA) record for your subdomain in the Cloudflare dashboard. Caddy's DDNS module updates the IP of an existing record, but it will not create a new one from scratch. - A Google Cloud OAuth 2.0 Client ID (or any OIDC provider)
- Ports 80, 443 (TCP + UDP), and 3478/UDP open on your firewall/router
git clone https://github.com/olife97/headscale-stack-crowdsec.git && cd headscale-stack-crowdsecThis will:
- Create all required directories and placeholder files
- Download the latest MaxMind GeoLite2-Country database (and prompt for updates if already present)
- Auto-generate the CrowdSec
acquis.yaml,profiles.yaml, andhttp.yamlnotification templates - Auto-generate a secure 256-bit key for the CrowdSec bouncer
- Copy
.env.exampleto.env
chmod +x init.sh && ./init.shπ οΈ Curious what init.sh actually does? (Click to expand)
For transparency and security, here is exactly what the initialization script automates:
- Directory Creation: Safely creates the necessary local directories for bind mounts (
headscale/config,headscale/data,crowdsec/...) before Docker starts, preventing permission issues. - Headscale Config Provisioning: Downloads the latest official
config-example.yamlfrom the Headscale repository and strictly adjusts thedb_pathto match our Docker container environment. - CrowdSec Provisioning: Auto-generates three critical YAML files if they don't exist:
acquis.yaml(Instructs CrowdSec to read Caddy's JSON logs).http.yaml(Sets up the payload format for NTFY/Gotify push notifications).profiles.yaml(Ties IP bans to the HTTP notification trigger).
- MaxMind GeoIP Database: Downloads the free
GeoLite2-Country.mmdb. - Secure
.envGeneration: Copies.env.exampleto.envand usesopenssl rand -hex 32to automatically generate a highly secure cryptographic token for theCROWDSEC_BOUNCER_KEY.
Safe to run multiple times! It checks for the existence of your config files before creating them, meaning it will never overwrite your custom
.envor YAML configurations.You can (and should) re-run
./init.shanytime to check the age of your MaxMind GeoIP database. If it's older than 30 days, the script will automatically prompt you to download the latest updates.
Open .env and fill in your values. Pay special attention to the OIDC Whitelist, GeoIP Countries, and Notification sections.
nano .env- Go to Cloudflare Dashboard β Profile β API Tokens
- Click Create Token β Edit zone DNS template
- Under Zone Resources, select your domain
- Copy the token into
CF_API_TOKENin.env
- Go to Google Cloud Console β APIs & Services β Credentials
- Click Create Credentials β OAuth 2.0 Client ID (Web application)
- Add the following Authorized redirect URI:
https://subdomain.domain.tld/oidc/callback - Copy Client ID and Client Secret into
.env
Warning
The redirect URI in Google Console must match exactly the Headscale server_url + /oidc/callback.
docker compose up -dNote
On first run, CrowdSec will download its collections and Caddy will request a TLS certificate from Let's Encrypt. Give the stack ~30 seconds to fully stabilize.
The stack automatically filters traffic based on geolocation. In your .env file, edit the ALLOWED_COUNTRIES variable using ISO 3166-1 alpha-2 codes (space-separated). Connections from any other country will be silently dropped by Caddy.
ALLOWED_COUNTRIES="IT SM VA CH"You can receive push notifications on your phone whenever CrowdSec bans a malicious IP. Configure the webhook in .env:
# Example for NTFY:
CROWDSEC_NOTIFY_URL=https://ntfy.sh/your_secret_topic
CROWDSEC_NOTIFY_AUTH_HEADER=Authorization
CROWDSEC_NOTIFY_AUTH_TOKEN="Bearer optional_token"
# Example for Gotify:
CROWDSEC_NOTIFY_URL=https://gotify.yourdomain.com/message
CROWDSEC_NOTIFY_AUTH_HEADER=X-Gotify-Key
CROWDSEC_NOTIFY_AUTH_TOKEN="your_app_token"Test notifications:
docker exec crowdsec cscli notifications test http_defaultHeadscale allows you to restrict who can join your VPN network. In the .env file, you must choose one of the following three methods:
HEADSCALE_OIDC_ALLOWED_USERS- Best for: Families, small teams (e.g.,
"user1@gmail.com user2@gmail.com")
- Best for: Families, small teams (e.g.,
HEADSCALE_OIDC_ALLOWED_DOMAINS- Best for: Companies with custom IdPs (e.g.,
"yourcompany.com") - (
β οΈ WARNING: Never use public domains likegmail.comhere!)
- Best for: Companies with custom IdPs (e.g.,
HEADSCALE_OIDC_ALLOWED_GROUPS- Best for: Homelabs using Authentik or Keycloak (e.g.,
"vpn-users")
- Best for: Homelabs using Authentik or Keycloak (e.g.,
| Variable | Description |
|---|---|
*_VERSION |
Docker image tags (defaults to latest) |
TZ |
Timezone (e.g., Europe/Rome) |
DOMAIN / SUBDOMAIN |
Root domain and subdomain (e.g., example.com and vpn) |
CF_API_TOKEN |
Cloudflare API token with Zone:DNS:Edit permissions |
ALLOWED_COUNTRIES |
ISO country codes allowed to access the server (e.g., IT US) |
CROWDSEC_NOTIFY_* |
Webhook URL and Auth tokens for ban alerts |
HEADSCALE_OIDC_ISSUER |
OIDC issuer URL (e.g., https://accounts.google.com) |
HEADSCALE_OIDC_CLIENT_ID |
OAuth2 Client ID |
# Create a new user (namespace)
docker exec -it headscale headscale users create <username>
# Generate a pre-auth key (24h expiry)
docker exec -it headscale headscale preauthkeys create -e 24h -u <userID># Check if the Caddy bouncer is registered
docker exec -it crowdsec cscli bouncers list
# View active decisions (bans)
docker exec -it crowdsec cscli decisions list
# Manually ban an IP (permanent)
docker exec -it crowdsec cscli decisions add --ip <IP_ADDRESS> --type ban --duration 0
# Unban an IP
docker exec -it crowdsec cscli decisions delete --ip <IP_ADDRESS># Pull latest images
docker compose pull
# Recreate containers with new images
docker compose up -d --force-recreate
# Remove old dangling images
docker image prune -f- Zero hardcoded secrets: All secrets are passed via
.env, for easy setup and replication. - Read-only mounts: Config files use
:ro(read-only) wherever possible. - Active IPS & GeoIP: CrowdSec analyzes JSON logs in real-time, while Caddy drops unauthorized countries at the edge.
The following paths contain persistent state and should be backed up regularly:
| Path | Contents |
|---|---|
headscale/data/db.sqlite |
All nodes, users, preauthkeys, routes |
headscale/data/*.key |
Private keys (loss = full re-enrollment of all clients) |
.env |
All secrets (store encrypted) |
Because Headscale uses a SQLite database (db.sqlite), copying the file while the container is actively writing to it can lead to severe database corruption.
Always stop the stack before running your backup script:
docker compose down
# Run your rsync / tar / backup command here
docker compose up -dBy design, this stack uses Bind Mounts (e.g., ./headscale/data:/var/lib/headscale) for persistent data instead of native Docker Named Volumes.
This was a deliberate choice to make backups extremely simple for homelab users, you can just tar or rsync the project folder without having to dive into /var/lib/docker/volumes/ as the root user.
However, if you are an advanced sysadmin migrating to a production environment (or running on a specialized filesystem like ZFS), you can easily convert these to Docker Named Volumes by editing the compose.yaml:
- Change
./headscale/data:/var/lib/headscaletoheadscale_data:/var/lib/headscale - Declare
headscale_data:under the top-levelvolumes:block.
Caution
Loss of noise_private.key or private.key from headscale/data/ requires
all clients to re-enroll. Treat these files as you would SSH private keys.
This stack is made possible by the incredible work of the open-source community. A huge thank you to:
- juanfont/headscale β The amazing open-source Tailscale control server
- caddyserver/caddy β The Go-based web server with automatic HTTPS
- crowdsecurity/crowdsec β The open-source IPS/IDS engine
- hslatman/caddy-crowdsec-bouncer β CrowdSec plugin for Caddy
- mholt/caddy-dynamicdns β Dynamic DNS module for Caddy
- P3TERX/GeoLite.mmdb β Free automated MaxMind GeoLite2 databases
- OLife97/dhi-caddy-cloudflare β My pre-built Caddy image with Cloudflare, CrowdSec and GeoIP modules
This repository orchestrates several open-source projects. By using this stack, you are also subject to their respective licenses:
- Headscale is licensed under the BSD 3-Clause License.
- Caddy is licensed under the Apache License 2.0.
- CrowdSec is licensed under the MIT License.
Note on MaxMind GeoIP: This product includes GeoLite2 data created by MaxMind, available from https://www.maxmind.com. If you use the GeoIP module, you must comply with the MaxMind End User License Agreement (EULA). This project is licensed under the MIT License.
Made with β€οΈ for self-hosters who believe their infrastructure should be fully under their control. This README and some-other parts of this repo are Vibe-coded.