juanfont/headscale · critical

saving private key to disk at path %q: %w

Error message

saving private key to disk at path %q: %w

What it means

Returned by readOrCreatePrivateKey when os.WriteFile fails to persist a newly generated machine/noise private key at the configured path (hscontrol/app.go:976-982). The key was generated in memory but never written, so on next start a different key will be created and all nodes registered against the old identity would break — the error is fatal to startup to prevent silently rotating the server identity.

Source

Thrown at hscontrol/app.go:979

	}

	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 {
			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)
	}

View on GitHub (pinned to 565fd254d0)

Solutions

  1. Check space: df -h <key_dir> and free space or enlarge the volume.
  2. Fix write permissions: chown headscale:headscale <key_dir> && chmod 750 <key_dir>.
  3. Ensure the path is a file location, not a directory.
  4. After fixing, restart headscale; since the write failed, the key was never persisted, so no identity rotation occurred.

Example fix

# before: /var/lib/headscale owned by root, running as headscale -> EACCES

# after
chown headscale:headscale /var/lib/headscale
chmod 750 /var/lib/headscale
systemctl restart headscale
Defensive patterns

Strategy: validation

Validate before calling

// Pre-flight: key directory writable and disk not full.
func canPersistKey(keyPath string) error {
    var stat syscall.Statfs_t
    if err := syscall.Statfs(filepath.Dir(keyPath), &stat); err == nil {
        if stat.Bavail*uint64(stat.Bsize) < 1<<20 {
            return fmt.Errorf("less than 1MiB free for key storage")
        }
    }
    return keyDirWritable(keyPath)
}

Try / catch

if err := h.Serve(); err != nil {
    if errors.Is(err, syscall.ENOSPC) || errors.Is(err, syscall.EROFS) || errors.Is(err, syscall.EACCES) {
        // storage issue: fix space/permissions, then restart; key was not rotated because write failed
    }
}

Prevention

When it happens

Trigger: Disk full (ENOSPC); directory not writable by the headscale user (EACCES); read-only filesystem; path is a directory (EISDIR); quota exceeded; container with a read-only volume mounted at the key path.

Common situations: First start on a fresh install before permissions are fixed; long-running servers whose disk filled; Docker deployments missing the writable data volume; moving the config dir without moving ownership.

Related errors


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