juanfont/headscale · critical
reading or creating DERP server private key: %w
Error message
reading or creating DERP server private key: %w
What it means
readOrCreatePrivateKey failed for the embedded DERP server key (same function as the Noise key, hscontrol/app.go:955, invoked when derp.server_enabled is true). It ensures the key directory, reads server_private_key_path — generating and writing a fresh key if absent — and parses existing content. Failure means unwritable directory, unreadable file, write failure for a new key, or invalid key content at that path.
Source
Thrown at hscontrol/app.go:231
// and dns.Config.Clone in tailscale drop map entries whose
// value is nil (see tailscale.com/tailcfg/tailcfg_clone.go and
// tailscale.com/net/dns/dns_clone.go: `if sv == nil { continue }`).
// Sending nil here caused the client's wgengine LinkChange:major
// handler to clobber /etc/resolv.conf on every tunnel-IP rebind
// — the handler reapplies a Clone of lastDNSConfig and the magic
// DNS routes vanish, taking the resolver with them for ~6 min
// until the next route-changing netmap. Empty slice survives
// Clone and carries the same "resolve locally" semantics
// (tailscale.com/ipn/ipnlocal/node_backend.go:869 documents the
// empty-resolver Routes form for Issue 2706).
app.cfg.TailcfgDNSConfig.Routes[d.WithoutTrailingDot()] = []*dnstype.Resolver{}
}
}
if cfg.DERP.ServerEnabled {
derpServerKey, err := readOrCreatePrivateKey(cfg.DERP.ServerPrivateKeyPath)
if err != nil {
return nil, fmt.Errorf("reading or creating DERP server private key: %w", err)
}
if derpServerKey.Equal(*noisePrivateKey) {
return nil, fmt.Errorf(
"DERP server private key and noise private key are the same: %w",
err,
)
}
if cfg.DERP.ServerVerifyClients {
t := http.DefaultTransport.(*http.Transport) //nolint:forcetypeassert
t.RegisterProtocol(
derpServer.DerpVerifyScheme,
derpServer.NewDERPVerifyTransport(app.handleVerifyRequest),
)
}
embeddedDERPServer, err := derpServer.NewDERPServer(View on GitHub (pinned to 565fd254d0)
Solutions
- Check the wrapped error stage: directory creation, read, write, or parse
- Give the headscale user write access to the key's parent directory, or pre-place a valid key with correct ownership
- If the existing file is corrupt, remove it and restart — a new DERP key is generated (clients must re-learn the region key)
- Never reuse the noise key file path for the DERP key (see the separate identical-keys error)
Defensive patterns
Strategy: validation
Validate before calling
if cfg.DERP.ServerEnabled {
p := cfg.DERP.ServerPrivateKeyPath
if p == cfg.NoisePrivateKeyPath {
return errors.New("DERP key path must differ from noise key path")
}
if err := util.EnsureDir(filepath.Dir(p)); err != nil { return err }
} Try / catch
if _, err := readOrCreatePrivateKey(cfg.DERP.ServerPrivateKeyPath); err != nil {
if errors.Is(err, os.ErrPermission) {
// grant write access to the DERP key directory, then restart
}
// corrupt existing file: remove to regenerate (clients re-learn region key)
} Prevention
- Give derp.server_private_key_path its own file location from day one
- Pre-create writable directories for both keys on locked-down hosts
- Never copy the noise key file to the DERP path when cloning configs
When it happens
Trigger: derp.server_private_key_path pointing at a read-only volume, a directory the headscale user cannot create, or an existing file that is not a valid machine private key (corrupt, empty, hand-edited).
Common situations: Enabling the embedded DERP server for the first time on a locked-down filesystem; sharing/mounting a config volume that already contains a placeholder or truncated key file; SELinux/AppArmor denying writes to the DERP key location.
Related errors
- reading or creating Noise protocol private key: %w
- ensuring private key directory: %w
- saving private key to disk at path %q: %w
- reading private key file: %w
- creating directory failed with permission error
AI-assisted analysis of juanfont/headscale@565fd254d0 (2026-08-15).
Data as JSON: /api/errors/0dfee573fb10b79e.
Report an issue: GitHub.