Podman & Rootless Container Engines

Docker Desktop's licensing and its root daemon push many teams toward Podman and rootless engines — and the DevContainer spec supports them, because it only needs a Docker-compatible engine, not Docker specifically. This guide covers running devcontainers on rootless Podman: the user-namespace model that keeps container processes off root, the keep-id mapping that keeps your files yours, and the perf and port caveats that come with rootless. It complements the engine-agnostic setup in the architecture overview.

The central concept is the user namespace. A rootless engine runs entirely as your unprivileged host user; inside the container, processes can be "root," but that root is mapped — through your subuid/subgid ranges — back to your host user. Understanding that mapping is what makes rootless devcontainers behave predictably.

Prerequisites

You need Podman installed with its socket enabled, subuid/subgid ranges configured for your user, the Dev Container CLI (or editor) pointed at podman, and an understanding of the keep-id userns option.

  • Podman installed and podman info succeeding as your user.
  • /etc/subuid and /etc/subgid containing a range for your user.
  • dev.containers.dockerPath (editor) or the CLI configured to use podman.
  • Awareness that ports below 1024 need extra configuration when rootless.

Rootless prerequisitesYou need Podman with a socket, subuid/subgid mappings, the CLI pointed at podman, and userns config.Podmaninstalled + socketRootlesssubuid/subgid mappeddevcontainer CLIdockerPath=podmanConfiguserns keep-id

The subuid/subgid prerequisite is the one people most often have half-configured, and the failure it produces is confusing because the engine starts but containers misbehave. When you install Podman, your user is typically granted a contiguous block of 65,536 subordinate IDs — a line like youruser:100000:65536 in /etc/subuid — which is what lets a container map its internal users onto real, distinct host IDs. If that range is missing, too small, or overlaps another user's range, Podman either refuses to start rootless containers or maps everything onto a single ID, which then breaks any image that expects more than one user to exist. Confirm the range is present and sized before anything else, because almost every "rootless is broken" report where podman info itself works traces back to a subuid range that was never provisioned or was clobbered by a later edit.

The socket prerequisite deserves the same scrutiny because the devcontainer tooling talks to Podman through a Docker-compatible API socket, not the podman binary directly. Rootless Podman exposes that socket per-user under $XDG_RUNTIME_DIR/podman/podman.sock, and it only exists while the user's socket service is running. On a headless server or a fresh login the socket may not be started, so podman info on the command line succeeds (it launches Podman directly) while the editor or CLI, which expects the socket, reports "cannot connect to the Docker daemon." Enabling the user socket service — and, on systems where user services stop at logout, enabling lingering so it survives — turns this intermittent connectivity problem into a solved one. Knowing the difference between "the binary works" and "the socket is up" is what separates a five-minute fix from an afternoon of confusion.

The last conceptual prerequisite is simply accepting that rootless trades a little capability for a lot of safety, and budgeting for the trade rather than fighting it. Low ports, some sysctl tweaks, and a handful of privileged operations are restricted precisely because there is no root daemon to grant them, and that restriction is the security benefit, not a bug to work around. Teams that go in expecting rootless to be a drop-in clone of rootful Docker hit these edges and conclude Podman is broken; teams that go in expecting a slightly different, safer model configure around the edges once and never think about them again. The prerequisite that matters most is the right mental model.

Architecture & Configuration Deep Dive

Rootless works by nesting namespaces. Your host user owns a range of subordinate UIDs (in /etc/subuid); when a rootless container starts, its internal UID 0 (root) maps to your host UID, and its other UIDs map into your subordinate range. This means a process running as root inside the container is, from the host's view, just you — it cannot touch anything you can't. The trade-off is that file ownership on bind mounts needs care, which is exactly what the keep-id mapping solves.

Rootless UID modelA rootless engine maps container users into your host user's namespace via subuid/subgid ranges.Rootless engineruns as your user, no daemon rootUser namespacecontainer root maps to host usersubuid/subgidrange of sub-IDs for other userskeep-id mappingaligns container UID with host

