juanfont/headscale · critical

fatal error reading config file: %w

Error message

fatal error reading config file: %w

What it means

viper.ReadInConfig failed with an error other than ConfigFileNotFoundError (which is tolerated with a warning and defaults). This means the config file was found but could not be read or parsed: malformed YAML/TOML syntax, unreadable file permissions, or an I/O error. Wrapped as a fatal configuration error at startup.

Source

Thrown at hscontrol/types/config.go:503

	viper.SetDefault("node.routes.ha.probe_interval", "10s")
	viper.SetDefault("node.routes.ha.probe_timeout", "5s")

	viper.SetDefault("tuning.notifier_send_timeout", "800ms")
	viper.SetDefault("tuning.batch_change_delay", "800ms")
	viper.SetDefault("tuning.node_mapsession_buffered_chan_size", 30)
	viper.SetDefault("tuning.node_store_batch_size", defaultNodeStoreBatchSize)
	viper.SetDefault("tuning.node_store_batch_timeout", "500ms")

	viper.SetDefault("prefixes.allocation", string(IPAllocationStrategySequential))

	err := viper.ReadInConfig()
	if err != nil {
		if _, ok := errors.AsType[viper.ConfigFileNotFoundError](err); ok {
			log.Warn().Msg("no config file found, using defaults")
			return nil
		}

		return fmt.Errorf("fatal error reading config file: %w", err)
	}

	return nil
}

// resolveEphemeralInactivityTimeout resolves the ephemeral inactivity timeout
// from config, supporting both the new key (node.ephemeral.inactivity_timeout)
// and the old key (ephemeral_node_inactivity_timeout) for backwards compatibility.
//
// We cannot use viper.RegisterAlias here because aliases silently ignore
// config values set under the alias name. If a user writes the new key in
// their config file, RegisterAlias redirects reads to the old key (which
// has no config value), returning only the default and discarding the
// user's setting.
func resolveEphemeralInactivityTimeout() time.Duration {
	// New key takes precedence if explicitly set in config.
	if viper.IsSet("node.ephemeral.inactivity_timeout") &&
		viper.GetString("node.ephemeral.inactivity_timeout") != "" {

View on GitHub (pinned to 565fd254d0)

Solutions

  1. Run a YAML/TOML linter on the config file; the wrapped error gives the exact line/column
  2. Compare against config-example.yaml shipped in the repo for structural reference
  3. Check file permissions: the headscale process must be able to read the path given to --config
  4. If a templating system writes the file, verify the rendered output, not the template

Example fix

# before (invalid: tab indent, unquoted yes-ish token)
odesc: my tailnet
	server_url: https://hs.example.com

# after
derp:
  server:
    enabled: false
server_url: https://hs.example.com
Defensive patterns

Strategy: validation

Validate before calling

// CI check before deploy:
import yaml, sys
cfg = yaml.safe_load(open(sys.argv[1]))  # raises on syntax error
print("ok")

Prevention

When it happens

Trigger: Starting headscale with a config.yaml containing invalid YAML (tabs, unclosed quotes, bad indentation), a TOML file with syntax errors, or a file the process lacks read permission on. Only the not-found case is soft; everything else is fatal.

Common situations: Hand-edited config with a stray tab or unquoted special character; config mounted read-only with wrong ownership in Docker; secrets-injection tooling wrote a truncated file; switching config formats (yaml→toml) leaving mixed syntax.

Related errors


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