E2E in Docker and Ephemeral Environments

Reproducible test environments are the foundation of reliable CI/CD. Learn how to use Docker Compose for local and CI E2E testing, and how to run smoke tests against ephemeral preview deployments.

This post is part 3 of the CI/CD Quality series. Part 2 covered GitHub Actions — workflows, caching, and Playwright integration.


Introduction — The “Works on My Machine” Problem

A test suite that passes locally but fails in CI is worse than no test suite. It trains developers to ignore CI failures.

The root cause is environmental inconsistency. Your local machine has different:

  • Node version
  • Database version
  • Environment variables
  • Network latency
  • File system behaviour

Docker solves this by packaging the application and its dependencies into a container that runs identically everywhere.

This post covers how to use Docker Compose for reproducible E2E testing locally and in CI, and how to run smoke tests against ephemeral preview environments.


Why Docker for E2E Tests

Reproducibility — Same container runs on developer laptops, CI runners, and production servers. No “works on my machine” failures.

Isolation — Each test run starts from a clean state. No leftover data from previous runs.

Parallelisation — Run multiple isolated environments simultaneously for parallel test execution.

Simplicity — Developers run docker compose up instead of manually installing Postgres, Redis, and Elasticsearch.

:::tip[Docker vs. Mocks] Mocking external dependencies (databases, message queues) in tests is fast but fragile. Mocks drift from real behaviour. Docker lets you test against the real database with minimal overhead. :::


Docker Compose for Local E2E

Docker Compose orchestrates multi-container applications. Define your app, database, and dependencies in docker-compose.yml.

Example: Web App + Postgres

# docker-compose.yml
version: '3.8'

services:
  db:
    image: postgres:16
    environment:
      POSTGRES_USER: testuser
      POSTGRES_PASSWORD: testpass
      POSTGRES_DB: testdb
    ports:
      - "5432:5432"
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U testuser"]
      interval: 5s
      timeout: 5s
      retries: 5

  app:
    build: .
    ports:
      - "3000:3000"
    environment:
      DATABASE_URL: postgresql://testuser:testpass@db:5432/testdb
      NODE_ENV: test
    depends_on:
      db:
        condition: service_healthy

Key features:

  • db service runs Postgres 16
  • healthcheck ensures Postgres is ready before starting the app
  • app service waits for db to be healthy (depends_on with condition)
  • Environment variables configured for test mode

Running E2E Tests with Docker Compose

# Start services
docker compose up -d

# Wait for app to be ready
npx wait-on http://localhost:3000

# Run Playwright tests
npm run test:e2e

# Stop services
docker compose down

Alternatively, script this in package.json:

{
  "scripts": {
    "test:e2e:docker": "docker compose up -d && npx wait-on http://localhost:3000 && npm run test:e2e; docker compose down"
  }
}

:::warning[Data Persistence] By default, Docker Compose persists data in volumes. For tests, you want a clean slate every run. Use docker compose down -v to remove volumes and reset state. :::


Docker Compose in CI

The same docker-compose.yml runs in GitHub Actions.

GitHub Actions Workflow with Docker Compose

name: E2E Tests

on: [pull_request]

jobs:
  e2e:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'npm'
      
      - run: npm ci
      
      - name: Start services with Docker Compose
        run: docker compose up -d
      
      - name: Wait for app to be ready
        run: npx wait-on http://localhost:3000 --timeout 60000
      
      - name: Install Playwright browsers
        run: npx playwright install --with-deps chromium
      
      - name: Run E2E tests
        run: npm run test:e2e
      
      - name: Stop services
        if: always()
        run: docker compose down -v
      
      - name: Upload test report
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: playwright-report
          path: playwright-report/

Why this works:

  • docker compose up -d starts services in the background
  • wait-on blocks until the app responds on http://localhost:3000
  • Playwright runs tests against http://localhost:3000
  • docker compose down -v cleans up containers and volumes (if: always() ensures cleanup even if tests fail)

:::info[CI Performance] Starting Docker containers adds 20–40 seconds to CI runs. This is acceptable for E2E tests but too slow for unit tests. Use Docker only for integration and E2E test suites. :::


Seeding Test Data

E2E tests need realistic data. Seed the database before running tests.

Database Seeding Script

// scripts/seed-test-db.ts
import { Client } from 'pg';

const client = new Client({
  connectionString: process.env.DATABASE_URL,
});

async function seed() {
  await client.connect();
  
  await client.query(`
    INSERT INTO users (email, name, role)
    VALUES 
      ('admin@test.com', 'Admin User', 'admin'),
      ('user@test.com', 'Regular User', 'user');
  `);
  
  await client.query(`
    INSERT INTO products (name, price, stock)
    VALUES 
      ('Product A', 29.99, 100),
      ('Product B', 49.99, 50);
  `);
  
  await client.end();
  console.log('Test database seeded');
}

seed().catch(console.error);

Run seeding after services start:

      - name: Start services
        run: docker compose up -d
      
      - name: Wait for app
        run: npx wait-on http://localhost:3000
      
      - name: Seed test data
        run: npm run seed:test
      
      - name: Run E2E tests
        run: npm run test:e2e

