Back to Blog
April 23, 2026

Level Up Your Testing: Spin Up Isolated Test Environments with Docker Compose

Your tests deserve their own sandbox — here's how to stop stomping on your dev database and start shipping with confidence.

Docker Compose spinning up isolated Postgres, Redis, and app containers for test runs

You know this bug. The test that passed on your laptop fails in CI. Or the test that passed yesterday fails today because your teammate ran a migration against the shared dev database. Or the test that corrupted your local data because it ran "DELETE FROM users" without a WHERE clause and now you need to re-seed everything.

The root cause is almost always the same: your tests are sharing state with something they should not be sharing state with. A dev database. A running Redis instance. A local API. And the fix is not "write more careful tests" — it is to give every test run its own disposable environment, torn down the moment the tests finish.

Docker Compose makes this trivial. According to the 2025 Stack Overflow Developer Survey, 59% of professional developers already use Docker, making it the second most popular tool after Git. And the DORA 2024 State of DevOps Report found that elite-performing teams — those who deploy on-demand and recover from incidents in under an hour — are 2.4x more likely to use isolated, containerized test environments than low performers. This is not an advanced technique. It is table stakes.

Why Should Vibe Coders Use Docker Compose for Testing?

Answer capsule: Docker Compose spins up a fresh, isolated database and services for each test run, so tests never share state with your dev machine.

The typical vibe-coder testing setup looks like this: one Postgres instance on localhost, one Redis instance on localhost, and a test suite that connects to both and hopes nothing else is writing to them at the same time. It works until it does not — and when it stops working, the failure mode is always a test suite that cannot be trusted.

A Docker Compose test environment gives you something better: a definition file that says "for this test run, create a fresh Postgres, a fresh Redis, a fresh anything-else I need, wire them together on their own network, and throw them away when the run finishes." No state leaks. No port collisions. No "did I remember to reset the database?" anxiety.

Shared Dev DB vs Docker Compose: What Actually Changes?

Before writing any config, here is how the two approaches compare in practice:

ConcernShared Local DBDocker Compose
Setup on a new machineInstall Postgres, Redis, match versionsdocker compose up
Isolation between testsManual cleanup, leakyFresh container per run
Version drift in CIFrequent (OS differences)Pinned image tags everywhere
Parallel test runsPort collisions, shared dataRandom ports, isolated networks
Test data setupTruncate tables, reset sequences, prayInit scripts run fresh every time
TeardownManual, easy to forgetdocker compose down -v
Works the same in CIRarelyAlways

Step 1: Write Your First docker-compose.test.yml

Keep your production compose file alone. Create a dedicated docker-compose.test.yml that defines exactly the services your tests need — nothing more, nothing less. Here is a battle-tested starting point for a typical Node.js app with Postgres and Redis:

# docker-compose.test.yml
services:
  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: test
      POSTGRES_PASSWORD: test
      POSTGRES_DB: app_test
    # Map to port 0 — Docker assigns a random free port on the host.
    # This lets parallel test runs coexist without collisions.
    ports:
      - "5432"
    # tmpfs mounts data in RAM — blazing fast, and wiped on container stop
    tmpfs:
      - /var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U test -d app_test"]
      interval: 2s
      timeout: 3s
      retries: 15

  cache:
    image: redis:7-alpine
    ports:
      - "6379"
    # Redis in-memory — no persistence needed for tests
    command: ["redis-server", "--save", "", "--appendonly", "no"]
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 2s
      timeout: 3s
      retries: 10

  app:
    build:
      context: .
      dockerfile: Dockerfile.test
    environment:
      NODE_ENV: test
      DATABASE_URL: postgres://test:test@db:5432/app_test
      REDIS_URL: redis://cache:6379
    depends_on:
      db:
        condition: service_healthy
      cache:
        condition: service_healthy
    command: ["npm", "run", "test"]

