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.

CLI prerequisitesThe CLI needs Node, an engine, and a committed config to drive headlessly.Node + CLInpm i -gEngineon the runnerConfig.devcontainer/

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

  1. 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.

  1. 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.

  1. 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.

  1. 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.

Headless commandsThe CLI builds, execs commands, validates config, and tears down for clean runs.upbuild + startexecrun testsread-configurationvalidate--remove-existingclean run

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.

Headless triageA triage path from an editor-coupled config to a clean headless run.Does devcontainer up succeed on a cleanrunner?NOConfig assumes editor/host stateDoes exec run the same as a localterminal?YESRebuild fresh each CI runCI uses the developer environment

SymptomRoot CauseRemediation
up works locally, fails in CIConfig depends on host/editor stateRemove host assumptions; use spec variables
Stale results between runsReused an old containerAdd --remove-existing-container
Secrets missing headlesslyEditor supplied them interactivelyPass secrets via CI env/secret store
Slow every pipelineNo cache reuseCache 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.

CLI capabilitiesThe CLI builds, execs, and validates headlessly for CI and prebuilds.CLI gives youHeadless buildIn-container execConfig validationUse it forCI test runsPrebuild warmingConfig linting

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.