Headscale uses PostgreSQL as its database backend to store user data, node information, routes, and ACL policies. This guide covers database configuration, connection parameters, and maintenance.
Database Service
The stack uses PostgreSQL 18 Alpine for a lightweight, production-ready database.
postgres:
image: postgres:18-alpine
container_name: headscale-db
restart: unless-stopped
environment:
POSTGRES_DB: ${POSTGRES_DB:-headscale}
POSTGRES_USER: ${POSTGRES_USER:-headscale}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-changeme}
volumes:
- postgres-data:/var/lib/postgresql/data
networks:
- headscale-network
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER:-headscale}"]
interval: 10s
timeout: 5s
retries: 5
Environment Variables
POSTGRES_DB
string
default:"headscale"
Name of the PostgreSQL database to create.
POSTGRES_USER
string
default:"headscale"
PostgreSQL user for Headscale.
PostgreSQL password. Must match the password in config/config.yaml.POSTGRES_PASSWORD=your_secure_password_here
Change this from the default changeme value immediately!
Headscale Database Configuration
Configure database connection in config/config.yaml:
database:
type: postgres
postgres:
host: postgres
port: 5432
name: headscale
user: headscale
pass: changeme # Must match POSTGRES_PASSWORD in .env
max_open_conns: 10
max_idle_conns: 10
conn_max_idle_time_secs: 3600
Connection Parameters
Database type. Use postgres for production.Options: sqlite, postgres
PostgreSQL server hostname. Use Docker service name (postgres) for container deployments.
Database name. Must match POSTGRES_DB environment variable.
Database username. Must match POSTGRES_USER environment variable.
Database password. Must match POSTGRES_PASSWORD environment variable.This is the critical sync point between .env and config.yaml!
Connection Pool Settings
database.postgres.max_open_conns
Maximum number of open connections to the database.Recommendations:
- Small deployments (< 50 nodes): 10
- Medium deployments (50-200 nodes): 20
- Large deployments (> 200 nodes): 50+
database.postgres.max_idle_conns
Maximum number of idle connections in the pool.Typically set equal to max_open_conns for consistent performance.
database.postgres.conn_max_idle_time_secs
Maximum time (in seconds) a connection can remain idle before being closed.Default: 3600 seconds (1 hour)
Critical: Password Synchronization
The PostgreSQL password must match in two places:
.env file: POSTGRES_PASSWORD=your_password
config/config.yaml: database.postgres.pass: your_password
Mismatched passwords will prevent Headscale from starting.
Verification
Test the database connection:
docker exec -it headscale-db psql -U headscale -d headscale -c "SELECT 1;"
Expected output:
?column?
----------
1
(1 row)
Health Check
The database includes a health check that runs every 10 seconds:
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER:-headscale}"]
interval: 10s
timeout: 5s
retries: 5
Headscale waits for the database to be healthy before starting:
headscale:
depends_on:
postgres:
condition: service_healthy
Data Persistence
Database data is stored in a Docker volume:
volumes:
postgres-data:
driver: local
Volume location: /var/lib/docker/volumes/postgres-data/_data
Data persists across container restarts and stack updates.
Backup and Restore
Database Backup
Create a complete database backup:
docker exec headscale-db pg_dump -U headscale headscale > backup.sql
Backup with timestamp:
docker exec headscale-db pg_dump -U headscale headscale > backup-$(date +%Y%m%d-%H%M%S).sql
Database Restore
Restore from a backup file:
cat backup.sql | docker exec -i headscale-db psql -U headscale -d headscale
Automated Backup Script
#!/bin/bash
BACKUP_DIR="./backups"
mkdir -p "$BACKUP_DIR"
TIMESTAMP=$(date +%Y%m%d-%H%M%S)
BACKUP_FILE="$BACKUP_DIR/headscale-db-$TIMESTAMP.sql"
echo "Creating database backup: $BACKUP_FILE"
docker exec headscale-db pg_dump -U headscale headscale > "$BACKUP_FILE"
if [ $? -eq 0 ]; then
echo "Backup successful!"
# Keep only last 7 days of backups
find "$BACKUP_DIR" -name "headscale-db-*.sql" -mtime +7 -delete
else
echo "Backup failed!"
exit 1
fi
Schedule with cron
# Daily backup at 2 AM
0 2 * * * /path/to/scripts/backup-db.sh >> /var/log/headscale-backup.log 2>&1
Database Maintenance
View Database Size
docker exec headscale-db psql -U headscale -d headscale -c "SELECT pg_size_pretty(pg_database_size('headscale'));"
List Tables
docker exec headscale-db psql -U headscale -d headscale -c "\dt"
View Table Sizes
docker exec headscale-db psql -U headscale -d headscale -c "
SELECT
schemaname,
tablename,
pg_size_pretty(pg_total_relation_size(schemaname||'.'||tablename)) AS size
FROM pg_tables
WHERE schemaname = 'public'
ORDER BY pg_total_relation_size(schemaname||'.'||tablename) DESC;
"
Vacuum Database
Reclaim storage space and optimize performance:
docker exec headscale-db psql -U headscale -d headscale -c "VACUUM ANALYZE;"
Interactive Database Access
Open PostgreSQL shell:
docker exec -it headscale-db psql -U headscale -d headscale
Common psql commands:
-- List all tables
\dt
-- Describe a table
\d table_name
-- List all databases
\l
-- Show current database
\conninfo
-- Quit
\q
For High-Traffic Deployments
Increase connection pool size in config/config.yaml:
database:
postgres:
max_open_conns: 50
max_idle_conns: 50
conn_max_idle_time_secs: 1800
PostgreSQL Tuning (Advanced)
Create custom PostgreSQL configuration:
# Performance tuning
max_connections = 100
shared_buffers = 256MB
effective_cache_size = 1GB
maintenance_work_mem = 64MB
wal_buffers = 8MB
Mount in docker-compose.yml:
postgres:
volumes:
- ./postgresql.conf:/etc/postgresql/postgresql.conf:ro
command: postgres -c config_file=/etc/postgresql/postgresql.conf
Troubleshooting
Connection Refused
Symptoms: Headscale logs show “connection refused”
Solutions:
# Check if PostgreSQL is running
docker compose ps postgres
# Check PostgreSQL logs
docker compose logs postgres
# Verify network connectivity
docker exec headscale ping postgres
Authentication Failed
Symptoms: “password authentication failed for user”
Solution: Verify password matches in both .env and config/config.yaml
# Check environment variable
docker compose exec postgres env | grep POSTGRES_PASSWORD
# Test connection manually
docker exec -it headscale-db psql -U headscale -d headscale
Database Not Ready
Symptoms: Headscale exits immediately or shows database errors
Solution: Wait for health check to pass
# Check health status
docker compose ps
# Watch logs
docker compose logs -f postgres
The health check runs every 10 seconds with 5 retries (max 50 seconds wait).
Slow Queries
Symptoms: Slow API responses or timeouts
Solutions:
# Check slow queries
docker exec headscale-db psql -U headscale -d headscale -c "
SELECT pid, now() - pg_stat_activity.query_start AS duration, query
FROM pg_stat_activity
WHERE state = 'active' AND now() - pg_stat_activity.query_start > interval '5 seconds';
"
# Run VACUUM ANALYZE
docker exec headscale-db psql -U headscale -d headscale -c "VACUUM ANALYZE;"
Migration from SQLite
If migrating from SQLite to PostgreSQL:
-
Backup SQLite database:
cp data/db.sqlite data/db.sqlite.backup
-
Update configuration to use PostgreSQL
-
Restart Headscale - it will initialize the new database
-
Migrate data using Headscale’s built-in migration (if available) or manual export/import
Direct SQLite to PostgreSQL migration may require manual data transfer. Test thoroughly before production migration.
Security Best Practices
Strong Passwords
Use a strong, randomly generated password: Restrict Network Access
PostgreSQL is only accessible within the Docker network, not from the host.
Regular Backups
Schedule automated daily backups with retention policy.
Monitor Logs
Regularly review PostgreSQL logs for suspicious activity:docker compose logs postgres | grep -i error