What you get
The tailnet policy file living in a git repository, changing only through pull requests, with an automated check that reads every proposed version, validates it, and runs your access assertions against it before anything reaches the tailnet. Reviewers read a diff with reasons attached instead of squinting at a text box. A change that would silently remove access to something important turns the check red and never merges.
The reason to do this is not tidiness. The policy file is the one piece of configuration in your network where a single careless edit can remove your own ability to undo the edit. Delete the grant that lets your operators reach the management hosts, and the path you would use to restore the grant is the path you just deleted. Every other misconfiguration in this guide is recoverable by walking over to a machine. This one is recoverable by opening a support ticket.
Tests in the policy file are the specific answer to that specific failure. They are assertions that run every time the policy changes, and a failed assertion causes Tailscale to reject the new policy file with an error. That is the whole idea: you cannot delete the door you came in through, because a test is standing in front of it.
How it works
Three mechanisms stack, and each one is doing a distinct job.
The format is HuJSON, human JSON, which permits comments and trailing commas. That is not cosmetic. Comments let a rule carry the reason it exists on the line directly above it, which is the single highest value thing a reviewer can be given. Trailing commas mean adding an entry to a list produces a one line diff instead of a two line diff that also touches the previous line to add a comma. Review quality is a function of diff noise, and HuJSON removes a whole category of it.
The assertions are the tests and sshTests sections inside the policy file itself. They are not a separate test suite in a separate language. They travel with the thing they describe, in the same file, in the same commit, so the rule and the proof of the rule can never drift apart.
The pipeline is the official GitOps action, tailscale/gitops-acl-action. On a pull request it runs with action: test, which sends the proposed policy file to Tailscale to determine whether the policy is valid and whether all tests pass. On a push to the main branch it runs with action: apply, which validates and tests again, and only then updates the tailnet.
Build it
-
Move the current policy into a repository, unchanged. Copy the contents of the Access controls page in the admin console into a file named
policy.hujsonat the repository root, commit it, and change nothing else in the first commit. That commit is your baseline. If the pipeline later disagrees with the tailnet, you want the disagreement to be about a change you made, not about a transcription error. -
Mint the credential the pipeline will use. Create an OAuth client and give it the
policy_filescope, which permits reading, validating, and modifying the policy file. If you want a credential that can only run the pull request check and can never ship, usepolicy_file:read, which permits reading and validating. OAuth access tokens are issued from the client ID and secret and expire after one hour, so the pipeline mints a fresh one on every run and there is nothing long lived sitting in a runner. -
Store the secrets under the names the action expects:
TS_TAILNETfor the tailnet name, plusTS_OAUTH_IDandTS_OAUTH_SECRETfor the client. Keep them repository secrets. A credential withpolicy_filescope can rewrite who reaches what in your entire network. -
Add the workflow. Two steps, one guarded to pull requests and one guarded to pushes.
name: Sync Tailscale ACLs on: push: branches: [ "main" ] pull_request: branches: [ "main" ] jobs: acls: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - 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 - 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: testThe action reads
policy.hujsonfrom the repository root by default. If you keep the file somewhere else, pass thepolicy-fileinput. -
Write the assertions that name what you cannot lose. A test runs from the perspective of a device authenticated as the given identity. Destinations are written as
host:portwith a single numeric port.protois optional and, when omitted, the check covers TCP or UDP access."tests": [ { "src": "group:operators", "proto": "tcp", "accept": ["lab-vm-1:22", "cloud-1:22"], "deny": ["node-b:5432"], }, ], -
Write the lockout assertion first and treat it as untouchable. This is the whole point of the exercise. Name the identity that repairs the network and the hosts it repairs the network from, and assert that path in both directions of the argument: the repair path is accepted, and the paths that must never open are denied. Put a HuJSON comment above it saying, in words, that removing this test is itself the outage.
// Removing this test is the incident. The operators group must keep // a path to the management hosts, because that path is how any bad // policy merge gets reverted. { "src": "group:operators", "accept": ["lab-vm-1:22"], }, -
Cover SSH separately, because ACL tests do not cover it.
sshTestsasserts on the SSH user that an identity may become, withacceptfor users allowed outright,checkfor users that require a further authentication check, anddenyfor users never permitted."sshTests": [ { "src": "group:operators", "dst": ["lab-vm-1"], "accept": ["ops"], "check": ["admin"], "deny": ["root"], }, ], -
Make the check mandatory in the forge, not merely present. Require the test check to pass and require at least one review before merge. Without branch protection the action is a suggestion. With it, the action is a gate.
-
Give reviewers a local dry run. The API validates a proposed policy without changing anything, at
POST /api/v2/tailnet/{tailnet}/acl/validate. Send a policy object and it validates the syntax and runs the tests included in it, or send an array of test objects and it runs those against the current policy.curl "https://api.tailscale.com/api/v2/tailnet/example.com/acl/validate" \ -u "${TS_API_TOKEN}:" \ -H "Content-Type: application/json" \ --data-binary '[{"src": "group:operators", "accept": ["lab-vm-1:22"]}]'
Verify it
-
Run a negative control before you trust a green result. Open a pull request that deliberately deletes the grant your lockout test depends on. The check must go red and must name the failing assertion. A pipeline that has never been observed failing is not a pipeline, it is decoration.
-
Run the positive control. Merge a change that only adds a HuJSON comment, then confirm the admin console shows that comment. That proves the apply step has the write scope and is actually reaching your tailnet, which the test step alone never proves.
-
Prove which side is authoritative. Make a small edit directly in the admin console, then merge any change from the repository. The apply step ships the repository version, so your console edit disappears. Watch that happen once, deliberately, on a change you do not care about, so nobody discovers it during an incident.
-
Know what failure looks like. The validate endpoint returns a 200 status even when tests fail, and the outcome is in the response body. Any wrapper you build yourself must read the body rather than the status code, or it will report success on every broken policy you ever write.
Gotchas
-
The disaster this prevents has a specific shape. Someone tightens a rule, the change looks correct in review, and it removes access to the exact hosts an operator would need in order to revert it. There is no local console, no out of band path, and the person who can fix it in the admin console is on a plane. A single test naming that path costs one commit and prevents that entire evening.
-
Tests only assert what you wrote down. They cannot infer intent. Treat coverage of the repair path, the monitoring path, and the backup path as mandatory and everything else as nice to have.
-
The admin console stays writable. Adopting GitOps does not lock the web form, so drift is possible in the window between a console edit and the next apply. If you script your own updates instead of using the action, the API supports an
If-Matchheader carrying the ETag from a prior read, so a concurrent change causes a rejection instead of a silent overwrite. -
API keys expire in ninety days. The action documentation is explicit that if you use an API key you should schedule a monthly rotation, or use a trust credential such as an OAuth client or federated identity instead. A pipeline that fails closed on an expired key on a Friday afternoon is a self inflicted wound.
-
Pick the narrower scope where you can.
policy_file:readcan validate but never modify. If your review workflow runs in a less trusted context than your deploy workflow, that difference is worth the extra client. -
Asking the API for JSON costs you your comments. A read returns HuJSON by default and returns plain JSON when you ask for
application/json. Comments do not survive the JSON representation. This is another reason the repository, not a round trip through the API, is the source of truth. -
Assertions reference identities that must exist. A test naming a group that your identity provider no longer syncs will fail, and it will fail on whatever pull request happens to be open at the time. That is correct behavior with confusing timing.
Where to take it next
- Add posture conditions to your assertions with
srcPostureAttrs, so the tests describe not only who may reach a host but from what kind of device, and a posture rule change cannot quietly widen a path. - Use the preview endpoint to generate review evidence automatically: post a comment on each pull request listing what a named identity can reach under the proposed policy, so reviewers compare outcomes instead of reading diffs of rules.
- Mirror the same pipeline in whichever forge you actually use. The identical workflow exists for GitLab CI and Bitbucket, and the credential model is the same, so there is no reason to hand roll a script.
- Extend the pattern to the rest of your tailnet configuration. Once the access policy is reviewable, the fact that tags, auth keys, and device approvals still change by hand starts to look like the anomaly it is.