DevContainer Security & Secrets Management
A devcontainer is executable configuration — opening a repository can build an image and run lifecycle commands — so its security posture matters as much as its ergonomics. This guide covers the four aspects of a hardened devcontainer: running non-root, keeping secrets out of the image, scanning the base and Features, and gating lifecycle auto-run behind workspace trust. It builds on the permission and registry discipline from the architecture overview.
The mindset is least privilege plus provenance: give the container only the access it needs, never bake a secret into an image or config, and be able to prove — via scans and checks — that both hold. Every recommendation here reduces what a compromised or malicious repository could do.
Prerequisites
You need an explicit non-root remoteUser, a secret store (the host or Codespaces secret store) to inject credentials at runtime, image scanning wired into CI, and workspace-trust configured so opening a repo doesn't silently run code.
remoteUserset to a non-root user in every config.- A secret store for tokens/keys — never the repo or image.
trivy/grypescanning the image in CI, failing on criticals.- Editor workspace trust enabled so lifecycle hooks require confirmation.
Before the four aspects, it is worth naming the threat model precisely, because "secure the devcontainer" is too vague to act on. A devcontainer faces threats from three distinct directions, and each aspect answers one or more of them. First, the supply chain: the base image and Features you pull are third-party code that runs with whatever privilege the container has, so a compromised or vulnerable upstream is a direct risk — this is what scanning and digest pinning address. Second, the repository itself: because opening a repo can execute lifecycle commands, a malicious or compromised repository is executable code aimed at your machine, which is what workspace trust guards. Third, credential exposure: any secret the environment touches can leak through image layers, git history, or a compromised process, which is what runtime injection and non-root execution contain. Sorting every hardening decision by which of these three threats it answers keeps the effort focused rather than performative.
The non-root aspect is load-bearing in a way that is easy to underrate, because it changes the outcome of every other failure. If a process is compromised, a scanning gap is exploited, or a lifecycle hook does something unexpected, the damage is bounded by the privileges the container ran with. Running as an unprivileged remoteUser means the worst case is scoped to that user's reach rather than to root inside the container — and, when combined with a rootless engine, ultimately to your unprivileged host user. This is why the prerequisite is an explicit non-root user in every config, not merely "usually non-root": the aspect only protects you if it holds by default, on every environment, without depending on anyone remembering to add it.
The secret-store prerequisite encodes a hard rule with an unusually sharp edge: a secret that ever enters an image layer or a committed file is compromised the moment it does, not merely at risk. Image layers are content-addressed and immutable, so a token written in one layer and deleted in a later one still lives in the earlier layer and travels with every copy of the image. Git history is the same — a committed secret survives a later deletion commit and is recoverable from the reflog and every clone. Because these leaks are effectively unrecoverable by deletion, the only correct response to a baked secret is to rotate it, and the only way to avoid that is to never bake it: inject at runtime from a store, every time, with no exceptions for "just this once."
Architecture & Configuration Deep Dive
A hardened devcontainer has four layers. It runs non-root by default (remoteUser), so a process compromise is contained to an unprivileged user. Its secrets live outside the image, injected at runtime via remoteEnv from a store, so nothing sensitive is baked into a layer or committed. Its base image and Features are scanned, so no known-critical vulnerability ships. And workspace trust gates lifecycle auto-run, so merely opening an untrusted repository does not execute its postCreateCommand.
The secrets layer is where mistakes are most costly. A token baked into a Dockerfile ENV or committed to .devcontainer/ persists in image layers and git history, so even after deletion it is recoverable. The rule — enforced across this site — is that secrets are injected at runtime from a store and never written into an image or config, matching the remoteEnv guidance in the property reference.
The four layers are deliberately independent, and understanding why matters for how you prioritize. Defense in depth means no single layer is trusted to be perfect: non-root execution assumes a process might be compromised and limits the blast radius; runtime secret injection assumes the image might leak and keeps secrets out of it; scanning assumes upstream might ship a vulnerability and catches the known ones; workspace trust assumes a repository might be hostile and gates auto-run. Because they cover different failure modes, they compose rather than overlap — adding the second layer does not make the first redundant. A team that only does one, however good, has a single point of failure; a team that does all four has to lose several independent bets before anything sensitive is exposed.
The remoteEnv mechanism is the technical heart of the secrets layer, and its behavior is worth being precise about. Values in remoteEnv are applied to the container's runtime environment at attach time, after the image is built, which is exactly why they never enter an image layer — the build has already finished when they arrive. Referencing ${localEnv:NPM_TOKEN} pulls the value from the host's environment (which in Codespaces is backed by the encrypted secret store), so the token exists only in the running container's process environment and vanishes when the container stops. Contrast this with a Dockerfile ENV NPM_TOKEN=... or an ARG baked at build time, which persist in the image; the whole point of remoteEnv is that it is a runtime channel, not a build-time one, and that timing is what makes it safe.
Workspace trust closes a gap specific to the executable nature of the config, and it is worth internalizing why it exists at all. A devcontainer.json can specify a postCreateCommand, an initializeCommand that runs on the host, and Features that run install scripts — all of which execute as a consequence of opening the repository in a container. Without a trust gate, cloning an unfamiliar repository and reopening it in its container would run that author's code on your machine automatically, which is a textbook remote-code-execution vector. Workspace trust interposes an explicit human decision — "do you trust the authors of this folder?" — before any of that runs, converting a silent auto-execution into a reviewed one. It is the cheapest of the four aspects to enable and the one that most directly protects against a hostile repository.
Step-by-Step Implementation
Set a non-root user, inject secrets from the store at runtime, scan in CI, and require trust before hooks run.
{
"name": "Hardened DevContainer",
"image": "mcr.microsoft.com/devcontainers/base:ubuntu@sha256:PINNED",
"remoteUser": "vscode",
"remoteEnv": {
"NPM_TOKEN": "${localEnv:NPM_TOKEN}"
},
"postCreateCommand": "npm ci"
}
# Scan the pinned image in CI, failing the build on criticals
trivy image --severity CRITICAL --exit-code 1 mcr.microsoft.com/devcontainers/base@sha256:PINNED
Safe secret injection is detailed in injecting secrets into a devcontainer safely, non-root execution in running devcontainers as a non-root user, and image scanning in scanning devcontainer images for vulnerabilities.
Each step in the hardening flow has a failure mode worth anticipating so you implement it correctly the first time. Setting remoteUser is trivial, but it only helps if the image actually has that user and owns the paths the user needs to write — pointing remoteUser at a user that does not exist, or that cannot write its home directory, produces confusing permission errors that tempt people to revert to root. The official base images ship a ready vscode user precisely to make this step safe, so preferring them over a bare OS image removes a whole class of setup friction. When you must use a minimal base, creating the non-root user and chown-ing its paths in the Dockerfile is the part not to skip.
Injecting secrets correctly means being disciplined about the whole path a secret travels, not just its final destination. A token referenced through remoteEnv is safe in the container, but if it also lives in a plaintext .env committed to the repo, or is echoed into a log during postCreateCommand, the runtime channel has been undermined by a leak elsewhere. The implementation discipline is to trace every place the secret appears — the store, the host environment, the remoteEnv reference, and any command that consumes it — and confirm none of those places is committed, logged, or baked. A secret is only as protected as its least careful appearance, so the injection step is really an audit of the secret's entire lifecycle, not a single config line.
Wiring the scan into CI is the step teams most often implement as a warning rather than a gate, which quietly defeats it. A scanner that reports criticals but does not fail the build produces a report nobody reads and ships the vulnerability anyway; the --exit-code 1 flag is what turns the scan from informational into enforcing. The nuance is choosing the severity threshold and the allowlist deliberately: failing on every low-severity finding creates noise that trains people to ignore the gate, while failing only on criticals (with a reviewed, time-boxed allowlist for accepted risks) keeps the gate meaningful. The scan is only a control if a failing scan actually stops the pipeline, so the exit-code behavior is the part that matters most.
Performance & Resource Optimization
Security here is mostly about where secrets live, not raw performance — but the handling model has a clear right answer. Secrets never belong in the image or the committed config; they are injected at runtime from a store, ideally as short-lived tokens so a leak has a bounded blast radius.
Prefer runtime injection via remoteEnv pulling from ${localEnv:…} (backed by the host/Codespaces secret store) over any baked value, and mount secret files only from outside the build context. Short-lived, scoped tokens beat long-lived ones because they limit exposure — a discipline that costs nothing and pays off the day a laptop is lost.
The short-lived-token discipline is worth dwelling on because it changes the consequence of a leak, not just its probability. Every secret will eventually be exposed somehow — a lost laptop, a misconfigured log, a compromised dependency — and the question that determines the damage is how long the exposed credential stays valid. A long-lived, broadly-scoped token that leaks is a standing liability until someone notices and rotates it, which can be months. A short-lived, narrowly-scoped token that leaks is often already expired or useless by the time it is exploited, and even if not, its scope bounds what it can reach. Preferring tokens that expire in hours and grant only the access the task needs converts "a leak is a breach" into "a leak is a shrug," which is the highest-leverage secret-handling decision available and costs essentially nothing to adopt.
There is a subtle interaction between the secrets model and caching that teams optimizing build speed sometimes get wrong. Because secrets must never enter an image layer, they also must never be part of what the layer cache keys on or stores — which is automatically true when you use remoteEnv runtime injection, but not true if someone reaches for a build-time ARG to pass a token, because BuildKit can cache that argument's effect into a layer. The performance-safe pattern is therefore also the security-safe one: keep secrets entirely on the runtime side, and let the build cache freely because it never touches anything sensitive. When you find yourself wanting to pass a credential at build time for speed — to authenticate a package pull, say — use BuildKit's dedicated secret mount, which exposes the secret to a single build step without persisting it into the cached layer.
Validation & Testing
Validate three invariants: no secret is baked into the image or config, the container runs as a non-root user, and the base plus Features scan clean of criticals. A repo scan (for example gitleaks) plus the image scan and a user check covers all three.
# No baked secrets, non-root at runtime, clean image
gitleaks detect --source . --no-banner
devcontainer exec --workspace-folder . -- id -u # non-zero (not root)
The most important validation to automate is secret scanning across both the working tree and the git history, because the two catch different mistakes. A tool like gitleaks scanning the current tree catches a secret about to be committed; scanning the full history catches one that was committed and "removed" in a later commit but still lives in the past. Running the history scan periodically — not just on the latest diff — is what surfaces the credential someone thought they had deleted months ago. Pair this with the image scan (trivy image against the built container) and a runtime user check, and you have automated coverage of all three invariants: no secret in the source, no critical vulnerability in the image, and no root at runtime. Because all three run headlessly, they belong in CI where they gate every change rather than depending on anyone remembering to run them.
The non-root check deserves to be an explicit assertion rather than an assumption, because it is exactly the kind of thing that silently regresses. A base-image bump, a Feature that resets the user, or a stray USER root late in a Dockerfile can quietly return the container to root without any obvious symptom — everything still works, just with more privilege than intended. Asserting id -u is non-zero inside the built container, as a CI step that fails on root, turns that silent regression into a caught one. The same logic applies to the secrets invariant: rather than trusting that no one baked a token, assert it by scanning the built image's environment and layers, so the guarantee is verified on every build instead of assumed to hold because it held last time.
Common Pitfalls
The failures below are leaked secrets or excess privilege/auto-run. The triage below points at the fix.
| Symptom | Root Cause | Remediation |
|---|---|---|
| Token found in image history | Secret baked into a Dockerfile/config | Rotate it; inject via remoteEnv from a store |
| Container runs as root | remoteUser omitted | Set a non-root remoteUser |
| Critical CVE ships to developers | No image scan gate | Add trivy --exit-code 1 on criticals in CI |
| Opening a repo runs code unexpectedly | Workspace trust not enforced | Require trust before lifecycle hooks run |
| Long-lived token leaked widely | Static, broad-scope credential | Use short-lived, scoped tokens |
Two pitfalls deserve more than a table row because they are the ones that most often defeat an otherwise-careful setup. The first is the "removed" secret that lives on in history: a developer notices a committed token, deletes it in a new commit, and considers the problem solved — but the secret is fully recoverable from git history and every existing clone, so it is still compromised. The only correct response is to rotate the credential immediately, treating the exposure as real, and then clean history if feasible. Believing a deletion commit fixed the leak is worse than not noticing at all, because it produces false confidence around a live, exposed credential. Any time a secret touches the repository, the clock starts on rotation, not on cleanup.
The second is the privilege that creeps back in through a Feature or a base bump. A config that was carefully set to run non-root can be quietly returned to root by a Feature whose install script switches users, by a base image whose default user changed, or by a runArgs entry added to solve an unrelated problem. Because nothing breaks, the regression is invisible until a security review or an incident surfaces it. The defense is the automated non-root assertion described above, plus reviewing what each added Feature actually does — a Feature is third-party code that runs during the build, so adopting one is a supply-chain decision, not just a convenience. Treating privilege as something to continuously verify rather than set-once is what keeps the least-privilege posture from eroding over the life of the config.
Conclusion
Treat the devcontainer as code that runs, and secure it accordingly: run non-root, keep every secret out of the image and injected at runtime from a store, scan the base and Features on each build, and require trust before hooks execute. Reduce privilege, remove baked secrets, and prove the posture with scans — and a shared devcontainer becomes a safe default rather than a quiet liability.
The organizing insight to carry away is that a devcontainer's security is a property of the committed config, which means it is inherited, auditable, and enforceable in a way ad-hoc developer discipline never is. When the config runs non-root, injects secrets at runtime, and the pipeline scans and asserts on every build, every teammate gets the hardened posture automatically, and any regression shows up as a failing check rather than a latent risk. This is the same leverage the rest of the site emphasizes — encode the good default once, in the shared definition, so no one has to remember it — applied to security. The alternative, relying on each developer to run non-root and handle secrets carefully, fails the first time someone is in a hurry.
Framed that way, hardening a devcontainer is less a checklist you complete and more a set of invariants you make the config and CI continuously prove. Least privilege, secrets-outside-the-image, scanned provenance, and gated auto-run are not one-time tasks; they are properties you assert on every build so they cannot silently erode. The cost is modest — a non-root user, a remoteEnv reference, a scan gate, and a trust setting — and the payoff is that a shared environment, which is otherwise a single compromise away from affecting everyone who opens it, becomes a safe default that actively resists the most common ways container setups leak credentials and over-grant privilege.
FAQ
Where should API tokens and keys live?
Never in the image or the committed config — both persist in layers and git history. Store them in the host or Codespaces secret store and inject them at runtime with remoteEnv referencing ${localEnv:…}, or mount secret files from outside the build context. Prefer short-lived, narrowly-scoped tokens so any leak is bounded.
Why run the container as non-root?
Least privilege: if a process is compromised, running as an unprivileged user limits what it can touch, and it keeps bind-mounted workspace files correctly owned on the host. Set an explicit remoteUser; the official base images provide a non-root user (like vscode) ready to use.
What is workspace trust protecting against?
A malicious repository can define a postCreateCommand that runs the moment you open it in a container. Workspace trust makes lifecycle-hook execution require explicit confirmation, so cloning and opening an unfamiliar repo does not silently execute its setup code. Combine it with scanning the base and Features you pull.
A secret was committed once and then deleted — are we safe? No. Deleting a secret in a later commit does not remove it from history; it remains recoverable from earlier commits, the reflog, and every clone or fork that already pulled it. Treat any secret that ever entered the repository as compromised and rotate it immediately — issue a new credential and revoke the old one — regardless of how quickly you deleted it. History rewriting can reduce future exposure, but only rotation actually closes the hole, because you cannot recall the copies that already exist. The safe assumption is always that a once-committed secret is public.
Is scanning the base image enough, or do Features need scanning too? Both need scanning, because both introduce third-party code and third-party packages into the final image. A Feature runs an install script during the build and can add system packages, binaries, and their transitive dependencies — any of which can carry a known vulnerability. Scanning only the base misses everything the Features added on top. The robust approach is to scan the built image after Features are applied, so the scan sees the complete assembled result, and to treat adopting a Feature as a supply-chain decision: review what it installs and pin its version, just as you pin the base image by digest.
How do short-lived tokens work with a devcontainer that stays up for days? You refresh them rather than baking a long-lived one. The pattern is to inject a short-lived token at attach time and have the tooling — or a small helper the environment provides — renew it against the secret store or an identity provider as it nears expiry, so the container always holds a fresh, narrowly-scoped credential without ever storing a long-lived one. For many workflows the token only needs to be valid for a specific operation (a package pull, a deploy), so it can expire quickly and be reissued on demand, keeping the standing exposure close to zero even across a long-lived container.
Related
- DevContainer Architecture & Core Tooling — the permission and registry model security builds on.
- Injecting Secrets into a DevContainer Safely — runtime injection patterns.
- Running DevContainers as a Non-Root User — least-privilege execution.
- Scanning DevContainer Images for Vulnerabilities — gating criticals in CI.
- Container Registry Best Practices for Dev Images — pinned, scanned, provenance-tracked images.