Docker Lesson 36 – Docker in Dev vs Prod | Dataplexa
Section IV · Lesson 36

Docker in Dev vs Prod

A developer spends two days chasing a bug that only appears in production. The application works perfectly on their laptop. It fails consistently on the server. The cause: the dev setup mounts live source code into the container for hot-reload. The prod setup copies the code at build time. A single file — a config loader — behaves differently depending on whether it reads from a mounted volume or a baked layer. Same image. Completely different runtime. Completely different behaviour.

This lesson is about the deliberate differences between Docker in development and Docker in production — what they are, why each exists, and how to keep them from quietly breaking each other. Not theory. The exact Compose files, flags, and patterns that experienced teams use to make both environments reliable.

Dev vs Prod — Side by Side

Development priorities

  • Fast feedback — code changes visible instantly without rebuild
  • Source code mounted as a volume for hot-reload
  • All ports exposed for local debugging tools
  • Debug flags and verbose logging enabled
  • Dev dependencies installed (devDependencies, test runners)
  • No resource limits — use whatever the laptop has
  • Secrets in a local .env file — dummy values, not real credentials

Production priorities

  • Immutable image — code baked in at build time, never mounted
  • No source code on the host — only the image in the registry
  • Only required ports exposed — everything else closed
  • Minimal logging — structured JSON, no debug noise
  • Production dependencies only — smaller, faster, less attack surface
  • CPU and memory limits enforced on every container
  • Secrets injected at runtime — never in files on disk

The Environment Analogy

The Kitchen vs The Restaurant Floor Analogy

A development container is a chef's home kitchen — everything is within reach, the mess doesn't matter, you can experiment freely, leave the fridge door open, and taste-test everything mid-preparation. A production container is the restaurant floor during service — every component is in exactly the right place, nothing unnecessary is present, every action is deliberate, and a mistake affects every customer currently eating. The chef is the same person. The dish is the same recipe. But the environment demands completely different behaviour from both.

The Dockerfile — One File, Two Stages

The most important tool for managing dev vs prod differences is the multi-stage Dockerfile. A single file defines both a development image (with all dev tooling) and a production image (lean, hardened, minimal) — built from the same source with one command, no duplication.

# syntax=docker/dockerfile:1
FROM node:18-alpine AS base
# Stage 1 — shared foundation
# Everything both dev and prod need in common goes here.
WORKDIR /app
COPY package*.json ./

# ────────────────────────────────────────────────────────
FROM base AS development
# Stage 2 — development image
# npm install with ALL dependencies including devDependencies.
RUN npm install
# Source code is NOT copied here — it will be mounted as a volume
# so that edits on the host appear instantly inside the container.
EXPOSE 3000
CMD ["npm", "run", "dev"]
# npm run dev = nodemon or equivalent — watches for file changes and restarts

# ────────────────────────────────────────────────────────
FROM base AS production
# Stage 3 — production image
# Install production dependencies only — no test runners, no build tools.
RUN npm install --omit=dev
# Copy source code into the image — immutable from this point forward.
COPY . .
# Non-root user — security hardening from Lesson 32.
RUN addgroup -S appgroup && \
    adduser -S appuser -G appgroup && \
    chown -R appuser:appgroup /app
USER appuser
EXPOSE 3000
CMD ["node", "server.js"]
# node directly — no file watcher, no dev tooling, no restarter.
# Build the development image — targets the "development" stage
docker build --target development -t payment-api:dev .

# Build the production image — targets the "production" stage
docker build --target production -t payment-api:prod .

# The production image contains only production dependencies and compiled code.
# The development image contains devDependencies and no source code.
# Same Dockerfile. Same source. Completely different outputs.
# Compare image sizes — dev vs prod:
docker images | grep payment-api

REPOSITORY     TAG    IMAGE ID       SIZE
payment-api    dev    a1b2c3d4e5f6   412MB
payment-api    prod   b2c3d4e5f6a7   94MB

# Dev: 412 MB — includes nodemon, jest, eslint, typescript compiler, source maps
# Prod:  94 MB — production node_modules only, compiled output, no tooling
# The prod image is 77% smaller — faster to pull, smaller attack surface,
# less to scan for vulnerabilities, faster container startup.

What just happened?

One Dockerfile produced two completely different images. The production image is 77% smaller than the development image because it contains no devDependencies, no file watchers, and no source code outside of what the process needs to run. Docker's layer cache means the shared base stage is only built once — switching between --target development and --target production reuses those cached layers. Two images, one source of truth, no duplication.

