Injecting Secrets into a DevContainer Safely
A secret baked into an image layer or committed to .devcontainer/ is recoverable forever. This page injects secrets at runtime instead — via remoteEnv pulling from the host or Codespaces secret store — so credentials never touch an image layer or git history.
This task matters because a devcontainer sits at an awkward intersection: it is a build artifact that ships to teammates and CI, yet it also needs live credentials — an NPM_TOKEN to pull private packages, a registry login, a database URL — to actually do work. The moment those two facts collide, people reach for the easy answer and write the token into a Dockerfile ENV, a containerEnv block, or a checked-in .env. Every one of those choices persists the value somewhere durable, and a devcontainer image is meant to be shared, cached, and rebuilt, so the leak propagates to everyone who pulls it. You reach for runtime injection precisely because the container definition is public-by-design while the secret is not.
The mental model is a clean separation between the name of a secret and its value. Your .devcontainer/devcontainer.json is allowed to know that a variable called NPM_TOKEN exists and must be present at runtime; it is never allowed to know what that token is. The value lives in a store outside the repository — your host shell environment, a GitHub Codespaces secret, or an external secrets manager — and is stitched in only when a session attaches, held in process memory rather than written to any layer. Think of the config as a socket and the store as the plug: the shape of the connection is committed, the current running through it is not. Once you internalize that split, the rest of this page is just the mechanics of wiring the socket without ever soldering a value into it.
Prerequisites
You need a secret store and a devcontainer you can add remoteEnv to.
- A host env var or Codespaces/host secret store holding the secret.
- A devcontainer config you can edit.
- No secrets currently committed (rotate any that are).
The prerequisites look trivial, but the one detail people get wrong is the direction of the dependency. ${localEnv:NPM_TOKEN} resolves against the environment of the machine running the VS Code or CLI client — the place the devcontainer is launched from — not against anything inside the container. On a local workstation that means the variable must be exported in the shell (or login environment) that spawned the editor; if you set it in a terminal after VS Code is already running, the client will not see it and the reference resolves to empty. In Codespaces the same reference is satisfied by a secret you register in the repository or organization secret settings, which the platform exposes as localEnv during provisioning. Knowing which of these two sources is in play for a given launch is what saves you an hour of debugging a blank token.
The "no secrets currently committed" bullet is not a formality. If a token has ever been pushed, moving to runtime injection does nothing for the copy already sitting in git history, so rotate it before you start rather than after. Treat any credential that has touched the repository as burned, mint a fresh short-lived one for the store, and only then wire up remoteEnv. Doing the rotation first means the value you are about to reference is clean, and the old value becomes worthless the moment it is revoked.
Step-by-Step Implementation
- Store the secret outside the repo (host env or secret store).
export NPM_TOKEN=... # or set it in the Codespaces secret store
The export here is doing the load-bearing work: it promotes NPM_TOKEN from a plain shell variable into the exported environment, which is the only form ${localEnv:...} can read. A bare assignment without export stays local to the shell and is invisible to the child process that launches the editor, so the reference would silently resolve to nothing. On a workstation, put this in the profile that starts your GUI session (or launch the editor from a shell where it is already exported); on Codespaces, skip the shell entirely and register the value in the secret store, which is the equivalent durable location that lives outside the repo. Either way the point of this step is that the value comes to rest somewhere the container definition cannot see and git will never track.
- Reference it via remoteEnv — never a literal value.
{
"remoteEnv": { "NPM_TOKEN": "${localEnv:NPM_TOKEN}" },
"remoteUser": "vscode"
}
remoteEnv is deliberately chosen over containerEnv here, and the distinction is the whole security argument. containerEnv values are applied when the container is created and can be captured into image metadata during a build, whereas remoteEnv is applied by the client each time it attaches — after the image exists, per session, into the running process only. The right-hand side ${localEnv:NPM_TOKEN} is a reference, not a value: the file records the name of the variable to pull from the launching environment and nothing more, which is exactly why this JSON is safe to commit. Pairing it with "remoteUser": "vscode" also means the injected variable lands in the environment of the non-root user you actually work as, so the secret is present where your commands run without being exported into a root context.
- Consume it at runtime, e.g. in a hook.
{ "postCreateCommand": "npm ci" }
The hook consumes the secret indirectly: npm ci reads NPM_TOKEN from the environment (typically via an .npmrc that references ${NPM_TOKEN}) to authenticate against a private registry, and because remoteEnv has already placed the variable in the shell, the command just works without the token ever appearing on the command line. That last point matters — passing the value as a literal argument would expose it in process listings and, worse, in the command echo that many hooks emit. Note also the timing: postCreateCommand runs after the container is created and attached, which is exactly when a remoteEnv value is available, so a runtime-injected secret and a lifecycle hook line up naturally. If you had used a create-time mechanism instead, the value might not be present at the moment the hook fires.
- Verify the secret is not baked into the image.
docker history --no-trunc <image> | grep -i NPM_TOKEN || echo "clean"
This verification closes the loop by asking the image itself whether it remembers the secret. docker history --no-trunc prints the full command and metadata of every layer, so a token that leaked in through a Dockerfile ENV, ARG, or RUN line will show up in that output; piping it through grep -i NPM_TOKEN searches case-insensitively for the variable name, and the || echo "clean" fallback turns a no match (grep's non-zero exit) into an explicit, reassuring signal rather than silent empty output. Run it against the actual built image, not a base you pulled, and treat any hit as a rotation event, not a formatting nit. The failure mode this catches is the sneakiest one in secret handling: a value you thought was runtime-only that a stray build instruction quietly persisted into a shared layer.
Common Pitfalls
Secret leaks come from baking values into images, configs, or Dockerfile ENV.
The subtlest failure is a permissions-and-persistence one that shows up when a hook writes the injected value to disk. It is tempting to have postCreateCommand generate an .npmrc or a credentials file containing the resolved NPM_TOKEN so tooling can find it, but that file now lives in a bind mount or a named volume that survives the session and may be owned by whichever user the hook ran as. If that path is inside the workspace mount, you have effectively re-committed the secret to the host filesystem next to the repo, and a careless git add can push it. When a hook must materialize a token, write it to a path outside the workspace (the home directory of the remoteUser, for example) with restrictive permissions, and never into a directory that git or a shared cache volume tracks.
A second, topic-specific trap is assuming that masking in CI logs equals safety. Masking only hides a value from the rendered log; the token is still a real, valid credential that any step in the job can read from the environment and exfiltrate. The durable fix is scope and lifetime, not redaction: mint a token that can only do the one thing the container needs (read a single registry, for instance) and that expires quickly, so even a leaked value from a printed environment is worth little by the time anyone finds it. Runtime injection controls where the secret lives; short-lived scoped tokens control how much a leak costs, and you want both.
| Symptom | Root Cause | Remediation |
|---|---|---|
| Token found in image layers | Baked via Dockerfile ENV/ARG | Inject via remoteEnv at runtime; rotate |
| Secret committed to .devcontainer | Literal value in config | Reference ${localEnv:...} from a store |
| Secret leaks in CI logs | Echoed by a command | Mask it; don't print secret env |
| Long-lived token over-exposed | Static broad-scope credential | Use short-lived scoped tokens |
Conclusion
Keep secrets out of everything that persists. Store them in the host or Codespaces secret store and inject at runtime with remoteEnv referencing ${localEnv:…}, so no image layer or committed file ever holds the value. Prefer short-lived, scoped tokens to bound any leak.
The strategic payoff is that this approach makes the reproducible parts of your devcontainer genuinely shareable without dragging the non-reproducible parts along. The same discipline that says pin your base image and cache your dependencies also says commit the shape of your credentials and nothing more — the remoteEnv block names NPM_TOKEN, the store supplies it, and any teammate who clones the repo gets a config that works the instant they populate their own copy of the secret. Nothing in the checked-in definition is environment-specific or sensitive, so the artifact stays deterministic and portable while the credentials stay personal and revocable. That is the same separation-of-concerns that makes a pinned, cached build trustworthy, applied to the one class of input you can never afford to bake in.
Practically, treat the docker history check as a recurring guardrail rather than a one-time gate. Wire it into the review that gates image pushes so a regression — someone adds a Dockerfile ENV in a hurry — is caught before the layer is shared. Combined with rotation on every suspected exposure and tokens scoped to the narrowest useful permission, runtime injection becomes a standing property of how your containers are built: the image is always clean, the secret is always injected, and the blast radius of any single leak stays small.
FAQ
Why not use a Dockerfile ARG/ENV for a token?
Because it persists in the image's build history and layers, recoverable with docker history even after you remove it. ARG is especially treacherous: even though it is not baked into the final environment, its value is recorded in the build metadata of the layer that consumed it, so a token passed as a build argument is still recoverable. A later RUN rm or overwrite does not scrub the earlier layer that captured the value, because layers are immutable and additive. Inject secrets at runtime via remoteEnv from a store instead, so nothing sensitive is ever written into an image.
How does remoteEnv keep the secret out of the repo?
remoteEnv references an environment variable (for example ${localEnv:NPM_TOKEN}) resolved at attach time from your host or the Codespaces secret store. The config stores only the name, never the value, so the repo stays clean. The resolution happens in the client that launches the container, not inside the container image, which is why the same committed file can serve a workstation reading from an exported shell variable and a Codespace reading from a registered secret. Because the value is applied per attach rather than at build, it also never becomes part of anything you push to a registry.
What if a secret was already committed?
Rotate it immediately — assume it is compromised — then remove it from history and switch to runtime injection. A committed secret remains in git history until rewritten, so rotation is the real fix; scrubbing history is secondary. Rewriting history with a tool like git filter-repo only helps once every clone and fork has been updated, which in practice you cannot guarantee, so never rely on the rewrite alone. Assume any value that reached a remote has been scraped, and let the new short-lived token be the thing that actually protects you.
What is the difference between remoteEnv and containerEnv?
containerEnv is applied when the container is created and can end up in image metadata, so it is the wrong place for anything sensitive; use it only for non-secret configuration like NODE_ENV. remoteEnv is applied by the client on every attach, into the running process for the remoteUser, and is the correct home for injected credentials. When in doubt, put secrets in remoteEnv and settings in containerEnv.
Does ${localEnv:NPM_TOKEN} read from inside the container?
No — localEnv resolves against the environment of the machine launching the devcontainer, which is your host or the Codespaces control plane, never the container's own environment. This is the single most common source of a blank token: setting the variable in a shell after the editor has already started, so the launching environment never saw it. Export it before launching, or register it in the secret store, and it will resolve.
Related
- Up to DevContainer Security & Secrets Management — the overview of hardening.
- Running DevContainers as a Non-Root User — least-privilege execution.
- Scanning DevContainer Images for Vulnerabilities — catching baked secrets and CVEs.