中文 English

The Portainer 500 Error That Wasn't the YAML — A Two-librespeed_default-Network Story

Published: 2026-06-13
Docker Docker Compose Portainer Networking Containers DevOps Troubleshooting

The short version

You update a stack in Portainer. The browser slaps you with a red 500 Internal Server Error. You assume the YAML is wrong, fix the indentation, swap the quotes, drop the image tag. You hit Update again. Same 500. And again. And again.

The real culprit is buried at the deepest level of the HTTP response body: network librespeed_default is ambiguous (2 matches found on name). Two networks with the same name exist in the same Docker engine—two IDs, two Created timestamps, two com.docker.compose.config-hash labels, but a single shared name. Compose asks the engine to look up that name; the engine refuses to pick between them; compose up fails; Portainer wraps the error as a 500 and returns it to your browser.

The fix is embarrassingly simple: delete one of the two orphan networks (docker network rm <id> or click Remove in Portainer’s Networks page), then re-run Update the stack with the exact same content. It just works.

This post is a real debugging session: a stack update that was supposed to be a 30-second mount-path change turned into a 1-hour investigation. All private details (internal addresses, registry URLs, volume paths, container names, credentials) have been replaced with <PLACEHOLDER>. Only public source code, official docs, and the error text itself are preserved.

The uncanny valley: two librespeed_default networks at once

Figure 1: The author’s hero image. Portainer returns 500. The real story is that the engine has two networks sharing a single name.

1. Background: A “Harmless” Stack Update

The setup: a 24/7 home NAS, Portainer managing a dozen stacks, one of which is librespeed — the lightweight LinuxServer speedtest image. Nothing exotic.

The original compose file (anonymized):

version: '3'
services:
  librespeed:
    image: <REGISTRY>/linuxserver/librespeed:5.4.1
    container_name: librespeed
    restart: always
    environment:
      - PUID=1000
      - PGID=1000
      - TZ=Asia/Shanghai
    ports:
      - "8888:80"
    volumes:
      - /volume2/docker/Librespeed/config:/config
    stdin_open: true
    tty: true

The day came to move the config directory to a proper data volume, so I changed the mount path from /volume2/... to /volume1/... and dropped the image tag so it would track latest.

In Portainer UI:

  1. Go to Stacks → select librespeed → click Editor.
  2. Paste the updated compose text.
  3. Click Update the stack.
  4. Browser immediately pops up a red 500 Internal Server Error.

Wait 30 seconds, refresh, click again. Still 500.

First instinct: “Did I typo the YAML?” So I copied the text out, ran yamllint, ran docker compose config — both passed. Reverted the changes. Still 500. Which is absurd.

2. Symptoms: Portainer 500, Container Nowhere to Be Found

From the user-facing perspective:

Looking at the popup alone gives you no way forward. Open DevTools → Network → find that PUT request → look at the Response Body. You’ll see a JSON like this:

{
  "message": "failed to deploy a stack: compose up operation failed: Error response from daemon: network librespeed_default is ambiguous (2 matches found on name)",
  "details": "Failed to deploy a stack: compose up operation failed: Error response from daemon: network librespeed_default is ambiguous (2 matches found on name)"
}

That string is the entire smoking gun.

Figure 2 maps the call chain from your browser button down to dockerd:

End-to-end call chain for a stack update

Figure 2: The five-layer call chain. The error bubbles up from dockerd; Portainer merely wraps it as a 500.

A few subtle things to note:

3. First Round: Was It the YAML?

Before any other investigation, rule out “you wrote it wrong.” Otherwise, every conclusion later is suspect.

3.1 Static validation with docker compose config

Save the Portainer text locally as docker-compose.yml:

docker compose config --quiet
# (no output = static syntax is fine)

--quiet mode only prints errors. No output means YAML parsing, field names, version field, and image resolution are all OK.

3.2 Expand it with docker compose config (no --quiet)

docker compose config
# outputs the fully-expanded YAML: ports, volumes, env, etc.

