Skip to main content
Back to Journal
DevOpsDeveloper Tools

Docker for Web Developers: Containers Without the Complexity

For the longest time, I thought Docker was something only DevOps engineers needed to care about. I had Node installed locally, MongoDB running on my machine, and everything worked fine. Then I tried to help a teammate debug an issue on their machine and spent two hours discovering that their Node version, their MongoDB version, and their npm global packages were all different from mine. The bug was not in the code. It was in the environment.

That is the problem Docker solves. Your application runs in a container that has the exact same environment everywhere: your machine, your teammate's machine, the CI server, production. No more "it works on my machine."

The Dockerfile

A Dockerfile is a recipe for building a container image. It starts from a base image (usually a Linux distribution with your runtime pre-installed) and adds your application on top.

FROM node:14-alpine

WORKDIR /app

COPY package*.json ./
RUN npm ci

COPY . .

EXPOSE 3000
CMD ["node", "server.js"]

Line by line: start from the official Node.js 14 Alpine image (Alpine is a tiny Linux distribution, so the image is small). Set the working directory. Copy the package files first and install dependencies (this layer gets cached, so rebuilds are fast if dependencies have not changed). Copy the rest of the application. Expose port 3000 and run the server.

Building and running is two commands:

docker build -t my-app .
docker run -p 3000:3000 my-app

Multi-Stage Builds

For production images, you do not want dev dependencies, source maps, or build tools in the final image. Multi-stage builds let you use one stage for building and a different stage for running.

FROM node:14-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM node:14-alpine
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY package*.json ./
EXPOSE 3000
CMD ["node", "dist/server.js"]

The first stage installs everything and builds. The second stage only copies the built output and production dependencies. The final image is much smaller because it does not include source files, TypeScript, or dev dependencies.

Docker Compose for Local Development

This is where Docker really changed my workflow. Instead of installing MongoDB, Redis, and Elasticsearch on my machine, I define them in a docker-compose.yml file and spin everything up with one command.

version: '3.8'
services:
  app:
    build: .
    ports:
      - '3000:3000'
    volumes:
      - .:/app
      - /app/node_modules
    environment:
      - MONGO_URL=mongodb://mongo:27017/mydb
      - REDIS_URL=redis://redis:6379
    depends_on:
      - mongo
      - redis

  mongo:
    image: mongo:4.4
    ports:
      - '27017:27017'
    volumes:
      - mongo-data:/data/db

  redis:
    image: redis:6-alpine
    ports:
      - '6379:6379'

volumes:
  mongo-data:

Running docker-compose up starts the app, MongoDB, and Redis together. The services can talk to each other by name (the app connects to mongo and redis as hostnames). Volumes persist data between restarts.

The volumes: - .:/app line mounts your local source code into the container, so changes you make on your machine are reflected immediately. Combined with nodemon or a file watcher, you get hot reload inside Docker.

The .dockerignore File

Just like .gitignore, you need a .dockerignore to keep unnecessary files out of your image. Without it, Docker copies everything, including node_modules, .git, and any large files in your project.

node_modules
.git
.env
*.md
.DS_Store
coverage
dist

This keeps your build context small and your builds fast.

Networking

Docker Compose creates a default network for your services. Each service is reachable by its service name. So your Node app connects to MongoDB at mongodb://mongo:27017, not localhost:27017. This tripped me up the first time because I was used to everything being on localhost.

You can also create custom networks if you need to isolate groups of services, but for most development setups the default network is fine.

Practical Tips

After a few months of using Docker daily, here are the things I wish someone had told me upfront.

First, order your Dockerfile commands so that the things that change least are at the top. Docker caches each layer, so if your package.json has not changed, the npm ci step uses the cache. If you copy all your source code before installing dependencies, every code change invalidates the dependency cache and forces a full reinstall.

Second, use .dockerignore from day one. A forgotten node_modules directory in your build context can turn a 10-second build into a 5-minute build.

Third, use named volumes for database data. Anonymous volumes get cleaned up unexpectedly. Named volumes persist until you explicitly remove them.

Fourth, docker-compose down stops and removes containers, but it does not remove volumes by default. If you want a clean slate, use docker-compose down -v to also remove volumes. Be careful with this in development if your database has data you care about.

Docker has become one of those tools that I wonder how I ever worked without. The initial learning curve is real, but the payoff in consistency and convenience is worth it.

dockercontainersdocker-composedevelopment-environment