🚢 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.
📖 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.
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.
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 targetlatest - Run as non-root — add a dedicated user so a compromise can't own the container
- Scan images — tools like
docker scoutor 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
.envfiles 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 buildmakes an image;docker runstarts 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
- Docker — Get Started
- Docker — Dockerfile best practices
- Docker — Compose documentation
- Kubernetes — Overview (next step in orchestration)
🚀 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.