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"], // Cluster identity, on every device of that cluster
"tag:production-eks-example-com-appconnector": ["tag:k8s-operator"], // App connector device only
"tag:production-eks-example-com-exitnode": ["tag:k8s-operator"], // Exit node device only
"tag:staging-eks-example-com": ["tag:k8s-operator"],
"tag:staging-eks-example-com-appconnector": ["tag:k8s-operator"],
"tag:staging-eks-example-com-exitnode": ["tag:k8s-operator"]
}
}Every device of a cluster carries tag:k8s (for fleet-wide rules) and tag:<cluster-name> (for per-cluster access grants). On top of that, each connector carries a role tag:
tag:<cluster-name>-appconnectoron the app connector (the subnet router that advertises your VPC CIDR and anyextra_routes). Use it for route auto-approval, the app-connectornodeAttrs, and internet egressvia.tag:<cluster-name>-exitnodeon the exit node, which only exists whenexit_node_enabledis set. Use it for exit node approval and grants.
Use the role tags wherever a rule gives out a broad power (approve any route, allow traffic to the internet). That way the power applies to the one device that needs it, and not to every device that shares the cluster tag.
Important
Every tag a device carries must be listed in tagOwners and owned by tag:k8s-operator. If it is not, the operator cannot create that device’s auth key and the device never joins the tailnet (requested tags [...] are invalid or not permitted).
Important
tag:k8s-operator must be owned by autogroup:admin (not an empty list). With an empty owner list the operator cannot create 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
},
"exitNode": ["tag:production-eks-example-com-exitnode"] // Only needed with exit_node_enabled
}
}The ranges above are private, so approving them for tag:k8s is fine. A wide range is different: an app connector that reaches an external SaaS service needs 0.0.0.0/0 approved, and that grants “approve any route you advertise”. Scope it to the app connector’s own tag, so an exit node advertising a default route later does not get approved by the same rule:
{
"autoApprovers": {
"routes": {
"0.0.0.0/0": ["tag:production-eks-example-com-appconnector"]
}
}
}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-appconnector"],
"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"
]
}
]
}
}
]
}Point connectors at the app connector’s role tag, not at the shared cluster tag. If you list the cluster tag, every device of that cluster (including the exit node) acts as an app connector for those domains.
When the domains sit outside your VPC (an external SaaS service, for example), clients also need a grant that lets them leave the tailnet through that connector:
{
"grants": [
{
"src": ["group:developers"],
"dst": ["autogroup:internet"],
"ip": ["*"],
"via": ["tag:production-eks-example-com-appconnector"]
}
]
}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:production-eks-example-com-appconnector": ["tag:k8s-operator"],
"tag:production-eks-example-com-exitnode": ["tag:k8s-operator"],
"tag:staging-eks-example-com": ["tag:k8s-operator"],
"tag:staging-eks-example-com-appconnector": ["tag:k8s-operator"],
"tag:staging-eks-example-com-exitnode": ["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-appconnector"],
"domains": [
"ABC123.gr7.eu-west-1.eks.amazonaws.com"
]
},
{
"name": "eks-staging",
"connectors": ["tag:staging-eks-example-com-appconnector"],
"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 follow the pattern
tag:<cluster-name>for the cluster identity, plustag:<cluster-name>-appconnectorandtag:<cluster-name>-exitnodefor the two connector roles - 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