juanfont/headscale · critical
Fatal config error: %s
Error message
Fatal config error: %s
What it means
"Fatal config error: %s" is the aggregated validation error built up in hscontrol/types/config.go (line 707 returns errors.New(strings.TrimSuffix(errorText, "\n"))). Every failed check in LoadConfig's validation pass — server_url scheme, ephemeral inactivity timeout, dns.override_local_dns without global nameservers, HA probe_interval/probe_timeout ordering, and tuning.node_store_batch_size / tuning.node_store_batch_timeout positivity — appends one 'Fatal config error: ...' line to errorText. The returned error therefore may describe several problems at once, newline-separated.
Source
Thrown at hscontrol/types/config.go:707
// Validate tuning parameters
if size := viper.GetInt("tuning.node_store_batch_size"); size <= 0 {
errorText += fmt.Sprintf(
"Fatal config error: tuning.node_store_batch_size must be positive, got %d\n",
size,
)
}
if timeout := viper.GetDuration("tuning.node_store_batch_timeout"); timeout <= 0 {
errorText += fmt.Sprintf(
"Fatal config error: tuning.node_store_batch_timeout must be positive, got %s\n",
timeout,
)
}
if errorText != "" {
// nolint
return errors.New(strings.TrimSuffix(errorText, "\n"))
}
return nil
}
func tlsConfig() TLSConfig {
return TLSConfig{
LetsEncrypt: LetsEncryptConfig{
Hostname: viper.GetString("tls_letsencrypt_hostname"),
Listen: viper.GetString("tls_letsencrypt_listen"),
CacheDir: util.AbsolutePathFromConfigPath(
viper.GetString("tls_letsencrypt_cache_dir"),
),
ChallengeType: viper.GetString("tls_letsencrypt_challenge_type"),
},
CertPath: util.AbsolutePathFromConfigPath(
viper.GetString("tls_cert_path"),
),View on GitHub (pinned to 565fd254d0)
Solutions
- Read every 'Fatal config error:' line in the output — fix them together, not one at a time, since re-running validation re-reports the rest
- For durations always use explicit units ("60s", "1m"), never bare numbers, so Go's time.ParseDuration semantics match your intent
- Set dns.nameservers.global when dns.override_local_dns is true
- Ensure tuning.node_store_batch_size and tuning.node_store_batch_timeout are positive (defaults: 100 and a positive timeout)
- Verify with `headscale config verify` before deploying
Example fix
# before tuning: node_store_batch_timeout: 0 server_url: headscale.example.com # after tuning: node_store_batch_timeout: 100ms server_url: https://headscale.example.com
Defensive patterns
Strategy: validation
Validate before calling
// parse durations the same way the server does, before shipping config
if d, err := time.ParseDuration(viper.GetString("node.ephemeral.inactivity_timeout")); err != nil || d <= 65*time.Second {
return errors.New("node.ephemeral.inactivity_timeout must be > 65s and use explicit units")
}
if viper.GetBool("dns.override_local_dns") && len(viper.GetStringSlice("dns.nameservers.global")) == 0 {
return errors.New("dns.nameservers.global is required when dns.override_local_dns is true")
} Try / catch
if err := types.LoadConfig(); err != nil {
// err text may contain MULTIPLE 'Fatal config error:' lines — split on "\n" and fix all
for _, line := range strings.Split(err.Error(), "\n") {
log.Printf("config: %s", line)
}
os.Exit(1)
} Prevention
- Always suffix durations with units (s/m) — bare numbers parse as nanoseconds
- Parse the aggregated error's newline-separated lines instead of fixing one per restart
- Gate deploys on `headscale config verify`
When it happens
Trigger: Any combination of: server_url not starting with http:// or https://; node.ephemeral.inactivity_timeout <= 65s; dns.override_local_dns: true with empty dns.nameservers.global; node.routes.ha.probe_interval < 2s, probe_timeout < 1s, or probe_timeout >= probe_interval; tuning.node_store_batch_size <= 0; tuning.node_store_batch_timeout <= 0 (0 when unset). Starting the server or running config validation then returns the aggregated error.
Common situations: Freshly edited config.yaml with a typo in a duration ("60" parsed as 60ns instead of 60s); enabling dns.override_local_dns without providing global nameservers; copying tuning defaults from an old config where the keys did not exist; misordered HA probe values.
Related errors
- no IPv4 or IPv6 prefix configured, minimum one prefix is req
- errOIDCIssuerInvalid
- STUN address not set
- initial DERPMap is empty, Headscale requires at least one en
- database type not supported
AI-assisted analysis of juanfont/headscale@565fd254d0 (2026-08-15).
Data as JSON: /api/errors/114e965df8e8eee6.
Report an issue: GitHub.