Skip to main content
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.
docker-compose.yml
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.
.env
POSTGRES_DB=headscale
POSTGRES_USER
string
default:"headscale"
PostgreSQL user for Headscale.
.env
POSTGRES_USER=headscale
POSTGRES_PASSWORD
string
required
PostgreSQL password. Must match the password in config/config.yaml.
.env
POSTGRES_PASSWORD=your_secure_password_here
Change this from the default changeme value immediately!

Headscale Database Configuration

Configure database connection in config/config.yaml:
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
string
required
Database type. Use postgres for production.Options: sqlite, postgres
database.postgres.host
string
required
PostgreSQL server hostname. Use Docker service name (postgres) for container deployments.
database.postgres.port
integer
default:5432
PostgreSQL server port.
database.postgres.name
string
required
Database name. Must match POSTGRES_DB environment variable.
database.postgres.user
string
required
Database username. Must match POSTGRES_USER environment variable.
database.postgres.pass
string
required
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
integer
default:10
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
integer
default:10
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
integer
default:3600
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:
  1. .env file: POSTGRES_PASSWORD=your_password
  2. 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

scripts/backup-db.sh
#!/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

Performance Tuning

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:
postgresql.conf
# 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:
  1. Backup SQLite database:
    cp data/db.sqlite data/db.sqlite.backup
    
  2. Update configuration to use PostgreSQL
  3. Restart Headscale - it will initialize the new database
  4. 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

1

Strong Passwords

Use a strong, randomly generated password:
openssl rand -base64 32
2

Restrict Network Access

PostgreSQL is only accessible within the Docker network, not from the host.
3

Regular Backups

Schedule automated daily backups with retention policy.
4

Monitor Logs

Regularly review PostgreSQL logs for suspicious activity:
docker compose logs postgres | grep -i error

Build docs developers (and LLMs) love