tf/tailscale Foundation Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use dev-flow:subagent-driven-development (recommended) or dev-flow:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Stand up the tf/tailscale TFC workspace that adopts the fzymgc-house tailnet’s non-ACL config (DNS, tailnet settings) and mints the three OAuth clients (CI, nas, router) that the ACL-relocation and router-migration plans depend on.
Architecture: A new HCP Terraform workspace main-cluster-tailscale, modeled on tf/cloudflare: the tailscale provider authenticates via an ephemeral Vault read of the operator-seeded bootstrap OAuth client; tailscale_dns_configuration + tailscale_tailnet_settings adopt current live values as a near-no-op import (except deliberately flipping acls_externally_managed_on on); tailscale_oauth_client ×3 mint per-purpose credentials, each written to Vault (nas/router) via vault_kv_secret_v2. The ACL itself stays on gitops-acl-action (relocated in a sibling plan) — this workspace does not manage tailscale_acl.
Tech Stack: Terraform, HCP Terraform (TFC), the tailscale/tailscale provider, the vault provider (write), HashiCorp Vault (KV v2).
Scope note: This is plan 1 of 3 for the “Tailscale config as code” spec (docs/engineering/specs/2026-07-05-tailscale-config-as-code-design.md, hl-mjh0). Siblings (separate plans): ACL relocation (tailscale/policy.hujson + gitops-acl-action + autoApprovers/hl-o89e cleanup) and router-tailscale SOPS→Vault migration. Both consume the OAuth clients minted here.
Grounding (this session): provider resource schemas verified via context7 (/tailscale/terraform-provider-tailscale): tailscale_dns_configuration, tailscale_tailnet_settings, tailscale_oauth_client, provider oauth_client_id/oauth_client_secret. Live tailnet values captured via the Tailscale API (DNS, settings inventory). Repo patterns read: tf/cloudflare/terraform.tf, tf/vault/policy-terraform-workspaces.tf, tf/vault/jwt-hcp-terraform.tf, tf/hcp-terraform/workspaces.tf, tf/cloudflare/nas_email.tf. Provider version: pinned ~> 0.29 (latest 0.29.2, 2026-07-05); tailscale_dns_configuration requires >= 0.22 (it does not exist in 0.21.x) — do not downpin.
Model hints (for bead materialization, Rule 5): Task 2 = model:haiku (mechanical local.all_workspaces map entry mirrored from existing rows). Tasks 1, 3, 4, 5, 6, 7 = model:sonnet (Vault policy + JWT run-role wiring, provider/module scaffold, live-value DNS/settings adoption, OAuth-client + Vault writes, and the plan-verify-cutover gate — correctness-sensitive Vault/TFC/provider work where an error 403s, drifts, or leaks).
Prerequisite (operator, one-time — already done this session)
Section titled “Prerequisite (operator, one-time — already done this session)”The bootstrap OAuth client exists in Vault at secret/fzymgc-house/infrastructure/tailscale/bootstrap-oauth with fields client_id + client_secret (scopes: policy_file, dns, devices:core, devices:routes, auth_keys, oauth_keys, feature_settings). This plan’s provider reads it; without it, Task 3’s terraform plan fails auth. The client must be allowed to assign every tag the child clients carry — tag:nas, tag:core (nas) and tag:router, tag:ns, tag:security (router); verify in the admin console before Task 6, or the tailscale_oauth_client creates 403.
Task 1: Vault policy grant (tf/vault, applies FIRST)
Section titled “Task 1: Vault policy grant (tf/vault, applies FIRST)”Files:
- Modify:
tf/vault/policy-terraform-workspaces.tf(add theterraform_tailscale_adminpolicy) - Modify:
tf/vault/jwt-hcp-terraform.tf(add thetfc_tailscalerun-role that binds the policy)
Ordering:
tf/vault(main-cluster-vault) MUST apply beforetf/tailscale, exactly like the Cloudflare email-token grant. Without the KV write capability AND the run-role binding, Task 6’svault_kv_secret_v2writes (and even the provider’s bootstrap read) are denied.
- Step 1: Add the policy resource
Append to tf/vault/policy-terraform-workspaces.tf (mirrors the existing terraform_cloudflare_admin resource shape; note paired data/ + metadata/ grants per tf/CLAUDE.md):
# Tailscale workspace - reads the bootstrap OAuth client (provider auth) and# writes the per-node OAuth client credentials for the nas/router Ansible roles.# The ACL itself is managed by gitops-acl-action, NOT this workspace.resource "vault_policy" "terraform_tailscale_admin" { name = "terraform-tailscale-admin" policy = <<EOT# Read the bootstrap OAuth client (for the tailscale provider auth)path "secret/data/fzymgc-house/infrastructure/tailscale/bootstrap-oauth" { capabilities = ["read"]}
# Write the per-node OAuth client credentials (read by ansible/roles/nas-tailscale# and router-tailscale over the operator's Vault session). The metadata paths get# create/update/list (not just read/delete) because Task 6's vault_kv_secret_v2# resources carry a custom_metadata block, which writes the metadata path — mirrors# the nas_cloudflare_email grant (policy-terraform-workspaces.tf), NOT the metadata-# less hcp_terraform_hmac grant.path "secret/data/fzymgc-house/infrastructure/nas/tailscale-auth" { capabilities = ["create", "read", "update", "delete"]}path "secret/metadata/fzymgc-house/infrastructure/nas/tailscale-auth" { capabilities = ["create", "read", "update", "delete", "list"]}path "secret/data/fzymgc-house/infrastructure/router/tailscale-auth" { capabilities = ["create", "read", "update", "delete"]}path "secret/metadata/fzymgc-house/infrastructure/router/tailscale-auth" { capabilities = ["create", "read", "update", "delete", "list"]}EOT}- Step 2: Bind the policy via a TFC JWT run-role
In tf/vault/jwt-hcp-terraform.tf, add a role mirroring vault_jwt_auth_backend_role.tfc_cloudflare exactly. The workspace’s TFC_VAULT_RUN_ROLE var is auto-derived as tfc-tailscale from the tailscale tag by tf/hcp-terraform; this resource is the Vault side of that name, and without it the main-cluster-tailscale run authenticates with no policy and every Vault read/write in this module is denied.
resource "vault_jwt_auth_backend_role" "tfc_tailscale" { backend = vault_jwt_auth_backend.hcp_terraform.path role_name = "tfc-tailscale"
bound_audiences = ["vault.workload.identity"] bound_claims = { sub = "organization:fzymgc-house:project:main-cluster:workspace:main-cluster-tailscale:run_phase:*" } bound_claims_type = "glob" # Enable wildcard matching for run_phase:*
user_claim = "terraform_workspace_name" role_type = "jwt" token_ttl = 3600 # 1 hour for long-running applies token_max_ttl = 7200 # 2 hour max for complex infrastructure changes token_policies = ["terraform-tailscale-admin"]}- Step 3: Validate
Run: terraform -chdir=tf/vault fmt -check && terraform -chdir=tf/vault validate
Expected: formatted, valid. Do not apply locally — merge triggers main-cluster-vault.
- Step 4: Commit (VCS per
references/vcs-preamble.md).
Task 2: Register the TFC workspace (tf/hcp-terraform)
Section titled “Task 2: Register the TFC workspace (tf/hcp-terraform)”Files:
-
Modify:
tf/hcp-terraform/workspaces.tf(addmain-cluster-tailscaletolocal.all_workspaces) -
Step 1: Add the workspace map entry
In tf/hcp-terraform/workspaces.tf, add to local.all_workspaces (alphabetical-ish, after main-cluster-router-hosts):
main-cluster-tailscale = { dir = "tf/tailscale" tags = ["main-cluster", "tailscale"] }Agent execution mode is the default (no execution_mode key needed — the tailscale + vault providers reach their APIs over the network, no cluster-local access required).
- Step 2: Validate
Run: terraform -chdir=tf/hcp-terraform fmt -check && terraform -chdir=tf/hcp-terraform validate
Expected: formatted, valid.
- Step 3: Note the operator step (does NOT auto-apply on merge)
Per tf/CLAUDE.md: the hcp-terraform manager workspace is Local execution, manual apply, so merging this entry does not create the workspace. After merge, the operator runs terraform -chdir=tf/hcp-terraform apply (with Vault creds) to create main-cluster-tailscale + its 5 TFC_VAULT_* vars, then queues its first plan manually in the HCP UI. Record this in the PR description.
- Step 4: Commit.
Task 3: tf/tailscale module scaffold (provider + backend + vars)
Section titled “Task 3: tf/tailscale module scaffold (provider + backend + vars)”Files:
- Create:
tf/tailscale/versions.tf - Create:
tf/tailscale/terraform.tf - Create:
tf/tailscale/variables.tf - Create:
tf/tailscale/outputs.tf
Per
tf/CLAUDE.md, a module MUST includeversions.tf,terraform.tf,variables.tf,outputs.tf,main.tf(the resource files in Tasks 4-6 stand in formain.tfsplit by responsibility).
- Step 1:
versions.tf
# SPDX-License-Identifier: MIT# terraform: language=hclterraform { required_version = "~> 1.15" required_providers { tailscale = { source = "tailscale/tailscale" version = "~> 0.29" # 0.29.2 latest (2026-07-05); tailscale_dns_configuration needs >= 0.22 } vault = { source = "hashicorp/vault" version = "~> 5.9" } }}Sanity-check the constraints still match the sibling modules before commit: rg -n 'required_version|hashicorp/vault' tf/cloudflare/versions.tf (expect ~> 1.15 and ~> 5.9).
- Step 2:
terraform.tf(provider auth + cloud block — mirrorstf/cloudflare/terraform.tf)
# SPDX-License-Identifier: MIT# terraform: language=hcl
provider "tailscale" { oauth_client_id = ephemeral.vault_kv_secret_v2.tailscale_bootstrap.data["client_id"] oauth_client_secret = ephemeral.vault_kv_secret_v2.tailscale_bootstrap.data["client_secret"] # tailnet defaults to the OAuth client's own tailnet ("-"); set explicitly if needed.}
provider "vault" { address = var.tfc_vault_dynamic_credentials != null ? var.tfc_vault_dynamic_credentials.default.address : var.vault_addr skip_child_token = var.tfc_vault_dynamic_credentials != null
dynamic "auth_login_token_file" { for_each = var.tfc_vault_dynamic_credentials != null ? [1] : [] content { filename = var.tfc_vault_dynamic_credentials.default.token_filename } }}
# Ephemeral read of the bootstrap OAuth client (provider-config sink; never persists# in plan/state — same pattern as tf/cloudflare's bootstrap token).ephemeral "vault_kv_secret_v2" "tailscale_bootstrap" { mount = "secret" name = "fzymgc-house/infrastructure/tailscale/bootstrap-oauth"}
terraform { cloud { organization = "fzymgc-house" workspaces { tags = ["main-cluster", "tailscale"] } }}- Step 3:
variables.tf(copy the TFC/Vault plumbing vars verbatim fromtf/cloudflare/variables.tf)
Run: rg -n 'tfc_vault_dynamic_credentials|vault_addr' tf/cloudflare/variables.tf
Copy the exact variable "tfc_vault_dynamic_credentials" and variable "vault_addr" blocks (types + defaults) into tf/tailscale/variables.tf so the provider block above resolves identically.
- Step 4:
outputs.tf(non-sensitive identifiers only)
# SPDX-License-Identifier: MIT# terraform: language=hcloutput "ci_oauth_client_id" { description = "OAuth client ID for the ACL-sync CI (secret lives in GitHub Actions)." value = tailscale_oauth_client.ci.id}(The key/secret outputs are intentionally omitted — secrets flow to Vault/Actions, never to TF outputs, per tf/CLAUDE.md.)
- Step 5:
terraform init+ validate
Run: terraform -chdir=tf/tailscale init && terraform -chdir=tf/tailscale validate
Expected: providers install (pin the exact tailscale/tailscale version from the lock), validate passes. Commit .terraform.lock.hcl.
- Step 6: Commit.
Task 4: DNS adoption (tf/tailscale/dns.tf)
Section titled “Task 4: DNS adoption (tf/tailscale/dns.tf)”Files:
-
Create:
tf/tailscale/dns.tf -
Step 1: Write the DNS configuration (adopts live values)
Live values (verified via the API this session): MagicDNS on; global nameserver 2606:1a40::13:ee4c:7f63:7c6b:0; search_paths=["fzymgc.house"]; split-DNS fzymgc.house → 192.168.20.1; override_local_dns false (only MagicDNS was reported in dns/preferences).
# SPDX-License-Identifier: MIT# terraform: language=hcl# Adopts the tailnet's existing DNS configuration verbatim (near-no-op import).# tailscale_dns_configuration is the all-in-one DNS resource; do NOT also use the# split tailscale_dns_* resources (they conflict).resource "tailscale_dns_configuration" "this" { magic_dns = true override_local_dns = false search_paths = ["fzymgc.house"]
nameservers { address = "2606:1a40::13:ee4c:7f63:7c6b:0" }
split_dns { domain = "fzymgc.house" nameservers { address = "192.168.20.1" } }}- Step 2: Confirm
override_local_dnsfrom the live API before apply
Run (with a Vault session — reuse the inventory pattern from this session):
# token exchange with the bootstrap client, then:curl -s -H "Authorization: Bearer $TOKEN" https://api.tailscale.com/api/v2/tailnet/-/dns/preferencesExpected: {"magicDNS":true}. If it also reports a “restrict/override” flag, set override_local_dns to match. (The resource must reproduce live exactly so the first apply is a no-op.)
- Step 3: Import + zero-diff check (deferred to Task 7’s plan — the resource adopts existing state via
importthere). Commit the file.
Task 5: Tailnet settings adoption + drift lock (tf/tailscale/settings.tf)
Section titled “Task 5: Tailnet settings adoption + drift lock (tf/tailscale/settings.tf)”Files:
-
Create:
tf/tailscale/settings.tf -
Step 1: Write the settings (adopt live + flip
externally_managed_on)
Live values (verified this session): devicesApprovalOn=true, devicesAutoUpdatesOn=true, devicesKeyDurationDays=180, usersApprovalOn=true, usersRoleAllowedToJoinExternalTailnets=admin, networkFlowLoggingOn=false, regionalRoutingOn=false, postureIdentityCollectionOn=true, httpsEnabled=true. Deliberate change: acls_externally_managed_on false → true, link → the monorepo.
# SPDX-License-Identifier: MIT# terraform: language=hcl# Adopts live tailnet settings; DELIBERATELY flips acls_externally_managed_on to true# (locks the admin-console policy editor against drift). This is metadata only — the# gitops-acl-action still applies the policy via API (the lock is UI-only), so there is# no collision between this resource (owns the acls_* metadata) and the action (owns the# ACL content).resource "tailscale_tailnet_settings" "this" { acls_externally_managed_on = true acls_external_link = "https://github.com/fzymgc-house/selfhosted-cluster/blob/main/tailscale/policy.hujson" devices_approval_on = true devices_auto_updates_on = true devices_key_duration_days = 180 users_approval_on = true users_role_allowed_to_join_external_tailnet = "admin" network_flow_logging_on = false regional_routing_on = false posture_identity_collection_on = true https_enabled = true}- Step 2: Commit. (Zero-diff verified in Task 7.)
Task 6: OAuth clients + Vault writes (tf/tailscale/oauth_clients.tf)
Section titled “Task 6: OAuth clients + Vault writes (tf/tailscale/oauth_clients.tf)”Files:
-
Create:
tf/tailscale/oauth_clients.tf -
Step 1: Write the three clients + co-located Vault writes
# SPDX-License-Identifier: MIT# terraform: language=hcl# Per-purpose OAuth clients. Secrets are co-located to their Vault write (a sensitive# computed value cannot cross workspaces — same rule as tf/cloudflare/nas_email.tf).
# --- CI client: drives the gitops-acl-action ACL sync (policy_file only). Its secret# is set MANUALLY as a GitHub Actions repo secret (the repo has no github provider);# TF only mints the client + exposes its id (outputs.tf).resource "tailscale_oauth_client" "ci" { description = "selfhosted-cluster ACL-sync CI" scopes = ["policy_file"]}
# --- NAS app client: consumed by ansible/roles/nas-tailscale (custom app) via# TS_CLIENT_ID/TS_CLIENT_SECRET. auth_keys scope REQUIRES a tag. tags MUST cover# EVERY tag the nas node holds (live: tag:core, tag:nas) — a re-register assigns# only the client's tags, so a subset would silently drop the rest.resource "tailscale_oauth_client" "nas" { description = "nas-tailscale app auth" scopes = ["auth_keys"] tags = ["tag:nas", "tag:core"]}
resource "vault_kv_secret_v2" "nas_tailscale_auth" { mount = "secret" name = "fzymgc-house/infrastructure/nas/tailscale-auth" data_json = jsonencode({ client_id = tailscale_oauth_client.nas.id client_secret = tailscale_oauth_client.nas.key }) custom_metadata { max_versions = 10 data = { managed_by = "terraform" purpose = "nas-tailscale-oauth" } }}
# --- Router client: consumed by ansible/roles/router-tailscale (custom compose).# tags MUST cover every router-node tag (live: tag:ns, tag:router, tag:security).# tag:ns is load-bearing — it is an ACL dst for DNS :53; dropping it on a rebuild# breaks tailnet DNS to the router.resource "tailscale_oauth_client" "router" { description = "router-tailscale auth" scopes = ["auth_keys"] tags = ["tag:router", "tag:ns", "tag:security"]}
resource "vault_kv_secret_v2" "router_tailscale_auth" { mount = "secret" name = "fzymgc-house/infrastructure/router/tailscale-auth" data_json = jsonencode({ client_id = tailscale_oauth_client.router.id client_secret = tailscale_oauth_client.router.key }) custom_metadata { max_versions = 10 data = { managed_by = "terraform" purpose = "router-tailscale-oauth" } }}- Step 2: Confirm
data_json(not_wo) is the right pattern here
tf/CLAUDE.md prefers write-only (data_wo) for secrets flowing into resources, but the established precedent for TF-generated credentials written to Vault is data_json (tf/cloudflare/nas_email.tf vault_kv_secret_v2.nas_cloudflare_email). Run rg -n 'data_json|data_wo' tf/cloudflare/nas_email.tf to confirm, and match it. If the shop has since moved to data_wo + data_wo_revision, use that instead (check tf/cloudflare for the newest example).
- Step 3:
terraform -chdir=tf/tailscale fmt -check && validate. Commit.
Task 7: Plan, verify zero-drift, and cut over
Section titled “Task 7: Plan, verify zero-drift, and cut over”Files: none (verification is the “test” for infra).
- Step 1: Speculative plan (the acceptance gate)
After Tasks 1-2 have merged + applied (Vault grant live, workspace created + first plan queued), open the tf/tailscale PR. The HCP speculative plan MUST show:
tailscale_dns_configuration.this— import, 0 change (or add-with-identical-values if import blocks are used; addimport {}blocks referencingdns_configuration/tailnet_settingsper the provider import docs to make it a pure import).tailscale_tailnet_settings.this— the ONLY diff isacls_externally_managed_on false→true+ the link (the deliberate change).tailscale_oauth_client.{ci,nas,router}+vault_kv_secret_v2.{nas,router}_tailscale_auth— 5 to add.
A diff on any adopted DNS/settings field beyond the intended acls_* flip is a FAIL — reconcile the resource to live before merging.
- Step 2: Add
import{}blocks for DNS + settings (so adoption is import, not recreate)
# In dns.tf / settings.tf respectively (ID doesn't matter for these singletons):import { to = tailscale_dns_configuration.this, id = "dns_configuration" }import { to = tailscale_tailnet_settings.this, id = "tailnet_settings" }Re-plan: expected 2 to import, 3 to add (clients), 2 to add (vault), 1 to change (settings acls_* flip).
-
Step 3: Merge → apply (TFC auto-applies on merge). Confirm apply green.
-
Step 4: Seed the CI client secret into GitHub Actions (operator, one-time)
The ci client’s secret is only available in the apply log / can be re-read from the client — set it as repo secrets for the sibling ACL-sync workflow:
gh secret set TS_OAUTH_ID --repo fzymgc-house/selfhosted-cluster --body '<ci client id>'gh secret set TS_OAUTH_SECRET --repo fzymgc-house/selfhosted-cluster --body '<ci client secret>'gh secret set TS_TAILNET --repo fzymgc-house/selfhosted-cluster --body '-'(Record in the PR that the ACL-relocation plan’s workflow depends on these three secrets.)
- Step 5: Verify the outputs for downstream plans
vault kv get -field=client_id secret/fzymgc-house/infrastructure/nas/tailscale-authvault kv get -field=client_id secret/fzymgc-house/infrastructure/router/tailscale-authExpected: both return a tskey-client-… id (secret present but not printed). These unblock the nas-tailscale (Spec B) and router-tailscale (sibling) plans.
- Step 6: Confirm the console lock took effect
In the Tailscale admin console → Access Controls: the policy editor shows “externally managed” (read-only), and the link points at the monorepo. gitops-acl-action (sibling plan) still applies via API despite the lock.
Verification summary
Section titled “Verification summary”tf/vault+tf/hcp-terraformmerged/applied;main-cluster-tailscaleworkspace exists (operator-applied + first plan queued).tf/tailscalespeculative plan: DNS + settings imported with only the intendedacls_externally_managed_onflip; 3 OAuth clients + 2 Vault secrets created; apply green.secret/…/nas/tailscale-authandsecret/…/router/tailscale-autheach hold{client_id, client_secret}.TS_OAUTH_ID/SECRET/TAILNETset as repo Actions secrets for the ACL-sync workflow.- Admin console policy editor is externally-managed (locked); link → monorepo.
- No secret printed in any plan/state/output (only
ci_oauth_client_id).
Follow-on plans (this spec)
Section titled “Follow-on plans (this spec)”- ACL relocation:
tailscale/policy.hujson+.github/workflows/tailscale-acl-sync.yml(path-filtered, uses the CI secrets from Task 7 Step 4) +autoApprovers(hl-ww73) + droptag:k8s-operator(hl-o89e) + retirefzymgc-house/tailscale-config+ delete orphanedsecret/…/cluster/tailscale/oauth. - router-tailscale migration: SOPS auth key → Vault OAuth client (
router/tailscale-auth), compose envTS_AUTHKEY→TS_CLIENT_ID/TS_CLIENT_SECRET+--advertise-tags=tag:router,tag:ns,tag:security, retireansible/secrets/router-tailscale.sops.yaml.