Skip to main content
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

config/config.yaml
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": []
  }
}
groups
object
Map of group names to arrays of users.
{
  "groups": {
    "group:admins": ["[email protected]", "[email protected]"],
    "group:developers": ["[email protected]"],
    "group:guests": []
  }
}
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:guests

Guest devices

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
string
required
Action to take for matching traffic.Value: accept (currently only option)
src
array
required
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
dst
array
required
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
comment
string
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
array
SSH access rules with user specifications.
users
array
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"]
  }
}
autoApprovers.routes
object
Automatically approve subnet routes from specific tags.Key: CIDR subnetValue: Array of tags that can advertise this route
autoApprovers.exitNode
array
Tags that are automatically approved as exit nodes.
Auto-approval streamlines network setup by eliminating manual route approval.

Complete Policy Example

config/policy.json
{
  "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

1

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:*"]
2

Use Tags, Not Individual Devices

Organize by function, not by device:
// Good
"src": ["tag:personal"]

// Bad
"src": ["laptop-john", "phone-john", "tablet-john"]
3

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"
}
4

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] ✓

Assigning Tags to Devices

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-id> tag:servers

Testing ACL Policies

Validate Policy Syntax

docker exec headscale headscale policy validate /etc/headscale/policy.json

Apply Policy Changes

With database mode:
  1. Edit policy in Headplane GUI, or
  2. 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:
  1. Verify both devices are online:
    docker exec headscale headscale nodes list
    
  2. Check device tags:
    docker exec headscale headscale nodes list | grep node-name
    
  3. Review ACL rules for matching source and destination
  4. 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

Build docs developers (and LLMs) love