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
- Run a YAML/TOML linter on the config file; the wrapped error gives the exact line/column
- Compare against config-example.yaml shipped in the repo for structural reference
- Check file permissions: the headscale process must be able to read the path given to --config
- 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
- Lint config YAML/TOML in CI on every change
- Use config-example.yaml as the structural baseline
- After secret-templating, validate the rendered file, not the template
- Remember: a MISSING config is fine (defaults); a MALFORMED one is fatal
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
- configuration error: %w
- errOIDCIssuerInvalid
- unmarshalling dns extra records: %w
- STUN address not set
- initial DERPMap is empty, Headscale requires at least one en
AI-assisted analysis of juanfont/headscale@565fd254d0 (2026-08-15).
Data as JSON: /api/errors/512c3c8f713082e3.
Report an issue: GitHub.