How to Generate Persistent WireGuard Keys for Tailcat
Tailcat generates persistent WireGuard identities by bundling a node's private key with DERP region information into a PrivateKey struct, serializing it to JSON, and storing it in the user's config directory for reuse across program restarts.
Tailcat, the peer-to-peer connectivity tool from the tailscale/tailcat repository, relies on persistent WireGuard keys to maintain stable node identities. Unlike ephemeral keys that change on every restart, persistent keys allow firewall rules and ACLs to remain valid across server reboots and application restarts.
Understanding Tailcat's Key Architecture
At the core of Tailcat's identity system is the PrivateKey struct defined in tailcat.go. This structure combines a WireGuard private key with connection metadata required for DERP (Designated Encrypted Relay for Packets) region announcement.
The PrivateKey Structure
The PrivateKey type encapsulates a key.NodePrivate alongside a ConnInfo field that stores DERP region details. The constructor tailcat.NewPrivateKey (source) generates a fresh cryptographic key pair while leaving the region configuration flexible for later assignment.
// Simplified representation of the key generation
pk := tailcat.NewPrivateKey()
// pk now contains a new WireGuard key pair and empty connection info
Storage Location and Format
Persistent keys are serialized as JSON and written to disk according to the XDG Base Directory specification. The keyPath helper in cmd/tailcat/tailcat.go (source) resolves the storage path to:
$XDG_CONFIG_HOME/tailcat/keys/<name>.private.json
If XDG_CONFIG_HOME is unset, Tailcat defaults to ~/.config/tailcat/keys/. The JSON format preserves both the private key material and the public connection string, enabling the same node identity to be reloaded on subsequent invocations.
Generating Keys via the CLI
The tailcat genkey command provides a complete interface for creating and managing persistent keys. The implementation in cmd/tailcat/tailcat.go (source) handles key generation, region selection, and file I/O.
Command Flags
--key=<name>: Specifies the filename for the key (stored as<name>.private.json).--client: Generates a client-only key without DERP region assignment, printing the public key for server allow-lists.--region=<id|code|substring>: Pins a specific DERP region (orautofor dynamic selection).--fixed-region: Resolves the nearest region immediately and bakes it into the key, skipping future latency probes.--embed-derp-map: Includes DERP map nodes directly in the key for faster bootstrapping.--force: Overwrites existing key files without prompting.--delete: Removes a previously saved key file.--list: Displays all saved key names in the config directory.
Server Key Generation
To create a persistent server key with automatic region selection:
tailcat genkey --key=my-server
This writes to $XDG_CONFIG_HOME/tailcat/keys/my-server.private.json and prints the derived public key for peer configuration.
Client Key Generation
For client-only nodes that connect to an existing server:
tailcat genkey --client
This creates client-default.private.json and outputs the public key to stdout, which you can paste into server --allow lists.
Programmatic Key Generation in Go
For tools that embed Tailcat functionality, generate keys programmatically using the same primitives as the CLI.
Creating and Saving a Key
The following example generates a key, assigns a specific DERP region, and persists it to disk:
import (
"encoding/json"
"os"
"path/filepath"
"github.com/tailscale/tailcat"
)
func makePersistentKey(name string, regionID int) error {
// Generate fresh WireGuard identity
pk := tailcat.NewPrivateKey()
// Assign DERP region (e.g., 1 for US-West)
pk.Public.RegionID = regionID
// Serialize to JSON
data, err := json.MarshalIndent(pk, "", "\t")
if err != nil {
return err
}
// Ensure directory exists with restricted permissions
cfgDir, _ := os.UserConfigDir()
path := filepath.Join(cfgDir, "tailcat", "keys", name+".private.json")
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
return err
}
// Write with 0600 permissions (owner read/write only)
return os.WriteFile(path, data, 0600)
}
Loading a Persisted Key
To reuse an existing identity across application restarts:
func loadPersistentKey(name string) (*tailcat.PrivateKey, error) {
cfgDir, err := os.UserConfigDir()
if err != nil {
return nil, err
}
path := filepath.Join(cfgDir, "tailcat", "keys", name+".private.json")
raw, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var pk tailcat.PrivateKey
if err := json.Unmarshal(raw, &pk); err != nil {
return nil, err
}
return &pk, nil
}
Key Persistence and Reuse
Because Tailcat stores the complete PrivateKey struct—including the key.NodePrivate material—on disk, the same node public key is presented to peers every time the process starts. This persistence mechanism is critical for maintaining stable peer relationships: servers can maintain consistent --allow lists, and clients avoid triggering new handshake requirements on every connection attempt.
The JSON storage format also separates configuration from code, allowing operators to ship pre-generated keys to new instances or back up identity files for disaster recovery.
Summary
- Persistent storage: Tailcat saves WireGuard keys to
$XDG_CONFIG_HOME/tailcat/keys/<name>.private.jsonas JSON. - Core API: Use
tailcat.NewPrivateKey()intailcat.goto generate fresh cryptograhic identities. - CLI workflow: The
tailcat genkeycommand handles generation, region pinning, and file persistence automatically. - Security: Key files are created with
0600permissions and stored outside the repository in the user's config directory. - Stability: Persistent keys enable stable ACLs and peer allow-lists across application restarts.
Frequently Asked Questions
Where are Tailcat WireGuard keys stored on disk?
Tailcat stores persistent keys in JSON format under $XDG_CONFIG_HOME/tailcat/keys/<name>.private.json. If the XDG environment variable is unset, the fallback location is ~/.config/tailcat/keys/. Each key file contains the private key material and connection metadata required to reestablish the same node identity.
How do I regenerate or rotate an existing Tailcat key?
Use the --force flag with the genkey command to overwrite an existing key file. For example: tailcat genkey --key=my-server --force. To completely remove a key from disk, use tailcat genkey --key=<name> --delete.
What is the difference between client and server keys in Tailcat?
Server keys include DERP region information in their ConnInfo field, allowing the node to announce itself as a relay endpoint. Client keys, generated with the --client flag, omit this region data and are designed solely for initiating outbound connections to servers. Client keys print their public key to stdout for easy addition to server allow-lists.
Can I use the same Tailcat key file on multiple machines?
No. Each Tailcat key represents a unique WireGuard node identity. Reusing the same private.json file on multiple machines would cause key collisions and routing conflicts. Generate distinct keys for each node using tailcat genkey --key=<unique-name> and authorize each public key independently on your servers.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →