🚢 Docker in Production Environment
Running a container on your laptop and running one that serves paying customers at 3 a.m. are two very different jobs. This lesson shifts your Docker mindset from developer convenience to operational excellence — reliability, security, and performance under real load.
🎯 Learning Objectives
By the end of this lesson, you will be able to:
- Explain how production priorities (reliability, security, resource efficiency) differ from development priorities
- Compare single-host, clustered, and hybrid stateless/stateful deployment architectures
- Harden a container image by running as non-root, using minimal base images, and adding health checks and resource limits
- Design graceful shutdown, logging, and image-tagging strategies for production
- Convert a development Docker setup into a production-ready one
Estimated Time: 45–60 minutes • Difficulty: Intermediate
Hands-on: Rewrite a development Dockerfile and Compose file into a secure, resource-limited production configuration.
In This Lesson
Dev vs. Production Docker
In development, Docker exists to make your life easy: fast rebuilds, mounted source code for hot reload, verbose logs, and every debugging tool baked in. In production, the container's job changes completely. Now it must be reliable (recover from crashes), secure (small attack surface, no secrets baked in), efficient (bounded CPU and memory), and observable (structured logs and metrics).
💡 A useful analogy: A home kitchen and a professional restaurant kitchen both cook food, but the restaurant is built for volume, consistency, and food-safety standards. Production Docker is the professional kitchen — the same tool, held to a much stricter standard.
Production Architecture Patterns
How you arrange your containers determines how well the system tolerates failure and load. Three patterns cover most cases.
1. Single-host deployment
The simplest production setup runs every container on one host with Docker Compose. Great for MVPs, internal tools, and low-traffic sites — but the host is a single point of failure.
| Pattern | Pros | Cons |
|---|---|---|
| Single-host | Simple, low ops overhead, cheap | Single point of failure, limited scale |
| Clustered | High availability, horizontal scale, node-failure resilience | More operational complexity, networking to manage |
| Hybrid stateless/stateful | Easy scaling for stateless tiers, managed reliability for data | Cloud lock-in for managed services, cost |
2. Clustered deployment
At scale, an orchestrator (Kubernetes or Docker Swarm) spreads containers across multiple hosts. If one node dies, the workload reschedules onto healthy nodes.
3. Hybrid: stateless containers + managed state
A widely used pattern keeps stateless services (web, API) in containers you can scale freely, while stateful pieces (database, cache, queue) run as managed cloud services with built-in redundancy and backups.
✅ Why hybrid wins for most teams
Containers are easy to scale up and throw away when they are stateless. Databases are hard to run reliably yourself. Letting a managed service handle the stateful, backup-critical layer removes the scariest part of production operations.
Container Security
Security is not one setting — it is defense in depth across the image, the runtime, the host, the network, and your data. Start with the two highest-impact habits: minimal base images and running as a non-root user.
By default a container process runs as root. If an attacker escapes the app, they inherit root inside the container — and root inside is a big step toward root on the host. Create an unprivileged user and switch to it:
# Run as a non-root user on a minimal base image
FROM node:20-alpine
WORKDIR /app
# Create a dedicated non-privileged user and group
RUN addgroup -g 1001 appuser \
&& adduser -u 1001 -G appuser -s /bin/sh -D appuser
# Copy files owned by that user
COPY --chown=appuser:appuser package*.json ./
RUN npm ci --omit=dev
COPY --chown=appuser:appuser . .
# Drop privileges before running the app
USER appuser
CMD ["node", "server.js"]
📖 Key Terms
Attack surface: the total set of things an attacker could target. Fewer packages and tools in the image means less surface.
Distroless: an image containing only your app and its runtime — no shell, no package manager — so there is almost nothing for an intruder to use.
Secret: a password, token, or key. Never bake these into an image; inject them at runtime.
⚠️ Never bake secrets into images
Anything you COPY or set with ENV is baked into a layer forever — even if a later layer deletes it, docker history can recover it. Use runtime secrets (Docker/Swarm secrets, a secrets manager, or mounted files) instead.
Scan images before you ship them. Modern tooling makes this a one-liner in CI:
# Scan an image for known CVEs (Trivy is free and fast)
trivy image myorg/api:1.4.2
# Docker's built-in scanner (Scout)
docker scout cves myorg/api:1.4.2
Resource Limits & Health Checks
An unbounded container can consume all the host's memory and take neighbours down with it (the "noisy neighbour" problem). Set explicit CPU and memory limits, and add a health check so the platform can detect and restart a hung container automatically.
# compose.yaml — resource limits + health check
services:
api:
image: myorg/api:1.4.2
deploy:
resources:
limits: # hard ceiling
cpus: '0.50'
memory: 512M
reservations: # guaranteed minimum
cpus: '0.25'
memory: 256M
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
💡 Reservations vs. limits
Reservations guarantee the container a minimum so it always has room to work. Limits cap the maximum so one service cannot starve the others. Set both, and leave headroom above average usage for traffic spikes.
A health check turns "the container is running" into "the container is actually serving requests." Orchestrators use it to gate dependencies (depends_on: condition: service_healthy), to restart unhealthy containers, and to pull failing instances out of the load balancer.
Logging & Lifecycle
Structured, centralized logging
In production, containers are ephemeral — when one dies, its local logs die with it. Write logs to stdout/stderr in a parseable format (JSON), then let a log agent forward them to central storage. Include a correlation ID so you can trace a single request across services.
Graceful shutdown
When an orchestrator updates or scales down a service, it sends SIGTERM and gives the process a grace period before SIGKILL. Handle SIGTERM to stop accepting new work, finish in-flight requests, and close connections cleanly — otherwise you drop live user requests on every deploy.
// Node.js graceful shutdown
const server = app.listen(port, () => {
console.log(`Server listening on port ${port}`);
});
process.on('SIGTERM', async () => {
console.log('SIGTERM received — shutting down gracefully');
// 1. Stop accepting new connections, let in-flight requests finish
server.close(async () => {
// 2. Close database and other resources
await pool.end();
console.log('Connections closed — exiting');
process.exit(0);
});
// 3. Safety net: force exit if cleanup hangs
setTimeout(() => {
console.error('Forced shutdown after timeout');
process.exit(1);
}, 30_000).unref();
});
Deployment strategies
Ship updates without downtime using rolling updates (replace instances a few at a time), blue-green (stand up a full new version, then switch traffic), or canary (route a small percentage of traffic to the new version first). All three depend on health checks to know a new instance is truly ready.
Production-Ready Images
Size and layer caching
Smaller images deploy faster, cost less to store, and expose less surface. Choose slim or alpine bases, and order your Dockerfile so rarely-changing layers come first — Docker caches each layer and only rebuilds from the first change downward.
# AFTER: small base + dependency layer cached separately from code
FROM python:3.12-slim
WORKDIR /app
# Dependencies change rarely — copy + install them first so the
# layer stays cached even when app code changes.
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# App code changes often — put it last.
COPY app.py .
CMD ["python", "app.py"]
Versioning and tagging
Never deploy latest — it is ambiguous and makes rollbacks impossible to reason about. Tag images with a traceable identity: semantic version plus a git commit or build number.
# Good: traceable, reproducible tags
docker tag myapp registry.example.com/api:1.2.3
docker tag myapp registry.example.com/api:1.2.3-8f731a # + git SHA
# Bad: ambiguous, not reproducible
docker tag myapp myapp:latest
docker tag myapp myapp:new
Hands-on: Dev → Prod
🏋️ Harden a development setup
Objective: Convert this development Dockerfile and Compose file into a production-ready configuration.
Starting point (development):
# Dockerfile (dev)
FROM node:20
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
EXPOSE 3000
CMD ["npm", "run", "dev"]
Your task — make it production-grade:
- Use a minimal base image
- Run as a non-root user
- Install production dependencies only
- Add a health check
- Set
NODE_ENV=production - In Compose, add resource limits and a restart policy, and inject secrets at runtime
💡 Hint
Switch the base to node:20-alpine. Use npm ci --omit=dev for reproducible, prod-only installs. Create a user with addgroup/adduser and COPY --chown. Add a HEALTHCHECK that hits your /health route with wget --spider.
✅ Sample solution
# Dockerfile (production)
FROM node:20-alpine
RUN addgroup -g 1001 appuser \
&& adduser -u 1001 -G appuser -s /bin/sh -D appuser
WORKDIR /app
COPY --chown=appuser:appuser package*.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY --chown=appuser:appuser . .
USER appuser
ENV NODE_ENV=production
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1
CMD ["node", "dist/server.js"]
# compose.yaml (production)
services:
app:
image: ${REGISTRY_URL}/myapp:${VERSION}
deploy:
resources:
limits: { cpus: '0.50', memory: 512M }
reservations: { cpus: '0.25', memory: 256M }
restart_policy:
condition: on-failure
max_attempts: 3
ports:
- "3000:3000"
environment:
- NODE_ENV=production
- DATABASE_URL=${DB_URL}
secrets:
- app_secret
depends_on:
db:
condition: service_healthy
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: ${DB_USER}
POSTGRES_PASSWORD_FILE: /run/secrets/db_password
POSTGRES_DB: ${DB_NAME}
volumes:
- pgdata:/var/lib/postgresql/data
secrets:
- db_password
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER}"]
interval: 10s
timeout: 5s
retries: 5
volumes:
pgdata:
secrets:
app_secret:
file: ./secrets/app_secret.txt
db_password:
file: ./secrets/db_password.txt
Notice the wins: the base image dropped from ~1 GB to a fraction of that, the process runs unprivileged, secrets never touch the image, and the platform can now detect and restart an unhealthy container.
🎯 Quick Quiz
Question 1: Why should a production container run as a non-root user?
Question 2: What does a container health check let the platform do?
Question 3: Why avoid deploying the latest tag in production?
Best Practices
| ✅ Do | ❌ Don't |
|---|---|
| Use minimal base images (alpine, slim, distroless) | Ship a full OS image with build tools inside |
| Run as a dedicated non-root user | Leave the process running as root |
| Inject secrets at runtime | COPY or ENV secrets into a layer |
| Set CPU/memory limits and reservations | Let containers consume the host unbounded |
Add health checks and handle SIGTERM | Assume "running" means "healthy" |
| Tag images with version + git SHA | Deploy latest |
| Forward structured logs to central storage | Rely on logs trapped inside ephemeral containers |
Summary & Quiz
🎉 Key Takeaways
- Production Docker optimizes for reliability, security, and efficiency, not developer convenience.
- Pick an architecture — single-host, clustered, or hybrid — to match your scale and failure tolerance.
- Harden images: minimal base, non-root user, no baked-in secrets, scanned for CVEs.
- Bound resources and add health checks so the platform can self-heal.
- Handle graceful shutdown, forward structured logs, and tag images for traceability.
📚 Further Reading
🚀 What's Next?
Next we'll dive into multi-stage Docker builds — the technique that lets you keep a full toolchain for building while shipping a tiny, secure runtime image.
🎉 Well done!
You can now take a container from "works on my machine" to "runs safely in production." Let's make those images even leaner.