For a devcontainer, the important consequence is bind-mount ownership. Without keep-id, files a container writes to your workspace can appear owned by a subordinate UID rather than your user. Passing --userns=keep-id (via runArgs) maps the container user directly to your host UID, so files stay yours. The UID-mapping mechanics carry over from the permission discipline in the property reference's remoteUser/updateRemoteUserUID section — rootless just adds the namespace layer.

To make the keep-id mechanism concrete, it helps to trace what happens to a single file. Without keep-id, a container process running as its default UID — say 1000 inside the container — writes a file, and the kernel translates that container UID 1000 through your subordinate range into some high host UID like 101000. On the host, ls -l then shows the file owned by a UID with no matching username, which is why deleting or editing it from the host suddenly requires sudo. With --userns=keep-id, Podman arranges the mapping so that the container user lands back on your actual host UID, so the same file appears owned by you. The mapping is not magic; it is a deliberate identity mapping for one UID (yours) layered on top of the subordinate range that handles all the others.

There is a wrinkle worth understanding: keep-id maps your host UID to the container user, which means inside the container that user may no longer be UID 0. For most development work — editing files, running language toolchains, installing user-level packages — this is exactly what you want, because the point of rootless is that you rarely need in-container root. But an image whose build or entrypoint assumes it runs as UID 0 (writing to /usr, binding privileged ports, chown-ing system paths) can misbehave under keep-id. The resolution is to design the image and the remoteUser around an unprivileged user in the first place, which is the same non-root discipline the security guide recommends and which happens to align perfectly with how rootless wants to work.

A second architectural consequence is that networking is user-mode by default. Rootless Podman routes container traffic through a userspace network stack (slirp4netns or the newer pasta) rather than creating host bridge interfaces, because creating those interfaces needs root. For a devcontainer this is mostly invisible — outbound connections and forwarded high ports work fine — but it explains two observable behaviors: binding host ports below 1024 is restricted, and raw performance for very high-throughput networking is slightly lower than a rootful bridge. Both are acceptable trade-offs for development, and both follow directly from "there is no root here," which is the single idea the whole architecture flows from.

Step-by-Step Implementation

Point the tooling at Podman, set the userns mapping so ownership is correct, then build and attach.

Podman wiringPoint the CLI at podman, set userns keep-id, build, and attach with correct file ownership.Point CLIdockerPath=podmanusernskeep-id in runArgsBuildpodman builds imageAttachfiles stay your-owned

{
  "name": "Rootless Podman",
  "image": "mcr.microsoft.com/devcontainers/base:ubuntu@sha256:PINNED",
  "runArgs": ["--userns=keep-id"],
  "remoteUser": "vscode"
}
# Editor: point the Dev Containers extension at podman
#   "dev.containers.dockerPath": "podman"
# CLI: build headlessly with podman as the engine
DOCKER_HOST="unix://$XDG_RUNTIME_DIR/podman/podman.sock" devcontainer up --workspace-folder .

Rootless UID mapping in depth is covered in mapping UIDs for rootless Podman devcontainers, multi-service stacks in using podman-compose in a devcontainer, and switching engines in migrating from Docker to Podman in devcontainers.

Pointing the tooling at Podman is a small change with two distinct surfaces, and getting both right the first time avoids a frustrating half-working state. The editor path is the dev.containers.dockerPath setting (or the equivalent Docker Compose path setting for compose-based configs), which tells the Dev Containers extension to shell out to podman instead of docker. The CLI path is the DOCKER_HOST environment variable pointing at the rootless socket, which lets devcontainer up and any Docker-API client reach Podman. Teams that set only one of these end up with an editor that works but a broken CLI in CI, or vice versa. Treat them as a pair: whatever consumes the engine — editor, CLI, scripts — needs to be told where Podman lives.

