Multi-Service Docker Compose for Polyglot Stacks
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.