Using the devcontainer CLI for Headless Environments
You need the exact same environment your developers attach to, but running in CI where there is no editor. The Dev Container CLI builds, starts, and runs commands inside a devcontainer headlessly, so a pipeline can validate the config, run tests inside the container, and warm caches for prebuilds — all from the identical .devcontainer/ your team uses.
This matters because the alternative — a hand-maintained CI image that "roughly matches" the developer container — is where reproducibility quietly breaks. The moment the pipeline installs its own Node version, its own system packages, or its own toolchain, you have two environments that drift apart on their own schedules, and a green build stops meaning "this passes in the container people actually use." The devcontainer CLI closes that gap by making the pipeline a consumer of the same devcontainer.json, the same base image, and the same Features your team already resolves. There is exactly one source of truth, and CI reads it rather than re-implementing it.
Reach for the CLI whenever a container that normally opens behind an editor needs to run without one: pull-request test runs, nightly integration jobs, prebuild pipelines that bake a warm image for developers to attach to later, and config linting that catches a broken devcontainer.json before it wastes anyone's time. The mental model is simple — up is the editor's "Reopen in Container" reduced to a command, exec is the integrated terminal reduced to a command, and read-configuration is the resolver you can run on its own. Everything the editor does interactively, the CLI does as a script step, which is exactly what a headless runner needs.
Prerequisites
You need Node (for the CLI), a container engine, and a repository with a devcontainer config.
- Node.js installed to host the CLI (
npm i -g @devcontainers/cli). - A reachable container engine on the runner.
- A committed
.devcontainer/devcontainer.json.
Note that the CLI is distributed as an npm package (@devcontainers/cli) but is entirely separate from the editor's bundled copy. Installing it globally with npm i -g @devcontainers/cli gives you a stable, versioned binary the runner can pin, rather than relying on whatever version happens to ship inside an editor extension. Pin that CLI version in the same place you pin your other tooling, because the resolver's behavior — how Features are ordered, how metadata is merged — is part of what makes a build reproducible.
The container engine is the prerequisite people most often get wrong on a fresh runner. The CLI does not ship a daemon; it drives whatever engine is already present, and on a bare CI image that engine may be missing, unstarted, or reachable only through a rootless socket the CI user cannot see. Before you debug a single line of devcontainer.json, confirm the runner can talk to the engine on its own (a plain docker ps or podman ps is enough). A devcontainer up that fails instantly with a socket error is almost never a config problem — it is the engine not being wired up for the account the job runs as.
Step-by-Step Implementation
- Build and start the environment headlessly.
devcontainer up --workspace-folder .
The --workspace-folder . flag is what tells the CLI where to find .devcontainer/ and which directory to mount as the workspace, and it is not optional the way it feels in an editor that already knows your project root. up resolves the config, builds or pulls the image, applies every Feature, creates the container, and runs the onCreate/updateContent/postCreate lifecycle hooks — the same sequence the editor triggers on "Reopen in Container." Running it as the first pipeline step means a build failure surfaces here, on its own line, instead of being tangled up with a later test command. If this step succeeds, you have a running container that is byte-for-byte the environment your developers get.
- Run commands inside the running container — the headless equivalent of a terminal.
devcontainer exec --workspace-folder . -- npm test
The -- separator here is load-bearing: everything after it is the command run inside the container, not an argument the CLI tries to interpret for itself. Because exec reuses the container up already created, it inherits the same working directory, the same remoteUser, and the same remoteEnv the container was configured with — so npm test runs against the toolchain baked into the image, not against whatever happens to be on the runner's PATH. This is the difference that catches environment-specific bugs: a test that passes on a maintainer's laptop but relies on a globally installed binary will fail here, exactly as it would fail for a teammate who only has the container. Keep each logical stage in its own exec call so the pipeline log pins a failure to a specific command.
- Validate the config as a fast, cheap CI gate before the full build.
devcontainer read-configuration --workspace-folder . > /dev/null
read-configuration runs the resolver without building anything, so it is the cheapest gate you can put in front of an expensive job. It parses devcontainer.json, merges in the metadata every referenced Feature contributes, and emits the fully resolved config — which means a malformed JSON file, a Feature that no longer exists, or a variable that fails to substitute is caught in seconds rather than after a multi-minute image build. Redirecting to /dev/null discards the resolved output while still failing the step on a non-zero exit, which is all you want from a lint gate; if you need to inspect the merge instead, drop the redirect and read what the resolver actually produced. Placing this before up turns a slow, confusing build failure into a fast, obvious config failure.
- Tear down deterministically between CI runs.
devcontainer up --workspace-folder . --remove-existing-container
--remove-existing-container forces up to discard any container left over from a previous run and rebuild from the resolved config, which is the behavior you want on a shared or long-lived runner where the last job's container may still be sitting there. Without it, up will happily reuse an existing container, and a reused container carries the previous run's mutations — installed packages, edited files, cached state — that silently poison the current build and produce results no one can reproduce later. On an ephemeral runner that is torn down after every job, this flag costs you nothing; on a persistent runner it is the line that keeps runs independent. Treat a fresh container per run as the default and only relax it locally, where a warm container is a deliberate speed trade-off.
Common Pitfalls
Headless failures usually come from an editor-only assumption or a dirty container from a previous run.
The ownership trap shows up the moment you cache anything on the runner. If you mount a named volume or a host directory for a package cache — node_modules, a pip cache, a Go build cache — the files inside it are written as the container's remoteUser, and that UID may not match the UID the CI job runs as on the host. The next run then hits permission-denied errors that look like corruption but are really an ownership mismatch across the volume boundary. Pin the UID your container uses (via a Feature or the image itself), keep the cache owned by that UID consistently, and avoid switching between rootful and rootless engines on the same cache, because each writes files with a different ownership mapping and they do not agree.
The subtler headless pitfall is depending on state the editor quietly supplies. An editor session may forward your SSH agent, inject your ~/.gitconfig, expose host environment variables, or keep a login shell warm — none of which exist on a bare runner. A config or postCreate script that reaches for those things works every time locally and fails the first time it runs under the CLI. The fix is to make every dependency explicit in devcontainer.json: pass credentials through remoteEnv from the CI secret store, declare tooling as Features instead of assuming it is on the host, and test the config on a truly clean checkout so an accidental reliance on host state surfaces before it reaches the pipeline.
| Symptom | Root Cause | Remediation |
|---|---|---|
up works locally, fails in CI | Config depends on host/editor state | Remove host assumptions; use spec variables |
| Stale results between runs | Reused an old container | Add --remove-existing-container |
| Secrets missing headlessly | Editor supplied them interactively | Pass secrets via CI env/secret store |
| Slow every pipeline | No cache reuse | Cache image layers and named volumes on the runner |
Conclusion
The CLI is what makes 'works in CI' mean 'works in the developer environment,' because both build the identical .devcontainer/. Validate with read-configuration, run tests with exec, and rebuild fresh each run so results are deterministic.
The strategic payoff is that your pipeline stops being a second environment you have to maintain and becomes a consumer of the one you already have. Every hour spent pinning a base image digest, ordering Features deterministically, and keeping the config free of host assumptions now pays off twice — once when a developer opens the project, and again when the same config runs headlessly in CI. That is the whole pin-and-cache discipline expressed through a single tool: pin the inputs so the build is reproducible, cache the layers and named volumes so it is fast, and let the CLI resolve exactly what the editor resolves so the two never diverge.
This also unlocks prebuilds as a first-class workflow. Because up produces the same image the editor would, a scheduled job can run it ahead of time, warm the caches, and publish the result so developers attach to an already-built environment instead of waiting through a cold postCreate. The headless CLI is the bridge between the reproducibility you designed into devcontainer.json and the speed your team feels day to day — the config is the contract, and the CLI is what enforces it everywhere the config runs, editor or not.
FAQ
Is the CLI environment the same as the editor's?
Yes — both resolve and build the same .devcontainer/devcontainer.json. The editor adds an interactive attach experience, but the container, Features, and hooks are identical, so a config that passes the CLI behaves the same when a developer opens it. The one thing to keep in sync is the CLI version itself: because the resolver lives in the CLI, a stale global install can order Features or merge metadata differently from a newer editor extension. Pin the CLI where you pin your other tooling and the two stay in lockstep.
How do I pass secrets in CI without baking them in?
Supply them through your CI's secret store as environment variables and reference them via remoteEnv/containerEnv or at exec time. Never bake secrets into the image or the config; the CLI runs headlessly, so there is no interactive prompt to lean on. Prefer injecting a secret at the exec step that actually needs it rather than at up, so it lives only for the command that consumes it and never lands in an image layer. If a secret has to be present during the build, use a build-time secret mount rather than an ARG, because build arguments are recorded in the image history.
Do I need --remove-existing-container every run? In CI, yes — it guarantees a clean build rather than reusing a container from a previous job, which is the usual cause of non-deterministic results. Locally you can omit it to keep a warm container for speed. The distinction that matters is whether the runner is ephemeral or persistent: a runner that is destroyed after each job gives you a fresh container for free, while a long-lived, shared runner will hand you the last job's container unless you force the rebuild. When in doubt, keep the flag — a slightly slower clean build is cheaper than chasing a phantom failure caused by leftover state.
How do I speed up up without giving up a clean build?
Cache the expensive inputs rather than reusing the container. Pin your base image to a digest and let the runner cache image layers, and back package directories with named volumes that persist across jobs so dependency installs hit a warm cache. You still rebuild the container each run with --remove-existing-container, but the layers and caches it draws on are already present, so the build is both fresh and fast. A dedicated prebuild job that warms those caches on a schedule keeps the first run of the day from paying the cold-start cost.
Can I run the CLI without an interactive terminal at all?
Yes — that is the point of it. up, exec, and read-configuration all run non-interactively and communicate success or failure through their exit codes, which is exactly what a pipeline step reads. Avoid commands inside exec that expect a TTY or block on input, since there is no terminal attached; pass flags that select non-interactive behavior and provide any needed values through environment variables. If a lifecycle hook or test harness insists on a TTY, that is a signal to make it scriptable rather than to try to fake a terminal on the runner.
Which command should fail the build first?
Order the steps cheapest-to-most-expensive so failures surface early. Run read-configuration first as a fast lint of the config, then up to build and start, then one exec per logical test stage. That ordering means a typo in devcontainer.json fails in seconds, a build problem fails before any tests run, and a test failure is pinned to the exact exec command that produced it — turning a single opaque red build into a precise, readable failure.
Related
- Up to Understanding the DevContainer Specification — the config the CLI drives.
- How to Configure devcontainer.json from Scratch — the config to run headlessly.
- Forwarding and Securing Ports in DevContainers — port handling when running services in CI.