Skip to main content

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 RequirementsBefore 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.
1

Generate Strong Password

openssl rand -base64 32
Example output: xK7mP9nQ2wR5tY8vB3cD6fG1hJ4kL0mN
2

Update .env File

.env
POSTGRES_PASSWORD=xK7mP9nQ2wR5tY8vB3cD6fG1hJ4kL0mN
3

Update config.yaml

config/config.yaml
database:
  postgres:
    pass: xK7mP9nQ2wR5tY8vB3cD6fG1hJ4kL0mN  # Must match .env
4

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

  • Domain name registered and DNS configured
  • Ports 80 and 443 open in firewall
  • Valid email address for Let’s Encrypt
  • Server accessible from internet
# 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
nginx.conf
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:
docker-compose.yml
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:
config/config.yaml
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:
config/policy.json
{
  "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

1

Enable Database Mode

config/config.yaml
policy:
  mode: database
  path: /etc/headscale/policy.json
2

Create Policy File

cp config/acl.example.json config/policy.json
nano config/policy.json  # Customize for your network
3

Restart Headscale

docker compose restart headscale
4

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

nginx.conf
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: *\nDisallow: /\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

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-id>

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-id>

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:
.gitignore
# 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

1

Authentication

  • Database password changed from default
  • API keys rotated regularly
  • Pre-auth keys have short expiration
  • Unused keys expired
2

Encryption

  • SSL/TLS configured with valid certificate
  • TLS 1.3 enforced
  • HSTS header enabled
  • Cipher suites configured securely
3

Access Control

  • ACL policies implemented
  • Least privilege principle followed
  • Admin paths restricted by IP
  • Guest access limited appropriately
4

Network Security

  • Metrics endpoint localhost-only
  • Database not exposed publicly
  • Firewall rules configured
  • Rate limiting enabled
5

Operational Security

  • Backups encrypted
  • Sensitive files excluded from git
  • File permissions restrictive
  • Monitoring configured
  • Logs reviewed regularly

Incident Response

Compromised API Key

# Immediately expire the key
docker exec headscale headscale apikeys expire --key <compromised-key>

# 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-id>

# Expire user's pre-auth keys
docker exec headscale headscale preauthkeys list --user <username>
docker exec headscale headscale preauthkeys expire --user <username> --key <key-id>

# 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

Build docs developers (and LLMs) love