Three things make this file different from your production compose file. First, tmpfs puts the database on a RAM disk — fsync is a no-op, every insert is instant, and there is nothing to clean up because RAM is wiped when the container stops. Second, port 0 tells Docker to pick any free host port, so you can run this alongside another test run (or your dev server) without collisions. Third, healthchecks ensure the app container waits until the database is actually ready to accept connections — not just "the process started."

Edge case — Alpine image quirks: The Postgres Alpine image does not include pg_dump variants for non-default locales. If your tests rely on collation-sensitive sorting, switch to postgres:16 (Debian-based) or set POSTGRES_INITDB_ARGS="--locale=C".

Step 2: Wire the Environment Into Your Test Runner

The compose file defines the environment. Now you need a script that starts it, runs your tests against it, and tears it down — whether the tests pass, fail, or blow up. A single npm script handles all three:

// package.json
{
  "scripts": {
    "test": "vitest run",
    "test:integration": "./scripts/run-integration-tests.sh",
    "test:watch": "vitest watch"
  }
}
#!/usr/bin/env bash
# scripts/run-integration-tests.sh
set -euo pipefail

# Unique project name per run — lets parallel invocations coexist
PROJECT="test-${RANDOM}-$(date +%s)"

# Always tear down, even if tests fail
cleanup() {
  echo "Tearing down test environment..."
  docker compose -p "$PROJECT" -f docker-compose.test.yml down -v --remove-orphans
}
trap cleanup EXIT

echo "Starting test environment ($PROJECT)..."
docker compose -p "$PROJECT" -f docker-compose.test.yml up \
  --build --abort-on-container-exit --exit-code-from app

# exit code from the "app" service propagates via set -e

The trap cleanup EXIT line is the most important part. It guarantees docker compose down -v runs no matter how the script exits — passed tests, failed tests, Ctrl+C, OOM kill, whatever. The --exit-code-from app flag tells Docker to return the exit code of the app container, so CI knows whether your tests actually passed.

Now npm run test:integration does the whole dance. A new engineer joining your team clones the repo, installs Docker Desktop, runs one command, and is testing the same code against the same Postgres 16, the same Redis 7, the same network topology as you — and as CI. No onboarding doc needed.

Step 3: Seed Fresh Test Data Every Run

An isolated database is only useful if it has the right schema and data when your tests start. Postgres containers run any SQL or shell script in /docker-entrypoint-initdb.d/ automatically on first boot. Mount your migrations and fixtures there:

# docker-compose.test.yml (additions to the db service)
services:
  db:
    image: postgres:16-alpine
    # ... other config from before ...
    volumes:
      # Run migrations first (alphabetical order)
      - ./db/migrations:/docker-entrypoint-initdb.d/10-migrations:ro
      # Then seed fixtures
      - ./db/fixtures/test.sql:/docker-entrypoint-initdb.d/20-fixtures.sql:ro
-- db/fixtures/test.sql
-- Deterministic fixture data. Same UUIDs every run = stable assertions.
INSERT INTO users (id, email, name, created_at) VALUES
  ('00000000-0000-0000-0000-000000000001', 'alice@test.local', 'Alice', NOW()),
  ('00000000-0000-0000-0000-000000000002', 'bob@test.local', 'Bob', NOW());

INSERT INTO projects (id, owner_id, name) VALUES
  ('00000000-0000-0000-0000-00000000000A',
   '00000000-0000-0000-0000-000000000001',
   'Alice Project');

Because the compose file uses tmpfs, the database is destroyed between runs — which means these init scripts run every single time. Every test starts with the same clean, predictable state. No more "did my previous test leave a record that breaks this assertion?" debugging.

Gotcha — init scripts only run on an empty data dir. If you remove tmpfs and use a named volume for speed, the init scripts will not run on subsequent boots. Either keep tmpfs, or add docker compose down -v to your teardown to wipe the volume.

How Do You Keep Parallel Tests From Colliding?

Answer capsule: Use a unique compose project name per run, random host ports, and isolated networks — then read the assigned port back with docker compose port.

The script above already does the first piece: a unique $PROJECT name means Docker creates a separate network, separate container names, and separate volumes for each run. But when you want to run tests from your IDE while CI is also running them against the same branch, you need to read the dynamic port that Docker assigned:

