Dev Containers for Reproducible Test Environments
Every engineering team has experienced the same painful scenario: a test suite passes flawlessly on one developer's laptop, fails on another's, and produces yet different results in CI. The root cause is almost always environment drift — different Node versions, mismatched library versions, missing system dependencies, or subtle OS differences. Dev containers solve this definitively by packaging the entire development and test environment as code.
What Dev Containers Are
A dev container is a Docker-based development environment defined in a .devcontainer/devcontainer.json file. When you open a project in VS Code with the Dev Containers extension (or use the devcontainer CLI), the editor runs entirely inside the container. Your code, your terminal, your debugger, and your test runner all execute in a controlled, reproducible environment.
The key insight is that the environment definition lives in source control alongside the code. Every developer clones the repo and gets an identical environment. No setup docs, no manual dependency installs, no version mismatches.
The specification is open and supported by GitHub Codespaces, VS Code, JetBrains, and the devcontainer CLI for headless/CI use.
Defining a Test Environment in devcontainer.json
Here is a realistic .devcontainer/devcontainer.json for a Node.js application that requires PostgreSQL and Redis for integration tests:
{
"name": "myapp-dev",
"dockerComposeFile": "docker-compose.yml",
"service": "app",
"workspaceFolder": "/workspace",
"features": {
"ghcr.io/devcontainers/features/node:1": {
"version": "20"
},
"ghcr.io/devcontainers/features/common-utils:2": {
"installZsh": true,
"configureZshAsDefaultShell": true
}
},
"postCreateCommand": "npm ci",
"postStartCommand": "npm run db:migrate",
"forwardPorts": [3000, 5432, 6379],
"customizations": {
"vscode": {
"extensions": [
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode",
"ms-azuretools.vscode-docker"
],
"settings": {
"editor.formatOnSave": true,
"terminal.integrated.defaultProfile.linux": "zsh"
}
}
}
}The companion .devcontainer/docker-compose.yml defines all services:
version: "3.8"
services:
app:
build:
context: ..
dockerfile: .devcontainer/Dockerfile
volumes:
- ..:/workspace:cached
- node_modules:/workspace/node_modules
command: sleep infinity
environment:
DATABASE_URL: postgres://testuser:testpass@db:5432/testdb
REDIS_URL: redis://cache:6379
NODE_ENV: test
depends_on:
db:
condition: service_healthy
cache:
condition: service_healthy
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: testuser
POSTGRES_PASSWORD: testpass
POSTGRES_DB: testdb
healthcheck:
test: ["CMD-SHELL", "pg_isready -U testuser -d testdb"]
interval: 5s
timeout: 5s
retries: 10
cache:
image: redis:7-alpine
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 10
volumes:
node_modules:The Dockerfile in .devcontainer/ is minimal — it extends a base image and adds any system-level tools your tests need:
FROM mcr.microsoft.com/devcontainers/base:ubuntu-22.04
RUN apt-get update && apt-get install -y \
postgresql-client \
redis-tools \
curl \
&& rm -rf /var/lib/apt/lists/*Running Tests Inside the Container
VS Code
With the Dev Containers extension installed, open the Command Palette and select Dev Containers: Reopen in Container. VS Code rebuilds if needed, then attaches. From this point, the integrated terminal runs inside the container — npm test, psql, redis-cli all work without any local installs beyond Docker.
Your test runner configuration in package.json can rely on the environment variables set in docker-compose.yml:
{
"scripts": {
"test": "jest --runInBand",
"test:integration": "jest --testPathPattern=integration --runInBand",
"test:watch": "jest --watch",
"db:migrate": "node-pg-migrate up",
"db:reset": "node-pg-migrate down && node-pg-migrate up"
}
}CLI (devcontainer CLI)
The devcontainer CLI allows running tests without opening VS Code at all — critical for scripting and CI:
# Install
npm install -g @devcontainers/cli
<span class="hljs-comment"># Start the container
devcontainer up --workspace-folder .
<span class="hljs-comment"># Run a command inside it
devcontainer <span class="hljs-built_in">exec --workspace-folder . npm <span class="hljs-built_in">test
<span class="hljs-comment"># Run integration tests only
devcontainer <span class="hljs-built_in">exec --workspace-folder . npm run <span class="hljs-built_in">test:integration
<span class="hljs-comment"># Tear down
devcontainer down --workspace-folder .This is identical to what a developer runs locally. No special CI configuration required for the test command itself.
GitHub Actions Using devcontainer CI
GitHub Actions has native support for dev containers through the devcontainers/ci action. This runs your tests inside exactly the same container as your local environment:
name: Test Suite
on:
push:
branches: [main]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Cache devcontainer image
uses: actions/cache@v4
with:
path: /tmp/.buildx-cache
key: devcontainer-${{ hashFiles('.devcontainer/**') }}
restore-keys: devcontainer-
- name: Run tests in dev container
uses: devcontainers/ci@v0.3
with:
runCmd: npm run test:integration
env: |
NODE_ENV=testThe devcontainers/ci action builds the container image (using the cache), starts all services defined in the Docker Compose file, and runs your command. The build cache means subsequent runs skip the image build entirely, keeping CI fast.
For more control, you can run the devcontainer CLI directly:
- name: Install devcontainer CLI
run: npm install -g @devcontainers/cli
- name: Start container
run: devcontainer up --workspace-folder .
- name: Run unit tests
run: devcontainer exec --workspace-folder . npm test
- name: Run integration tests
run: devcontainer exec --workspace-folder . npm run test:integration
- name: Upload coverage
uses: actions/upload-artifact@v4
with:
name: coverage
path: coverage/Handling Secrets and Environment Variables
Test databases should use fixed credentials (it's a throwaway container), but real third-party API keys need proper handling. Pass them through Docker Compose's environment variable interpolation:
In .devcontainer/docker-compose.yml:
services:
app:
environment:
DATABASE_URL: postgres://testuser:testpass@db:5432/testdb
STRIPE_TEST_KEY: ${STRIPE_TEST_KEY}
SENDGRID_API_KEY: ${SENDGRID_API_KEY}Locally, put these in a .env file (gitignored). In GitHub Actions, use repository secrets:
- name: Run tests in dev container
uses: devcontainers/ci@v0.3
with:
runCmd: npm test
env: |
STRIPE_TEST_KEY=${{ secrets.STRIPE_TEST_KEY }}
SENDGRID_API_KEY=${{ secrets.SENDGRID_API_KEY }}Pre-building and Publishing Images
Rebuilding the dev container image on every CI run wastes time. GitHub publishes tooling to pre-build and cache these images in a container registry:
name: Pre-build Dev Container
on:
schedule:
- cron: "0 2 * * 0" # Weekly rebuild
push:
paths:
- ".devcontainer/**"
jobs:
prebuild:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: devcontainers/ci@v0.3
with:
imageName: ghcr.io/myorg/myapp-devcontainer
cacheFrom: ghcr.io/myorg/myapp-devcontainer
push: always
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}In .devcontainer/devcontainer.json, reference the pre-built image:
{
"image": "ghcr.io/myorg/myapp-devcontainer:latest",
"dockerComposeFile": "docker-compose.yml"
}CI runs that reference this pre-built image skip the build step entirely and go straight to running tests.
Benefits Beyond "Works on My Machine"
The reproducibility guarantee is the headline benefit, but dev containers deliver more:
Onboarding speed. A new engineer clones the repo, opens VS Code, and has a working test suite in minutes instead of hours.
Test environment parity. The same container runs locally, in PR checks, and on the main branch. If tests pass in the container, they pass everywhere.
Dependency auditing. Every system dependency is declared in the Dockerfile. There are no implicit dependencies pulled from a developer's machine.
Parallel test suites. Multiple developers can run the full integration suite simultaneously without port conflicts or database collisions — each gets their own isolated container stack.
Easy environment upgrades. Bumping Node from 20 to 22 is a one-line change in devcontainer.json. Every developer gets the update on next container rebuild, with no manual steps.
The initial investment — writing the devcontainer configuration — typically pays off within the first week of onboarding a new team member or tracking down a CI-only test failure.