Skip to main content

🚢 Docker and Web Development: A Developer's Journey

Sarah's code runs flawlessly on her MacBook. Tom pulls it onto his Windows laptop and everything explodes. This story is as old as software teams — and Docker is how modern teams end it. This tutorial is the big-picture tour: what containers are, why they matter, and how to grow from one container to a whole coordinated system.

Reference & Extra Tutorials · Resources · Containers Overview

🎯 What This Covers

By the end of this tutorial, you will be able to:

  • Explain the shipping-container analogy and why it maps onto software so well
  • Describe the "works on my machine" problem and how containers eliminate it
  • Build and run your first container from a Node app and a Dockerfile
  • Coordinate multiple services (web + database) with Docker Compose
  • Apply core security and performance best practices for production
  • Recognize where Docker fits into microservices and CI/CD workflows

Estimated Time: 70 minutes

Practice: Containerize a Node app, then extend it into a multi-service stack.

In This Tutorial

The Shipping Container Analogy

Before the 1950s, loading a cargo ship meant hand-stacking barrels, sacks, and crates of every shape — slow, damaging, and wildly inconsistent. The standardized shipping container changed global trade forever: one uniform box that any crane, truck, or ship could handle identically, whatever was inside.

Docker brought that same revolution to software. Your application, plus every dependency, library, and config file it needs, gets sealed into a uniform unit — a container — that any machine running Docker can handle identically. The host doesn't need to know or care what's inside; it just runs the box.

One host running three identical standardized containers side by side Host machine + Docker Engine (shared OS kernel) Web app Node + deps Database MongoDB Cache Redis
Multiple isolated containers share one host kernel — lighter and faster than running a full virtual machine per service.

📖 Containers vs virtual machines

A VM virtualizes hardware and boots a full guest operating system — gigabytes in size, minutes to start. A container shares the host's kernel and packages just the app and its libraries — megabytes in size, seconds to start. That efficiency is why you can run a dozen containers where you'd struggle to run three VMs.

Why Docker Matters

The classic pain: code that works on one developer's machine breaks on another's because their operating systems, language versions, or installed libraries differ. Multiply that across a team plus a production server and you get hours lost to "environment" bugs that have nothing to do with the actual code.

Docker eliminates the variable. The environment travels with the app inside the container, so every machine runs an identical setup — laptop, teammate's PC, CI server, production. What you tested is exactly what ships.

sequenceDiagram participant Dev as Developer participant CI as CI Pipeline participant Prod as Production Dev->>CI: Push code and Dockerfile CI->>CI: Build the same image CI->>Prod: Deploy the identical image Prod-->>Dev: Runs exactly as tested locally

Consider a realistic e-commerce platform. It might need a specific Node version for the API, MongoDB for storage, Redis for caching, and Nginx as a reverse proxy. Setting all of that up by hand on every developer's machine and the server would be a nightmare of README steps and version drift. With Docker, the entire stack comes up with a single command — and comes up the same way every time.

💡 The mental shift

Stop thinking "install this on the machine." Start thinking "declare it in a file." Docker turns environment setup from a manual, error-prone ritual into version-controlled, reproducible configuration that lives right next to your code.

Your First Container

Building your first container is like your first LEGO set — start with the base, add pieces in order. Create a project folder and a tiny Node app.

mkdir my-docker-app
cd my-docker-app
touch index.js Dockerfile package.json .dockerignore

A minimal Express app

// index.js
const express = require('express');
const app = express();

app.get('/', (req, res) => {
    res.send('Hello from Docker! 🐳');
});

const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
    console.log(`App running on port ${PORT}`);
});
// package.json
{
  "name": "my-docker-app",
  "version": "1.0.0",
  "main": "index.js",
  "scripts": { "start": "node index.js" },
  "dependencies": { "express": "^4.19.2" }
}

The Dockerfile — your blueprint

# Start from a small, version-pinned base image
FROM node:18-alpine

# Set the working directory inside the container
WORKDIR /app

# Copy the manifest first so the install layer caches well
COPY package*.json ./
RUN npm install --omit=dev

# Copy the rest of the application code
COPY . .

