Skip to main content

Command Palette

Search for a command to run...

CI/CD Parity with docker-compose

Making local and CI environments tell the same story

Updated
5 min read
CI/CD Parity with docker-compose
N
Senior-level Fullstack Web Developer with 10+ years experience, including 2 years of Team Lead position. Specializing in responsive design and full-stack web development across the Vue.js and .NET ecosystems. Skilled in Azure/AWS cloud infrastructure, focused on DevOps techniques such as CI/CD. Experienced in system design, especially with software architecture patterns such as microservices, BFF (backend-for-frontend). Hands-on with Agile practices in team leading, and AI-assisted coding.

Series: Containers, Actually: Building Real Local Dev Environments

One of the quiet failures of many CI pipelines is this sentence:

“It passes locally, but fails in CI.”

That sentence almost never means “CI is broken”.
It usually means local and CI environments are not the same system.

This article shows how to use docker-compose as a shared contract between local development and CI/CD—so the same services, versions, and assumptions run everywhere.


What “CI/CD Parity” Actually Means

Parity does not mean:

  • Identical scale

  • Identical security

  • Identical infrastructure

Parity does mean:

  • Same service topology

  • Same runtime versions

  • Same environment variables

  • Same startup order

  • Same failure modes

In other words:
If it breaks in CI, you should be able to reproduce it locally without guesswork.

docker-compose is one of the most effective tools for achieving this.


Why docker-compose Is a Better CI Tool Than It Looks

docker-compose is often dismissed as “local-only tooling”. That’s a misunderstanding.

What docker-compose actually provides is:

  • Declarative service definitions

  • Deterministic networking

  • Explicit dependencies

  • Version-pinned services

Those are exactly the things CI environments lack when everything is installed ad hoc.

The trick is how you use compose in CI.


The Core Pattern: One Compose File, Multiple Contexts

The goal is not to duplicate infrastructure.

The goal is:

  • One primary docker-compose.yml

  • Optional overrides per context (local, CI, etc.)

Example structure:

docker-compose.yml
docker-compose.ci.yml
.env
.env.ci

The base file defines the system.
Overrides adapt behavior.

Base docker-compose.yml (Shared Contract)

Example (simplified):

version: "3.9"

services:
  app:
    build: ./docker/php
    environment:
      DB_HOST: mysql
      REDIS_HOST: redis
    depends_on:
      - mysql
      - redis

  mysql:
    image: mysql:8
    environment:
      MYSQL_DATABASE: app
      MYSQL_USER: app
      MYSQL_PASSWORD: secret
    volumes:
      - mysql-data:/var/lib/mysql

  redis:
    image: redis:alpine

volumes:
  mysql-data:

This file answers:

  • What services exist?

  • How do they connect?

  • What state persists?

It is intentionally environment-agnostic.

CI Override File: docker-compose.ci.yml

CI has different needs:

  • No persistent volumes

  • Deterministic startup

  • Faster teardown

  • No exposed ports

Example:

services:
  mysql:
    volumes: []   # disable persistence in CI

  app:
    environment:
      APP_ENV: ci

This does not redefine services.
It modifies behavior.

Environment Variables: .env vs .env.ci

Local .env:

APP_ENV=local
DB_PASSWORD=secret

CI .env.ci:

APP_ENV=ci
DB_PASSWORD=ci-secret

In CI, you load this explicitly.


Running docker-compose in CI

The key realization:

CI runners can run Docker just like your machine can.

Most CI providers (GitHub Actions, GitLab CI, Bitbucket Pipelines) support Docker natively or via Docker-in-Docker.

Below is a provider-agnostic conceptual example.

Example CI Job Using docker-compose

# Build images
docker-compose \
  -f docker-compose.yml \
  -f docker-compose.ci.yml \
  --env-file .env.ci \
  build

# Start services
docker-compose \
  -f docker-compose.yml \
  -f docker-compose.ci.yml \
  --env-file .env.ci \
  up -d

# Run tests inside app container
docker-compose exec -T app php artisan test

# Tear down
docker-compose down

This is the same stack you run locally.

No special CI-only scripts.
No hand-installed databases.
No magic.

What Each Step Actually Guarantees

  • Build step
    Ensures Dockerfiles are valid and dependencies install cleanly

  • Up step
    Ensures services start in correct topology

  • Exec tests
    Runs tests inside the real runtime, with real services

  • Down step
    Leaves CI runners clean

If this passes in CI and fails locally, you’ve learned something valuable—and reproducible.


Common CI Failure Points (And Why They’re Good)

1. Services Not Ready Yet

CI is faster than humans.
Your app might start before MySQL is ready.

This exposes missing readiness checks.

Fixes include:

  • Retry logic in app

  • Wait scripts

  • Healthchecks

This is not a CI problem.
It’s a production bug being caught early.

2. Hidden Local State

If CI fails but local passes, ask:

  • Is my local DB pre-populated?

  • Is my cache warm?

  • Is my local .env masking something?

CI’s cleanliness is a feature.

3. Volume Assumptions

CI environments don’t preserve volumes between runs.

If your app depends on persistent state at startup, CI will catch it.

Again: valuable signal.


Healthchecks: Making Parity Explicit

Healthchecks make compose-based CI much more reliable.

Example:

mysql:
  image: mysql:8
  healthcheck:
    test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
    interval: 5s
    retries: 10

Then in CI:

docker-compose ps

You can block until services are healthy before testing.


Why This Beats “Install Stuff in CI”

The traditional approach:

  • Install MySQL

  • Install Redis

  • Configure services manually

  • Hope versions match

This approach:

  • Defines the system once

  • Reuses it everywhere

  • Makes CI failures explainable

CI becomes a consumer of your architecture, not a bespoke snowflake.


What CI/CD Parity Does Not Mean

It does not mean:

  • Running production-scale workloads in CI

  • Matching cloud infrastructure exactly

  • Eliminating environment variables

Parity is about shape, not scale.


When This Approach Shines

This works especially well when:

  • Multiple services are involved

  • Bugs depend on integration

  • Onboarding time matters

  • CI failures need to be debuggable locally

It’s less useful for:

  • Tiny single-binary apps

  • Extremely simple scripts

  • Non-containerized systems


The Long-Term Payoff

When CI/CD uses the same docker-compose stack as local dev:

  • CI failures reproduce locally

  • Local fixes pass CI predictably

  • New developers trust the pipeline

  • “It works on my machine” disappears

Not because people try harder—but because the system is the same.


Final Takeaway

docker-compose is not just a local convenience.

Used correctly, it becomes:

  • A living spec of your system

  • A shared contract between dev and CI

  • A force multiplier for confidence

If CI and local environments disagree, one of them is lying.

docker-compose helps make sure neither does.

Containers, Actually: Building Real Local Dev Environments

Part 4 of 18

This series explores full-stack local development with Docker—from core concepts and best practices to a real Windows implementation. The goal is to understand how things run, why they work, and how to build reproducible production environment.

Up next

Using Dev Containers (.devcontainer)

When Dev Containers help—and when they get in the way