Access Control Lists (ACLs) define which devices can communicate with each other in your Headscale network. This guide covers policy structure, tag-based access control, and best practices.
Policy Storage Modes
Headscale supports two policy storage modes:
Database Mode Editable via API and Headplane GUI. Recommended for production.
File Mode Read-only policy from JSON file. Legacy mode.
Configuration
policy :
mode : database # or "file"
path : /etc/headscale/policy.json
Use database mode to manage policies through the Headplane web interface.
Policy File Structure
The policy file (config/policy.json) uses JSON format:
{
"groups" : {},
"tagOwners" : {},
"acls" : [],
"ssh" : [],
"autoApprovers" : {}
}
Groups
Groups define collections of users for easy access control:
{
"groups" : {
"group:admins" : []
}
}
Map of group names to arrays of users.
Groups allow you to manage permissions for multiple users at once.
Tag Owners
Tags are labels assigned to nodes for organizing and controlling access. Tag owners define who can assign tags to devices.
{
"tagOwners" : {
"tag:personal" : [ "group:admins" ],
"tag:servers" : [ "group:admins" ],
"tag:services" : [ "group:admins" ],
"tag:private" : [ "group:admins" ],
"tag:semiprivate" : [ "group:admins" ],
"tag:guests" : [ "group:admins" ],
"tag:remote" : [ "group:admins" ]
}
}
Tag Organization Strategy
Organize nodes by function, not by individual device:
tag:personal Personal devices (laptops, phones)
tag:servers Infrastructure servers
tag:services Service endpoints
tag:private Fully private resources
tag:semiprivate Semi-public services
tag:remote Remote access nodes
Access Control Rules
ACL rules define which sources can access which destinations:
{
"acls" : [
{
"action" : "accept" ,
"src" : [ "tag:personal" ],
"dst" : [
"tag:private:*" ,
"tag:semiprivate:*" ,
"tag:servers:22,80,443" ,
"tag:services:*"
],
"comment" : "Personal devices have full access to private services"
}
]
}
ACL Rule Structure
Action to take for matching traffic. Value : accept (currently only option)
Source devices or groups that this rule applies to. Examples :
["tag:personal"] - Devices tagged as personal
["group:admins"] - All admin users
["[email protected] "] - Specific user
Destination devices and ports. Formats :
"tag:servers:*" - All ports on servers
"tag:servers:22,80,443" - Specific ports
"tag:servers:22" - Single port
"100.64.0.50:8080" - Specific IP and port
Human-readable description of the rule’s purpose.
Example ACL Policies
Personal Devices Access
{
"action" : "accept" ,
"src" : [ "tag:personal" ],
"dst" : [
"tag:private:*" ,
"tag:semiprivate:*" ,
"tag:servers:22,80,443" ,
"tag:services:*"
],
"comment" : "Personal devices have full access to private services and limited server access"
}
Server-to-Server Communication
{
"action" : "accept" ,
"src" : [ "tag:servers" ],
"dst" : [
"tag:servers:*" ,
"tag:services:*"
],
"comment" : "Servers can communicate with each other and services"
}
Guest Access (Restricted)
{
"action" : "accept" ,
"src" : [ "tag:guests" ],
"dst" : [
"tag:semiprivate:80,443,8080"
],
"comment" : "Guests can only access semi-private services on specific ports"
}
Remote Backup Nodes
{
"action" : "accept" ,
"src" : [ "tag:remote" ],
"dst" : [
"tag:servers:22" ,
"tag:services:*"
],
"comment" : "Remote backup nodes need SSH to servers and service access"
}
Admin Full Access
{
"action" : "accept" ,
"src" : [ "group:admins" ],
"dst" : [ "*:*" ],
"comment" : "Admins have full access to everything"
}
SSH Access Control
Define SSH access rules separately from general network ACLs:
{
"ssh" : [
{
"action" : "accept" ,
"src" : [ "tag:personal" , "group:admins" ],
"dst" : [ "tag:servers" ],
"users" : [ "root" , "autogroup:nonroot" ]
}
]
}
SSH access rules with user specifications.
Allowed SSH users on destination. Special values :
"root" - Root access
"autogroup:nonroot" - All non-root users
"specific-user" - Named user account
Auto Approvers
Automatically approve subnet routes and exit nodes:
{
"autoApprovers" : {
"routes" : {
"192.168.0.0/16" : [ "tag:servers" ],
"10.0.0.0/8" : [ "tag:servers" ]
},
"exitNode" : [ "tag:servers" ]
}
}
Automatically approve subnet routes from specific tags. Key : CIDR subnetValue : Array of tags that can advertise this route
Tags that are automatically approved as exit nodes.
Auto-approval streamlines network setup by eliminating manual route approval.
Complete Policy Example
{
"groups" : {
"group:admins" : []
},
"tagOwners" : {
"tag:personal" : [ "group:admins" ],
"tag:servers" : [ "group:admins" ],
"tag:services" : [ "group:admins" ],
"tag:private" : [ "group:admins" ],
"tag:semiprivate" : [ "group:admins" ],
"tag:guests" : [ "group:admins" ],
"tag:remote" : [ "group:admins" ]
},
"acls" : [
{
"action" : "accept" ,
"src" : [ "tag:personal" ],
"dst" : [
"tag:private:*" ,
"tag:semiprivate:*" ,
"tag:servers:22,80,443" ,
"tag:services:*"
],
"comment" : "Personal devices have full access to private services and limited server access"
},
{
"action" : "accept" ,
"src" : [ "tag:servers" ],
"dst" : [
"tag:servers:*" ,
"tag:services:*"
],
"comment" : "Servers can communicate with each other and services"
},
{
"action" : "accept" ,
"src" : [ "tag:guests" ],
"dst" : [
"tag:semiprivate:80,443,8080"
],
"comment" : "Guests can only access semi-private services on specific ports"
},
{
"action" : "accept" ,
"src" : [ "tag:remote" ],
"dst" : [
"tag:servers:22" ,
"tag:services:*"
],
"comment" : "Remote backup nodes need SSH to servers and service access"
},
{
"action" : "accept" ,
"src" : [ "group:admins" ],
"dst" : [ "*:*" ],
"comment" : "Admins have full access to everything"
}
],
"ssh" : [
{
"action" : "accept" ,
"src" : [ "tag:personal" , "group:admins" ],
"dst" : [ "tag:servers" ],
"users" : [ "root" , "autogroup:nonroot" ]
}
],
"autoApprovers" : {
"routes" : {
"192.168.0.0/16" : [ "tag:servers" ],
"10.0.0.0/8" : [ "tag:servers" ]
},
"exitNode" : [ "tag:servers" ]
}
}
Policy Best Practices
Principle of Least Privilege
Grant only the minimum access required: // Good: Specific ports
"dst" : [ "tag:servers:22,80,443" ]
// Bad: All ports when not needed
"dst" : [ "tag:servers:*" ]
Use Tags, Not Individual Devices
Organize by function, not by device: // Good
"src" : [ "tag:personal" ]
// Bad
"src" : [ "laptop-john" , "phone-john" , "tablet-john" ]
Document Rules with Comments
Always include meaningful comments: {
"action" : "accept" ,
"src" : [ "tag:guests" ],
"dst" : [ "tag:semiprivate:80,443" ],
"comment" : "Guests can access public web services only"
}
Create Visual Network Diagrams
Before implementing ACLs, diagram your desired connectivity: [Personal Devices] → [Private Services] ✓
[Personal Devices] → [Servers:SSH] ✓
[Guests] → [Private Services] ✗
[Guests] → [Public Services] ✓
During Pre-Auth Key Creation
docker exec headscale headscale preauthkeys create \
--user default \
--tags tag:personal \
--expiration 24h
After Device Registration
# List nodes
docker exec headscale headscale nodes list
# Tag a node
docker exec headscale headscale nodes tag < node-i d > tag:servers
Testing ACL Policies
Validate Policy Syntax
docker exec headscale headscale policy validate /etc/headscale/policy.json
Apply Policy Changes
With database mode:
Edit policy in Headplane GUI, or
Restart Headscale to reload from file:
docker compose restart headscale
Test Connectivity
From a device, test access:
# Test SSH access
ssh [email protected]
# Test HTTP access
curl http://service.headscale.net
# Test specific port
telnet server.headscale.net 80
Security Considerations
DNS Privacy : Users with DNS access can see all internal hostnames. Consider using IP-based access for guests:{
"src" : [ "tag:guests" ],
"dst" : [ "100.64.0.50:80" , "100.64.0.50:443" ],
"comment" : "Direct IP access, not DNS"
}
Audit Checklist
ACLs follow least privilege principle
Guest access is restricted to public services only
Admin access is limited to trusted users
SSH access is explicitly controlled
Routes are explicitly approved (or auto-approved from trusted tags)
All rules have descriptive comments
Policy is version controlled
Troubleshooting
Connection Blocked
Symptoms : Device cannot reach another device
Debug steps :
Verify both devices are online:
docker exec headscale headscale nodes list
Check device tags:
docker exec headscale headscale nodes list | grep node-name
Review ACL rules for matching source and destination
Test with admin device (should have full access)
Policy Not Loading
Symptoms : Changes don’t take effect
Solutions :
# Validate policy syntax
docker exec headscale headscale policy validate /etc/headscale/policy.json
# Check Headscale logs
docker compose logs headscale | grep -i policy
# Restart Headscale
docker compose restart headscale
Additional Resources
Headscale ACL Documentation Official ACL reference
Security best practices Security and operational best practices