The output matches expectations: ports resolves to 8888:80, volumes is in host:container form, no warnings.

3.3 Check the response code in Portainer DevTools

PUT /api/stacks/<stack_id>?endpointId=<endpoint_id>  HTTP/1.1 500

Status is 500, but the body has the full error. Portainer’s 500 doesn’t mean “I crashed” — it means “the engine threw an error, I wrapped it as 500 for you.”

Conclusion: YAML is not the problem. The error comes from somewhere in the orchestration chain.

4. The Pivot: That One “Ambiguous” Sentence

Look at the JSON body again. The error substance is:

network librespeed_default is ambiguous (2 matches found on name)

This sentence is brutally direct: there are 2 networks named librespeed_default in the engine; the engine can’t pick between them when compose asks “give me that one.”

But the first time you see it, you have questions:

To answer these, you have to look at the network list on the engine.

5. Root Cause: Two librespeed_default Networks at Once

In Portainer’s Networks page, librespeed_default appears twice:

NETWORK ID NAME DRIVER SCOPE CONTAINERS
a1b2... librespeed_default bridge local {}
7z8y... librespeed_default bridge local {}

Same NAME, different NETWORK ID. Open their labels and you see:

"com.docker.compose.project": "librespeed",
"com.docker.compose.network": "default",
"com.docker.compose.config-hash": "aacc11d868e9..."

Two crucial observations:

  1. Containers: {} is empty. Neither network has a container attached. They’re orphans.
  2. Both have the same config-hash. They were created from the same compose file in a tiny time window.

Now the Created timestamps:

a1b2...  Created: 2026-06-13T06:55:33.119070909+08:00
7z8y...  Created: 2026-06-13T06:55:33.119233258+08:00

Difference: 0.000162 seconds. This is almost certainly the same compose up call: the engine created two networks back-to-back, the second one didn’t get cleaned up, and the next compose up tried to look up the name and hit the ambiguous check.

Figure 3 reproduces the docker network ls output as it appeared on the engine:

docker network ls — the two ghosts side by side

Figure 3: The two “orphan” networks. Same NAME, same Driver, same project label, same config-hash, but different IDs.

At this point, the root cause is clear: the Docker engine holds two networks sharing a single name; docker compose asks the engine to resolve the name; the engine refuses to pick, so compose up fails.

6. Why Does the Engine Say “ambiguous”?

To understand fully, open the moby source code (the open-source implementation of the Docker engine). In daemon/network.go, look at FindNetwork():

// FindNetwork returns a network based on:
// 1. Full ID
// 2. Full Name
// 3. Partial ID
// as long as there is no ambiguity
func (daemon *Daemon) FindNetwork(term string) (*libnetwork.Network, error) {
    var listByFullName, listByPartialID []*libnetwork.Network
    for _, nw := range daemon.getAllNetworks() {
        nwID := nw.ID()
        if nwID == term {
            return nw, nil
        }
        if strings.HasPrefix(nw.ID(), term) {
            listByPartialID = append(listByPartialID, nw)
        }
        if nw.Name() == term {
            listByFullName = append(listByFullName, nw)
        }
    }
    switch {
    case len(listByFullName) == 1:
        return listByFullName[0], nil
    case len(listByFullName) > 1:                     // ← we hit this branch
        return nil, errdefs.InvalidParameter(
            fmt.Errorf("network %s is ambiguous (%d matches found on name)",
                term, len(listByFullName)))
    case len(listByPartialID) == 1:
        return listByPartialID[0], nil
    case len(listByPartialID) > 1:
        return nil, errdefs.InvalidParameter(
            fmt.Errorf("network %s is ambiguous (%d matches found based on ID prefix)",
                term, len(listByPartialID)))
    }
    return nil, errdefs.NotFound(libnetwork.ErrNoSuchNetwork(term))
}

The lookup order is:

  1. Full ID match — return immediately if found.
  2. Full NAME match — collect everything where nw.Name() == term. If exactly one, return it.
  3. If ≥ 2 share the name, immediately error with is ambiguous (N matches found on name), never pick.
  4. Otherwise fall back to ID-prefix match; same ambiguity check applies.

