Migrating from Docker to Podman in DevContainers

You're moving off Docker Desktop to rootless Podman and want your devcontainers to come along. This page migrates in place: point the tooling at Podman, add the keep-id mapping, and handle the two rootless differences — low ports and the Compose provider — so the same .devcontainer/ runs on Podman.

The reassuring fact underneath this migration is that the DevContainer spec was never tied to Docker. It requires a Docker-compatible engine, and Podman provides exactly that — an OCI-compliant image builder and a Docker-compatible API socket — which is why the migration is overwhelmingly a matter of pointing tools at a different engine rather than rewriting your environment. Your base image, your Features, your lifecycle hooks, and your extensions all describe what the environment contains, not which engine runs it, so they carry over unchanged. What changes is the thin layer where the tooling talks to the engine, plus a small number of accommodations that follow from Podman running rootless by default.

Those accommodations are worth naming up front so they do not surprise you mid-migration. Because rootless Podman runs entirely as your unprivileged user, two things behave differently than under Docker's root daemon: file ownership on bind mounts (handled by keep-id) and the ability to bind privileged ports below 1024 (handled by using higher ports). If you use Compose, there is a third: the compose command is podman-compose or Podman's own compose provider rather than docker compose. Address these three — ownership, ports, and compose — and the migration is essentially complete, because everything else about the devcontainer is engine-agnostic. The rest of this page walks each one, but the mental model to hold is "same environment, different engine, three small rootless adjustments."

Prerequisites

You need rootless Podman installed and your existing devcontainer config. Before you touch the devcontainer, confirm Podman itself is healthy as your user — podman info should report the engine, and podman run --rm hello-world should succeed — because a migration issue is much easier to diagnose once you know the engine underneath is working. If podman info fails, resolve that first: it usually means the subuid/subgid ranges for your user are missing or the rootless socket service is not running, both of which are engine-level prerequisites independent of any devcontainer.

  • Rootless Podman working (podman info).
  • Your existing .devcontainer/ config.
  • podman-compose if you use multi-service stacks.

It also helps to have the rootless model in mind before migrating, because the adjustments make sense only in that light. Under rootless Podman there is no root daemon; everything runs as your unprivileged host user, and container UIDs map into your subordinate ID range. That single fact is the source of every difference you will encounter — ownership, ports, and networking all behave the way they do because "there is no root here." Keeping that framing in view turns the migration steps from arbitrary incantations into obvious consequences of the security model you are adopting.

Migration prerequisitesPoint the tooling at Podman, fix ownership, adjust ports, and set the Compose provider.Point toolingdockerPath=podmankeep-idownershipPorts>1024Composepodman-compose

Step-by-Step Implementation

  1. Point the tooling at Podman.
"dev.containers.dockerPath": "podman"
"dev.containers.dockerComposePath": "podman-compose"

This is the one change that actually redirects the engine. The Dev Containers extension shells out to whatever dockerPath names, so setting it to podman makes every build and run go through Podman instead of Docker. If you drive the environment from the CLI or CI instead of the editor, the equivalent is pointing DOCKER_HOST at the rootless Podman socket (typically unix://$XDG_RUNTIME_DIR/podman/podman.sock) so devcontainer up reaches Podman. Set whichever paths your workflow uses — often both — because an editor pointed at Podman but a CLI still defaulting to Docker produces a confusing half-migrated state where one works and the other does not.

  1. Add the userns mapping so file ownership stays correct.
{ "runArgs": ["--userns=keep-id"], "remoteUser": "vscode" }

This is the most important line in the migration, and its absence is a silent, delayed failure. Without keep-id, a file the container writes to your workspace lands owned by a subordinate UID on the host, so ls -l shows it owned by a number with no username and editing or deleting it from the host suddenly needs sudo. --userns=keep-id maps the container user directly to your host UID, so files stay yours. Because the config still builds and attaches without it, the problem only surfaces the first time you write a file from inside the container — which is why adding it deliberately during migration, with a comment explaining why, saves a future round of confusion.

  1. Raise any low ports above 1024.
{ "forwardPorts": [8080] }

Rootless Podman cannot bind host ports below 1024 without extra privilege, because binding privileged ports is exactly the kind of operation the rootless model withholds. If your Docker config forwarded port 80 or 443, move the service to a high port inside the container and forward that instead — the developer experience is identical, and the host's privileged-port protection stays intact. Resist the temptation to lower the host's ip_unprivileged_port_start to make a low port bind; that weakens a host-wide security boundary to solve a single container's convenience and undercuts part of why you chose rootless in the first place.

  1. Verify the same config builds and attaches on Podman.
devcontainer up --workspace-folder .

The verification is deliberately the same command you would run under Docker, because the whole point is that the environment is engine-agnostic. A successful devcontainer up on Podman, followed by writing a test file and confirming it is owned by your host user, proves both that the engine swap worked and that keep-id is doing its job. If the build succeeds but a written file is owned by a strange UID, the tooling migrated but the ownership mapping did not — revisit step 2. If the tooling still invokes docker, the path from step 1 did not take effect for the surface you are using.

Migration layersPoint the tooling at Podman, fix ownership and ports; the config is otherwise unchanged.Point at podmandockerPath/composePathkeep-idcorrect ownershipHigh portsrootless-friendlySame configotherwise unchanged

Common Pitfalls

Migration snags are ownership, low ports, or the Compose provider — and notably, they cluster in exactly the three areas the rootless model touches, which is why anticipating them makes the migration smooth. Almost every migration failure is one of these three, so when something breaks, the fastest diagnosis is to ask which of ownership, ports, or compose is involved rather than suspecting a deep incompatibility. The table below maps each symptom to its cause, and the through-line is that none of them are Podman "bugs" — they are the expected consequences of running without a root daemon, each with a clean, one-line fix.

The compose pitfall in particular catches teams who assume "Docker-compatible" extends all the way to the command line. It does not: Podman's Docker compatibility is at the engine API level, not at every tool in the Docker ecosystem. A config that shells out to docker compose will not find a daemon under rootless Podman, so multi-service stacks need the compose command routed through podman-compose (or Podman's built-in compose provider) via dockerComposePath. Anywhere your workflow invokes a Docker-family CLI by name, check it has a Podman equivalent wired up rather than assuming compatibility reaches the command line.

