Dev Containers for Reproducible Test Environments

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=test

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

Read more