Compose Files — One per Environment

Docker Compose supports file overrides — a base file defines what's common, and an environment-specific file adds or overrides what's different. This keeps configuration DRY: shared service definitions live in one place, and environment-specific differences are explicit and reviewable.

# docker-compose.yml — base, shared across all environments
version: "3.8"

services:
  api:
    build:
      context: .
      dockerfile: Dockerfile
    depends_on:
      - db

  db:
    image: postgres:15-alpine
    volumes:
      - pgdata:/var/lib/postgresql/data

volumes:
  pgdata:
# docker-compose.dev.yml — development overrides
version: "3.8"

services:
  api:
    build:
      target: development
      # Build the development stage — includes devDependencies
    volumes:
      - .:/app
      # Mount the entire project into /app — edits appear instantly
      - /app/node_modules
      # Anonymous volume prevents the host node_modules from
      # overwriting the container's node_modules
    ports:
      - "3000:3000"
      - "9229:9229"
      # 9229 is the Node.js debugger port — exposed for IDE attachment
    environment:
      - NODE_ENV=development
      - LOG_LEVEL=debug
    env_file:
      - .env.development
      # Local .env file with dummy credentials — safe to have on disk

  db:
    ports:
      - "5432:5432"
      # Expose Postgres locally so developers can connect with pgAdmin or psql
# docker-compose.prod.yml — production overrides
version: "3.8"

services:
  api:
    build:
      target: production
      # Build the production stage — hardened, minimal, non-root
    image: acmecorp/payment-api:${GIT_SHA}
      # Use the pre-built registry image — never build on the production server
    ports:
      - "3000:3000"
      # Only the application port — no debugger, no database port
    environment:
      - NODE_ENV=production
      - LOG_LEVEL=info
    env_file:
      - .env.production
    deploy:
      resources:
        limits:
          cpus: "1.5"
          memory: 512M
        reservations:
          cpus: "0.5"
          memory: 256M
    restart: unless-stopped
    logging:
      driver: json-file
      options:
        max-size: "10m"
        max-file: "5"
    read_only: true
    # Read-only root filesystem — from Lesson 32
# Running each environment — the -f flag stacks the files
# Development:
docker compose -f docker-compose.yml -f docker-compose.dev.yml up

# Production:
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d

# The second file's values override the first file's values where they conflict.
# Keys that only exist in the base file are inherited unchanged.
# Keys that only exist in the override file are added.
# Development startup:
docker compose -f docker-compose.yml -f docker-compose.dev.yml up

[+] Running 2/2
 ✔ Container postgres-db    Started
 ✔ Container payment-api    Started

payment-api  | [nodemon] starting `node server.js`
payment-api  | Server listening on port 3000
payment-api  | [nodemon] watching: /app/**/*
# Edit any file on the host → nodemon detects the change → restarts in <1s
# No rebuild required. Feedback loop: instant.

# Production startup:
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d

[+] Running 2/2
 ✔ Container postgres-db    Started
 ✔ Container payment-api    Started
# Detached — no live output. Structured logs sent to json-file driver.
# Resource limits applied. Read-only filesystem. Non-root user. Restart policy active.

What just happened?

Development gets hot-reload, the debugger port, and verbose logging — all the things that make a developer fast. Production gets resource limits, a read-only filesystem, a restart policy, and structured logs — all the things that make a service reliable. Neither environment's config is duplicated. The base docker-compose.yml holds the shared structure. Each override file adds exactly what's different. Switching environments is one flag change.

The Complete Environment Differences Map

Every difference, explained

Setting Development Production
Code delivery Volume mount — live edits Baked into image at build
Build stage --target development --target production
Dependencies All — including devDeps Production only
CMD npm run dev (nodemon) node server.js
Ports App + debugger + DB App port only
Secrets .env.development (dummy) Runtime inject / Swarm secrets
Resource limits None CPU + memory enforced
Filesystem Writable (dev convenience) read_only: true
User root (simplifies bind mounts) Non-root appuser
Restart policy None (crash is visible) unless-stopped

The Golden Rule

Never Build on the Production Server

The production server should never run docker build. It should only run docker pull and docker run. Building on the server means the server needs git, Node.js, build tools, and source code — massively expanding the attack surface. It also means production is running a build that was never tested. The image that passes CI is the image that goes to production — pulled from the registry, not built on-site. If the registry image isn't good enough to deploy, fix the image. Never work around it by building directly on the server.

