Tech Expert & Vibe Coder

With 15+ years of experience, I specialize in self-hosting, AI automation, and Vibe Coding – building applications using AI-powered tools like Google Antigravity, Dyad, and Cline. From homelabs to enterprise solutions.

Implementing Custom Encryption Layers for Tailscale State Files After Default Encryption Removal

Why I Built Custom Encryption for Tailscale State Files

I run Tailscale on everything—Proxmox hosts, Docker containers, my Synology NAS, and a handful of Linux VMs scattered across my homelab. It’s the backbone of how I access systems remotely without exposing anything to the public internet.

When Tailscale 1.86 introduced built-in state file encryption, I was relieved. But then I hit a problem: I have older ARM devices and some custom Linux builds where the TPM-based encryption didn’t work reliably. The state files were sitting there unencrypted, which made me uncomfortable.

I needed a solution that worked consistently across all my systems, regardless of hardware support. So I built my own encryption layer on top of Tailscale’s state management.

What’s Actually in These State Files

Before encrypting anything, I wanted to understand what I was protecting. I opened /var/lib/tailscale/tailscaled.state on one of my VMs and found:

  • WireGuard private keys for each tailnet connection
  • Machine authentication keys
  • Client settings (exit nodes, DNS overrides, etc.)
  • Tailnet Lock keys if enabled

The private keys are what matter. If someone copies that file to another machine, they can impersonate my node. The coordination server sees it as the same device, just on a different network.

This isn’t hypothetical. I’ve seen this happen in my own testing when cloning VMs without clearing Tailscale state first.

My Setup: What I Actually Run

I have about 15 nodes across my network:

  • 3 Proxmox hosts running Debian
  • 8 Docker containers (Alpine and Ubuntu base images)
  • 2 Raspberry Pi 4s (one with a TPM hat, one without)
  • 1 Synology DS920+ (no TPM)
  • 1 old Odroid HC2 (ARM, definitely no TPM)

Tailscale’s built-in encryption works on the Proxmox hosts and the Pi with the TPM hat. Everything else either doesn’t support it or fails silently, leaving state files unencrypted.

The Approach I Took

I didn’t want to reinvent cryptography or mess with TPM APIs directly. My goal was simple: wrap Tailscale’s state file with an additional encryption layer that works everywhere, even on devices without hardware security modules.

I chose age (the file encryption tool) for three reasons:

  1. Single static binary, easy to deploy
  2. Simple passphrase-based encryption
  3. Works identically on any Linux system

The trade-off is obvious: I’m protecting the state file with a passphrase stored elsewhere on the same system. This doesn’t stop an attacker with root access who’s actively running code. But it does stop simple file exfiltration—someone grabbing backups, copying files during a brief compromise, or pulling data from a cloned disk.

Implementation Details

I wrote a bash wrapper script that intercepts Tailscale’s state file operations:

  1. When tailscaled starts, the script decrypts the state file to a tmpfs mount
  2. Tailscaled runs normally, reading and writing to the decrypted file
  3. On shutdown or every 5 minutes (via cron), the script re-encrypts the state file
  4. The decrypted version is immediately wiped from tmpfs

The passphrase lives in /root/.tailscale_key, which is only readable by root. Not perfect, but better than nothing.

Here’s the core of the encryption wrapper:

#!/bin/bash
STATE_FILE="/var/lib/tailscale/tailscaled.state"
ENCRYPTED_FILE="${STATE_FILE}.age"
KEY_FILE="/root/.tailscale_key"
TMPFS_PATH="/run/tailscale"

# Ensure tmpfs mount exists
if ! mountpoint -q "$TMPFS_PATH"; then
    mkdir -p "$TMPFS_PATH"
    mount -t tmpfs -o size=10M,mode=0700 tmpfs "$TMPFS_PATH"
fi

# Decrypt on start
if [ -f "$ENCRYPTED_FILE" ]; then
    age --decrypt -i "$KEY_FILE" "$ENCRYPTED_FILE" > "$TMPFS_PATH/tailscaled.state"
    ln -sf "$TMPFS_PATH/tailscaled.state" "$STATE_FILE"
fi

# Re-encrypt on shutdown (called by systemd or cron)
encrypt_state() {
    if [ -f "$TMPFS_PATH/tailscaled.state" ]; then
        age --encrypt -p  "$ENCRYPTED_FILE"
        shred -u "$TMPFS_PATH/tailscaled.state"
    fi
}

trap encrypt_state EXIT

I modified the systemd service file to call this script instead of running tailscaled directly. The ExecStop directive ensures re-encryption happens on service shutdown.

What Worked

This setup has been running for about two months now across all my non-TPM systems. A few things went better than expected:

  • Zero performance impact. State file access is infrequent enough that the decrypt/encrypt overhead is invisible.
  • Survives reboots cleanly. The systemd integration handles startup and shutdown without manual intervention.
  • Works on ARM devices. The Odroid and one of the Pis have no issues with age’s ARM builds.
  • Protects against casual file copying. I tested this by copying the encrypted state file to another VM—it’s useless without the key file.

The tmpfs mount is key here. Decrypted state never touches disk, only RAM. When the system shuts down, that data disappears.

What Didn’t Work

I ran into a few problems that required adjustments:

Initial key generation was manual. I had to SSH into each system and create the passphrase file by hand. I tried automating this with Ansible, but storing the passphrase in the playbook felt worse than the original problem. I ended up generating unique keys per device and storing them in my password manager.

Cron-based re-encryption is clunky. I wanted continuous encryption, but polling every minute felt excessive. Every 5 minutes is a compromise—there’s a window where the state file exists unencrypted in tmpfs. An inotify-based solution would be better, but I haven’t built that yet.

No protection against active compromise. If someone has root and can execute code, they can read the key file, decrypt the state, and do whatever they want. This solution only helps if an attacker grabs files and leaves.

Docker containers need volume mounts. Containers don’t have systemd, so I had to modify the entrypoint script and mount the tmpfs path as a volume. This works, but it’s another layer of complexity.

Why I’m Not Using Tailscale’s Built-In Encryption Everywhere

On systems where TPM encryption works, I use it. It’s better than my wrapper—hardware-backed, no passphrases, and fully integrated.

But on older hardware, custom builds, and ARM devices, TPM support is inconsistent or missing entirely. I needed something that works everywhere without requiring hardware upgrades.

My wrapper isn’t as secure as TPM-based encryption. But it’s significantly better than unencrypted state files, and it works on every system I run.

Key Takeaways

  • Tailscale’s state files contain private keys that can be used to impersonate your nodes. Protecting them matters.
  • TPM-based encryption is great when available, but many systems don’t support it.
  • A passphrase-encrypted wrapper with tmpfs storage is a practical middle ground for older hardware.
  • This approach stops file exfiltration, but not active root-level compromise.
  • Systemd integration is critical for handling encryption/decryption during service lifecycle events.

If you’re running Tailscale on a mix of old and new hardware, consider adding an encryption layer where the built-in option isn’t available. It’s not perfect, but it’s better than leaving state files exposed.

Leave a Comment

Your email address will not be published. Required fields are marked *