juanfont/headscale · critical

ensuring private key directory: %w

Error message

ensuring private key directory: %w

What it means

Thrown by readOrCreatePrivateKey (hscontrol/app.go:958) when util.EnsureDir cannot create the directory that holds the server's noise private key file (the parent of noise.private_key_path / the private key path passed in). This runs during startup before the server accepts connections; a failure means the key directory is unwritable or uncreatable.

Source

Thrown at hscontrol/app.go:960

		MinVersion:   tls.VersionTLS12,
	}

	cert, err := tls.LoadX509KeyPair(h.cfg.TLS.CertPath, h.cfg.TLS.KeyPath)
	if err != nil {
		return nil, err
	}

	tlsConfig.Certificates[0] = cert

	return tlsConfig, nil
}

func readOrCreatePrivateKey(path string) (*key.MachinePrivate, error) {
	dir := filepath.Dir(path)

	err := util.EnsureDir(dir)
	if err != nil {
		return nil, fmt.Errorf("ensuring private key directory: %w", err)
	}

	privateKey, err := os.ReadFile(path)
	if errors.Is(err, os.ErrNotExist) {
		log.Info().Str("path", path).Msg("no private key file at path, creating...")

		machineKey := key.NewMachine()

		machineKeyStr, err := machineKey.MarshalText()
		if err != nil {
			return nil, fmt.Errorf(
				"converting private key to string for saving: %w",
				err,
			)
		}

		err = os.WriteFile(path, machineKeyStr, privateKeyFileMode)
		if err != nil {

View on GitHub (pinned to 565fd254d0)

Solutions

  1. Create and own the directory: install -d -o headscale -g headscale /var/lib/headscale (match whatever path noise.private_key_path uses).
  2. If a file blocks a directory component, inspect each level: namei -l <path> and fix the offending entry.
  3. In containers, mount a writable volume at the state directory: -v headscale-data:/var/lib/headscale.
  4. Relax or correct SELinux contexts: restorecon -Rv /var/lib/headscale.

Example fix

# before: dir created by root package install, service runs as headscale
ls -ld /var/lib/headscale   # drwx------ root root

# after
chown -R headscale:headscale /var/lib/headscale
systemctl restart headscale
Defensive patterns

Strategy: validation

Validate before calling

// Pre-flight for the private key directory.
func keyDirWritable(keyPath string) error {
    dir := filepath.Dir(keyPath)
    if err := os.MkdirAll(dir, 0o700); err != nil { return err }
    probe := filepath.Join(dir, ".probe")
    if err := os.WriteFile(probe, nil, 0o600); err != nil { return err }
    return os.Remove(probe)
}

Try / catch

if err := h.Serve(); err != nil && strings.Contains(err.Error(), "ensuring private key directory") {
    log.Fatalf("state dir unwritable: check ownership of %s", filepath.Dir(cfg.NoisePrivateKeyPath))
}

Prevention

When it happens

Trigger: The private key path's parent is root-owned and headscale runs unprivileged (EACCES on MkdirAll); a path component exists as a regular file (ENOTDIR); read-only root filesystem without a mounted data volume; SELinux/AppArmor denial on the state directory.

Common situations: Fresh installs where /var/lib/headscale was created by root during packaging but never chowned; running the binary manually as a user while systemd config expects the headscale user; containers without a persistent writable /var/lib/headscale volume.

Related errors


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