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

  1. Fix ownership/permissions: chown headscale:headscale <key_file> && chmod 600 <key_file>.
  2. If systemd races a mount, add Requires= and After= for the mount unit to headscale.service.
  3. Confirm the path is a regular file: ls -l <path>.
  4. 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

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


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