Docker for Full-Stack Developers: From Dockerfile to Production Compose | Roman Humagain

Docker for Full-Stack Developers: From Dockerfile to Production Compose | Roman Humagain

A practical Docker guide for full-stack developers: image layers, multi-stage builds, Compose for local development, healthchecks, secrets and production hardening.

Docker gets taught either as a list of commands to memorise or as a prelude to Kubernetes. Neither is what a full-stack developer needs. What you actually want is: the same environment on every machine, a one-command local stack, and an image you can deploy without surprises. That is a fairly small amount of Docker, done properly. The mental model: layers An image is a stack of read-only layers, one per instruction in your Dockerfile. When you rebuild, Docker reuses every layer whose inputs have not changed and rebuilds from the first one that has. This single fact explains the most important Dockerfile idiom: # dependencies first — this layer is cached until package.json changes COPY package*.json ./ RUN npm ci # source last — changes constantly, and only invalidates what follows COPY . . Copy everything first and every one-character source edit reinstalls your entire dependency tree. Split it, and rebuilds go from minutes to seconds. Multi-stage builds Your build tools do not belong in your runtime image. A multi-stage build compiles in one stage and copies only the output into a clean final image. FROM node:20-alpine AS build WORKDIR /app COPY package*.json ./ RUN npm ci COPY . . RUN npm run build FROM nginx:alpine COPY --from=build /app/dist /usr/share/nginx/html EXPOSE 80 The result contains a web server and static files — no node_modules, no source, no build toolchain. Smaller to push and pull, and a far smaller attack surface. For a Node API where you still need a runtime: FROM node:20-alpine AS deps WORKDIR /app COPY package*.json ./ RUN npm ci --omit=dev FROM node:20-alpine WORKDIR /app ENV NODE_ENV=production COPY --from=deps /app/node_modules ./node_modules COPY . . USER node EXPOSE 5000 CMD ["node", "server.js"] USER node is not decoration. Containers run as root by default, and root in the container is root on the host kernel if anything escapes. Write a .dockerignore Without one, the build context includes node_modules, .git and your local .env — slow to send, and secrets baked into a layer. node_modules .git .env dist *.log Note that deleting a file in a later layer does not remove it from the image; the earlier layer still contains it. A secret copied in and deleted later is still extractable. Compose for local development Compose is where the day-to-day value is: your API, database and frontend start together with one command. services: api: build: ./server ports: ["5000:5000"] environment: MONGO_URI: mongodb://mongo:27017/app depends_on: mongo: condition: service_healthy volumes: - ./server:/app - /app/node_modules mongo: image: mongo:7 volumes: ["mongo-data:/data/db"] healthcheck: test: ["CMD", "mongosh", "--eval", "db.adminCommand('ping')"] interval: 10s retries: 5 volumes: mongo-data: Three things to notice. Services reach each other by name. The API connects to mongo:27017, not localhost — Compose provides DNS on a shared network. The anonymous /app/node_modules volume is the fix for a classic problem: bind-mounting your source shadows the container's node_modules with your host's, which breaks native modules built for a different platform. The extra volume keeps the container's copy. depends_on with a healthcheck waits for the database to be ready . Plain depends_on only waits for the container to start, which is not the same thing, and produces intermittent startup failures. Secrets Never COPY a .env into an image, and do not pass secrets as build arguments — both end up in the image history, readable by anyone who can pull it. Inject them at runtime instead: environment variables from your platform, a mounted secrets file, or Docker secrets. Locally, env_file in Compose reads a gitignored .env without baking it in. Healthchecks and restarts A container whose process is alive but whose application is wedged looks healthy to the orchestrator. Give it a real check: HEALTHCHECK --interval=30s --timeout=3s --start-period=20s \ CMD wget -qO- http://localhost:5000/health || exit 1 Pair it with restart: unless-stopped so a crash recovers on its own. And make the endpoint honest — if it reports healthy without checking the database connection, it is decoration. Handle signals properly Docker sends SIGTERM on stop and kills after a grace period. Node does not get signals correctly when it is not PID 1, which happens if you use the shell form of CMD. Use the exec form — CMD ["node", "server.js"] — and handle the signal: process.on("SIGTERM", async () => { await server.close(); await mongoose.connection.close(); process.exit(0); }); Without this, in-flight requests are severed on every deploy. A short production checklist Pin base image versions — node:20-alpine, not node:latest Multi-stage builds, so no build tools ship to production Run as a non-root user A .dockerignore that excludes .git and .env Secrets injected at runtime, never baked in Meaningful healthchecks and a restart policy Graceful shutdown on SIGTERM Resource limits, so one container cannot starve the host That is most of the value. Orchestration is a genuinely different problem, and worth reaching for when you have enough services that starting them by hand hurts — not before.

Continue to Roman Humagain