{"record":{"id":"114e965df8e8eee6","repo":"juanfont/headscale","slug":"fatal-config-error-s","errorCode":null,"errorMessage":"Fatal config error: %s","messagePattern":"Fatal config error: (.+?)","errorType":"validation","errorClass":null,"httpStatus":null,"severity":"critical","filePath":"hscontrol/types/config.go","lineNumber":707,"sourceCode":"\n\t// Validate tuning parameters\n\tif size := viper.GetInt(\"tuning.node_store_batch_size\"); size <= 0 {\n\t\terrorText += fmt.Sprintf(\n\t\t\t\"Fatal config error: tuning.node_store_batch_size must be positive, got %d\\n\",\n\t\t\tsize,\n\t\t)\n\t}\n\n\tif timeout := viper.GetDuration(\"tuning.node_store_batch_timeout\"); timeout <= 0 {\n\t\terrorText += fmt.Sprintf(\n\t\t\t\"Fatal config error: tuning.node_store_batch_timeout must be positive, got %s\\n\",\n\t\t\ttimeout,\n\t\t)\n\t}\n\n\tif errorText != \"\" {\n\t\t// nolint\n\t\treturn errors.New(strings.TrimSuffix(errorText, \"\\n\"))\n\t}\n\n\treturn nil\n}\n\nfunc tlsConfig() TLSConfig {\n\treturn TLSConfig{\n\t\tLetsEncrypt: LetsEncryptConfig{\n\t\t\tHostname: viper.GetString(\"tls_letsencrypt_hostname\"),\n\t\t\tListen:   viper.GetString(\"tls_letsencrypt_listen\"),\n\t\t\tCacheDir: util.AbsolutePathFromConfigPath(\n\t\t\t\tviper.GetString(\"tls_letsencrypt_cache_dir\"),\n\t\t\t),\n\t\t\tChallengeType: viper.GetString(\"tls_letsencrypt_challenge_type\"),\n\t\t},\n\t\tCertPath: util.AbsolutePathFromConfigPath(\n\t\t\tviper.GetString(\"tls_cert_path\"),\n\t\t),","sourceCodeStart":689,"sourceCodeEnd":725,"githubUrl":"https://github.com/juanfont/headscale/blob/565fd254d06c4c7f9a8cad1714a43445c79ba420/hscontrol/types/config.go#L689-L725","documentation":"\"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.","triggerScenarios":"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.","commonSituations":"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.","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"],"exampleFix":"# before\ntuning:\n  node_store_batch_timeout: 0\nserver_url: headscale.example.com\n\n# after\ntuning:\n  node_store_batch_timeout: 100ms\nserver_url: https://headscale.example.com","handlingStrategy":"validation","validationCode":"// parse durations the same way the server does, before shipping config\nif d, err := time.ParseDuration(viper.GetString(\"node.ephemeral.inactivity_timeout\")); err != nil || d <= 65*time.Second {\n\treturn errors.New(\"node.ephemeral.inactivity_timeout must be > 65s and use explicit units\")\n}\nif viper.GetBool(\"dns.override_local_dns\") && len(viper.GetStringSlice(\"dns.nameservers.global\")) == 0 {\n\treturn errors.New(\"dns.nameservers.global is required when dns.override_local_dns is true\")\n}","typeGuard":null,"tryCatchPattern":"if err := types.LoadConfig(); err != nil {\n\t// err text may contain MULTIPLE 'Fatal config error:' lines — split on \"\\n\" and fix all\n\tfor _, line := range strings.Split(err.Error(), \"\\n\") {\n\t\tlog.Printf(\"config: %s\", line)\n\t}\n\tos.Exit(1)\n}","preventionTips":["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`"],"tags":["config","validation","startup","aggregated-error"],"backgroundTag":null,"analyzedSha":"565fd254d06c4c7f9a8cad1714a43445c79ba420","analyzedAt":"2026-08-15T13:12:30.133Z","schemaVersion":2},"datasetVersion":"2026-08-15T22:17:37.221Z"}