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

  1. Check the wrapped error stage: directory creation, read, write, or parse
  2. Give the headscale user write access to the key's parent directory, or pre-place a valid key with correct ownership
  3. If the existing file is corrupt, remove it and restart — a new DERP key is generated (clients must re-learn the region key)
  4. 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

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


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