Running the devcontainer CLI in GitHub Actions
You want CI to run against the exact environment developers use. This page runs the Dev Container CLI in GitHub Actions — via the official devcontainers/ci action or the CLI directly — so your build and tests execute inside the committed .devcontainer/, not a separate runner setup.
This matters because the most common cause of "works on my machine, fails in CI" is that CI and the developer laptop are two different environments that happen to overlap. A GitHub Actions runner ships its own preinstalled Node, Python, and system libraries, and a workflow that installs a toolchain with setup-node or apt-get is quietly authoring a second environment definition that has to be kept in sync with .devcontainer/devcontainer.json by hand. Running the Dev Container CLI in Actions collapses those two definitions into one: the same Dockerfile, image, Features, and postCreateCommand that build a contributor's container also build the container that runs on the runner. When the config changes, both move together, because there is only one config.
Reach for this pattern when your repository already commits a devcontainer that developers use daily and you want CI to inherit it rather than reinvent it. The mental model is a thin outer shell and a faithful inner environment: the Actions job owns only checkout, the container build, the command to run, and the layer cache, while everything about how code is built and tested lives inside the container image. The runner becomes a place to stand up the container and stream its logs, not a place where your toolchain is configured. Once you internalize that split, the rest of this page — the action inputs, the CLI invocation, the caching flags — is just wiring that outer shell to the inner environment you already trust.
Prerequisites
You need a GitHub repo with a committed devcontainer config.
- A committed
.devcontainer/devcontainer.json. - A GitHub Actions workflow file.
- Optionally a registry for build caching.
The prerequisite people most often get wrong is the workspace folder. The Dev Container CLI resolves .devcontainer/devcontainer.json relative to the workspace root you hand it, so the actions/checkout step has to land the repository — config included — before the container build runs, and the build has to be pointed at that checkout root rather than a subdirectory. If your devcontainer lives in a monorepo package instead of the top level, the workspace folder is the directory that contains the .devcontainer/ folder, not the repository root; getting this wrong produces a "config not found" failure that looks like a missing file when it is really a wrong path. The optional registry for build caching is genuinely optional for a first working pipeline, but you will want it the moment build time starts dominating the job, so it is worth having a ghcr.io image name in mind from the start.
Step-by-Step Implementation
- Add the devcontainers/ci action to build and run inside the container.
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: devcontainers/ci@v0.3
with:
runCmd: npm ci && npm test
This is the whole faithful pipeline in six lines. actions/checkout@v4 brings the repository and its .devcontainer/ onto the runner; devcontainers/ci@v0.3 then reads that config, builds the container image exactly as a developer's editor would, and runs runCmd inside the built container. The important detail is that npm ci && npm test never touches the runner's host Node — it executes against whatever Node version, global packages, and environment variables the devcontainer image defines, which is precisely the point. Pinning the action to a released tag like @v0.3 rather than a floating branch keeps the wrapper's behavior stable, so a change in CI results always traces back to your config or your code, never to the action silently updating underneath you.
- Add layer caching with a registry image.
with:
cacheFrom: ghcr.io/acme/dev-image
push: never
cacheFrom tells the build to pull ghcr.io/acme/dev-image first and reuse any layers whose inputs have not changed, which turns a cold multi-minute image build into a fast incremental one on every run after the first. Setting push: never says this pull-request job is a consumer of the cache, not a producer — it reads published layers but does not write new ones back to the registry, which is the right posture for jobs triggered by untrusted forks or by contributors without registry write permissions. Populating that cache image is a separate concern, typically a scheduled or main-branch workflow that builds and pushes the dev image so pull-request runs have something warm to pull from; keeping the producer and the consumer as distinct jobs prevents a fork's PR from ever needing your registry credentials.
- Or use the CLI directly if you need finer control.
npm i -g @devcontainers/cli
devcontainer up --workspace-folder .
devcontainer exec --workspace-folder . -- npm test
Dropping to the CLI trades the action's convenience for explicit control over each phase. devcontainer up --workspace-folder . builds the image and starts the container as a long-lived background process; devcontainer exec --workspace-folder . then runs commands inside that already-running container, so you can issue several exec calls — lint, unit tests, integration tests, a coverage upload — against one warm container instead of rebuilding for each. The --workspace-folder . on both commands is what ties them together: up and exec must name the same folder so exec attaches to the container up just created, and everything after the -- is passed verbatim into the container rather than being interpreted by your shell on the runner. This is the pattern to choose when the action's single runCmd string is too coarse for the sequence of steps you actually want.
- Confirm the logs show a build from
.devcontainer/.
Confirmation is not busywork — it is how you catch a job that has silently regressed to running on the host. In a healthy run the logs show the CLI resolving .devcontainer/devcontainer.json, building or pulling the image, and only then executing your test command, with the container's own tool versions echoed back. If the logs jump straight to npm test with no build phase and a Node version that matches the runner rather than your config, the command escaped the container and is testing the wrong environment. Reading the build lines once, deliberately, is cheaper than chasing a green check that never actually exercised your devcontainer.
Common Pitfalls
Actions failures are a missing config, host-run tests, or no cache.
The permission angle bites hardest around the registry cache. GitHub Actions builds run as a specific user, and the GITHUB_TOKEN handed to a pull-request job from a fork is deliberately read-only, so a cacheFrom that quietly assumes push access will fail — or worse, appear to work while writing nothing. Keep the cache-producing job (which needs packages: write and pushes to ghcr.io) separate from the cache-consuming PR job (which only reads), and let the container itself run as the non-root remoteUser your config declares so files created during the build and test steps do not end up owned by root in a mounted volume. When a cache mount or a bind-mounted workspace picks up root-owned files in one run, a later run as the devcontainer's user hits permission-denied on those exact paths, which reads as a flaky test but is really an ownership mismatch left behind by an earlier job.
The most topic-specific pitfall is the silent host escape: a workflow that keeps a setup-node step and a bare run: npm test alongside the devcontainers action tests on the runner's Node, not the container's, even though the container built successfully right above it. The build succeeding lulls you into trusting the job, but the assertion that matters — that tests ran against the committed environment — was never met. Every test invocation has to flow through runCmd or devcontainer exec; any run: step that shells out directly on the runner is, by definition, outside the container and should be treated as a bug in the workflow rather than a convenience.
| Symptom | Root Cause | Remediation |
|---|---|---|
| Tests run on the runner, not the container | Ran outside runCmd/exec | Use runCmd or devcontainer exec |
| Every run rebuilds fully | No cacheFrom | Add a registry cache image |
| Config not found | Wrong workspace folder | Point at the repo root |
| Slow base pulls | Cold pull each run | Use a prebuilt or cached image |
Conclusion
The devcontainers/ci action (or the CLI) makes GitHub Actions build and test inside your committed .devcontainer/, so CI and developers share one environment. Add a registry layer cache and the pipeline is both faithful and fast.
The strategic payoff is that your devcontainer config becomes the single source of truth for what "the environment" means, and CI becomes a consumer of it rather than a competing definition. Every time a Feature is added, a base image is bumped, or a postCreateCommand changes, the pull request that makes that change also proves it in CI, because the same build runs on the runner. This is the reproducibility theme applied to continuous integration: pin the image and Features so a build is deterministic, cache the resulting layers so determinism does not cost you speed, and let the outer Actions shell stay thin enough that it never becomes a place where drift can hide.
Tie this back to the wider pin-and-cache discipline and the picture is coherent end to end. A pinned base image and pinned Feature versions give you a build that resolves to the same bytes today and next quarter; cacheFrom against a prebuilt registry image gives you that same build in seconds instead of minutes; and running tests through runCmd or devcontainer exec guarantees the thing you cached and pinned is the thing actually under test. Adopt all three together and the gap between a developer's laptop and the CI runner closes to nearly nothing — which is the entire reason to run the Dev Container CLI in GitHub Actions in the first place.
FAQ
Should I use the action or the CLI?
The devcontainers/ci action is simplest for common cases — it wraps build, run, and caching behind a few inputs. Use the CLI directly when you need finer control, such as running multiple exec steps or custom lifecycle handling. Both build the same committed config. A practical rule is to start with the action because it is the least code to get a faithful pipeline, and switch to the CLI only when you hit a concrete limit — you want to run four separate exec phases against one warm container, or you need to inspect the container between steps. Since both read the identical .devcontainer/devcontainer.json, moving from one to the other never changes what environment your tests run in, only how the job orchestrates them.
How do I run tests inside the container in Actions?
Put your command in the action's runCmd (for example npm ci && npm test), or with the CLI run devcontainer exec -- npm test after devcontainer up. Either executes against the container's toolchain rather than the runner's host. The failure mode to watch for is a stray run: step that calls your test command directly on the runner — it will pass or fail using the runner's preinstalled tools and tell you nothing about your actual environment. If a command is meant to test your code, it belongs inside runCmd or behind devcontainer exec, never in a bare workflow run: block.
How do I make CI builds fast?
Set cacheFrom to a registry image so the build reuses layers between runs, and publish a prebuilt dev image on a schedule so CI pulls it. Caching turns a cold multi-minute build into a fast pull-and-run. Order your Dockerfile and Features so the slowest, least-frequently-changing layers sit near the bottom, because a cache hit only helps up to the first layer whose inputs changed. The combination of a nightly prebuild pushing a warm image and pull-request jobs pulling it with push: never gives you the best of both: the expensive build happens once, off the critical path, and every PR run starts from it.
Why did my job build the container but still test the wrong Node version?
Almost always because the test command ran outside the container. A successful build followed by a host-run test is a common trap: the devcontainers action or devcontainer up builds the image correctly, but a separate run: npm test step executes on the runner using its preinstalled Node. Route the command through runCmd or devcontainer exec and the version echoed in the logs will match your config rather than the runner image.
Can forks run this pipeline without registry access?
Yes, as long as the pull-request job only consumes the cache. Set push: never so the job reads layers from cacheFrom but never needs write credentials, and keep the cache-producing build on a trusted trigger like a push to your default branch or a scheduled workflow. GitHub deliberately restricts the GITHUB_TOKEN for fork PRs, so a consumer-only job is what lets outside contributors get fast, faithful CI without ever touching your registry.
Does this work with a Dockerfile, an image, or Docker Compose config?
All three. The Dev Container CLI and the devcontainers/ci action honor whatever .devcontainer/devcontainer.json declares, whether that is a prebuilt image, a build pointing at a Dockerfile, or a dockerComposeFile with a named service. Your workflow does not change based on which one you use — you still checkout, build via the action or CLI, and run through runCmd or exec — because the config, not the workflow, decides how the container comes to exist.
Related
- Up to CI/CD Integration with DevContainers — the overview of CI integration.
- Caching DevContainer Builds in CI — registry and volume caching.
- Testing Inside a DevContainer in CI — the exec/runCmd test pattern.