Overview
Securing your Headscale deployment is critical for protecting your network infrastructure and connected devices. This guide covers authentication, encryption, access control, and operational security practices.
Critical Security Requirements Before deploying to production, you MUST:
Change default database password
Configure SSL/TLS certificates
Implement ACL policies
Secure API endpoints
Enable firewall rules
Password Management
Change Default Passwords
The repository contains example configurations with weak passwords for development only.
Generate Strong Password
Example output: xK7mP9nQ2wR5tY8vB3cD6fG1hJ4kL0mN
Update .env File
POSTGRES_PASSWORD = xK7mP9nQ2wR5tY8vB3cD6fG1hJ4kL0mN
Update config.yaml
database :
postgres :
pass : xK7mP9nQ2wR5tY8vB3cD6fG1hJ4kL0mN # Must match .env
Restart Services
docker compose down
docker compose up -d
The password must be identical in both .env and config/config.yaml. Mismatched credentials will prevent Headscale from connecting to the database.
Secure Password Storage
# Set restrictive permissions
chmod 600 .env
chmod 600 config/config.yaml
# Verify ownership
chown $USER : $USER .env config/config.yaml
# Never commit these files
grep -E "^\.env$|^config/config\.yaml$" .gitignore
SSL/TLS Configuration
Production Deployment Checklist
# Initial certificate request
./scripts/nginx.sh ssl-init
# Or manual certbot request
docker compose run --rm certbot certonly \
--webroot \
--webroot-path=/var/www/certbot \
--email [email protected] \
--agree-tos \
--no-eff-email \
-d headscale.example.com
server {
listen 443 ssl http2;
server_name headscale.example.com;
ssl_certificate /etc/letsencrypt/live/headscale.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/headscale.example.com/privkey.pem;
# Mozilla Modern configuration
ssl_protocols TLSv1.3;
ssl_prefer_server_ciphers off ;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
# OCSP stapling
ssl_stapling on ;
ssl_stapling_verify on ;
ssl_trusted_certificate /etc/letsencrypt/live/headscale.example.com/chain.pem;
# Security headers
add_header Strict-Transport-Security "max-age=63072000" always;
add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
}
# HTTP to HTTPS redirect
server {
listen 80 ;
server_name headscale.example.com;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://$ server_name $ request_uri ;
}
}
The certbot container automatically renews certificates: certbot :
image : certbot/certbot:latest
volumes :
- ./certbot/conf:/etc/letsencrypt
- ./certbot/www:/var/www/certbot
entrypoint : "/bin/sh -c 'trap exit TERM; while :; do certbot renew --quiet; sleep 12h & wait $${!}; done;'"
Test renewal: docker compose run --rm certbot renew --dry-run
Update Server URL
After SSL configuration, update Headscale:
server_url : https://headscale.example.com:443
# Restart to apply
docker compose restart headscale
Access Control Lists (ACLs)
Tag-Based Security Model
Organize devices by function using tags:
{
"groups" : {
"group:admins" : [ "user1" , "user2" ]
},
"tagOwners" : {
"tag:personal" : [ "group:admins" ],
"tag:servers" : [ "group:admins" ],
"tag:services" : [ "group:admins" ],
"tag:private" : [ "group:admins" ],
"tag:guests" : [ "group:admins" ]
},
"acls" : [
{
"action" : "accept" ,
"src" : [ "group:admins" ],
"dst" : [ "*:*" ],
"comment" : "Admins have full access"
},
{
"action" : "accept" ,
"src" : [ "tag:personal" ],
"dst" : [ "tag:services:80,443" , "tag:servers:22" ],
"comment" : "Personal devices access web services and SSH"
},
{
"action" : "accept" ,
"src" : [ "tag:guests" ],
"dst" : [ "tag:services:80,443" ],
"comment" : "Guests only access public web services"
}
]
}
Principle of Least Privilege
DO
Grant minimum required ports
Use specific IP/tag combinations
Regularly audit and remove access
Document ACL policies
DON'T
Allow *:* for non-admins
Use overly broad rules
Grant permanent guest access
Leave unused rules in place
Implement ACL Policies
Enable Database Mode
policy :
mode : database
path : /etc/headscale/policy.json
Create Policy File
cp config/acl.example.json config/policy.json
nano config/policy.json # Customize for your network
Restart Headscale
docker compose restart headscale
Verify Policy
# Check logs for policy load
docker compose logs headscale | grep policy
# Test connectivity between nodes
DNS Privacy
Prevent hostname enumeration:
{
"acls" : [
{
"action" : "accept" ,
"src" : [ "tag:guests" ],
"dst" : [ "100.64.0.50:80" , "100.64.0.50:443" ],
"comment" : "Direct IP access prevents DNS enumeration"
}
]
}
Guests using IP-based rules cannot use MagicDNS to discover other hostnames on your network.
Network Security
Port Exposure
From docker-compose.yml:
services :
headscale :
ports :
# Metrics - localhost only
- 127.0.0.1:9090:9090
nginx :
ports :
# Public access
- 80:80
- 443:443
Never expose these ports publicly:
9090: Prometheus metrics (contains sensitive data)
5432: PostgreSQL database (security risk)
8080: Headscale internal API (bypass nginx security)
Firewall Configuration
# UFW (Ubuntu)
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 22/tcp # SSH
sudo ufw enable
# firewalld (RHEL/CentOS)
sudo firewall-cmd --permanent --add-service=http
sudo firewall-cmd --permanent --add-service=https
sudo firewall-cmd --reload
# iptables
sudo iptables -A INPUT -p tcp --dport 80 -j ACCEPT
sudo iptables -A INPUT -p tcp --dport 443 -j ACCEPT
sudo iptables -A INPUT -p tcp --dport 22 -j ACCEPT
sudo iptables -A INPUT -j DROP
Reverse Proxy Security
Rate Limiting
http {
limit_req_zone $ binary_remote_addr zone=api_limit:10m rate=10r/s;
limit_req_zone $ binary_remote_addr zone=auth_limit:10m rate=5r/m;
server {
location /api {
limit_req zone=api_limit burst=20 nodelay;
}
location /auth {
limit_req zone=auth_limit burst=2 nodelay;
}
}
}
Admin Path Protection
# Restrict admin paths to specific IPs
location ~ ^/(admin|api/v1/admin) {
allow 192.168.1.0/24; # Local network
allow 100.64.0.0/10; # Tailscale network
deny all ;
proxy_pass http://headscale:8080;
}
Block Search Engine Indexing
location = /robots.txt {
add_header Content-Type text/plain;
return 200 "User-agent: * \n Disallow: / \n " ;
}
Pre-Auth Key Security
Short-Lived Keys
# Reusable but short-lived (recommended)
docker exec headscale headscale preauthkeys create \
--user myuser \
--reusable \
--expiration 1h
# Single-use ephemeral
docker exec headscale headscale preauthkeys create \
--user myuser \
--ephemeral \
--expiration 24h
Expiration: 1-24 hours
Use ephemeral for temporary devices
Revoke immediately after use
Expiration: Up to 7 days
Reusable for testing
Still expire unused keys
Tag Assignment
Assign security tags during key creation:
docker exec headscale headscale preauthkeys create \
--user myuser \
--tags tag:guests \
--expiration 2h
Key Auditing
# List all keys
docker exec headscale headscale preauthkeys list --user myuser
# Expire unused keys
docker exec headscale headscale preauthkeys expire \
--user myuser \
--key < key-i d >
API Security
API Key Management
# Generate long-lived API key for Headplane
docker exec headscale headscale apikeys create --expiration 999d
# List all API keys
docker exec headscale headscale apikeys list
# Expire compromised keys
docker exec headscale headscale apikeys expire --key < key-i d >
Secure API Key Storage
# Store in environment variable (not in files)
export HEADPLANE_API_KEY = $( docker exec headscale headscale apikeys create --expiration 999d | grep -o 'hs_[a-zA-Z0-9_-]*' )
# Use Docker secrets (production)
echo " $HEADPLANE_API_KEY " | docker secret create headplane_api_key -
Bearer Token Authentication
All API requests require authentication:
curl -H "Authorization: Bearer $API_KEY " \
http://localhost:8000/api/v1/user
File Security
Sensitive Files
These files MUST be excluded from version control:
# Sensitive configuration
.env
config/config.yaml
# State and keys
data/
backups/
# SSL certificates
certbot/
*.pem
*.key
# Headplane secrets
headplane/config.yaml
File Permissions
# Configuration files
chmod 600 .env config/config.yaml
chmod 700 config/
# Data directory
chmod 700 data/
chown -R $USER : $USER data/
# SSL certificates
chmod 644 certbot/conf/live/ * /fullchain.pem
chmod 600 certbot/conf/live/ * /privkey.pem
Backup Security
Encrypted Backups
# Encrypt with GPG
tar -czf - config/ data/ | \
gpg --symmetric --cipher-algo AES256 \
> backup- $( date +%Y%m%d ) .tar.gz.gpg
# Decrypt
gpg --decrypt backup-YYYYMMDD.tar.gz.gpg | tar -xz
Secure Remote Storage
# Upload to S3 with encryption
aws s3 cp backup.tar.gz.gpg \
s3://bucket/backups/ \
--sse AES256
# Restrict access
aws s3api put-bucket-policy \
--bucket bucket \
--policy file://backup-policy.json
Monitoring for Security Events
Failed Authentication Attempts
# Monitor authentication failures
docker compose logs -f headscale | grep -i "authentication failed\|unauthorized\|forbidden"
# Count failures per hour
docker compose logs --since 1h headscale | grep -i "authentication failed" | wc -l
Unusual Activity
# New node registrations
docker compose logs headscale | grep "node registered"
# Route changes
docker compose logs headscale | grep "route enabled\|route disabled"
# Policy violations
docker compose logs headscale | grep "ACL denied"
Security Audit Checklist
Incident Response
Compromised API Key
# Immediately expire the key
docker exec headscale headscale apikeys expire --key < compromised-ke y >
# Generate new key
new_key = $( docker exec headscale headscale apikeys create --expiration 999d )
# Update Headplane configuration
nano headplane/config.yaml # Update api_key
# Restart Headplane
docker compose restart headplane
# Audit recent activity
docker compose logs --since 24h headscale | grep -i "api"
Suspicious Node Activity
# List all nodes
docker exec headscale headscale nodes list
# Delete suspicious node
docker exec headscale headscale nodes delete --identifier < node-i d >
# Expire user's pre-auth keys
docker exec headscale headscale preauthkeys list --user < usernam e >
docker exec headscale headscale preauthkeys expire --user < usernam e > --key < key-i d >
# Review ACL policies
cat config/policy.json
Database Breach
# Immediately shut down
docker compose down
# Restore from clean backup
tar -xzf backup-YYYYMMDD.tar.gz
# Change all credentials
nano .env # New POSTGRES_PASSWORD
nano config/config.yaml # Match password
# Regenerate all API keys
docker compose up -d
docker exec headscale headscale apikeys expire --prefix hs_
# Force re-authentication of all nodes
docker exec headscale headscale nodes expire --all
ACL Configuration Detailed ACL policy configuration
Monitoring Monitor security events and alerts
Backup & Restore Secure backup procedures
Updates Security update procedures