# Document the port the app listens on
EXPOSE 3000

# The command that runs when the container starts
CMD ["node", "index.js"]

The original of this tutorial used FROM node:16; we've modernized to a supported LTS line and the smaller alpine variant, and pinned the version rather than chasing latest. A quick .dockerignore keeps the image lean:

# .dockerignore
node_modules
npm-debug.log
.git
.env

Building & Running

With the blueprint written, build the image and run a container from it — following the instructions to snap the LEGO set together.

# Build an image and tag it "my-web-app"
docker build -t my-web-app .

# Run a container, mapping host port 3000 to the container's 3000
docker run -p 3000:3000 my-web-app

Visit http://localhost:3000 and you'll see the greeting. You've just containerized a web app. A few commands you'll reach for constantly:

docker ps                 # list running containers
docker ps -a              # include stopped ones
docker images             # list images on your machine
docker logs <id>          # view a container's output
docker stop <id>          # stop a running container
docker rm <id>            # remove a stopped container
docker run -d -p 3000:3000 my-web-app   # -d runs it detached (background)

Output of docker ps

CONTAINER ID   IMAGE        STATUS         PORTS                    NAMES
a1b2c3d4e5f6   my-web-app   Up 12 seconds  0.0.0.0:3000->3000/tcp   my-web-app

⚠️ Rebuild after code changes

An image is a frozen snapshot. Editing index.js won't change a container already built from an old image — you must docker build again (or use a bind-mount volume during development, as Compose does below).

Compose: Multiple Containers

Real applications rarely stop at one container. You'll have a web service, a database, maybe a cache. Wiring those together with individual docker run commands is tedious and fragile. Docker Compose is the conductor that coordinates the whole orchestra from one score.

# docker-compose.yml
services:
  web:
    build: .
    ports:
      - "3000:3000"
    depends_on:
      - mongodb          # start the database before the web service
    environment:
      - MONGO_URL=mongodb://mongodb:27017/app

  mongodb:
    image: mongo:7       # pull a ready-made image instead of building
    ports:
      - "27017:27017"
    volumes:
      - mongodb_data:/data/db   # named volume keeps the data

volumes:
  mongodb_data:

Two subtle but important points. First, depends_on controls start order so the database boots before the web app. Second, notice the connection string uses the hostname mongodb — Compose puts every service on a shared private network where each is reachable by its service name. No IP addresses to hunt down.

graph LR A["web service"] -->|"reads and writes"| B["mongodb service"] B --> C["mongodb_data volume"] A -.->|"same private network"| B

Bring the whole stack up or down with one command each:

docker compose up --build -d    # build and start everything in the background
docker compose ps               # see what's running
docker compose logs -f web      # follow the web service logs
docker compose down             # stop and remove all of it

💡 Named volumes vs bind mounts

A named volume like mongodb_data is managed by Docker and ideal for databases. A bind mount like ./src:/app/src maps a host folder into the container and is ideal for live-reloading code during development. Reach for the one that matches the job.

Production Best Practices

Shipping a container to production is like prepping a spacecraft for launch — everything should be deliberate, minimal, and secure.

🔒 Security

  • Pin versions — use node:18-alpine, never the moving target latest
  • Run as non-root — add a dedicated user so a compromise can't own the container
  • Scan images — tools like docker scout or Trivy flag known vulnerabilities
  • Keep images minimal — a smaller base has a smaller attack surface
  • Never bake in secrets — inject them at runtime via environment or a secrets manager
# Run as a non-root user in production
FROM node:18-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
USER node          # drop root privileges before running
EXPOSE 3000
CMD ["node", "index.js"]

⚡ Performance

  • Multi-stage builds — build in one stage, ship only the artifacts in a lean final image
  • Order layers for caching — copy the manifest and install before copying source
  • Add a healthcheck — let the orchestrator know when the app is actually ready
  • Log to stdout — let the platform collect and route logs
# Multi-stage build: build stage, then a slim runtime stage
FROM node:18-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM node:18-alpine
WORKDIR /app
COPY --from=build /app/dist ./dist
COPY package*.json ./
RUN npm ci --omit=dev
USER node
CMD ["node", "dist/index.js"]

