Using podman-compose in a DevContainer

You want a Compose-style multi-service devcontainer but you're on Podman, not Docker. This page wires the devcontainer to podman-compose (or Podman's Docker-compatible Compose provider), so your app and its backing services run rootless with the same networking and health-gating you'd get on Docker.

This matters because the devcontainer tooling was written against a Docker daemon that listens on a socket and speaks the Docker API. Podman has no long-running daemon by default and runs each container in your own user namespace, yet the Dev Containers extension still tries to invoke a docker compose binary to bring the stack up. Left unconfigured it either finds nothing or shells out to a real Docker install you did not intend to use. The fix is not to rewrite your docker-compose.yml; it is to redirect the one call the tooling makes so it lands on podman-compose (or the podman compose provider that ships with newer Podman), which reads the exact same Compose file and produces the same set of services, networks, and volumes without ever asking for root.

Reach for this pattern whenever you already model your dev environment as several cooperating services — an app plus a Postgres, a Redis, a message broker — and you want that whole topology reproduced rootless on a workstation or CI runner where a Docker daemon is unavailable or unwanted. The mental model to hold is that Compose is a description and the provider is an interpreter: the description (services, networks, depends_on, healthcheck) is portable across engines, and only the interpreter underneath changes from docker compose to podman-compose. Once you internalise that split, most "it works on Docker but not Podman" reports resolve into one of three concrete gaps — the wrong provider path, services left off a shared network, or a rootless port restriction — each of which this page addresses directly.

Prerequisites

You need rootless Podman and podman-compose (or the compose provider) installed.

  • Rootless Podman working.
  • podman-compose installed, or Podman's Compose provider enabled.
  • A Compose file and a devcontainer that names its service.

podman-compose prerequisitesYou need podman-compose, the Compose path set, a shared network, and a workspace service.podman-composeinstalleddockerComposePathset to itBridge netsharedAttachworkspace service

Rootless Podman working means a bit more than podman --version succeeding. Confirm that podman info reports a runRoot under your user's /run/user/$UID and that podman run --rm alpine true completes without a sudo prompt; if either fails you likely have a leftover subuid or cgroups issue that must be sorted before Compose has any chance of coming up. The podman-compose package is a separate Python tool and is versioned independently of the engine, so pin a known-good release rather than pulling whatever the distribution feed offers on the day you build the image. If you instead rely on the built-in provider, verify it with podman compose version (note the space, not a hyphen) — that subcommand only exists on recent Podman and it delegates to an external Compose implementation it discovers on PATH.

The one detail people get wrong here is assuming that "a Compose file and a devcontainer that names its service" is automatically satisfied. The service key in devcontainer.json must match a service defined in the Compose file exactly, character for character, because that is the container the editor attaches to and runs its lifecycle commands inside. A mismatch does not produce a helpful error; the attach silently fails or the tooling brings up a stack you never see into. Decide up front which service is your workspace, name it consistently, and keep that name identical across dockerComposeFile, the service field, and the networks list you write later.

Step-by-Step Implementation

  1. Point the tooling at podman-compose.
{
  "dockerComposeFile": "docker-compose.yml",
  "service": "app",
  "workspaceFolder": "/workspace",
  "remoteUser": "vscode"
}

This devcontainer.json is engine-agnostic on purpose. dockerComposeFile names the Compose file the tooling hands to the provider, service selects which of that file's services becomes your workspace, workspaceFolder tells the editor where your bind-mounted source lives inside that container, and remoteUser is the account commands run as. Nothing here mentions Podman, and that is the point — you keep the same descriptor you would use on Docker so the project stays portable. The failure this prevents is coupling your project configuration to one engine: if you hard-coded engine paths in devcontainer.json itself, a teammate on Docker could no longer open the same repository, so the engine choice belongs in editor settings instead of the checked-in descriptor.

  1. Set the editor's compose path to podman-compose.
"dev.containers.dockerComposePath": "podman-compose"

This is the single redirect that makes the whole scheme work. dev.containers.dockerComposePath is a user or workspace setting, not part of devcontainer.json, so it lives where engine choice belongs — on the machine, not in the shared repository. When the extension needs to run up, down, or exec, it invokes the binary named here instead of the default docker compose, and because podman-compose accepts the same subcommands and reads the same Compose file, the substitution is transparent. If you use the provider variant, set this to a wrapper that runs podman compose so the argument shape stays intact. The failure mode this heads off is the tooling silently falling back to a Docker install that happens to be on PATH, which would bring your stack up as root under a daemon you were specifically trying to avoid.

  1. Share a network so services resolve by name.
services:
  app: { networks: [devnet] }
  db: { image: postgres:16-alpine, networks: [devnet] }
networks: { devnet: {} }

Declaring devnet explicitly and attaching both app and db to it is what makes db resolve as a hostname from inside app. On a user-defined bridge, the engine runs an internal DNS resolver that maps each service name to its container address; the default network you get by omission does not give you that name resolution in the same reliable way, which is why services that "can't find each other" almost always turn out to be sitting on separate or default networks. Podman implements this with its Netavark backend and honours the same Compose semantics, so the same block that works on Docker works here. Keeping the network definition minimal — an empty devnet: {} — is deliberate: you want name resolution, not custom subnets or driver options that add rootless edge cases you would then have to debug.

  1. Verify the app reaches the service by name.
devcontainer exec --workspace-folder . -- getent hosts db

getent hosts db is a deliberately low-level probe: it asks the container's own name-service stack to resolve db and prints the address it gets back, which isolates the networking question from anything your application code might be doing. Running it through devcontainer exec --workspace-folder . guarantees the lookup happens inside the workspace service exactly as your app will experience it, rather than from your host where the name would never resolve. If this returns an address, name resolution is working and any remaining connection failure is a port, credential, or readiness problem rather than a network one; if it returns nothing, you have confirmed the services are not sharing a bridge and can go straight back to step three.

