Simplified monorepo with docker compose for local development, CI and production

Docker Compose is often treated as a “local-only” tool, while CI pipelines reimplement the same logic with custom scripts and duplicated image definitions. That duplication causes drift, surprises, and unnecessary complexity.

Using multi-stage Dockerfiles combined with build targets selected via environment variables, Docker Compose can become the single source of truth for:

  • local development
  • production-like local testing
  • CI build
  • CI push

No separate compose files. No duplicated CI job definitions per image.


The core idea

Docker supports named build stages (AS development, AS production, etc.). Docker Compose supports selecting a build target. Compose also supports environment variable interpolation.

Put these together:

  • Define all images once in docker-compose.yml
  • Switch build behavior by changing one environment variable
  • Reuse the exact same configuration locally and in CI

docker-compose.yml

services:
  api:
    image: ${CI_REGISTRY_IMAGE}/api:${CI_COMMIT_SHORT_SHA:-latest}
    build:
      context: .
      dockerfile: ./apps/api/Dockerfile
      target: ${DOCKER_TARGET:-development}
    volumes:
      - .:/app

Key points:

  • image is defined once and reused everywhere
  • target is dynamic and defaults to development
  • image tag is filled in on CI and defaults to latest locally

.env (local defaults)

CI_REGISTRY_IMAGE=registry.gitlab.com/example/example

Locally, Compose automatically loads this file and uses defaults. CI will override these values.


The Dockerfile (simplified)

The implementation details do not matter. What matters is the contract between Compose and the Dockerfile.

FROM node:24-alpine AS development
ENV NODE_ENV=development
WORKDIR /app/my-app
CMD ["npm", "run", "dev"]

FROM node:24-alpine AS build
ENV NODE_ENV=production
COPY . /build
WORKDIR /build/my-app
RUN npm install
RUN npm run build

FROM node:24-alpine AS production
ENV NODE_ENV=production
WORKDIR /srv/my-app
COPY --from=build /build/my-app/dist ./dist
CMD ["node", "dist/index.js"]

Three targets:

  • development – fast startup, hot reload, local tooling
  • build – compilation and dependency resolution
  • production – minimal runtime image

Note that all targets use different directory – it is important to do so.


How this works in practice

Local development

docker compose up -d --build
  • Uses development target
  • Same compose file as CI
  • No flags, no overrides
  • Local files mounted via development volume, here /app

Local production testing

DOCKER_TARGET=production docker compose up -d --build

This is the critical win.

You are now running:

  • the same Dockerfile
  • the same image layout
  • the same startup command
  • the same environment assumptions

as production.

No “it worked locally” excuses.

Note that application will run on different working dir (in this example /srv) so that docker compose volume mounts at /app would not override production files.


CI build

DOCKER_TARGET=production docker compose build
  • Builds the production image
  • Uses the same image name as local
  • No duplicated docker build commands
  • No custom scripts per service

CI push

DOCKER_TARGET=production docker compose push

Compose already knows:

  • which images exist
  • what their tags are
  • where they should be pushed

CI becomes a thin wrapper, not a second system.


Problems this solves

1. Configuration drift

Without this approach:

  • Compose defines one image
  • CI defines another
  • Dockerfiles are reused incorrectly
  • Flags differ
  • Bugs appear only after deployment

With this approach:

  • One compose file
  • One Dockerfile
  • One image definition

2. “Production-only” failures

Most teams never run production images locally.

This guarantees:

  • production stage builds locally
  • runtime-only dependencies are tested
  • missing files are caught early
  • permissions and paths are correct

3. CI complexity explosion

Typical CI pipelines:

  • duplicate image names
  • duplicate build commands
  • separate jobs per service
  • constant maintenance

Here:

  • add a service once in docker-compose.yml
  • CI automatically includes it
  • no pipeline rewrites

GitLab-specific notes

GitLab injects variables like:

  • CI_REGISTRY_IMAGE
  • CI_COMMIT_SHA

Compose consumes them without special handling.

This is not GitLab-only. GitHub Actions, Bitbucket, or any CI that exports env vars works the same way – just replace the variables.


What this is not

  • Not a replacement for Kubernetes
  • Not a deployment strategy
  • Not a CI framework

This is about removing duplication and tightening feedback loops in small to medium apps.


Takeaway

If you are:

  • maintaining multiple compose files
  • duplicating Docker build logic in CI
  • unable to test production images locally

You are doing unnecessary work, especially in early stages of application development.

Multi-stage Dockerfiles + Compose build targets + environment variables give you:

  • one configuration
  • predictable builds
  • fewer moving parts

Compose is not just for local dev. Treat it as your image manifest, and CI becomes trivial.

Solutions architect, Node.JS developer, Knowledge Analyst

https://www.linkedin.com/in/bartosz-pasinski/

Leave a Reply

Your email address will not be published. Required fields are marked *