The --userns=keep-id entry in runArgs is the load-bearing line, and it is worth committing with an explanatory comment because a future reader will not remember why it is there. Without it the config still builds and attaches — everything looks fine until the first file written from inside the container shows up host-owned by a strange UID — so the argument's absence is a silent, delayed failure rather than an obvious one. Making it a deliberate, commented part of the committed config, rather than something each developer discovers and adds locally, is what keeps the whole team's file ownership consistent. This is the same principle the property reference applies to remoteUser: ownership-affecting settings belong in the shared config, not in individual muscle memory.

Once the wiring is in place, the build-and-attach step is genuinely identical to Docker, which is the reassuring payoff of the spec's engine-agnosticism. Podman reads the same Dockerfile or image reference, runs the same lifecycle hooks in the same order, and mounts the same workspace. A developer who never looked at runArgs would not be able to tell from the editing experience whether they were on Docker or rootless Podman, and that is exactly the goal — the engine swap should change the security posture and the licensing story without changing how anyone works day to day.

Performance & Resource Optimization

Rootless engines run at near-parity with rootful Docker; the main performance variable is the storage driver. Native overlayfs (available rootless on recent kernels) is fastest; fuse-overlayfs is the portable fallback with a modest overhead. Build caching and named volumes work the same as with Docker.

Relative build throughputRootless engines run at near-parity; the storage driver is the main variable.Rootful DockerbaselineRootless Podman~96%Rootless + fuse-overlayfs~88%illustrative relative throughput

To keep rootless fast, prefer native rootless overlayfs where your kernel supports it, and cache package managers on named volumes exactly as you would under Docker — the registry pull-caching and per-language cache strategies are engine-agnostic. The one place to budget extra time is the first build, where the userns and storage setup add a small overhead.

The storage-driver choice deserves more than a passing mention because it is the single biggest lever on rootless build speed. Native rootless overlayfs became possible once the kernel gained unprivileged overlay mounts, and where it is available it runs at essentially rootful speed because the copy-on-write layering happens in the kernel. fuse-overlayfs, the portable fallback, implements the same semantics in userspace through FUSE, which adds a context-switch cost to every file operation during a build — usually a modest but measurable slowdown that compounds on images with many layers or large file trees. Checking which driver Podman actually selected (podman info reports the graph driver) is worth doing once per machine, because a host that silently fell back to fuse-overlayfs on an older kernel will be slower than a colleague's for reasons that are otherwise invisible.

Beyond the driver, the caching strategies that make Docker fast are engine-agnostic and carry over unchanged, which is worth stating plainly so nobody re-solves a solved problem. Named volumes for package-manager caches, digest-pinned base images that stay in the local store, and BuildKit-style layer ordering all behave the same under Podman as under Docker, because they operate above the engine boundary. The one rootless-specific budget item is the first build on a new machine, where the userns setup, the initial subordinate-ID mapping, and populating the local image store add a one-time cost. Amortized across a week of rebuilds that reuse the cache, rootless performance is indistinguishable from rootful for the workloads a devcontainer actually runs.

There is also a resource-isolation upside to rootless that rarely gets counted as "performance" but matters on shared machines. Because every user runs their own engine with their own storage and their own containers, one developer's runaway build cannot exhaust a shared daemon's resources or interfere with another user's containers — there is no shared daemon to contend over. On multi-user build servers and shared workstations this per-user isolation is a genuine operational win, turning "the Docker daemon is pegged again" into a problem contained to whichever user caused it rather than one that takes down everyone's environments at once.

Validation & Testing

Validate two things: the engine is reachable by the tooling, and workspace files created inside the container are owned by your host user (proving keep-id works). The CLI drives this headlessly, so it works in rootless CI too.

Rootless validationConfirm the engine is reachable and workspace files stay owned by your host user.Does podman info reach the engine?NOFix the rootless socket pathAre workspace files owned by your user?YESuserns keep-id is workingRootless devcontainer is correct

