juanfont/headscale · critical

renaming forced_tags to tags: %w

Error message

renaming forced_tags to tags: %w

What it means

Migration '202511131445-node-forced-tags-to-tags' fails when GORM Migrator().RenameColumn cannot rename nodes.forced_tags to nodes.tags. Typical causes: the forced_tags column does not exist (schema already has tags, e.g. from a newer AutoMigrate run or a partially applied upgrade), the DB lacks ALTER rights, or the table is locked. Unlike the AddColumn guards above, RenameColumn here has no HasColumn check and is not idempotent.

Source

Thrown at hscontrol/db/db.go:591

						err := tx.Exec(createSQL).Error
						if err != nil {
							return fmt.Errorf("creating index: %w", err)
						}
					}

					return nil
				},
				Rollback: func(db *gorm.DB) error { return nil },
			},
			{
				// Rename forced_tags column to tags in nodes table.
				// This must run after migration 202505141324 which creates tables with forced_tags.
				ID: "202511131445-node-forced-tags-to-tags",
				Migrate: func(tx *gorm.DB) error {
					// Rename the column from forced_tags to tags
					err := tx.Migrator().RenameColumn(&types.Node{}, "forced_tags", "tags")
					if err != nil {
						return fmt.Errorf("renaming forced_tags to tags: %w", err)
					}

					return nil
				},
				Rollback: func(db *gorm.DB) error { return nil },
			},
			{
				// Migrate RequestTags from host_info JSON to tags column.
				// In 0.27.x, tags from --advertise-tags (ValidTags) were stored only in
				// host_info.RequestTags, not in the tags column (formerly forced_tags).
				// This migration validates RequestTags against the policy's tagOwners
				// and merges validated tags into the tags column.
				// Fixes: https://github.com/juanfont/headscale/issues/3006
				ID: "202601121700-migrate-hostinfo-request-tags",
				Migrate: func(tx *gorm.DB) error {
					// 1. Load policy from file or database based on configuration
					policyData, err := PolicyBytes(tx, cfg)
					if err != nil {

View on GitHub (pinned to 565fd254d0)

Solutions

  1. Verify the actual schema: \d nodes (Postgres) or PRAGMA table_info(nodes) (SQLite) - if 'tags' exists and 'forced_tags' does not, the rename is already done; repair the migrations bookkeeping or pre-rename manually so the call succeeds
  2. If both columns exist, copy data: UPDATE nodes SET tags = forced_tags; then drop forced_tags, and restart
  3. Check the wrapped error for permissions/locks (grant ALTER, stop other instances)
  4. Never downgrade headscale across this boundary - restore from a backup taken before the version jump if state is inconsistent

Example fix

-- before: migrations table expects rename but schema already has tags
ALTER TABLE nodes RENAME COLUMN forced_tags TO tags; -- fails: no such column
-- after: reconcile schema with history, or complete the rename manually
UPDATE nodes SET tags = forced_tags WHERE tags IS NULL;
ALTER TABLE nodes DROP COLUMN forced_tags;
Defensive patterns

Strategy: type-guard

Validate before calling

// Verify column state before upgrading across this boundary
cols := map[string]bool{}
rows, _ := db.Query("PRAGMA table_info(nodes)") // or information_schema.columns on Postgres
for rows.Next() {
	var cid int; var name, typ string; var notnull, pk int; var dflt any
	rows.Scan(&cid, &name, &typ, &notnull, &dflt, &pk)
	cols[name] = true
}
if cols["forced_tags"] && cols["tags"] {
	log.Fatal("both forced_tags and tags exist; reconcile manually before upgrading")
}
if !cols["forced_tags"] && !cols["tags"] {
	log.Fatal("nodes has neither forced_tags nor tags; schema/history mismatch - restore from backup")
}

Type guard

// Go helper to assert the rename is possible
func columnExists(db *sql.DB, table, column string) bool {
	var n int
	db.QueryRow(`SELECT count(*) FROM pragma_table_info(?)`, table).Scan(&n) // SQLite
	return n > 0
}
// guard: columnExists(db, "nodes", "forced_tags") must be true before this migration runs

Prevention

When it happens

Trigger: Starting a headscale binary whose embedded schema (AutoMigrate-created nodes.tags) raced ahead of the migration history - for example restoring a migrations table from a newer backup onto an older schema, or running headscale versions out of order (downgrade then upgrade). Also ALTER privilege missing or SQLite lock held by another process.

Common situations: Rolling back to an old headscale version and then jumping forward again, leaving columns renamed but migrations unrecorded; restoring a database dump where the migrations table and schema disagree; two instances of different versions sharing one DB.

Related errors


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