Skip to main content

NixOS module + CLI for managing Tailscale auth keys via Terraform

Project description

tailscale-manager

tailscale-manager

Declaratively manage Tailscale auth keys via Terraform on NixOS.

A NixOS module + Python CLI that wraps the Tailscale Terraform provider to create, rotate, and expire auth keys — all packaged hermetically with uv2nix.

$ tailscale-manager status
Tailscale Manager — your-tailnet.ts.net
State dir: /var/lib/tailscale-manager

Last apply: 2026-05-31T00:00:00+00:00
  Result: ok

Terraform state: found
Managed keys: 1
  ✓ k123abc — managed key
     tags: tag:ci

Features

  • Declarative key management — one nixos-rebuild switch to create, update, or rotate auth keys. No imperative API calls.
  • Automatic rotationrecreate_if_invalid = "always" means expired keys are replaced automatically on the next apply. No cron, no expiry tracking.
  • Failure-safe — tfstate is backed up before every apply. On failure, the previous state is restored and the error is written to last-apply.json.
  • Credential watcher — a systemd path unit re-runs apply when the OAuth secret file changes (e.g. after agenix rotation).
  • Read-only TUI — optional Textual dashboard showing managed keys and system status. No write operations from the UI.
  • Monitoring-readytailscale-manager status --json with exit code signaling for waybar, Prometheus node_exporter textfile collector, etc.
  • Hermetic builds — full dependency tree locked via uv.lock and built by Nix. No pip install outside of Nix.

Quick start

1. Add the flake

# flake.nix
{
  inputs = {
    nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
    tailscale-manager = {
      url = "github:Cairnstew/tailscale-manager";
      inputs.nixpkgs.follows = "nixpkgs";
    };
  };

  outputs = { self, nixpkgs, tailscale-manager, ... }: {
    nixosConfigurations.your-host = nixpkgs.lib.nixosSystem {
      modules = [
        tailscale-manager.nixosModules.default
        ./configuration.nix
      ];
    };
  };
}

2. Create an OAuth client

  1. Go to Tailscale admin console → Settings → OAuth clients
  2. Click Generate OAuth client
  3. Under Scopes, enable:
    • auth_keys — write access (required)
    • devices — read access (optional, for TUI status)
  4. Under Tag ownership, add every tag you intend to pass via the tags option (e.g. tag:server, tag:ci). The OAuth client must own the tags it creates keys for — this is enforced by Tailscale and will cause apply to fail if misconfigured.
  5. Save the Client ID and Client Secret
  6. Store them in your secrets manager (agenix/sops) as:
    TAILSCALE_OAUTH_CLIENT_ID=<client-id>
    TAILSCALE_OAUTH_CLIENT_SECRET=<client-secret>
    

Important: set tailnet = "-" in your module config to auto-resolve the tailnet from the OAuth credential. This is the recommended value.

3. Configure the module

# configuration.nix
{ config, ... }: {

  services.tailscale-manager = {
    enable = true;
    tailnet = "-";                              # auto-resolve from OAuth
    credentialsFile = "/run/secrets/tailscale-oauth";
    tags = [ "tag:ci" ];
  };
}

4. Deploy

nixos-rebuild switch

On first deploy, the service will:

  1. Back up any existing tfstate (none on first run)
  2. Generate main.tf.json
  3. Run terraform init (downloads the Tailscale provider)
  4. Run terraform apply (creates the auth key)
  5. Write the result to last-apply.json

Every subsequent nixos-rebuild switch repeats steps 1–5. If a key has expired, recreate_if_invalid = "always" causes Terraform to delete it and create a new one — automatic rotation with zero custom logic.


NixOS module reference

All options under services.tailscale-manager.

Option Type Default Description
enable bool false Enable the tailscale-manager service
tailnet string (required) Tailnet name, e.g. example.com. Pass "-" to auto-resolve from the OAuth credential.
credentialsFile null or path null (required via assertion) Path to an EnvironmentFile containing TAILSCALE_OAUTH_CLIENT_ID and TAILSCALE_OAUTH_CLIENT_SECRET. Encrypt with agenix or sops-nix.
tags list of strings [] Tags to apply to the managed auth key (e.g. ["tag:ci"]). All tags must start with tag:. The OAuth client must own these tags.
stateDir string /var/lib/tailscale-manager Directory for Terraform state and backups
package package (auto from flake) Package providing the CLI
terraformBin path "${pkgs.terraform}/bin/terraform" Path to the Terraform binary
backupCount int 5 Number of tfstate backups to retain in stateDir/backups/
watchCredentials bool true Create a systemd path unit that re-runs apply when credentialsFile changes
enableTimer bool false Enable a daily systemd timer to automatically re-run apply
recreateIfInvalid enum "always" Whether to recreate the key if invalid ("always" or "never")
providerVersion string "~> 0.29" Tailscale Terraform provider version constraint