# Prove ownership is correct after a rootless build
devcontainer exec --workspace-folder . -- sh -c 'touch /workspace/.owncheck'
stat -c '%U' .owncheck   # should be your host username, not a subuid

The ownership check is the validation that matters most, and it is worth automating rather than eyeballing, because ownership bugs are exactly the kind that pass a casual look and bite weeks later. The touch plus stat pattern proves the round trip: a file created inside the container appears on the host owned by your username, not a bare numeric subuid. Wiring that check into CI — build headlessly with Podman, create a file, assert the host sees it as the expected owner — catches a regression the moment someone removes the keep-id argument or a base-image change reshuffles the default user. Because the devcontainer CLI drives Podman the same way it drives Docker, this validation runs in a rootless CI runner unchanged, so the guarantee you check locally is the same one CI enforces.

Validating engine reachability is the other half, and the useful subtlety is testing the socket path the tooling uses, not just the binary. Running podman info proves Podman itself works, but the devcontainer tooling connects through the Docker-compatible socket, so a more faithful test is to point a Docker-API client at $DOCKER_HOST and confirm it responds. This distinction is what catches the classic "works on the command line, fails in the editor" split: the binary is healthy but the per-user socket service is not running. Making the socket check explicit — in a setup script or a CI preflight — turns an intermittent, login-dependent connectivity failure into something you detect deterministically before it wastes anyone's afternoon.

Common Pitfalls

The failures below are almost all UID-mapping or low-port issues unique to rootless. The triage below sorts them.

Rootless pitfall triageA triage path from ownership or port issues to a clean rootless setup.Are workspace files root-owned on thehost?YESAdd userns=keep-idCan't bind a low port?YESUse a port above 1024Rootless works cleanly

SymptomRoot CauseRemediation
Workspace files owned by a strange UIDNo keep-id userns mappingAdd --userns=keep-id to runArgs
Cannot bind port 80/443Rootless can't bind low portsUse a port above 1024, or configure the range
podman info failsRootless socket not runningEnable the user podman socket service
Slow builds vs Dockerfuse-overlayfs overheadUse native rootless overlayfs if supported
Compose services don't startUsed docker compose, not podman-composeUse podman-compose or the compose provider

The low-port pitfall is worth expanding because the "fix" of raising ip_unprivileged_port_start is often the wrong instinct. When a service in a rootless container cannot bind port 80 or 443, the reflex is to lower the host's unprivileged-port threshold so the bind succeeds — but that weakens a host-wide security boundary to solve a single container's convenience, and it undoes part of why you chose rootless. The better pattern in almost every development case is to run the service on a high port inside the container and let forwardPorts present it wherever you want locally; the developer never notices, and the host's privileged-port protection stays intact. Reserve the sysctl change for the rare case where an inflexible piece of software genuinely refuses to run on anything but a low port, and even then scope it as narrowly as the platform allows.

The compose pitfall catches teams migrating from Docker who assume docker compose and Podman are interchangeable. Podman provides compose support either through the separate podman-compose tool or through Podman's own compose provider, and a config that shells out to docker compose will simply not find a daemon under rootless Podman. The remediation is to route compose through Podman's mechanism, which the dedicated podman-compose how-to covers in full, but the conceptual point is that "Podman is Docker-compatible" refers to the engine API, not to every tool in the Docker ecosystem. Anywhere your workflow invokes a Docker-family CLI by name, check that it has a Podman equivalent wired up rather than assuming compatibility extends all the way to the command line.

Conclusion

Rootless Podman gives you devcontainers with no root daemon and a smaller attack surface, at the cost of understanding one thing well: the user namespace. Map the container user to your host user with keep-id so bind-mounted files stay yours, use native rootless storage for speed, and keep ports above 1024. Get those right and a rootless devcontainer is indistinguishable from a Docker one — just safer.

Rootless trade-offsRootless removes the root daemon at the cost of UID-mapping and low-port nuances.Rootless winsNo daemon running as rootSmaller attack surfacePer-user isolationWatch forUID mapping quirksStorage-driver perfPort <1024 limits

