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

  1. Add the policy file as tailscale/policy.hujson in the repository
  2. Configure GitHub Actions with the GitHub OAuth Client credentials:
    • Add TAILSCALE_OAUTH_CLIENT_ID as a GitHub secret
    • Add TAILSCALE_OAUTH_CLIENT_SECRET as a GitHub secret
  3. Create a workflow to automatically push policy changes to Tailscale on commits
  4. 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.hujson

Restricting 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
Last updated on