Post

Tailscale ACLs as Code with GitHub Actions

Moving my Tailscale tailnet policy out of the web-based ACL editor and into a git-managed policy.hujson file, tested against the live tailnet on every PR and applied automatically on merge to main.

Tailscale ACLs as Code with GitHub Actions

Tailscale’s admin console makes it easy to get a tailnet running, and just as easy to leave the ACL policy exactly as broad as the default. Every device can reach every other device until you go and lock it down, and locking it down means editing a big JSON blob in a browser text box with no history, no review, and no way to know whether a change breaks something until it’s already live.

For me the trigger was DNS. I run a couple of Raspberry Pis as internal DNS servers, and moving from a pair of Pi 2Ws to a Pi 5 meant renaming hosts and rewriting grants in the policy more than once in a short window. Doing that by hand in the admin console, with no diff and no dry run, was exactly the kind of change I didn’t want to get wrong at 11pm on a Sunday.

This post covers moving the whole tailnet policy into a git-managed policy.hujson file, the GitHub Actions pipeline that tests every change against the live tailnet before it merges and applies it automatically afterwards, and the host/tag structure I ended up with for a single-operator homelab tailnet.


The Goal

  • Move the tailnet ACL out of the web-based editor and into version control
  • Test every policy change against the live tailnet before it can merge
  • Apply approved changes automatically on merge to main, with no manual step in the admin console
  • Keep the structure simple, just host aliases and a handful of tags, not a full mesh design I don’t need for a handful of boxes
  • Pull the Tailscale API key from Vault at runtime, with no static secret stored in GitHub

Part 1 - The Policy File

Tailscale’s policy format is HUJSON (human JSON, essentially regular JSON plus comments and trailing commas), and it’s the same file whether you paste it into the admin console or manage it as code. The only difference is where the source of truth lives.

Mine defines a handful of host aliases, tags for ownership, and a set of grants. The values below are a stand-in shape, not my actual tailnet. The pattern is what matters.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
{
	"hosts": {
		"media":   "100.100.100.11",
		"web":     "100.100.100.12",
		"dns-a":   "100.100.100.13",
		"dns-b":   "100.100.100.14",
	},
	"tagOwners": {
		"tag:media":      ["you@example.com"],
		"tag:web":        ["you@example.com"],
		"tag:dns":        ["you@example.com"],
		"tag:management": ["you@example.com"],
		"tag:docker":     ["you@example.com"],
	},
	"grants": [
		// The media box is reachable by members, web, and management on every port.
		{
			"src": ["autogroup:member", "tag:web", "tag:management"],
			"dst": ["host:media"],
			"ip":  ["*"],
		},
		// The generic web box is reachable on 80/443 the same way.
		{
			"src": ["autogroup:member", "tag:web", "tag:management"],
			"dst": ["host:web"],
			"ip":  ["*:80", "*:443"],
		},
		// Everyone can resolve DNS; only management gets the web UIs.
		{
			"src": ["*"],
			"dst": ["host:dns-a", "host:dns-b"],
			"ip":  ["*:53", "*:853"],
		},
		{
			"src": ["tag:management"],
			"dst": ["host:dns-a", "host:dns-b"],
			"ip":  ["*:80", "*:443"],
		},
		// Management reaches everything, for admin and troubleshooting.
		{
			"src": ["tag:management"],
			"dst": ["*"],
			"ip":  ["*"],
		},
	],
}

Host aliases matter more than they look like they should. Naming a device host:dns-a instead of hardcoding its 100.x address everywhere means the grants don’t change when a box gets re-provisioned. Only the one line in hosts does. That’s the difference between a five-minute DNS migration and an afternoon of grepping every grant for an IP that just changed.


Part 2 - Validating Before It’s Live

HUJSON being JSON-plus-comments means a bad edit is still just a syntax error, and I’d rather catch that on my laptop than in CI. A small script strips the comments and trailing commas and confirms what’s left parses.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
#!/usr/bin/env python3
"""Strip HUJSON comments/trailing commas and confirm the result parses as JSON."""
import json
import re
import sys