Systemd units

Three units are created when enabled:

tailscale-manager.serviceType=oneshot, runs on every nixos-rebuild switch (via wantedBy = ["multi-user.target"]):

  1. Backs up terraform.tfstate to backups/<timestamp>.tfstate
  2. Prunes old backups to backupCount
  3. Generates main.tf.json
  4. Runs terraform init
  5. Runs terraform apply -auto-approve
  6. Writes result to last-apply.json
  7. On failure: restores the most recent backup, writes error to last-apply.json, exits 1 (systemd shows red)

tailscale-manager-watch.path — if watchCredentials = true: writes the file path changes. Re-triggers the service when credentialsFile changes via atomic rename (e.g. agenix rotation).

tailscale-manager.timer — if enableTimer = true: runs the service daily via OnCalendar=daily with Persistent=true. Useful for catching drift or rotating keys near expiry.

Activation script

After every nixos-rebuild switch, the system prints:

tailscale-manager: last apply [ok]

or:

tailscale-manager: last apply [error]

This is informational only — does not trigger re-apply.


Home Manager module

For user-level CLI install without systemd service:

{ config, ... }: {

  homeManagerModules.tailscale-manager = {
    enable = true;
    tailnet = "-";
    credentialsFile = "/run/secrets/tailscale-oauth";
  };
}

Options: enable, package, tailnet, credentialsFile.


Credential setup

The credentials file must be an EnvironmentFile (KEY=VAL format) containing:

TAILSCALE_OAUTH_CLIENT_ID=<your-client-id>
TAILSCALE_OAUTH_CLIENT_SECRET=<your-client-secret>

With agenix

# secrets.nix
{
  "tailscale-oauth.age".publicKeys = [ <your-host-key> ];
}
# configuration.nix
age.secrets.tailscale-oauth = {
  file = ./secrets/tailscale-oauth.age;
};

services.tailscale-manager = {
  enable = true;
  tailnet = "-";
  credentialsFile = config.age.secrets.tailscale-oauth.path;
  tags = [ "tag:ci" ];
};

The path watcher automatically re-runs apply when agenix rotates the file.

With sops-nix

sops.secrets.tailscale-oauth = {
  format = "dotenv";
  sopsFile = ./secrets/tailscale-oauth.env;
};

services.tailscale-manager = {
  enable = true;
  tailnet = "-";
  credentialsFile = config.sops.secrets.tailscale-oauth.path;
  tags = [ "tag:ci" ];
};

CLI reference

tailscale-manager init          # terraform init + provider download
tailscale-manager plan          # terraform plan (shows pending changes)
tailscale-manager apply         # backup → generate → init → apply
tailscale-manager destroy       # backup → terraform destroy
tailscale-manager status        # read-only TUI dashboard
tailscale-manager status --json # JSON for scripting
tailscale-manager backup-state  # manual tfstate backup
tailscale-manager restore-state # manual tfstate restore
tailscale-manager version       # show version

Environment variables

| Variable | Required | Default | Description | |---|---|---|---|---| | TAILSCALE_OAUTH_CLIENT_ID | ✅ | — | Tailscale OAuth client ID | | TAILSCALE_OAUTH_CLIENT_SECRET | ✅ | — | Tailscale OAuth client secret | | TAILSCALE_TAILNET | ✅ | — | Tailnet name or "-" to auto-resolve | | TAILSCALE_MANAGER_STATE_DIR | — | /var/lib/tailscale-manager | State and backup directory | | TAILSCALE_MANAGER_TERRAFORM_BIN | — | terraform | Terraform binary path | | TAILSCALE_MANAGER_BACKUP_COUNT | — | 5 | Number of backups to retain | | TAILSCALE_MANAGER_TAGS | — | "" | Comma-separated tags, e.g. tag:ci,tag:infra | | TAILSCALE_MANAGER_RECREATE_IF_INVALID | — | "always" | Key rotation policy ("always" or "never") | | TAILSCALE_MANAGER_PROVIDER_VERSION | — | "~> 0.29" | Tailscale Terraform provider version constraint |