The design is sensible: instead of letting the engine pick wrong, refuse and force the caller to clean up. The trouble is, the caller (docker compose) can’t self-heal once it sees this error.

7. What Is Compose Doing About It?

In pkg/compose/create.go, find resolveOrCreateNetwork():

// First, try to find a unique network matching by name or ID
res, err := s.apiClient().NetworkInspect(ctx, n.Name, ...)
if err == nil {
    inspect := res.Network
    if inspect.Name == n.Name || inspect.ID == n.Name {
        // Found, return inspect.ID
        ...
    }
}
// ignore other errors. Typically, an ambiguous request by name
// results in some generic `invalidParameter` error

// Either not found, or name is ambiguous - use NetworkList to list by name
nwList, err := s.apiClient().NetworkList(ctx, client.NetworkListOptions{
    Filters: make(client.Filters).Add("name", n.Name),
})
...

The comment is explicit: “name ambiguous” returns invalidParameter; compose sees it, gives up on lookup, and falls back to NetworkList.

But NetworkList uses substring match (not exact match). The result still has 2 networks. Compose then filters them client-side:

networks := slices.DeleteFunc(nwList.Items, func(net network.Summary) bool {
    return net.Name != n.Name
})

It removes anything where net.Name != n.Name. Logically, only exact matches should remain. But “exact match” still produces 2 results, so the function continues: it can’t find “the unique network with the correct project label,” or it finds 2 and doesn’t know which to use.

Either way, compose gives up on cleanup and propagates the engine’s ambiguous error verbatim. Portainer wraps it as 500.

Figure 4 puts the two source-code paths side by side:

Source-code walkthrough: who throws “is ambiguous”

Figure 4: moby’s FindNetwork() and compose’s resolveOrCreateNetwork(). The engine throws the error but only reports; compose wants to catch it but can’t.

8. Postmortem: Where Do Orphan Networks Come From?

Once you know the root cause, the next natural question is: how did the orphans get there?

Looking at the code, when compose up runs it calls resolveOrCreateNetwork; if not found, it calls NetworkCreate. When the old network has a mismatched config-hash, it first calls removeDivergedNetwork() to delete the old one, then NetworkCreate() to build the new.

The most common causes of “twin” networks:

8.1 Startup Interrupted by SIGKILL

compose up is killed in the middle of network creation (OOM, Portainer retry, host reboot). The “delete old network” step never runs, but the new network has already been created. Next up finds 2 networks, neither has containers — both are orphans.

8.2 Concurrent compose up for the Same Project

If two compose up processes run at once (Portainer auto-retry + manual CLI, two replicas of a CI job, etc.), they both call NetworkCreate. NetworkCreate is supposed to return Conflict for an existing name, but in the very short “neither has created it yet” window, both succeed and you get two networks.

8.3 Repeated Retries After Portainer 500

This is the most common and most infuriating scenario:

  1. User clicks Update the stack in Portainer. Engine says ambiguous.
  2. Portainer returns 500.
  3. User immediately clicks again (and again, 5 or 6 times). Each click sends the same compose.
  4. Each click triggers another resolveOrCreateNetwork → fails. Failure does not clean up any existing orphan.
  5. The orphan list keeps growing while the user keeps thinking the YAML is wrong.

8.4 Failed compose down

If compose down fails at the network-removal step (because the network still has lingering references, or because of an interrupted transaction), the next compose up sees “the old one is still there.” Combined with retry and orphaned-when-no-container cases, you get accumulation.

In my case, it was a combination of 8.1 + 8.3: the first mount-path update was interrupted and left an orphan; I then repeatedly clicked Update in Portainer, each click reproducing the 500, and nothing ever cleaned up the orphan.

9. The Fix: Delete the Orphan, Then Retry

Three steps: locate, delete, retry.

9.1 Locate the orphans

