juanfont/headscale · critical
reading private key file: %w
Error message
reading private key file: %w
What it means
Returned by readOrCreatePrivateKey when os.ReadFile on the private key path fails with an error other than os.ErrNotExist (hscontrol/app.go:988). The ErrNotExist branch creates a new key; any other read error — permission denied, path is a directory, I/O error — is surfaced here. It means a key file is present (or the path is broken) but cannot be read.
Source
Thrown at hscontrol/app.go:988
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 {
return nil, fmt.Errorf(
"saving private key to disk at path %q: %w",
path,
err,
)
}
return &machineKey, nil
} else if err != nil {
return nil, fmt.Errorf("reading private key file: %w", err)
}
trimmedPrivateKey := strings.TrimSpace(string(privateKey))
var machineKey key.MachinePrivate
if err = machineKey.UnmarshalText([]byte(trimmedPrivateKey)); err != nil { //nolint:noinlineerr
return nil, fmt.Errorf("parsing private key: %w", err)
}
return &machineKey, nil
}
// Change is used to send changes to nodes.
// All change should be enqueued here and empty will be automatically
// ignored.
func (h *Headscale) Change(cs ...change.Change) {
h.mapBatcher.AddWork(cs...)
}View on GitHub (pinned to 565fd254d0)
Solutions
- Fix ownership/permissions: chown headscale:headscale <key_file> && chmod 600 <key_file>.
- If systemd races a mount, add Requires= and After= for the mount unit to headscale.service.
- Confirm the path is a regular file: ls -l <path>.
- If ownership cannot be fixed, run headscale as the user that owns the key file (matching the packaging defaults).
Example fix
# before: key file owned by root, service runs as headscale -rw------- root root /var/lib/headscale/noise_private.key # after chown headscale:headscale /var/lib/headscale/noise_private.key chmod 600 /var/lib/headscale/noise_private.key
Defensive patterns
Strategy: validation
Validate before calling
// Pre-flight: key file (if present) must be readable.
func keyFileReadable(path string) error {
f, err := os.Open(path)
if err != nil { return err }
return f.Close()
} Try / catch
if err := h.Serve(); err != nil {
var pe *os.PathError
if errors.As(err, &pe) && errors.Is(pe.Err, syscall.EACCES) {
// ownership mismatch: chown the key file to the service user and restart
}
} Prevention
- Never run headscale under two different users against the same data dir.
- After restores or secrets-manager deployments, verify ownership and mode 0600 on the key file.
- Order systemd units after mounts holding the key file (Requires=/After=).
When it happens
Trigger: The key file exists but is mode 0600 owned by root while headscale runs as another user (EACCES); the configured path points at a directory (EISDIR); the file is on a failing disk or an unavailable mount at boot time (systemd starting before the mount).
Common situations: Running the binary manually as root once (key written as root), then via systemd as headscale; mounts not ready before the service starts (missing Requires/After on the mount unit); restoring backups with wrong ownership.
Related errors
- reading or creating Noise protocol private key: %w
- reading or creating DERP server private key: %w
- ensuring private key directory: %w
- saving private key to disk at path %q: %w
- creating directory failed with permission error
AI-assisted analysis of juanfont/headscale@565fd254d0 (2026-08-15).
Data as JSON: /api/errors/a3fdb243f5fef4cb.
Report an issue: GitHub.