#3443·headscale

configtest runs database migrations against the configured DB, not a pure config validator

Author: DavieyCreated Aug 30, 2026Updated Sep 1, 2026

What happened

headscale configtest is documented as "Run a test of the configuration and exit", but it constructs the full server, which opens the configured database and runs all pending migrations. We hit this operationally while staging an upgrade:

  1. Production server running X, live sqlite DB at schema X.
  2. Ran configtest with the Y (newer) binary against the same config/DB to pre-validate the config; it exited 0.
  3. Rolled back the server to X for unrelated reasons and X refused to start: schema had been forward-migrated to Y by configtest alone. (Migrations are forward-only, so rollback required a DB restore from snapshot.)

Code path (master @ cbe3030)

  • cmd/headscale/cli/configtest.go: RunE calls newHeadscaleServerWithConfig()
  • cmd/headscale/cli/utils.go:89: same constructor serve uses
  • hscontrol/app.go: NewHeadscale -> state.NewState(cfg)
  • hscontrol/state/state.go:228: hsdb.NewHeadscaleDatabase(cfg) opens the DB and runs gormigrate with the full migration list

So configtest is exactly serve minus the listen, including DB mutation. NewState also loads all nodes and marks them offline in memory; whether any of that persists depends on the gorm session, but the migrations definitely do.

Expected

Either:

  • configtest validates config (and policy/ACL syntax) without opening the database, or
  • it opens the DB read-only / skips migrations, or
  • at minimum the help text warns that it mutates the configured database.

Happy to send a PR if there's agreement on which behaviour is intended. My vote is the first: validate config + policy parsing only, no DB, since that's what the command name and help promise.