Working With Product, DevOps, and Business
Same architecture decision, three languages — how I translate NFRs and trade-offs for product, DevOps, and business partners without losing technical honesty.
Multi-stage builds, minimal images, health checks, and secrets management — the Docker patterns that separate dev containers from production-grade deployments.
Tutorial Docker is: FROM node:latest, copy everything, npm install, expose port, run. This creates images that are 1.2GB, run as root, have dev dependencies baked in, and leak environment variables into image layers.
Production Docker is a different discipline.
The single most impactful change: separate your build environment from your runtime environment.
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --frozen-lockfile
COPY . .
RUN npm run build
FROM node:20-alpine AS runner
WORKDIR /app
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder --chown=nextjs:nodejs /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
USER nextjs
EXPOSE 3000
CMD ["node", "dist/main.js"]
Result: build tools, source files, and dev dependencies stay in the builder stage and never ship. Final image is 200MB instead of 1.2GB.
Docker needs to know when your container is actually ready to serve traffic, not just "running":
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
CMD curl -f http://localhost:3000/health || exit 1
Your app needs a /health endpoint:
@Get('/health')
health() {
return {
status: 'ok',
uptime: process.uptime(),
timestamp: new Date().toISOString(),
};
}
Without health checks, Kubernetes and Docker Swarm will route traffic to containers that are still booting or have silently crashed.
Never put secrets in environment variables baked into the image. The ENV directive writes values into image layers permanently, visible to anyone who pulls the image.
Use Docker secrets or mount at runtime:
services:
api:
image: my-api:latest
secrets:
- db_password
environment:
- NODE_ENV=production
secrets:
db_password:
external: true
In your app, read from the mounted file:
function getSecret(name: string): string {
const secretPath = `/run/secrets/${name}`;
if (fs.existsSync(secretPath)) {
return fs.readFileSync(secretPath, 'utf8').trim();
}
return process.env[name.toUpperCase()] ?? '';
}
Every file you don't explicitly exclude gets sent to the Docker build context. A missing .dockerignore sends node_modules, .git, test files, and local env files to the daemon on every build:
node_modules
.git
.env*
*.test.ts
coverage
.next
dist
This alone can cut build times from 30 seconds to 3 seconds.
.dockerignore: build context is minimalnode:20.11.0-alpine not node:latestMost production incidents I've seen with containers trace back to missing one of these.