// tests/setup/docker-env.ts
import { execSync } from 'node:child_process';

const project = process.env.COMPOSE_PROJECT_NAME ?? 'test';

function port(service: string, containerPort: number): string {
  const out = execSync(
    `docker compose -p ${project} -f docker-compose.test.yml port ${service} ${containerPort}`,
    { encoding: 'utf8' }
  );
  // Output looks like "0.0.0.0:49153"
  return out.trim().split(':').pop()!;
}

export const testEnv = {
  DATABASE_URL: `postgres://test:test@localhost:${port('db', 5432)}/app_test`,
  REDIS_URL: `redis://localhost:${port('cache', 6379)}`,
};

Now your Vitest or Jest setup file imports testEnv and your test runner connects to whatever port Docker assigned. Five developers can run integration tests on the same machine at the same time — something that simply does not work with a shared local Postgres.

Running Your Compose Environment in CI

The biggest payoff is that the same compose file works identically in GitHub Actions. Here is a minimal but production-ready workflow:

# .github/workflows/integration-tests.yml
name: Integration Tests
on:
  pull_request:
  push:
    branches: [main]

jobs:
  integration:
    runs-on: ubuntu-latest
    timeout-minutes: 15
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: npm

      - name: Install dependencies
        run: npm ci

      # Speed up subsequent runs by caching Docker layers
      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Run integration tests
        run: npm run test:integration

      - name: Upload test report
        if: ${{ !cancelled() }}
        uses: actions/upload-artifact@v4
        with:
          name: test-report
          path: test-report/
          retention-days: 14

That is it. Ubuntu runners ship with Docker pre-installed, so no extra setup is needed. Your tests run against Postgres 16, Redis 7, and your actual app — not mocks, not in-memory SQLite. The same stack you run in production.

Troubleshooting: When Compose Fights Back

Every engineer hits these issues at least once. Here is the short list of failures and the fixes that actually work:

  • "Connection refused" the first 5 seconds after compose up. Classic race condition — your app started before Postgres was ready. The fix is always the same: add a healthcheck to the db service and depends_on.condition: service_healthy on the app service. Never rely on depends_on alone — that only waits for the container to start, not for the process inside to be ready.
  • Tests pass locally but hang in CI. Usually a missing --abort-on-container-exit flag. Without it, the app exits but the db and redis keep running, so compose waits forever. Always include that flag in your CI command.
  • "No space left on device" on your laptop. Docker accumulates images, volumes, and stopped containers over time. Run docker system prune -a --volumes weekly. For a more aggressive cleanup, also run docker builder prune -a to clear build cache.
  • Port already allocated. You have another compose stack or a local Postgres running. Either stop it, or switch to port "0" mapping (as shown above) so Docker picks a free port automatically.
  • Changes to init scripts are not taking effect. Postgres only runs /docker-entrypoint-initdb.d/ scripts once, on first boot with an empty data directory. Run docker compose down -v to wipe the volume, or use tmpfs so every boot is "first boot."
  • Slow compose up on Apple Silicon Macs. You may be pulling an amd64 image on an arm64 host. Explicitly pin the platform: add platform: linux/arm64 to services using multi-arch images, or switch to images that publish native arm64 builds (all official Alpine and Debian images do).
# Useful debug commands when something goes wrong

# See what is actually happening inside a container
docker compose -f docker-compose.test.yml logs -f db

# Connect to the test database while it is running
docker compose -f docker-compose.test.yml exec db \
  psql -U test -d app_test

# Inspect the container's environment and network
docker compose -f docker-compose.test.yml config
docker compose -f docker-compose.test.yml ps

# Force a rebuild when the Dockerfile changed but compose is using cache
docker compose -f docker-compose.test.yml build --no-cache

# Nuclear option: wipe everything and start fresh
docker compose -f docker-compose.test.yml down -v --remove-orphans
docker system prune -a --volumes