# List all networks whose project label is librespeed
docker network ls --filter label=com.docker.compose.project=librespeed

# Look at each one's Labels and Containers
docker network inspect <id1> <id2>

Key signs of an “orphan”:

9.2 Delete one

# CLI
docker network rm <orphan_id>

# Or in Portainer UI
# Networks → select librespeed_default (the one with a different ID) → Remove

Never delete both at once. Delete one, verify the effect, then delete the other if needed.

9.3 Retry the Portainer stack update

Go back to the stack in Portainer, click Update the stack, with the same compose content. If all goes well, the response is HTTP/1.1 200 OK, the container starts automatically, port 8888 is bound.

$ curl -I http://<NAS_HOST>:8888/
HTTP/1.1 200 OK
Server: nginx
Content-Length: 10252

Figure 5 shows the full repair path:

The fix path: delete the orphan and retry

Figure 5: Locate orphan → delete → retry Portainer update. Three steps and you’re done.

10. Prevention: Four Reusable Techniques

Orphan networks are an “undefined behavior” of the compose/engine interaction. You can’t fully prevent them, but you can make them very easy to discover.

10.1 docker network prune

docker network prune
# or force-skip confirmation
docker network prune -f

network prune defaults to skipping the three system networks (bridge, host, none) and removes any custom network that has no container reference. Our orphan networks have no container reference, so prune will clean them up.

Add filters for more precision:

# only remove orphans older than 24h
docker network prune --filter "until=24h"

# only remove orphans with a specific label
docker network prune --filter "label=com.docker.compose.project=oldproject"

Caveat: if a stack has pre-created its network before bringing up containers (e.g. in a debug phase), prune will delete that too.

10.2 docker compose --remove-orphans

docker compose up --remove-orphans removes containers that the compose file doesn’t declare but that share the stack’s label. It manages containers, not networks. Still, enabling it reduces the “half-finished stack” residue.

10.3 COMPOSE_REMOVE_ORPHANS environment variable

export COMPOSE_REMOVE_ORPHANS=1
docker compose up -d

Equivalent to --remove-orphans. This env var also works for Portainer, as long as you set it in the stack’s environment.

10.4 Periodic inspection script

#!/bin/bash
# Find orphan networks under any compose project label
docker network ls --quiet --filter label=com.docker.compose.project \
  | while read net_id; do
      containers=$(docker network inspect "$net_id" \
                   --format '{{ len .Containers }}')
      if [ "$containers" = "0" ]; then
          echo "Orphan network: $net_id"
          docker network rm "$net_id"
      fi
  done

Run it via cron daily, or wire it into Prometheus alerting: alert when docker_network_orphans_total > 0.

10.5 Don’t Trust Portainer’s Top-Bar Stats

Portainer’s top bar often shows “all stacks healthy” — but it checks container counts, not network state. Orphan networks are not part of its health check. docker network ls is the only source of truth.

Figure 6 reproduces what the UI actually shows:

Portainer stack editor and 500 popup

Figure 6: The 500 popup as it flashes on the Portainer UI. The real diagnostic info is hidden in Details or the DevTools Response Body.

11. Q&A

Q1: docker network ls shows two librespeed_default, but docker compose down says “network not found.” Why?

compose down filters networks by project label AND network label, only matching networks it “owns.” An orphan network’s labels might be slightly off (e.g. missing com.docker.compose.network=default), so down skips it. In other words, compose down does not clean up orphan networks — it only cleans up the resources it claims.

Q2: Is the 500 error Portainer’s bug?

No. Portainer’s 500 means “I called the layer below you, the layer below told me it failed, I wrapped that error as 500 and returned it to you.” The actual cause of the 500 is the engine’s is ambiguous error. Fix the engine layer, and Portainer naturally returns 200.

Q3: Will deleting an orphan network lose data?

No. A network is just a subnet definition plus a bridge; it doesn’t store business data. Business data lives on the mounted volumes, which are independent of networks. As an extra safety check, look at Containers: {} — an empty container map means the network carried no workload, so deletion has zero side effects.

