Running DevContainers as a Non-Root User
A devcontainer running as root is both a security risk and a source of root-owned files on your host mount. This page runs it as a non-root user via remoteUser, aligning the UID so workspace files stay yours — least privilege and correct ownership in one move.
The reason this matters more than it first appears is that a devcontainer is not a passive sandbox. It executes code straight from your repository — install scripts, build tooling, test runners, and every transitive dependency they pull in. When that process is root, anything it does inside the container happens with unrestricted privileges: it can rewrite system paths, install packages globally, and reach whatever the container's capabilities allow. Dropping to an ordinary user narrows that surface so that a hostile postinstall hook or a compromised package is confined to files the user can already touch, rather than owning the whole environment. The remoteUser key is the switch that makes the editor, its terminals, and every task you launch run as that unprivileged account.
Reach for this the moment you bind-mount your workspace from the host, which is the default for nearly every devcontainer. Under a root container, files the container creates — node_modules, build output, generated migrations — land on the host owned by UID 0, and your host user then hits permission denied trying to edit, stage, or delete them. The mental model is a two-part fix: remoteUser chooses who the process runs as, and updateRemoteUserUID makes that user's numeric UID match yours so the ownership of everything written through the mount lines up on both sides. Get both right and the security win and the ergonomic win arrive together, from what amounts to two lines of JSON.
Prerequisites
You need a base image with a non-root user (or to create one).
- A base image providing a non-root user (the official images do).
remoteUserset to that user.- UID alignment via
updateRemoteUserUIDwhere host UIDs differ.
The one detail people get wrong here is assuming remoteUser and containerUser are the same thing. remoteUser controls the identity that VS Code (or the CLI) uses after the container is up — the account your editor server, terminals, and tasks run as. containerUser sets the identity the container's main process starts with. For most setups you only need remoteUser, because the official base images already ship a vscode user and the container itself can start as root to run lifecycle setup before dropping you in unprivileged. Confirm your chosen image actually contains the user by name; remoteUser: "vscode" fails at attach time if no such account exists in /etc/passwd.
Also settle on the numeric UID you are aligning to before you start. On Linux, run id -u on the host: the common value is 1000, and the official images create vscode at 1000 precisely so alignment is a no-op in the typical case. If your host account is not 1000, that is exactly when updateRemoteUserUID earns its keep, and it is the scenario the steps below assume you may be in.
Step-by-Step Implementation
- Use a base image with a non-root user, and select it.
{
"image": "mcr.microsoft.com/devcontainers/base:ubuntu@sha256:PINNED",
"remoteUser": "vscode"
}
The image here is pinned by digest (@sha256:PINNED) rather than by a floating tag like :ubuntu, so the vscode user's UID and the account it ships with are frozen alongside everything else in the layer. That pin matters for this task specifically: if the base image were free to move, a rebuild could in principle change how the non-root user is provisioned, and your alignment assumptions would drift silently. Naming remoteUser: "vscode" tells the devcontainer runtime to switch to that account after the container starts, so the editor server, integrated terminal, and any postCreateCommand you add all run unprivileged. The failure mode this prevents is the quiet default where an image with no remoteUser leaves you attached as root, which you often do not notice until a root-owned file appears on the host.
- Align the UID with the host so bind-mount files stay yours.
{ "updateRemoteUserUID": true }
Setting updateRemoteUserUID to true tells the runtime to rewrite the container user's UID and GID to match the host user that launched the build. It does this during container creation by adjusting vscode's entry so its numeric ID equals your host id -u, then re-owning the user's home directory to suit. The point is that Linux bind mounts carry raw numeric UIDs across the boundary, not names: the kernel does not know or care that both sides call the account vscode, only that the number matches. Align the number and a file the container writes to the workspace shows up on the host owned by you; leave it misaligned and you get files stamped with 1000 (or whatever the image baked in) that your host account cannot modify. This is the setting that turns "runs as non-root" into "runs as your non-root," and it is a no-op on the common case where you are already UID 1000.
- Create a non-root user if your base lacks one.
RUN useradd -m -s /bin/bash dev
USER dev
When a minimal base image ships with only root, you create the account yourself before pointing remoteUser at it. The -m flag is not optional cosmetics: it creates /home/dev and populates it, so shell history, tool caches, and dotfiles have somewhere writable to live — without it, tools that expect $HOME fail with confusing permission errors. -s /bin/bash gives the user a real login shell so the integrated terminal behaves normally. The trailing USER dev sets the image's default identity, which means even a plain docker run against this image starts unprivileged, not just the devcontainer path. Name this same account in remoteUser so the editor attaches as it, and consider pairing it with updateRemoteUserUID since a freshly created user typically lands at UID 1000 and may still need aligning to your host.
- Verify the container runs non-root.
devcontainer exec --workspace-folder . -- id -u # non-zero
This check runs id -u inside the running container through the devcontainer CLI, and the number it prints is the whole verdict. A non-zero result — 1000 for the standard vscode user — confirms your process is unprivileged and that remoteUser took effect. A 0 means you are still root despite the config, which usually points to a remoteUser that names an account the image does not contain, or a cached container built before you added the key. Prefer id -u over eyeballing a shell prompt, because a $-versus-# prompt is only a theming convention and can lie; the numeric UID is the ground truth. For the ownership half of the story, follow up by writing a file from inside and running ls -n on the host to confirm it lands with your UID rather than a mismatched one.
Common Pitfalls
Root-run issues are an omitted remoteUser or a UID mismatch.
The ownership trap is the one that bites hardest when a cache or named volume enters the picture. It is common to mount a volume for node_modules, a package cache, or a language toolchain to speed up rebuilds, and the first time that volume is written by a root container it becomes root-owned at the mount point. Switch the same project to a non-root remoteUser afterward and the unprivileged user suddenly cannot write into its own cache — installs fail with EACCES even though the config looks correct. The fix is to recreate the volume so it is initialized under the aligned UID, or to chown the mount to the non-root user during postCreateCommand; updateRemoteUserUID handles the workspace bind mount but does not retroactively re-own a pre-existing named volume.
A subtler pitfall is expecting UID alignment to work on Docker Desktop for macOS or Windows the way it does on native Linux. There, the file sharing layer already translates ownership, so bind-mounted files appear owned by the container user regardless of the raw number, and updateRemoteUserUID becomes largely a no-op. Do not chase a UID mismatch that the platform is already papering over; the setting is essential on Linux hosts and rootless Docker, and mostly invisible elsewhere. Knowing which regime you are in stops you from debugging a problem that only exists on one of them.
| Symptom | Root Cause | Remediation |
|---|---|---|
| Container runs as root | remoteUser omitted | Set a non-root remoteUser |
| Root-owned files on the host | UID not aligned | Enable updateRemoteUserUID |
| Permission denied writing workspace | Container UID != host UID | Align UIDs / fix ownership |
| Base has no non-root user | Minimal image | Create one in the Dockerfile |
Conclusion
Run non-root by default: select a non-root remoteUser and align its UID with the host, so a compromise is contained and workspace files stay owned by you. It is a one-line change with an outsized payoff for both security and ergonomics.
The strategic payoff is that non-root execution is a default you set once and stop thinking about, rather than a control you have to remember to apply. Baked into devcontainer.json and committed to the repository, it travels with the project: every teammate and every CI run that materializes the container inherits the same unprivileged identity and the same UID alignment, so nobody has to reason about privilege on a case-by-case basis. That is the difference between security as a habit and security as a property of the environment, and the latter is the only kind that survives contact with a busy team.
This ties directly into the same pin-and-cache discipline the rest of these guides lean on. The digest-pinned base image that makes vscode reproducible is the same pin that keeps its UID stable, and the named volumes you cache dependencies in only stay writable when they are initialized under the aligned user. Non-root is not a bolt-on to that reproducibility story; it is part of it. Pin the image, align the user, initialize caches under that user, and you get an environment that is simultaneously reproducible, fast to rebuild, and safe to run untrusted code inside — the three properties reinforcing each other rather than competing.
FAQ
Why run non-root if it's just my dev machine?
Because a devcontainer runs code from a repository, and a compromise (a malicious dependency, a bad script) is far more contained as an unprivileged user. Non-root also prevents root-owned files appearing on your host mount, which break Git and local tooling. "Just my dev machine" is exactly where untrusted code runs most freely — you clone a repo, open it, and lifecycle scripts execute before you have read a line of it. An unprivileged process cannot silently rewrite system paths or install global packages, so the blast radius of a bad postinstall stays small. The habit costs nothing once it lives in devcontainer.json, and it is the cheapest security control you will ever add.
What does updateRemoteUserUID do?
It aligns the container user's UID/GID with your host user's, so files the container writes to the bind-mounted workspace are owned by you rather than a mismatched UID. It is the companion to remoteUser for correct ownership. Under the hood it rewrites the container account's numeric ID during creation, because Linux bind mounts carry raw UIDs across the boundary and ignore the account name entirely. On the common case where both sides are UID 1000 it changes nothing; its value shows up when your host account is a different number and the container would otherwise stamp files you cannot edit.
My base image has no non-root user — now what?
Create one in the Dockerfile (useradd + USER) and set remoteUser to it. The official devcontainer base images already provide a non-root vscode user, so prefer those to avoid the extra step. When you do roll your own, use useradd -m so the home directory exists and remember that the new account usually lands at UID 1000, which may still need aligning to your host. Pointing remoteUser at an account the image does not actually contain is the classic mistake here — attach fails or silently falls back to root, so create the user first and verify with id -u.
Do I still need remoteUser if the Dockerfile already ends with USER?
Yes, keep it explicit. USER in the Dockerfile sets the container's starting identity, but the devcontainer runtime may run lifecycle setup as root and then attach you based on remoteUser; if that key is missing, the editor can end up attached as a different identity than you expect. Setting remoteUser to the same account you named in USER removes the ambiguity and guarantees the editor server and terminals run unprivileged.
Can I run some setup as root but still work as non-root?
Yes, and that split is the intended pattern. Let the container start as root (or use containerUser) so onCreateCommand and postCreateCommand can install packages that need elevated rights, then rely on remoteUser to drop you into the unprivileged account for actual work. The privileged phase is short and scripted; the interactive phase, where untrusted repo code and your editor live, stays non-root.
Why do I get permission denied on a cached volume after switching to non-root?
Because the named volume was first written while the container ran as root, so its contents are owned by UID 0 and your non-root user cannot write into them. updateRemoteUserUID fixes the workspace bind mount but does not re-own a pre-existing volume. Recreate the volume so it initializes under the aligned user, or chown it to that user in postCreateCommand.
Related
- Up to DevContainer Security & Secrets Management — the overview of hardening.
- Injecting Secrets into a DevContainer Safely — keeping secrets out of the image.
- devcontainer.json Property Reference — the remoteUser and UID keys.