Skip to content

Docker Development Workflows That Actually Work

Stop fighting Docker in local dev: multi-stage builds, bind mounts, Compose overrides, and the patterns that make containers feel native.

3 min read
Docker compose configuration with multi-stage build and development overrides

Docker is supposed to make your environment reproducible. In practice, a lot of teams end up with Dockerfiles that work in CI, break in local dev, and require a tribal knowledge ritual to get running. The tooling is powerful — the gap is in how it's used.

The Multi-Stage Build Is Non-Negotiable

A single-stage Dockerfile that copies everything into the image is the root cause of most Docker frustrations. It makes images large, slow to rebuild, and impossible to optimize for both development and production.

dockerfiledockerfile
# ❌ Single-stage — ships dev dependencies, source maps, everything
FROM node:22-alpine
WORKDIR /app
COPY . .
RUN npm install
RUN npm run build
CMD ["node", "dist/index.js"]
 
# ✅ Multi-stage — lean production image, proper layer caching
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --frozen-lockfile
 
FROM node:22-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
 
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/dist ./dist
COPY --from=deps /app/node_modules ./node_modules
COPY package.json ./
USER node
CMD ["node", "dist/index.js"]

The deps stage is cached separately. If package.json doesn't change, Docker reuses the layer — your npm ci only runs when dependencies actually change.

Docker Compose Overrides for Local Dev

Running the same docker-compose.yml in development and production forces painful compromises. Use a base file plus environment-specific overrides instead.

ymlyaml
# docker-compose.yml (base — shared config)
services:
  api:
    build:
      context: .
      target: deps # stop at deps stage for dev
    environment:
      DATABASE_URL: postgres://postgres:password@db:5432/app
    depends_on:
      db:
        condition: service_healthy
 
  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_PASSWORD: password
      POSTGRES_DB: app
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 5s
      timeout: 5s
      retries: 5
ymlyaml
# docker-compose.override.yml (dev — auto-merged by Compose)
services:
  api:
    build:
      target: deps
    command: npm run dev # hot reload in dev
    volumes:
      - .:/app # bind mount source
      - /app/node_modules # keep container's node_modules
    environment:
      NODE_ENV: development
    ports:
      - "3000:3000"
      - "9229:9229" # Node debugger
ymlyaml
# docker-compose.prod.yml (production — explicit override)
services:
  api:
    build:
      target: runner
    restart: unless-stopped
    environment:
      NODE_ENV: production

Run docker compose up locally (auto-merges the override), docker compose -f docker-compose.yml -f docker-compose.prod.yml up in production.

Bind Mounts and the node_modules Trick

The most common local dev pain point: you bind-mount your source code into the container, but now the container's node_modules (built for Linux) is overwritten by your Mac's node_modules (built for macOS/Windows).

The fix is an anonymous volume that masks the bind mount at node_modules:

ymlyaml
volumes:
  - .:/app # sync source code
  - /app/node_modules # hide host's node_modules, keep container's

The empty volume target /app/node_modules takes precedence over the parent bind mount for that specific path. Your source code syncs; your platform-specific binaries stay intact.

Layer Caching in CI

Build times compound. A poorly ordered Dockerfile redownloads all dependencies on every code change. The rule: order layers from least-changed to most-changed.

dockerfiledockerfile
FROM node:22-alpine AS builder
WORKDIR /app
 
# 1. Copy only the manifest — cached until deps change
COPY package.json package-lock.json ./
RUN npm ci --frozen-lockfile
 
# 2. Copy generated/config files that rarely change
COPY tsconfig.json ./
COPY prisma ./prisma
RUN npx prisma generate
 
# 3. Copy source — changes on every commit
COPY src ./src
RUN npm run build

In GitHub Actions, use cache-from and cache-to with the registry driver to persist layer cache across runs:

ymlyaml
- name: Build image
  uses: docker/build-push-action@v6
  with:
    context: .
    target: runner
    cache-from: type=registry,ref=ghcr.io/org/app:cache
    cache-to: type=registry,ref=ghcr.io/org/app:cache,mode=max
    tags: ghcr.io/org/app:${{ github.sha }}

Health Checks and Graceful Shutdown

Services that declare health checks let Compose (and Kubernetes) wait for them to be ready before starting dependent services. Skipping them causes race conditions that manifest as inconsistent test failures.

dockerfiledockerfile
# In your Dockerfile
HEALTHCHECK --interval=10s --timeout=5s --retries=3 \
  CMD wget -qO- http://localhost:3000/health || exit 1

Graceful shutdown matters in containers because docker stop sends SIGTERM. If your process doesn't handle it, Docker force-kills after the timeout, dropping in-flight requests.

tstypescript
// Handle SIGTERM for graceful shutdown
const server = app.listen(3000);
 
async function shutdown() {
  console.log("SIGTERM received, shutting down gracefully");
  server.close(async () => {
    await db.end(); // close DB connections
    process.exit(0);
  });
  // Force exit if graceful shutdown takes too long
  setTimeout(() => process.exit(1), 10_000);
}
 
process.on("SIGTERM", shutdown);
process.on("SIGINT", shutdown);

Key Takeaways

  1. Multi-stage builds are the foundation — separate dependency installation, build, and runtime stages for smaller images and better caching
  2. Use Compose overrides, not environment variables, for dev/prod differences — the override pattern keeps both environments explicit and reviewable
  3. The node_modules anonymous volume trick prevents platform-specific binary collisions with bind mounts
  4. Order Dockerfile layers from least-changed to most-changed — copying package manifests before source code is the single highest-ROI cache optimization
  5. Health checks and graceful shutdown are production requirements — depends_on with health conditions prevents race conditions; SIGTERM handling prevents dropped requests
Wilfredo Rujel

Wilfredo Rujel

Full Stack Software Engineer

Share this postX