Real-World Architectures

Once services are containerized, they compose into larger systems. A common shape is microservices: instead of one giant application, you run many small, independently deployable services — a food court of specialized trucks rather than one enormous kitchen.

# A microservices e-commerce stack (sketch)
services:
  frontend:
    build: ./frontend
    ports:
      - "80:80"

  auth-service:
    build: ./auth
    environment:
      - JWT_SECRET=${JWT_SECRET}   # injected at runtime, not hard-coded

  product-service:
    build: ./products
    depends_on:
      - mongodb

  order-service:
    build: ./orders
    depends_on:
      - redis
      - mongodb

  mongodb:
    image: mongo:7
    volumes:
      - mongodb_data:/data/db

  redis:
    image: redis:7
    volumes:
      - redis_data:/data

volumes:
  mongodb_data:
  redis_data:

Notice the secret is read from ${JWT_SECRET} in the environment rather than written into the file — a small change that keeps credentials out of version control. This is exactly the kind of setup where Compose shines for local development, while a production cluster would hand orchestration to a heavier tool like Kubernetes.

📖 Where CI/CD fits

Because the image built in CI is byte-for-byte the image deployed to production, containers slot naturally into automated pipelines: on every push, the CI system builds the image, runs the tests inside it, and — if green — promotes that same image to staging and production. Build once, run anywhere, deploy with confidence.

Practice & Quiz

🏋️ Exercise 1: Containerize and run

Goal: Take the minimal Express app from earlier, build an image tagged hello-docker, run it in the background on port 8080, confirm it responds, then stop it.

💡 Hint

Change the host side of the port map to 8080 while keeping the container side at 3000. The format is -p host:container.

✅ Solution
docker build -t hello-docker .
docker run -d -p 8080:3000 --name hello hello-docker
curl http://localhost:8080          # Hello from Docker! 🐳
docker stop hello
docker rm hello

🏋️ Exercise 2: Add a cache service

Goal: Extend the web + mongodb Compose file to also run a Redis cache with a persistent named volume.

✅ Solution
services:
  web:
    build: .
    ports:
      - "3000:3000"
    depends_on:
      - mongodb
      - redis
  mongodb:
    image: mongo:7
    volumes:
      - mongodb_data:/data/db
  redis:
    image: redis:7
    volumes:
      - redis_data:/data

volumes:
  mongodb_data:
  redis_data:

🎯 Quick Quiz

Question 1: How does a container differ from a virtual machine?

Question 2: In Compose, how does the web service reach the database?

Question 3: Which is a sound production security practice?

Best Practices & Pitfalls

✅ Do

  • Declare your environment in files and commit them alongside the code
  • Use small, version-pinned base images and a .dockerignore
  • Persist stateful services with named volumes
  • Inject secrets and config through environment variables at runtime
  • Adopt multi-stage builds to keep production images lean

❌ Don't

  • Chase latest — it makes builds unreproducible and breaks silently
  • Run production containers as root when you don't have to
  • Store database data inside a container with no volume
  • Commit .env files or secrets into the image or repository

⚠️ depends_on is not "wait until ready"

It only controls start order, not readiness — the database container may be started but not yet accepting connections. For real robustness, add a healthcheck or retry logic in your app's connection code so it waits for the database to truly be up.

Summary

🎉 Key Takeaways

  • Containers standardize software the way shipping containers standardized trade — portable and uniform
  • They ship the environment with the app, ending the "works on my machine" problem
  • docker build makes an image; docker run starts a container from it
  • Docker Compose coordinates multi-service stacks; services reach each other by name
  • Production means pinned versions, non-root users, volumes, injected secrets, and lean images

📚 Additional Resources

🚀 What's Next?

You can now build, run, and coordinate containers with confidence. Next in the reference track we switch gears to a different everyday skill: Git and GitHub Sync Issues — Understanding and Resolving Mismatches, so those version-control headaches never slow you down.

🎉 Every expert was once a beginner!

You've gone from the shipping-container analogy to a multi-service production mindset. Keep experimenting — Docker rewards tinkering.