podman-compose modelpodman-compose brings up services rootless on a shared network the workspace attaches to.podman-composebrings services up rootlessShared bridgename resolutionHealth checksgate startup orderAttach pointworkspace service

Common Pitfalls

Podman Compose issues are a wrong provider path or missing shared network.

The ownership angle is the one that catches people who came from Docker and never had to think about it. Under rootless Podman your UID inside the container is mapped through subuid ranges, so a named volume that a service like Postgres writes to may end up owned by a high, unexpected UID on the host, and files your workspace bind-mount creates may appear as root or nobody from the other side. The Compose file itself is not where you fix this; you either run the workspace with Podman's --userns=keep-id behaviour so your host UID maps straight through, or you accept the remapping and make sure the service's data directory is initialised with the right ownership before it is used. The db volume in a postgres:16-alpine service is the usual place this surfaces, because Postgres refuses to start on a data directory it does not own.

The other pitfall worth expanding is the rootless low-port restriction. An unprivileged user cannot bind ports below 1024, so a Compose file that publishes 80:80 or 443:443 — perfectly fine under a root Docker daemon — fails outright under rootless Podman with a permission error that looks unrelated to privileges. Publish on a high host port such as 8080:80 and reach the service there, or, if you genuinely need the low port, lower net.ipv4.ip_unprivileged_port_start deliberately rather than reaching for sudo. This only bites on published ports that cross the host boundary; container-to-container traffic on the shared bridge is unaffected, which is why an app can talk to db internally while still failing to expose itself to your browser.

Compose triageA triage path from a failing rootless stack to a working one.Does the stack come up with podman?NOSet the compose provider pathDo services resolve by name?YESAll on one shared bridgeRootless multi-service works

SymptomRoot CauseRemediation
Compose commands failDocker compose path assumedSet dockerComposePath to podman-compose
Services can't resolve namesNot on a shared networkPut services on one bridge network
Ports not reachableRootless low-port limitUse ports above 1024
App starts before DBNo health gateAdd healthcheck + depends_on

Conclusion

A multi-service devcontainer runs fine on Podman: point the tooling at podman-compose, put services on a shared bridge so names resolve, and gate startup with health checks. The Compose model is identical — only the provider underneath changes.

The strategic payoff is that keeping the Compose file engine-agnostic and pushing the provider choice into editor settings means one repository opens cleanly for a teammate on Docker and for you on rootless Podman, with no per-engine forks of the descriptor. That is the same reproducibility discipline you apply when you pin image tags like postgres:16-alpine rather than tracking latest: you fix the parts that must not drift and isolate the parts that are allowed to vary. Here the Compose file is the pinned, shared artifact and dockerComposePath is the local variable, so the environment stays deterministic even though the engine under it differs from machine to machine.

Ties this back to the broader pin-and-cache and rootless themes running through these pages. The shared bridge is what makes service names a stable contract your code can depend on; the health checks are what make startup ordering deterministic instead of a race; and the keep-id ownership handling is what keeps a cached named volume usable across rebuilds instead of tripping a permission error each time you tear the stack down and bring it back. Treat those three — provider path, shared network, and ownership — as the checklist you run whenever a rootless Compose stack misbehaves, and most incidents collapse into a single, already-solved cause.

Same vs differentThe Compose model is unchanged; only the provider and rootless caveats differ.Same as DockerCompose fileService networkingHealth checksPodman-specificCompose provider pathRootless portskeep-id ownership

FAQ

Can I use a docker-compose.yml unchanged with Podman? Mostly yes. Point the tooling at podman-compose (or Podman's Docker-compatible Compose provider) and the same Compose file brings the stack up rootless. Watch for rootless port and ownership differences, but the service and network definitions carry over. The two edits you are most likely to make are moving any published port below 1024 up above it and adding a --userns=keep-id behaviour for the workspace so bind-mounted files keep your ownership. Neither touches the structure of the file, so a Docker teammate can still open the same repository without seeing your changes.

Why can't my services find each other? They need to share a user-defined bridge network for name resolution, exactly as on Docker. Put every service that must communicate on one network and address peers by service name. If a lookup still fails, run getent hosts <service> from inside the workspace to confirm whether the name resolves at all; a blank result points squarely at a missing networks entry rather than an application bug. Remember that the default network Compose creates when you omit networks does not give you the same dependable DNS, which is why an explicit devnet is worth the two extra lines.

Do health checks work the same? Yes — declare healthcheck on services and gate the workspace with depends_on: condition: service_healthy. Podman honours the same Compose health semantics, so startup ordering behaves as it does on Docker. This matters most for backing stores like Postgres that accept a socket connection before they are actually ready to serve queries; without the health gate your app races the database and fails its first connection on a cold start. Write the check against something that proves readiness, such as pg_isready, rather than a bare process check that passes too early.

Should I use podman-compose or the built-in podman compose provider? Both read the same Compose file, so the choice is mostly about what is packaged for your platform. The standalone podman-compose is a Python tool you version independently and it has the widest feature coverage today; the podman compose subcommand is thinner and simply delegates to an external Compose implementation it finds on PATH. Pick one, pin it, and point dockerComposePath at it consistently so every machine brings the stack up the same way.

How do I tear the stack down cleanly? Use the same Compose lifecycle you would on Docker — podman-compose down removes the containers and the shared bridge, and adding -v also drops the named volumes. Be deliberate about -v on a database service, because that discards the data directory whose ownership you carefully set up; omit it when you want the cached volume to survive into the next up. The Dev Containers extension issues these commands for you when you rebuild or close the folder, which is another reason dockerComposePath must point at the right provider.