Q4: docker compose config passes; why is it still 500?

config only tells you the compose text is syntactically legal. It can’t tell you whether the runtime environment has conflicts: duplicate network names, port collisions, unreachable images, missing volume paths. YAML validation ≠ successful deployment.

Q5: Can I completely avoid “ghost” networks?

Completely avoiding them is hard, because there’s a tiny time window between NetworkCreate and NetworkRemove in the engine. You can reduce the chance by:

  1. Running docker network prune -f before each deploy.
  2. Adding --remove-orphans to up.
  3. Writing CI/periodic scripts that detect orphans.
  4. Don’t spam-click Update in Portainer — each retry is another compose up that makes things worse.

Q6: Both duplicate networks have containers attached. Can I delete them?

Not directly — docker network rm will say “has active endpoints.” You need to:

  1. Run docker compose down first to stop the containers.
  2. Or use docker network disconnect <net> <container> to detach.
  3. Then delete the network.

Q7: Beyond librespeed_default, what other networks can collide?

Any compose project has a <project>_default. Common collision scenarios:

Q8: Does Portainer 6.x still have this problem?

The engine layer hasn’t changed; is ambiguous is still thrown by dockerd. Portainer 6.x still wraps it as 500. This is docker engine behavior, not a Portainer bug.

Q9: Is there a safer alternative to docker network rm?

For an orphan (no containers), rm is safe. If you’re paranoid, you can docker network disconnect every endpoint first, then rm. Prune is also safer in that it won’t touch any network that still has a reference.

Q10: Can iptables show the network conflict?

No. iptables shows network rules, not network objects. To detect name collisions, you must use docker network ls + docker network inspect. The namespace table lives inside dockerd.

12. Postmortem: From 500 to Root Cause

The most valuable lesson from this debugging session is: I spent the first 30 minutes hunting the YAML, because 500 usually means “you wrote it wrong.” But Portainer 500 almost never comes from its own code — it’s just a shell.

If I had opened DevTools first and read the full 4-line JSON response, “is ambiguous (2 matches found on name)” would have immediately redirected my attention from the compose text to the engine’s network table. The next 90% of the time was spent confirming the cause and writing the fix script, not finding the problem.

The lesson is one sentence: When you see 500, the first thing to do is read the entire response body. Don’t trust the popup, don’t trust “try again,” don’t trust the green checkmark in Portainer’s top bar. The error is always in the longest string.

Don’t test non-interactive behavior in an interactive terminal. It sounds unrelated to today’s bug, but the logic is the same: what you can see as “normal” is often not what the engine is actually doing.

References

  1. moby source: daemon/network.go FindNetwork() — where “is ambiguous (N matches found on name)” is thrown: https://github.com/moby/moby/blob/master/daemon/network.go
  2. docker compose source: pkg/compose/create.go resolveOrCreateNetwork() — “Either not found, or name is ambiguous” comment: https://github.com/docker/compose/blob/main/pkg/compose/create.go
  3. docker compose source: pkg/compose/create.go removeDivergedNetwork() — config-hash mismatch handling: https://github.com/docker/compose/blob/main/pkg/compose/create.go
  4. docker compose source: pkg/compose/down.go removeNetwork()compose down network cleanup logic: https://github.com/docker/compose/blob/main/pkg/compose/down.go
  5. compose-spec: 06-networks.mddefault network and name field: https://github.com/compose-spec/compose-spec/blob/main/06-networks.md
  6. compose-spec: pkg/api/labels.gocom.docker.compose.project and com.docker.compose.config-hash constants: https://github.com/docker/compose/blob/main/pkg/api/labels.go
  7. Docker official docs: docker network prune: https://docs.docker.com/reference/cli/docker/network/prune/
  8. Docker official docs: Networking overview: https://docs.docker.com/engine/network/
  9. Docker official docs: Compose networking: https://docs.docker.com/compose/networking/
  10. Portainer docs: Stacks and stack update: https://docs.portainer.io/user/docker/stacks