
`docker-compose up` is the first command you learn. What comes next — networking, secrets, healthchecks, profiles for different environments — is what separates a functional configuration from a production-ready one.
Multi-stage builds: fewer MBs, more security
A production Dockerfile should never include development tools:
# Stage 1: dependencies and build
FROM node:22-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci # [!code highlight]
COPY . .
RUN npm run build
# Stage 2: minimal final image
FROM node:22-alpine AS production # [!code ++]
WORKDIR /app # [!code ++]
# [!code ++]
# Only copy what's necessary # [!code ++]
COPY --from=builder /app/dist ./dist # [!code ++]
COPY --from=builder /app/node_modules ./node_modules # [!code ++]
# [!code ++]
USER node # do not run as root // [!code ++]
EXPOSE 3000
CMD ["node", "dist/server.js"]
The difference in size can be from 600 MB → 80 MB.
Profiles for different environments
With `profiles` you can activate services based on context without maintaining multiple Compose files:
services:
api:
# no profile = always active
build: .
adminer:
image: adminer
profiles: [dev, debug] # only in dev // [!code highlight]
ports:
- "8080:8080"
prometheus:
image: prom/prometheus
profiles: [monitoring] # only when you need it // [!code highlight]
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml
# Only bring up API + DB
docker compose up
# Bring up with dev tools
docker compose --profile dev up
# Entire monitoring stack
docker compose --profile monitoring up
Healthchecks that actually work
The basic `depends_on` only waits for the container to start, not for the service to be ready. The difference matters:
services:
redis:
image: redis:7-alpine
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 10
start_period: 10s # initial grace period // [!code highlight]
worker:
build: .
depends_on:
redis:
condition: service_healthy # wait for green healthcheck // [!code highlight]
Networking: isolation by default
Every `compose.yml` creates its own network. To communicate separate stacks:
networks:
frontend:
driver: bridge
backend:
driver: bridge
internal: true # no internet access // [!code highlight]
services:
nginx:
networks: [frontend, backend] # the only one touching both networks
api:
networks: [backend] # isolated from the outside // [!code highlight]
db:
networks: [backend] # ditto
Checklist before production
- [ ] Sensitive variables in `secrets` or `.env` outside the repository
- [ ] Multi-stage build active
- [ ] `restart: unless-stopped` on all critical services
- [ ] Healthchecks configured with proper `start_period`
- [ ] `depends_on` with `condition: service_healthy`
- [ ] Non-root users in containers (`USER node`, `USER app`)
- [ ] Named volumes for persistent data (no bind mounts in prod)
- [ ] `–max-old-space-size` configured according to container memory
> The difference between a tutorial `compose.yml` and a production one is not in the number of lines — it’s in knowing what can fail and having accounted for it.
Leave a comment