A Complete Dev and Prod Scenario

The scenario: You're setting up a new Node.js payment service for a team of eight developers. You need a setup where developers get hot-reload and local debugging, the CI pipeline builds and tests the production image, and the production server only pulls and runs. Here's the complete file set that makes this work.

# File structure:
# ├── Dockerfile                  ← multi-stage: dev + prod stages
# ├── docker-compose.yml          ← base: shared service definitions
# ├── docker-compose.dev.yml      ← dev overrides: volumes, ports, hot-reload
# ├── docker-compose.prod.yml     ← prod overrides: limits, logging, read-only
# ├── .env.development            ← dummy credentials — safe, committed
# ├── .env.production             ← real credentials — on server, gitignored
# └── .gitignore
#     .env.production
#     .env*.local

# Developer workflow — on their laptop:
git clone git@github.com:acmecorp/payment-api.git
cd payment-api
docker compose -f docker-compose.yml -f docker-compose.dev.yml up
# → Hot-reload running. Edit code. Changes live in <1s. No rebuild.

# CI pipeline — on every merge to main:
docker build --target production -t acmecorp/payment-api:${GIT_SHA} .
docker push acmecorp/payment-api:${GIT_SHA}
# → Production image built, tested, pushed to registry.

# Production server — deploy:
GIT_SHA=a3f2c8d docker compose \
  -f docker-compose.yml \
  -f docker-compose.prod.yml \
  up -d
# → Pulls the tested image from registry. No git. No build. No source code on server.
# Developer's terminal — instant feedback loop:
payment-api  | [nodemon] starting `node server.js`
payment-api  | Server listening on :3000
# → Developer edits src/payment.js
payment-api  | [nodemon] restarting due to changes...
payment-api  | [nodemon] starting `node server.js`
payment-api  | Server listening on :3000
# 800ms total. No rebuild. No docker restart.

# CI output — production image:
[+] Building 4.2s (12/12) FINISHED
 => CACHED [base 1/2] WORKDIR /app
 => CACHED [base 2/2] COPY package*.json ./
 => CACHED [production 1/3] RUN npm install --omit=dev
 => [production 2/3] COPY . .                        ← only this layer rebuilt
 => [production 3/3] RUN addgroup -S appgroup ...
Successfully tagged acmecorp/payment-api:a3f2c8d

# Production server — pull and run:
a3f2c8d: Pulling from acmecorp/payment-api
3a7f2c9e1b4d: Already exists
8b1c4e7a9d2f: Already exists
c9d1e5f2a3b7: Pull complete
Status: Downloaded newer image for acmecorp/payment-api:a3f2c8d
payment-api started. Resource limits: 1.5 CPU / 512MB RAM.

What just happened?

Three completely different workflows — developer, CI, and production server — all driven by the same Dockerfile and the same base Compose file. The developer gets sub-second feedback without ever running a build. CI produces a hardened production image with no dev tooling. The production server never touches source code or build tools — it only pulls a pre-tested image and runs it. This is the workflow that makes "it works on my machine" a non-issue: the production image is tested in CI before it ever reaches the server.

Teacher's Note

The most common mistake teams make is using the same Compose file for both environments and commenting out the parts that don't apply. That approach degrades — comments get forgotten, someone uncomments the wrong line, and suddenly production has the debugger port exposed. Separate override files, committed to the repository, make the differences between environments explicit, reviewable, and safe. Differences you can see are differences you can reason about.

Practice Questions

1. In a multi-stage Dockerfile with a development stage and a production stage, which docker build flag specifies which stage to build?



2. To stack a base docker-compose.yml with an environment-specific override file when running docker compose up, which flag is used to specify each file?



3. In development, source code is delivered to the container via a what — so that edits on the host are visible inside the container instantly without a rebuild?



Quiz

1. A team's deployment script runs docker build directly on the production server before starting the container. What is wrong with this approach?


2. A developer wants code changes to appear inside their running container immediately without rebuilding the image. What is the correct approach?


3. A team wants one Dockerfile that produces a 400 MB development image with all tooling and a 90 MB production image with no dev dependencies. What pattern achieves this?


Up Next · Lesson 37

Dockerizing Backend Applications

Dev and prod environments sorted — now the practical work: taking a real backend application and containerizing it correctly from scratch. Node.js, Python, and Go — three languages, three runtimes, one set of principles that applies to all of them.