juanfont/headscale · critical

DERP server private key and noise private key are the same:

Error message

DERP server private key and noise private key are the same: %w

What it means

Startup guard rejecting the configuration where the embedded DERP server key and the Noise protocol key are literally the same key (derpServerKey.Equal(*noisePrivateKey)). Reusing one machine key for both protocols is unsafe because key compromise or usage contexts collide. Note a code defect in the source: this fmt.Errorf wraps `err`, which is nil at this point (the read succeeded), so the rendered message ends with '%!w(<nil>)' instead of a cause — the check itself is still a hard startup failure.

Source

Thrown at hscontrol/app.go:235

			// 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(
			cfg.ServerURL,
			key.NodePrivate(*derpServerKey),
			&cfg.DERP,
		)

View on GitHub (pinned to 565fd254d0)

Solutions

  1. Point derp.server_private_key_path at a different, non-existent path — headscale will generate a fresh DERP key on startup
  2. Or pre-generate a second, independent key file for the DERP server
  3. Remove any manual copy of the noise key at the DERP path and restart
  4. Code-level: replace the nil-err wrap with a sentinel error so the message renders correctly

Example fix

# before (config.yaml)
noise_private_key_path: /var/lib/headscale/noise_private_key
derp:
  server_enabled: true
  server_private_key_path: /var/lib/headscale/noise_private_key  # same file -> error

# after
noise_private_key_path: /var/lib/headscale/noise_private_key
derp:
  server_enabled: true
  server_private_key_path: /var/lib/headscale/derp_private_key  # auto-generated
Defensive patterns

Strategy: validation

Validate before calling

if cfg.DERP.ServerPrivateKeyPath == cfg.NoisePrivateKeyPath {
    return errors.New("derp.server_private_key_path must differ from noise_private_key_path")
}
// stronger: compare file contents, since identical copies also trigger the guard
if b1, e1 := os.ReadFile(cfg.DERP.ServerPrivateKeyPath); e1 == nil {
    if b2, e2 := os.ReadFile(cfg.NoisePrivateKeyPath); e2 == nil && bytes.Equal(b1, b2) {
        return errors.New("DERP key and noise key are identical; generate a separate DERP key")
    }
}

Prevention

When it happens

Trigger: Setting derp.server_private_key_path to the same file as noise_private_key_path in the config; the DERP key file being a copy of the noise key file; both paths defaulting to one shared mounted secret.

Common situations: Ops configuring a single generic 'headscale key' secret mounted at both paths; copying an existing config and changing only one section; Docker/K8s setups mounting one key volume at both locations.

Related errors


AI-assisted analysis of juanfont/headscale@565fd254d0 (2026-08-15). Data as JSON: /api/errors/e4feb2326d401ddd. Report an issue: GitHub.