The strategic reason to adopt rootless goes beyond avoiding Docker Desktop's licensing, though that is often what starts the conversation. A root daemon is a standing, privileged, always-on process that every container request flows through, and it has historically been a rich source of privilege-escalation vulnerabilities: a container-escape bug against a rootful daemon lands the attacker as root on the host. Rootless removes that single high-value target entirely — there is no privileged daemon to compromise, and the worst an escape can achieve is the privileges of your unprivileged user. For teams whose threat model includes running untrusted code, building from third-party images, or simply reducing the blast radius of any container bug, that reduction in attack surface is the durable justification, and it applies whether or not licensing was ever a concern.

Adopting rootless is therefore best framed as a security posture the whole team inherits from one committed config, not a per-developer preference. When the .devcontainer/ points at Podman, sets keep-id, and runs as an unprivileged remoteUser, every teammate who opens the repository gets the safer model automatically, with no root daemon on their machine and no ability for a container to reach beyond their own user. The one-time cost of understanding the user namespace pays for itself in a standing reduction of risk that requires no ongoing vigilance — which is exactly the kind of security win worth building into the environment definition rather than leaving to individual discipline.

FAQ

Do devcontainers work without Docker at all? Yes. The spec requires a Docker-compatible engine, which Podman provides. Point the Dev Containers extension or CLI at podman (and podman-compose for multi-service stacks), and the same .devcontainer/ builds and attaches. Rootless adds a user-namespace layer, but the configuration is otherwise unchanged.

Why are my workspace files owned by a weird user ID after a rootless build? Because rootless maps container UIDs into your subordinate ID range, so a file written by container root lands as a subordinate UID on the host. Add --userns=keep-id to runArgs so the container user maps directly to your host UID, and files created in the workspace stay owned by you.

Can I bind low ports (80, 443) in a rootless devcontainer? Not by default — rootless engines can't bind ports below 1024 without extra privileges. Forward a higher port (for example 8080) instead, or configure net.ipv4.ip_unprivileged_port_start on the host if you genuinely need a low port. For most dev servers, a high port plus forwardPorts is simplest.

Is rootless Podman slower than Docker for real work? For the workloads a devcontainer actually runs — building images, installing dependencies, editing and testing code — the difference is small and usually invisible once caches are warm, provided you use native rootless overlayfs. The measurable gap shows up mainly on the fuse-overlayfs fallback and on very high-throughput networking, neither of which dominates a typical development loop. Check your graph driver once with podman info; if it is native overlay, you are within a few percent of rootful Docker for everything that matters day to day.

Can I use the same config on Docker and Podman? Largely yes, with one caveat: the --userns=keep-id argument is Podman-specific and is a no-op or error on Docker. Teams supporting both engines often keep the shared config Docker-clean and add the Podman-only argument through a small overlay or a documented local setting, so a Docker user and a Podman user consume the same .devcontainer/ with only the userns line differing. Everything else — the image, the Features, the lifecycle hooks, the extensions — is identical because it lives above the engine boundary.

Does rootless work in CI, or only on a developer laptop? It works in CI, and CI is often where it shines, because rootless containers can run inside an unprivileged CI job without granting the runner a privileged Docker socket. The devcontainer CLI drives Podman headlessly the same way it drives Docker, so the same build-and-test commands you run locally run in a rootless CI runner unchanged. This is what lets you validate the exact rootless environment developers use, rather than testing against a different rootful engine and hoping the two agree.

What happens to sudo inside a rootless container? It still works, but it means something narrower than you might expect. sudo inside the container elevates you to container root, which — thanks to the user namespace — maps back to your own unprivileged host user, not to real host root. So you can apt install a package or chown a file inside the container freely, and none of it grants any privilege on the host beyond what your user already had. This is the whole point of rootless: in-container root is real enough to build and configure software, yet powerless outside the namespace, so the convenience of sudo no longer carries the risk of host-level escalation.