Migration triageA triage path from a Docker-only config to one running on rootless Podman.Does the tooling use podman now?NOSet dockerPath/composePathOwnership + ports correct?YESCompose provider setRuns on Podman

SymptomRoot CauseRemediation
Tooling still calls dockerdockerPath not setSet dev.containers.dockerPath to podman
Files root/subuid-ownedNo keep-idAdd --userns=keep-id
Low port fails to bindRootless can't bind <1024Forward a port above 1024
Compose stack failsDocker compose assumedSet dockerComposePath to podman-compose

Conclusion

Migrating to Podman is mostly configuration: point the tooling at podman, add keep-id for ownership, raise low ports, and set the Compose provider. The .devcontainer/ itself barely changes, so the same environment runs rootless with a smaller attack surface.

The strategic payoff makes the small effort worthwhile: you trade a root daemon — a standing, privileged, always-on process that has historically been a rich source of container-escape vulnerabilities — for a model where the worst a container escape can achieve is the privileges of your unprivileged user. That reduction in attack surface applies to every developer who opens the repository, and because the migration lives in the committed config, they inherit it automatically. Beyond security, moving off Docker Desktop also sidesteps its licensing terms, which is what starts many of these migrations in the first place.

Because the config stays host-agnostic, the migration need not be a hard cutover. You can keep the base image, Features, and hooks exactly as they are and let team members switch engines individually, so the transition is gradual and reversible rather than a risky all-at-once change. Once everyone is on Podman, the only trace of the migration is a handful of extra lines — the engine path, keep-id, and adjusted ports — sitting quietly in a config that otherwise looks identical to the Docker one it replaced. That near-invisibility is the sign of a clean migration: the environment your team knows is preserved intact, and only the engine and its rootless nuances have changed underneath it, leaving you with the same workflow on a safer foundation.

Change vs keepPoint the tooling at Podman and fix rootless nuances; keep the rest of the config.ChangedockerPath -> podmanAdd keep-idRaise low portsKeepBase imageFeaturesLifecycle hooks

FAQ

Will my Dockerfile and Features work on Podman? Yes — Podman builds OCI images and consumes Features the same way. The base image, Features, and lifecycle hooks are unchanged; you only adjust the engine path, add keep-id, and handle rootless ports and the Compose provider. This is because Features and images live above the engine boundary: a Feature is an install script that runs during the build regardless of which engine performs it, and an image is an OCI artifact any compliant engine can build and run. That layering is precisely what makes the migration cheap — the parts of your devcontainer that took real effort to get right (the toolchain, the setup hooks, the extensions) are exactly the parts that carry over untouched.

What breaks most often in the switch? File ownership (fixed with --userns=keep-id) and low ports (rootless can't bind below 1024). Address those two and most migrations are seamless. Multi-service stacks additionally need the compose provider pointed at podman-compose. Ownership is the one most likely to slip through, because the config builds and attaches fine without keep-id — the breakage only appears the first time a container-written file shows up host-owned by a strange UID, which can be well after the migration seemed successful. Adding keep-id deliberately as part of the switch, rather than waiting to discover its absence, is what keeps the migration truly seamless rather than seemingly-seamless-until-you-write-a-file.

Can Docker and Podman coexist during migration? Yes. You can point the tooling at either engine, so team members can migrate individually. Keep the config host-agnostic (no engine-specific paths baked in) and the same .devcontainer/ works on both while you transition. The one Podman-specific line is --userns=keep-id, which is a no-op or error on Docker, so teams supporting both often keep the shared config Docker-clean and add the userns argument through a local setting or a small overlay — so a Docker user and a Podman user consume the same .devcontainer/ with only that one line differing.

Do I need to change my Dockerfile at all? Almost never. Podman builds the same Dockerfile syntax Docker does, so FROM, RUN, COPY, and multi-stage builds all work unchanged. The rare exceptions involve instructions that assume the build runs as root in ways rootless cannot grant, but standard devcontainer Dockerfiles — installing packages, adding a non-root user, copying setup scripts — build identically. If your image was already designed to run as a non-root user (which the security guidance recommends anyway), it aligns naturally with how rootless Podman wants to operate.

What about existing Docker volumes and images — do they carry over? Podman maintains its own separate image and volume storage from Docker, so they do not automatically appear under Podman. Images are easily re-pulled or rebuilt from your pinned base and Features, which is the reproducible path anyway. Named volumes used for caches are recreated empty on first use under Podman and repopulate on the next build, so you lose a warm cache once but not any source of truth — everything the volumes held was reconstructible from the config and lockfiles to begin with.