caddyserver/caddy · error

: field must be a string or number

Error message

%s: %s field must be a string or number

What it means

indexConfigObjects recursively walks the decoded JSON config and registers '@id' fields. When an '@id' value decodes to something other than string or float64 (JSON numbers decode as float64) — i.e. it was an object, array, boolean, or null — the walk aborts with '<path>: @id field must be a string or number', naming the exact config path of the offending node. Surfaced through 'indexing config' during config changes.

Solutions

  1. Go to the config path in the error message and change that '@id' to a plain unique string.
  2. Grep your JSON for '"@id"' followed by {, [, true, false, or null.
  3. If the ID must carry structure, flatten it into the string (e.g. "group:item").

Example fix

// before
"handle": { "@id": { "app": "foo" } }

// after
"handle": { "@id": "foo" }
Defensive patterns

Strategy: type-guard

Type guard

func validIDType(v any) bool {
    switch v.(type) {
    case string, float64:
        return true
    }
    return false
}

Prevention

When it happens

Trigger: JSON config containing "@id": {"name":"x"}, "@id": [1], "@id": true, or "@id": null at any depth; PUT/PATCH to /config/ that writes such a value; template engines rendering @id as a nested structure.

Common situations: Hand-edited JSON configs; YAML->JSON converters emitting booleans for flags mistakenly placed under @id; copy-paste where an @id block was pasted inside another @id.

Related errors


AI-assisted analysis of caddyserver/caddy@50e54ee279 (2026-08-15). Data as JSON: /api/errors/ed3f637094cce07f. Report an issue: GitHub.

Appendix: source

Thrown at caddy.go:302

}

// indexConfigObjects recursively searches ptr for object fields named
// "@id" and maps that ID value to the full configPath in the index.
// This function is NOT safe for concurrent access; obtain a write lock
// on currentCtxMu.
func indexConfigObjects(ptr any, configPath string, index map[string]string) error {
	switch val := ptr.(type) {
	case map[string]any:
		for k, v := range val {
			if k == idKey {
				var idStr string
				switch idVal := v.(type) {
				case string:
					idStr = idVal
				case float64: // all JSON numbers decode as float64
					idStr = fmt.Sprintf("%v", idVal)
				default:
					return fmt.Errorf("%s: %s field must be a string or number", configPath, idKey)
				}
				if existingPath, ok := index[idStr]; ok {
					return fmt.Errorf("duplicate ID '%s' found at %s and %s", idStr, existingPath, configPath)
				}
				index[idStr] = configPath
				continue
			}
			// traverse this object property recursively
			err := indexConfigObjects(val[k], path.Join(configPath, k), index)
			if err != nil {
				return err
			}
		}
	case []any:
		// traverse each element of the array recursively
		for i := range val {
			err := indexConfigObjects(val[i], path.Join(configPath, strconv.Itoa(i)), index)
			if err != nil {

View on GitHub (pinned to 50e54ee279)