Tailscale ACL File
Structure
The policy file uses HuJSON format (JSON with comments) and includes several key sections:
1. Groups
Define groups of users for easier access management:
{
"groups": {
"group:devops": [
"alice@example.com",
"bob@example.com"
],
"group:developers": [
"charlie@example.com",
"diana@example.com"
]
}
}2. Tag Owners
Define which users or groups can assign specific tags to devices. Tags are used to identify and group connectors:
{
"tagOwners": {
"tag:k8s-operator": ["autogroup:admin"], // The Tailscale operator's OAuth client
"tag:k8s": ["tag:k8s-operator"], // Connector/proxy devices the operator creates
"tag:production-eks-example-com": ["tag:k8s-operator"],
"tag:staging-eks-example-com": ["tag:k8s-operator"]
}
}Important
tag:k8s-operator must be owned by autogroup:admin (not an empty list). With an empty owner list the operator cannot mint its own bootstrap auth key and fails to start with requested tags [tag:k8s-operator] are invalid or not permitted.
3. Auto Approvers
Automatically approve route advertisements from tagged devices. This eliminates manual approval for trusted connectors:
{
"autoApprovers": {
"routes": {
"10.0.0.0/8": ["tag:k8s"], // All RFC1918 10.x networks
"172.16.0.0/12": ["tag:k8s"], // All RFC1918 172.16-31.x networks
"192.168.0.0/16": ["tag:k8s"] // All RFC1918 192.168.x networks
}
}
}4. Grants
Define access permissions using the grants system. Grants are more flexible than traditional ACLs:
{
"grants": [
{
"src": ["group:devops"], // Source: who can access
"dst": ["*"], // Destination: all devices
"ip": ["*"] // All ports
},
{
"src": ["group:developers"],
"dst": ["tag:staging-eks-example-com"],
"ip": ["*"]
}
]
}Note
Grants also gate the operator’s capabilities: a service you expose to the tailnet, or the Kubernetes API server proxy, is only reachable by the sources your grants allow to reach the connector’s tag (e.g. tag:<cluster-name>).
5. App Connectors
Configure app connectors to expose specific services (like Kubernetes API servers or internal applications) through Tailscale:
{
"nodeAttrs": [
{
"target": ["*"],
"app": {
"tailscale.com/app-connectors": [
{
"name": "eks-production",
"connectors": ["tag:production-eks-example-com"],
"domains": [
"ABCD1234.gr7.eu-west-1.eks.amazonaws.com", // EKS API endpoint. Note: the domain needs to be in capitals
"grafana.example.com",
"prometheus.example.com"
]
}
]
}
}
]
}Example Complete Policy File
Here’s a simplified example for a customer setup:
{
"groups": {
"group:admins": [
"admin@example.com"
],
"group:developers": [
"dev1@example.com",
"dev2@example.com"
]
},
"tagOwners": {
"tag:k8s-operator": ["autogroup:admin"],
"tag:k8s": ["tag:k8s-operator"],
"tag:production-eks-example-com": ["tag:k8s-operator"],
"tag:staging-eks-example-com": ["tag:k8s-operator"]
},
"autoApprovers": {
"routes": {
"10.0.0.0/8": ["tag:k8s"],
"192.168.0.0/16": ["tag:k8s"]
}
},
"grants": [
{
"src": ["group:admins"],
"dst": ["*"],
"ip": ["*"]
},
{
"src": ["group:developers"],
"dst": ["tag:staging-eks-example-com"],
"ip": ["*"]
}
],
"nodeAttrs": [
{
"target": ["*"],
"app": {
"tailscale.com/app-connectors": [
{
"name": "eks-production",
"connectors": ["tag:production-eks-example-com"],
"domains": [
"ABC123.gr7.eu-west-1.eks.amazonaws.com"
]
},
{
"name": "eks-staging",
"connectors": ["tag:staging-eks-example-com"],
"domains": [
"DEF456.gr7.eu-west-1.eks.amazonaws.com"
]
}
]
}
}
]
}Setting Up ACL Management with GitHub Actions
- Add the policy file as
tailscale/policy.hujsonin the repository - Configure GitHub Actions with the GitHub OAuth Client credentials:
- Add
TAILSCALE_OAUTH_CLIENT_IDas a GitHub secret - Add
TAILSCALE_OAUTH_CLIENT_SECRETas a GitHub secret
- Add
- Create a workflow to automatically push policy changes to Tailscale on commits
- Disable manual ACL editing in the Tailscale admin console to enforce GitOps practices: https://login.tailscale.com/admin/settings/policy-file-management and set the External reference to the URL of your repository: https://github.com/skyscrapers/
/blob/master/tailscale/policy.hujson
Example GitHub Actions workflow:
name: Update Tailscale ACL
on:
push:
branches: ["master", "main"]
paths:
- 'tailscale/policy.hujson'
pull_request:
branches: ["master", "main"]
paths:
- 'tailscale/policy.hujson'
jobs:
acls:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Deploy ACL
if: github.event_name == 'push'
id: deploy-acl
uses: tailscale/gitops-acl-action@v1
with:
oauth-client-id: ${{ secrets.TS_OAUTH_ID }}
oauth-secret: ${{ secrets.TS_OAUTH_SECRET}}
tailnet: ${{ secrets.TS_TAILNET }}
action: apply
policy-file: tailscale/policy.hujson
- name: Test ACL
if: github.event_name == 'pull_request'
id: test-acl
uses: tailscale/gitops-acl-action@v1
with:
oauth-client-id: ${{ secrets.TS_OAUTH_ID }}
oauth-secret: ${{ secrets.TS_OAUTH_SECRET}}
tailnet: ${{ secrets.TS_TAILNET }}
action: test
policy-file: tailscale/policy.hujsonRestricting Serve and Funnel
Tailscale ships two features that let a user re-expose a local port (for example, a service reached with kubectl port-forward):
- Serve publishes the port to other devices on the same tailnet.
- Funnel publishes the port to the public internet over an HTTPS URL.
A common requirement is to allow these on a permissive tailnet (for example a development environment) while forbidding them on restricted tailnets (for example staging and production), so that developers cannot re-expose private services. Each environment is a separate tailnet with its own policy, so the controls are applied per tailnet.
Funnel
Funnel requires the funnel node attribute in the policy and HTTPS Certificates enabled for the tailnet. Because ACLs are deny-by-default, simply not granting the funnel attribute forbids funnel. Only add it on tailnets where you want it:
{
"nodeAttrs": [
{
"target": ["autogroup:member"],
"attr": ["funnel"]
}
]
}On restricted tailnets, omit that attribute entirely.
Serve
There is no policy attribute that disables the serve command itself. What you control instead is reachability: a served port is only useful if another device can connect to it. Scope the member grant to the infrastructure destinations they actually need, and do not grant member-to-member access. A served port then has no audience:
{
"grants": [
{
"src": ["autogroup:member"],
"dst": [
"10.0.0.0/16", // AWS VPC CIDR (via subnet routers)
"172.20.0.0/16" // internal Kubernetes CIDR
],
"ip": ["*"]
}
]
}Note
Grant the routed destinations (the CIDRs the connectors advertise), not the connector’s tag. Granting only the tag authorizes the connector node’s own IP, not the routes behind it. Where all infrastructure is private (peered databases, private API endpoints, VPC endpoints), no autogroup:internet grant is needed and there are no exit nodes to route it.
Note
This restriction is one-directional. To let admins keep visibility of member devices while members still cannot reach each other, add a separate grant with src: ["autogroup:admin"] and dst: ["autogroup:member"].
Making the restriction durable
The controls above live in the policy file, so they only hold if the people being restricted cannot change them:
- Enabling serve or funnel is an admin-level action. A user prompted with “Serve is not enabled on your tailnet” can enable it (and funnel) only if they hold an admin/owner role. Keep developers as plain members.
- Lock the policy editor. Turn on “Prevent edits in the admin console” so the repository is the only way to change the policy. This also blocks the in-console quick-enable path for serve and funnel.
- The GitOps sync is push-based, not continuously reconciled. The action applies the policy only when the policy file changes; manual console edits persist until the next policy commit overwrites them. Locking the editor and keeping roles tight is what prevents drift, not the sync alone.
Important Notes
- Tag Naming: Tags should follow the pattern
tag:<cluster-name>for consistency - Route Approval: Auto-approvers eliminate the need for manual route approval in the Tailscale admin console
- App Connectors: Required for accessing Kubernetes API servers and internal services through Tailscale
- Testing: Always test ACL changes in a non-production environment first
- Version Control: Keep your ACL policy file in version control for audit history and easy rollback