Exit codes

Command Exit 0 Exit 1
apply Key created/updated Apply failed (error in last-apply.json)
destroy Key destroyed Destroy failed
status --json Last result was ok Last result was error
plan No changes (or changes pending) Plan failed

Exit code 2 from terraform plan -detailed-exitcode (non-empty diff) is treated as success — it means there are changes to apply, not an error.

last-apply.json schema

Written to stateDir/last-apply.json after every apply:

{
  "timestamp": "2026-05-31T00:00:00.000000+00:00",
  "result": "ok"
}

On failure:

{
  "timestamp": "2026-05-31T00:00:00.000000+00:00",
  "result": "error",
  "error_message": "terraform apply ... failed (exit 1):\nError creating tailnet key: ..."
}

Failure handling & recovery

flowchart TD
    A[nixos-rebuild switch] --> B[Backup tfstate]
    B --> C[Generate main.tf.json]
    C --> D[terraform init]
    D --> E[terraform apply]
    E --> F{Success?}
    F -->|Yes| G[Write last-apply.json]
    F -->|No| H[Restore backup]
    H --> I[Write error to last-apply.json]
    I --> J[Exit 1 — systemd shows red]
    G --> K[Exit 0]

Key guarantees:

  • Before every mutation: tfstate is backed up to backups/<timestamp>.tfstate
  • On any failure: the most recent backup is restored, leaving state exactly as it was before the apply
  • Monitoring surface: last-apply.json is the single source of truth for the last operation's result. The TUI, status --json, and activation script all read from it.
  • Systemd visibility: non-zero exit code means systemctl status tailscale-manager shows red on failure. The error message is in the journal and last-apply.json.

Key rotation strategy

This project does not implement custom key rotation logic. Instead, it relies on a single Terraform attribute:

"recreate_if_invalid": "always"

