caddyserver/caddy · error
duplicate ID '%s' found at %s and %s
Error message
duplicate ID '%s' found at %s and %s
What it means
indexConfigObjects builds a global map of '@id' value -> config path so the admin API can route /id/<id>/... requests. If the same '@id' string (after number-to-string normalization) appears at two different paths, the second occurrence fails with "duplicate ID '<id>' found at <path1> and <path2>", rejecting the config change. IDs must be unique across the entire config, not just within a scope.
Source
Thrown at caddy.go:305
// "@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 {
return err
}
}View on GitHub (pinned to 50e54ee279)
Solutions
- Rename one of the duplicates — the error gives both paths.
- In generators, derive @id from the loop variable (e.g. "@id": "route-" + name).
- Watch for number/string normalization: 1 and "1" are the same ID.
- Remove @id fields that are not used for /id/ addressing.
Example fix
// before
{"@id":"upstream", "handle":[{"@id":"upstream"}]}
// after
{"@id":"upstream", "handle":[{"@id":"upstream-0"}]} Defensive patterns
Strategy: validation
Validate before calling
ids := map[string]string{}
var walk func(n any, p string) error
walk = func(n any, p string) error {
if m, ok := n.(map[string]any); ok {
if raw, ok := m["@id"]; ok {
key := fmt.Sprint(raw) // numbers normalize like Caddy does
if prev, dup := ids[key]; dup {
return fmt.Errorf("duplicate @id %q at %s and %s", key, prev, p)
}
ids[key] = p
}
for k, v := range m { if err := walk(v, p+"/"+k); err != nil { return err } }
}
if a, ok := n.([]any); ok { for i, e := range a { if err := walk(e, fmt.Sprintf("%s/%d", p, i)); err != nil { return err } } }
return nil
}
err := walk(decodedCfg, "") Prevention
- Derive @ids from unique keys when generating configs.
- Remember 1 and "1" collide after normalization.
- Only add @id where you actually use /id/ addressing.
When it happens
Trigger: Two objects anywhere in the JSON config sharing "@id": "my-id"; array items templated with a constant @id; merging two config fragments that each define "@id": "apps"; numeric 1 and string "1" collide because numbers are normalized via %v.
Common situations: Config generators looping over sites without varying the ID; manual copy-paste of route blocks; config merging tools; subtle collisions like "@id": 0 vs "@id": "0".
Related errors
- indexing config: %v
- %s: %s field must be a string or number
- parsing listener address: %v
- must be exactly one listener address; cannot listen on: %s
- %v; additionally, restoring old config: %v
AI-assisted analysis of caddyserver/caddy@50e54ee279 (2026-08-15).
Data as JSON: /api/errors/dfc2655a0ffc98f9.
Report an issue: GitHub.