background-shape
Multi-Service Docker Compose for Polyglot Stacks
July 4, 2022 · 5 min read · by Muhammad Amal programming

TL;DR — One compose file per stack, services point at language-specific Dockerfiles (some in the same repo, some in sibling repos), shared infra (DB, Redis, broker) in the same file. Naming convention: <role>-<lang> (api-go, worker-python). Build contexts are relative paths.

After the intro, the most common real-world Compose use case: polyglot local dev. A typical 2022 backend stack has a Go API, a Node BFF, a Python ETL worker, plus Postgres + Redis + Kafka. Compose lets you bring it all up in one command.

The shape

name: my-stack

services:
  postgres: { image: postgres:14-alpine, ... }
  redis: { image: redis:7-alpine, ... }

  # Go API
  api:
    build:
      context: ./services/api
      dockerfile: Dockerfile
      target: dev
    depends_on:
      postgres: { condition: service_healthy }
      redis: { condition: service_healthy }
    volumes:
      - ./services/api:/app
    ports: ["8080:8080"]

  # Node BFF
  bff:
    build:
      context: ./services/bff
      target: dev
    depends_on: [api]
    volumes:
      - ./services/bff:/app
      - /app/node_modules
    ports: ["3000:3000"]

  # Python ETL
  etl:
    build:
      context: ./services/etl
      target: dev
    depends_on:
      postgres: { condition: service_healthy }
    volumes:
      - ./services/etl:/app
    profiles: ["etl"]

Three patterns to notice:

Per-service build context. Each Dockerfile lives next to its code. ./services/api/Dockerfile, ./services/bff/Dockerfile, etc. Compose just points at them.

Anonymous volume for language deps. /app/node_modules shadows the bind mount. Same pattern for /app/vendor (PHP, Go via go mod download), /app/.venv (Python).

Profiles for non-default services. ETL doesn’t need to run for most dev tasks. Profile it; opt in when needed.

Monorepo vs polyrepo

Two shapes I see:

Monorepo — all services in one repo:

my-stack/
├── docker-compose.yml
├── services/
│   ├── api/
│   │   └── Dockerfile
│   ├── bff/
│   │   └── Dockerfile
│   └── etl/
│       └── Dockerfile
└── infra/
    └── db-init.sql

Compose file in the root. Build contexts are relative paths within the repo.

Polyrepo — each service in its own repo, compose in a separate “dev environment” repo:

dev-env/
├── docker-compose.yml
└── services -> ../*/   # symlinked sibling repos

../api/
../bff/
../etl/

Compose file references sibling directories. Slightly fiddlier but lets each repo have its own lifecycle.

I prefer monorepo for new projects. Polyrepo when org structure forces it.

Language-specific Dockerfiles

Each service has a multi-stage Dockerfile with dev and prod targets.

Go (services/api/Dockerfile):

# syntax=docker/dockerfile:1.4

FROM golang:1.18-alpine AS dev
WORKDIR /app
RUN apk add --no-cache git
RUN go install github.com/cosmtrek/air@v1.40.4
COPY go.mod go.sum ./
RUN go mod download
COPY . .
CMD ["air"]

FROM golang:1.18-alpine AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /out/server ./cmd/server

FROM gcr.io/distroless/static-debian11:nonroot AS prod
COPY --from=build /out/server /server
USER nonroot
ENTRYPOINT ["/server"]

dev uses air for live reload. prod is the distroless image from the January post.

Node (services/bff/Dockerfile):

FROM node:18-alpine AS dev
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
CMD ["npm", "run", "dev"]

FROM node:18-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM node:18-alpine AS prod
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY --from=build /app/dist ./dist
CMD ["node", "dist/server.js"]

Python (services/etl/Dockerfile):

FROM python:3.10-slim AS dev
WORKDIR /app
RUN pip install --no-cache-dir poetry
COPY pyproject.toml poetry.lock ./
RUN poetry config virtualenvs.create false && poetry install
COPY . .
CMD ["python", "-m", "etl"]

FROM python:3.10-slim AS prod
WORKDIR /app
RUN pip install --no-cache-dir poetry
COPY pyproject.toml poetry.lock ./
RUN poetry config virtualenvs.create false && poetry install --without dev
COPY . .
CMD ["python", "-m", "etl"]

Same shape across languages: stage for dev with live reload + dev tools, stage for prod with minimal runtime.

Naming conventions

For 6+ services, naming matters:

  • Infrastructure: bare names — postgres, redis, kafka
  • Application services: <role>-<lang> or <role>api, bff, worker-python, worker-go
  • Background helpers: <role>migrator, seeder

The role is the user-facing concept. Language suffix when you have multiple workers in different languages doing similar things.

Boot order

With condition: service_healthy, you can chain services:

api:
  depends_on:
    postgres: { condition: service_healthy }
    redis: { condition: service_healthy }
    migrator: { condition: service_completed_successfully }

The migrator service runs migrations and exits 0; service_completed_successfully waits for that exit code. API then starts. Clean.

For long chains, compose handles topological sorting. You don’t need to start in order; just declare deps.

When to split into multiple compose files

If your stack is 10+ services, one big file gets messy. Two approaches:

Approach A — override files.

docker-compose.yml          # base infra + core services
docker-compose.observability.yml   # Grafana, Loki, Tempo
docker-compose.kafka.yml    # Kafka + Zookeeper + connect

Bring up specific layers: docker compose -f docker-compose.yml -f docker-compose.kafka.yml up.

Approach B — profiles (preferred).

Same single file, optional services behind profiles:

kafka:
  profiles: ["events", "full"]
grafana:
  profiles: ["observability", "full"]

docker compose --profile observability up brings up the observability stack.

I prefer profiles. One file, all options visible.

Common Pitfalls

Service names that collide with system commands. service: "build" — confusing in docker compose build build. Name unambiguously.

Forgetting anonymous volumes for node_modules. Host bind clobbers. Add the anonymous volume.

Hardcoding localhost in service URLs. Inside the network, use service names. Hosts on the outside use mapped ports.

Building images during up for slow Dockerfiles. Pre-build with docker compose build. Faster startup.

Mixing dev and prod images via the same image: field. Use build.target: dev vs target: prod to distinguish. Or separate compose files.

Wrapping Up

Polyglot stacks live happily in one compose file. Per-service Dockerfile with dev/prod stages, anonymous volumes for lang deps, profiles for heavyweight extras. Wednesday: profiles in depth.