:::tip[Idempotent Seeds] Make seeding scripts idempotent — safe to run multiple times. Use INSERT ... ON CONFLICT DO NOTHING or DELETE FROM table before inserting. This prevents failures when running tests repeatedly during development. :::


Ephemeral Preview Environments

Ephemeral environments are temporary deployments created for each pull request. They allow you to test the application in a production-like environment before merging.

Services like Vercel, Netlify, and Heroku Review Apps automatically deploy preview environments for every PR.

Vercel Preview Deployments

Vercel automatically deploys every push to a unique URL:

https://my-app-pr123-abc1234.vercel.app

This URL is posted as a comment on the pull request.

Running Smoke Tests Against Preview Deployments

Wait for the preview deployment to complete, then run smoke tests against it.

name: Preview E2E

on:
  pull_request:

jobs:
  preview-smoke-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'npm'
      
      - run: npm ci
      
      - name: Wait for Vercel deployment
        uses: UnlyEd/github-action-await-vercel@v1
        with:
          deployment-url: ${{ github.event.pull_request.head.sha }}
          timeout: 300
        env:
          VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
      
      - name: Install Playwright
        run: npx playwright install --with-deps chromium
      
      - name: Run smoke tests against preview
        env:
          BASE_URL: ${{ steps.await-vercel.outputs.url }}
        run: npm run test:e2e:smoke
      
      - name: Upload test report
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: preview-smoke-report
          path: playwright-report/

Key features:

  • await-vercel action waits for Vercel deployment to complete
  • BASE_URL environment variable points tests at the preview URL
  • Smoke tests run against the deployed preview, not localhost

Configuring Playwright for Dynamic Base URL

// playwright.config.ts
import { defineConfig } from '@playwright/test';

export default defineConfig({
  use: {
    baseURL: process.env.BASE_URL || 'http://localhost:3000',
  },
  projects: [
    {
      name: 'chromium',
      use: { browserName: 'chromium' },
    },
  ],
});

Now tests use the preview URL when BASE_URL is set:

test('homepage loads', async ({ page }) => {
  await page.goto('/');  // Uses baseURL from config
  await expect(page.locator('h1')).toContainText('Welcome');
});

:::warning[Preview Environment Limitations] Preview environments often use separate databases or mock data. Do not assume data parity with production. Test UI rendering, navigation, and authentication — not production data integrity. :::


Comparing Local Docker vs. Preview Environments

AspectLocal DockerPreview Environment
Speed20–40s startup60–180s deployment
IsolationFull control over servicesCloud-managed, limited control
DataSeed scripts, full controlMock data or separate test DB
Use caseIntegration/E2E tests in CISmoke tests on real infra
CostFree (runs on CI runner)Depends on platform tier

Recommendation: Use Docker Compose for full E2E test suites in CI. Use preview environments for lightweight smoke tests that validate deployment and routing.


Practical Example — Full E2E CI Workflow

Combine Docker Compose for local testing and preview smoke tests in a single workflow.

name: E2E Tests

on: [pull_request]

jobs:
  e2e-local:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'npm'
      - run: npm ci
      
      - name: Start services
        run: docker compose up -d
      
      - name: Wait for app
        run: npx wait-on http://localhost:3000 --timeout 60000
      
      - name: Seed test data
        run: npm run seed:test
      
      - name: Install Playwright
        run: npx playwright install --with-deps chromium
      
      - name: Run full E2E suite
        run: npm run test:e2e
      
      - name: Stop services
        if: always()
        run: docker compose down -v
      
      - name: Upload report
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: e2e-report
          path: playwright-report/

  preview-smoke:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'npm'
      - run: npm ci
      
      - name: Wait for Vercel
        id: await-vercel
        uses: UnlyEd/github-action-await-vercel@v1
        with:
          deployment-url: ${{ github.event.pull_request.head.sha }}
          timeout: 300
        env:
          VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
      
      - name: Install Playwright
        run: npx playwright install --with-deps chromium
      
      - name: Run smoke tests
        env:
          BASE_URL: ${{ steps.await-vercel.outputs.url }}
        run: npm run test:e2e:smoke
      
      - name: Upload report
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: preview-smoke-report
          path: playwright-report/

Two jobs:

  1. e2e-local — Full E2E suite against Docker Compose services
  2. preview-smoke — Smoke tests against Vercel preview deployment

Both run in parallel. Total CI time: ~5–7 minutes.


Conclusion — Environments as Code

Reproducible test environments are the foundation of reliable CI/CD. Docker Compose makes it trivial to package your application, dependencies, and test data into a portable, versioned environment definition.

The principles:

  • Use Docker Compose for local and CI E2E tests
  • Seed test data in a repeatable, idempotent way
  • Clean up containers and volumes after every run
  • Run smoke tests against preview deployments to validate real infrastructure
  • Separate concerns — full E2E in Docker, smoke tests in preview

When “works on my machine” becomes “works in the container,” CI failures become actionable and trustworthy.

Action for this week: Create a docker-compose.yml for your project with your app and its primary dependency (database, cache, etc.). Run one E2E test against it locally. Measure the time from docker compose up to test completion.


Next in this series: Part 4 — Reports, Artifacts, and Failure Triage in CI