Getting started with Docker for web developers
"Works on my machine" is almost always a difference in installed versions, environment variables, or OS-level dependencies that never made it into a README. Docker fixes that by packaging your app together with everything it needs to run.
What a container actually is
A container is not a virtual machine — it doesn't boot its own OS. It's an isolated process that shares your machine's kernel but gets its own filesystem, built from instructions in a file called a Dockerfile. That isolation is what makes "it works in the container" mean the same thing everywhere.
Writing your first Dockerfile
For a small Node.js app, a Dockerfile is usually four sections: a base image, dependency install, copying your code, and a start command.
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
CMD ["node", "server.js"]FROM picks a starting image — node:20-alpine is a minimal Linux image with Node preinstalled. Copying package*.json before the rest of your code, then installing, means Docker can reuse that layer on rebuilds if your dependencies haven't changed — one of the biggest speedups new Docker users miss.
Building and running it
docker build -t my-app .
docker run -p 3000:3000 my-app-p 3000:3000 maps port 3000 inside the container to port 3000 on your machine — without it, the container's server is running but unreachable from your browser.
Forgetting a .dockerignore file means node_modules and .git get copied into the image, bloating build time and image size. Add one with the same entries as your .gitignore, plus node_modules.
Checking what's running
docker ps
docker logs <container-id>docker ps lists running containers; docker logs is the first place to look when a container starts but the app inside isn't responding — most "it's not working" issues turn out to be a crashed process, visible immediately in the logs.
Running your app alongside a database
A real app usually needs more than one container — the app itself, plus a database it talks to. docker compose runs both from one file instead of two separate docker run commands you have to keep in sync by hand:
# docker-compose.yml
services:
app:
build: .
ports:
- "3000:3000"
environment:
DATABASE_URL: postgres://postgres:postgres@db:5432/myapp
depends_on:
- db
db:
image: postgres:16-alpine
environment:
POSTGRES_PASSWORD: postgres
POSTGRES_DB: myapp
volumes:
- db_data:/var/lib/postgresql/data
volumes:
db_data:docker compose updepends_on starts db before app, but only waits for the container to start, not for Postgres inside it to finish accepting connections — a real app should still retry its first database connection rather than assuming db is ready the instant it's up. DATABASE_URL points at db, not localhost — inside Compose's network, each service is reachable by its service name. The volumes entry keeps Postgres's actual data on disk outside the container, so running docker compose down doesn't erase the database along with the containers.
Live-reload without rebuilding the image
The Dockerfile earlier in this tutorial copies your code into the image once, at build time — fine for running the app, but it means every code change needs a rebuild to see it. For active development, a bind mount overlays your local folder onto the container's filesystem, so a saved file shows up inside the container immediately:
services:
app:
build: .
ports:
- "3000:3000"
volumes:
- .:/app
- /app/node_modules
command: npm run devThe first volume line mounts your entire project directory into /app inside the container — edit a file locally, and the running process (started with a dev server that watches for changes) sees it right away. The second line is a deliberate exception: it keeps the container's own /app/node_modules from being overwritten by an empty or platform-mismatched one from your host machine, which is the most common reason a bind-mounted Node container fails to start with a confusing "module not found" error.
Using this same bind-mount setup in production. Bind mounts are a development convenience — they defeat the actual point of a container image being a fixed, reproducible artifact. Production should run the image built by your Dockerfile as-is, with no live-editable mount into it.
Multi-stage builds for a smaller production image
The Dockerfile earlier installs full node_modules and copies your entire source tree into the final image — fine for development, but a production image doesn't need dev dependencies, build tooling, or source files that only exist to produce a compiled output. A multi-stage build compiles in one throwaway stage and copies only the result into the final one:
# Stage 1: install everything and build
FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Stage 2: run with only what's needed
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY --from=build /app/dist ./dist
CMD ["node", "dist/server.js"]AS build names the first stage so the second can reference it. COPY --from=build /app/dist ./dist pulls only the compiled output across — the first stage's dev dependencies, TypeScript compiler, and raw source never make it into the final image at all. The result is a smaller image with a smaller attack surface, since nothing that isn't needed at runtime is present to begin with.
FAQ
Do I need docker compose for a single-container app?
No — a single docker run command is simpler when there's only one service. Compose earns its place once there's a second service (a database, a cache, a queue) to coordinate.
Why does my bind-mounted container say "module not found" right after I set it up?
This is almost always the node_modules overwrite problem covered above — your host's node_modules (or none at all) is overlaying the one the image installed. The - /app/node_modules line in the compose example above is the fix.
Does a multi-stage build change how I run the container?
No — docker build and docker run (or docker compose up) work exactly the same way. Multi-stage only changes what ends up inside the final image, not how you invoke it.
Is docker-compose (with a hyphen) the same as docker compose?
docker-compose was a separate Python tool; docker compose (no hyphen, a subcommand) is its actively maintained successor, bundled directly with current Docker installs. If a hyphenated command isn't found, that's the version gap — check docker compose version first.
Where this goes next
With a container running alongside a real database and a production-sized image, the natural next step is understanding what happens when that image needs to actually reach the internet — how DNS resolution works covers the lookup a deployed container's outbound requests depend on.