Docker Course
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
.envfile — 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
--target development
--target production
npm run dev (nodemon)
node server.js
.env.development (dummy)
Runtime inject / Swarm secrets
read_only: true
appuser
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.