Edge Cases and Gotchas Worth Knowing

  • Network access from the host. When your test runner runs on the host (not in a container), it connects to the db service via localhost:<mapped-port>. But when a containerized app connects to db, it uses the service name db:5432. Keep both URLs in mind when writing setup code.
  • File sync performance on macOS and Windows. Bind mounts (./src:/app/src) are slow outside Linux. For test runs, prefer COPY in the Dockerfile over bind mounts. Reserve bind mounts for hot-reload dev setups.
  • CI caching layers. Pin your compose images to specific tags (postgres:16.3-alpine, not postgres:latest). Floating tags make your CI non-reproducible and will bite you at the worst moment.
  • Seed data volume and test performance. If your init scripts insert 100,000 rows, every test run spends 10+ seconds seeding. Use minimal fixtures and create per-test data in the tests themselves — fast database operations are cheap on tmpfs.
  • Secrets in compose files. Test credentials like test:test are fine in version control. Real secrets are not. Use \${VAR} interpolation and pass secrets via your CI's secret store — never commit a .env with production keys.
  • Cleaning up after canceled CI jobs. A test job killed mid-run may leave containers behind on shared runners. Add a docker compose down -v --remove-orphans step with if: always() to your workflow to guarantee cleanup.

Your Migration Checklist

A quick path from "I run tests against my dev DB" to "I run tests against a disposable environment":

  • Install Docker Desktop (free for personal and small business use).
  • Create docker-compose.test.yml with only the services your tests need.
  • Add healthchecks and service_healthy conditions to every service dependency.
  • Map service ports to "0" for random host port assignment.
  • Mount your migrations and fixtures into /docker-entrypoint-initdb.d/.
  • Wrap compose up/down in a shell script with trap cleanup EXIT.
  • Add the script as an npm/pnpm/bun command so anyone can run it with one command.
  • Wire it into GitHub Actions (or your CI of choice) — same file, same behavior.
  • Delete the old README section that says "install Postgres 16 and Redis 7 locally before running tests."

The Compound Return on Clean Test Environments

The first time you set this up, it feels like overhead. "I just wanted to run my tests." But every week afterward pays you back. Your CI matches your laptop. Your tests stop having ghost failures. New hires get productive on day one. And when you need to add a new service — a second database, a message queue, an object store — you add one block to the compose file and it just works.

That is the leverage of Docker Compose for testing: you stop debugging your infrastructure and start debugging your code. You stop apologizing for flaky tests and start trusting your test results. You stop saying "works on my machine" and start saying "works in the container, which is the only thing that matters."

You already had the discipline to write tests. This is the next level — giving those tests a clean, isolated home where they can actually do their job. Spin it up once; ship with confidence forever.

Ready to level up your dev toolkit?

Desplega.ai helps developers transition to professional tools smoothly. Whether you are in Barcelona, Madrid, Valencia, or Malaga, we provide consulting and tooling to keep your velocity while adding production-grade practices.

Get Started

Frequently Asked Questions

Do I need Docker Compose if I already use Testcontainers?

Both work. Testcontainers fits tightly coupled tests in one language. Docker Compose wins for polyglot stacks and reusing the same env across CI and local dev setups.

How slow is spinning up a fresh Postgres container per test run?

Cold start is 2-4 seconds with Alpine images and tmpfs storage. Hot reuse with reuseExistingServer keeps it under 200ms. Most suites spend more time on app startup than on the database itself.

Can I run my production docker-compose.yml for tests too?

Not directly. Split into a base compose file and a docker-compose.test.yml override. The test override uses ephemeral volumes, random ports, and lighter images so production config stays clean.

What about running tests in CI — is Docker Compose supported?

Yes, first-class on GitHub Actions, GitLab CI, and CircleCI. Ubuntu runners ship with Docker pre-installed. Add one setup step and your compose file runs identically to your laptop.

How do I keep parallel test runs from colliding on the same ports?

Let Docker pick ports by mapping to "0" instead of a fixed host port, then read the assigned port with docker compose port. This lets N parallel suites run without stepping on each other.