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

  1. 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
  2. For durations always use explicit units ("60s", "1m"), never bare numbers, so Go's time.ParseDuration semantics match your intent
  3. Set dns.nameservers.global when dns.override_local_dns is true
  4. Ensure tuning.node_store_batch_size and tuning.node_store_batch_timeout are positive (defaults: 100 and a positive timeout)
  5. 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

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


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