When a key expires, Terraform detects it as "invalid" and replaces it on the next apply — deleting the old resource and creating a new one. This means:

  • No cron jobs, no expiry date tracking, no manual intervention
  • The rotation happens on the next nixos-rebuild switch or credential watcher trigger after expiry
  • The key id changes (it's a new key), so any system that consumes the key value needs to re-read it from Terraform state or the Tailscale admin console

Key defaults: reusable = true, ephemeral = false, preauthorized = true, expiry = 90 days (Tailscale default, configurable in the provider).


TUI (optional)

Install with uv add textual or enable the tui extra, then run tailscale-manager status.

┌─────────────────────────────────────────┐
│  Tailscale Manager — your-tailnet.ts.net│
├────────────────┬────────────────────────┤
│ KEY STATUS     │  SYSTEM STATUS         │
│                │                        │
│ DataTable:     │  Last apply: 2026-...  │
│  ✓ k123 — ci  │  Result: ✓ ok          │
│                │  Terraform state: found│
│                │  Credentials: found    │
│                │  Backups: 3 retained   │
│                │                        │
│                │  State dir: /var/lib/..│
│                │  Tailnet: your-tailnet │
└────────────────┴────────────────────────┘
│  Q: Quit  R: Refresh  L: View Logs      │
└─────────────────────────────────────────┘
  • Left panel: DataTable of managed auth keys from local tfstate
  • Right panel: System status (last apply, backups, credentials)
  • Footer: Q=Quit, R=Refresh (or auto-refresh every 30s), L=View Logs (tails journalctl -u tailscale-manager.service)
  • Read-only: zero write operations from the UI

Waybar / scripting integration

{
  "custom/tailscale-manager": {
    "exec": "tailscale-manager status --json",
    "return-type": "json",
    "format": "{}"
  }
}

The status --json command exits 0 on success, 1 on failure, and outputs:

{
  "last_apply": {
    "timestamp": "2026-05-31T00:00:00+00:00",
    "result": "ok"
  },
  "managed_keys": [
    {
      "id": "k123abc",
      "description": "ci runner key",
      "tags": ["tag:ci"],
      "revoked": false
    }
  ]
}

For Prometheus node_exporter textfile collector:

#!/bin/sh
# /etc/periodic/tailscale-manager-metrics
STATUS=$(tailscale-manager status --json 2>/dev/null) || STATUS='{"result":"error"}'
RESULT=$(echo "$STATUS" | jq -r '.last_apply.result // "unknown"')
COUNT=$(echo "$STATUS" | jq '.managed_keys | length')
cat > /var/lib/node_exporter/textfile/tailscale-manager.prom <<EOF
# HELP tailscale_manager_last_apply Last apply result (1=ok, 0=error)
# TYPE tailscale_manager_last_apply gauge
tailscale_manager_last_apply $([ "$RESULT" = "ok" ] && echo 1 || echo 0)
# HELP tailscale_manager_managed_keys Number of managed auth keys
# TYPE tailscale_manager_managed_keys gauge
tailscale_manager_managed_keys $COUNT
EOF

Development

# Enter the dev environment
nix develop

# Fast environment (lint/typecheck only)
nix develop .#bootstrap

# Add a dependency
nix develop .#bootstrap
uv add <package>

# Lint
ruff check src/

# Type check
mypy src/tailscale_manager/

# Test
pytest tests/unit/ -v

# Build
nix build .#default

# Full check
nix flake check

See CONTRIBUTING.md for pull request workflow.


Architecture

pyproject.toml  ──uv add/lock──►  uv.lock
                                      │
                                      ▼
flake.nix  ──workspace.mkPyprojectOverlay──►  Nix overlay
 │                                                 │
 │  pyproject-build-systems ───────────────────────┤
 │                                                 │
 └── composeManyExtensions ───────────────────────► pythonSet
                                                           │
                                               ┌───────────┼───────────────────┐
                                               ▼           ▼                   ▼
                                    nix/default.nix   nix/devshell.nix    nix/module.nix
                                    (mkApplication)   (mkShell)           (systemd service)

The project uses uv2nix to convert uv.lock into Nix package derivations. The NixOS module provides the systemd service, credential watcher, and activation hook. The Python CLI wraps the terraform binary — the Tailscale provider does all the actual API work.

Package layers (import direction rules):

src/tailscale_manager/
├── core/           imports nothing from the package
├── models/         pure data shapes
├── services/       imports models/ and repositories/
├── repositories/   data access (tfstate I/O)
├── utils/          stateless pure functions
└── cli.py          Typer entrypoint (imports services/)

Common issues

  • "tailnet-owned auth key must have tags set" — the OAuth client needs tag ownership configured. See OAuth tag ownership.
  • "requested tags are invalid or not permitted" — same cause. Add the tags to the OAuth client's tag ownership list in the admin console.
  • Provider download fails on first runterraform init needs outbound internet to registry.terraform.io. See GOTCHAS.md for airgap workarounds.
  • terraform binary not found — the module sets terraformBin to ${pkgs.terraform}/bin/terraform by default. When running the CLI outside the NixOS service, ensure terraform is in PATH or set TAILSCALE_MANAGER_TERRAFORM_BIN.

For a full list of gotchas, see GOTCHAS.md.


Related resources

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

tailscale_manager-0.3.0.tar.gz (25.8 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

tailscale_manager-0.3.0-py3-none-any.whl (25.2 kB view details)

Uploaded Python 3

File details

Details for the file tailscale_manager-0.3.0.tar.gz.

File metadata

  • Download URL: tailscale_manager-0.3.0.tar.gz
  • Upload date:
  • Size: 25.8 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: uv/0.11.16 {"installer":{"name":"uv","version":"0.11.16","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

File hashes

Hashes for tailscale_manager-0.3.0.tar.gz
Algorithm Hash digest
SHA256 9a78a9bc0118e1595b748dea20834710e6bfd242c4370d3025e3d9060846d51a
MD5 3f89c6f33df3293000f0383d694145af
BLAKE2b-256 6878cdb783eefb9ee958898dfe648e8f8a60962bc2b7bc82670730d8a07cbee5

See more details on using hashes here.

File details

Details for the file tailscale_manager-0.3.0-py3-none-any.whl.

File metadata

  • Download URL: tailscale_manager-0.3.0-py3-none-any.whl
  • Upload date:
  • Size: 25.2 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: uv/0.11.16 {"installer":{"name":"uv","version":"0.11.16","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

File hashes

Hashes for tailscale_manager-0.3.0-py3-none-any.whl
Algorithm Hash digest
SHA256 82478eac14aa71029decf9de967e2d99fd67109d2e35289a01d284217c906b1a
MD5 aa84386d00d7d6d833108c050fffdf32
BLAKE2b-256 9a2bd034a9e5c84a11721df98351f76d022837773be2f4c4965eea057337e4ed

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page