def strip_hujson(text: str) -> str:
    text = re.sub(r"//[^\n]*", "", text)
    text = re.sub(r",(\s*[}\]])", r"\1", text)
    return text


def main() -> int:
    path = sys.argv[1] if len(sys.argv) > 1 else "policy.hujson"
    with open(path, "r", encoding="utf-8") as f:
        raw = f.read()
    try:
        json.loads(strip_hujson(raw))
    except json.JSONDecodeError as e:
        print(f"INVALID: {path}: {e}", file=sys.stderr)
        return 1
    print(f"OK: {path} parses.")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

That catches typos, but it can’t tell me whether the policy will actually be accepted by Tailscale, or what it changes relative to what’s live. For that there’s tailscale/gitops-acl-action, which calls the real Tailscale API in test mode, running the same validation the admin console does without applying anything.


Part 3 - The GitHub Actions Pipeline

The pipeline runs off two triggers on the same workflow.

  • A pull request tests the change and comments the diff
  • A push to main applies it for real

The Tailscale API key never touches GitHub Secrets. It’s fetched from Vault at the start of each job using JWT/OIDC authentication, the same approach covered in my Vault post. A five-minute Vault token gets issued to that one job, scoped to a policy that can only read secret/data/tailscale/*.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
name: Tailscale - ACL Sync

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]
  workflow_dispatch:

concurrency:
  group: tailscale-acl-sync
  cancel-in-progress: false

jobs:
  acl-test:
    if: github.event_name == 'pull_request'
    runs-on: ubuntu-latest
    permissions:
      id-token: write
      contents: read
      pull-requests: write
    steps:
      - uses: actions/checkout@v6

      - name: Import secrets from Vault
        uses: hashicorp/vault-action@v4
        with:
          url: https://vault.yourdomain.com:8200
          method: jwt
          role: tailscale
          secrets: |
            secret/data/tailscale/config ts_api_key | TS_API_KEY ;
            secret/data/tailscale/config ts_tailnet | TS_TAILNET

      - name: Test ACL
        id: test-acl
        uses: tailscale/gitops-acl-action@v1
        with:
          api-key: $
          tailnet: $
          action: test
      - name: Comment ACL diff on PR
        if: $
        uses: actions/github-script@v9
        with:
          script: |
            github.rest.issues.createComment({
              issue_number: context.issue.number,
              owner: context.repo.owner,
              repo: context.repo.repo,
              body: `### Tailscale ACL Diff\n\`\`\`diff\n$\n\`\`\``
            });

  acl-apply:
    if: github.event_name == 'push' && github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    permissions:
      id-token: write
      contents: read
    environment: production
    steps:
      - uses: actions/checkout@v6

      - name: Import secrets from Vault
        uses: hashicorp/vault-action@v4
        with:
          url: https://vault.yourdomain.com:8200
          method: jwt
          role: tailscale
          secrets: |
            secret/data/tailscale/config ts_api_key | TS_API_KEY ;
            secret/data/tailscale/config ts_tailnet | TS_TAILNET

      - name: Deploy ACL
        uses: tailscale/gitops-acl-action@v1
        with:
          api-key: $
          tailnet: $
          action: apply

The PR comment is the part that changed how carefully I write these edits. Instead of guessing what a grant change will do, I get an actual diff of the live policy against the proposed one, sitting in the PR before I merge it. The production environment on the apply job is a second gate on top of that, a required reviewer if I ever want one, and a clear audit trail of exactly when a policy went live and via which commit.


The Day-to-Day

Changing the tailnet now looks like editing any other piece of infrastructure.

  • Adding a device? Tag it on tailscale up, add the tag to tagOwners if it’s new, open a PR.
  • Reviewing a change? Read the grant diff GitHub Actions posted, not the raw policy.
  • Merging it? The same test runs again for safety, then it applies to the live tailnet in seconds.

The admin console’s policy editor is still there, and Tailscale will tell you not to touch it once a repo is wired up as the source of truth. That’s exactly the point. The policy that’s live is the policy that’s in git, and the only way to change either one is a pull request.

This post is licensed under CC BY 4.0 by the author.