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.

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:
| Concern | Shared Local DB | Docker Compose |
|---|---|---|
| Setup on a new machine | Install Postgres, Redis, match versions | docker compose up |
| Isolation between tests | Manual cleanup, leaky | Fresh container per run |
| Version drift in CI | Frequent (OS differences) | Pinned image tags everywhere |
| Parallel test runs | Port collisions, shared data | Random ports, isolated networks |
| Test data setup | Truncate tables, reset sequences, pray | Init scripts run fresh every time |
| Teardown | Manual, easy to forget | docker compose down -v |
| Works the same in CI | Rarely | Always |
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 -eThe 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: 14That 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
healthcheckto the db service anddepends_on.condition: service_healthyon the app service. Never rely ondepends_onalone — 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-exitflag. 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 --volumesweekly. For a more aggressive cleanup, also rundocker builder prune -ato 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. Rundocker compose down -vto 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/arm64to 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 --volumesEdge 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 namedb: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, preferCOPYin 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, notpostgres: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:testare fine in version control. Real secrets are not. Use\${VAR}interpolation and pass secrets via your CI's secret store — never commit a.envwith 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-orphansstep withif: 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.ymlwith only the services your tests need. - Add healthchecks and
service_healthyconditions 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 StartedFrequently 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.
Related Posts
Hot Module Replacement: Why Your Dev Server Restarts Are Killing Your Flow State | desplega.ai
Stop losing 2-3 hours daily to dev server restarts. Master HMR configuration in Vite and Next.js to maintain flow state, preserve component state, and boost coding velocity by 80%.
The Flaky Test Tax: Why Your Engineering Team is Secretly Burning Cash | desplega.ai
Discover how flaky tests create a hidden operational tax that costs CTOs millions in wasted compute, developer time, and delayed releases. Calculate your flakiness cost today.
The QA Death Spiral: When Your Test Suite Becomes Your Product | desplega.ai
An executive guide to recognizing when quality initiatives consume engineering capacity. Learn to identify test suite bloat, balance coverage vs velocity, and implement pragmatic quality gates.