Docker for Beginners: Containerize Your First App Step by Step
What containers actually are, why developers use them, and a hands-on walkthrough containerizing a Node.js API — with the Dockerfile mistakes to avoid.
Marcus Webb
Senior Writer
Elena Ruiz
Fact Checked
“Works on my machine” used to be the punchline of software development. Docker is the reason you hear it less: it packages your application together with its entire environment — runtime, libraries, system dependencies, configuration — into a unit that runs identically on your laptop, your teammate’s laptop and the production server.
If you’ve bounced off Docker before because tutorials drowned you in terminology, this one takes the other route: minimum concepts, maximum doing. By the end you’ll have containerized a real Node.js API, understood why each line exists, and picked up the habits that separate clean Docker setups from cargo-culted ones.
The three concepts you actually need
Image — a frozen, layered snapshot of a filesystem plus instructions: “Ubuntu-ish base, Node 22 installed, my code in /app, run this command.” Images are immutable blueprints.
Container — a running (or stopped) instance of an image, isolated from the host: its own filesystem, network and process tree. Blueprint → building; class → instance; image → container. You can run five containers from one image.
Registry — where images live. Docker Hub is the default; docker pull node:22-slim downloads the official Node image published there.
That’s genuinely it. Volumes, networks and compose are refinements of these three.
Getting set up
Install Docker Desktop (Windows/macOS) or Docker Engine (Linux). On Windows, Docker Desktop rides on WSL2 — say yes when the installer offers to enable it. Verify with the traditional incantation:
docker run hello-world
If that prints a welcome message, Docker pulled an image, created a container, ran it and exited. You’ve already done the whole lifecycle once.
Containerizing a real app
We’ll containerize a Node/Express API — conveniently, the exact one from our Express REST API tutorial; any Node app with a package.json works the same. In the project root, create a Dockerfile:
# 1. Base image: Node 22 on a slim Debian
FROM node:22-slim
# 2. Everything from here happens in /app inside the image
WORKDIR /app
# 3. Copy dependency manifests FIRST (layer-caching trick — see below)
COPY package*.json ./
# 4. Install exactly what the lockfile says, production deps only
RUN npm ci --omit=dev
# 5. Now copy the source code
COPY . .
# 6. Document the port the app listens on
EXPOSE 3000
# 7. Run as the non-root user the Node image provides
USER node
# 8. The command a container runs on start
CMD ["node", "src/server.js"]
And — do not skip this — a .dockerignore:
node_modules
.git
*.md
.env
Without it, COPY . . ships your host’s node_modules (wrong OS binaries, possibly), your git history and your secrets into the image.
Build and run:
docker build -t task-api .
docker run -p 3000:3000 task-api
-t task-api names the image. -p 3000:3000 maps host port → container port; without it, the app runs but nothing can reach it (the number-one beginner confusion). Visit http://localhost:3000/health — your API is answering from inside a container.
The layer trick that makes builds fast
Docker caches each Dockerfile step as a layer and reuses layers until it hits the first changed one. That’s why we copy package*.json and install before copying source: edit your code and rebuild, and Docker reuses the (slow) dependency layer, redoing only the (instant) source copy. Reverse the order — COPY . . first — and every code change triggers a full npm ci. This single line-ordering decision is the difference between two-second and two-minute rebuilds.
Compose: your environment as a file
Real apps have company — a database, a cache. Instead of juggling docker run flags, describe everything in compose.yaml:
services:
api:
build: .
ports:
- "3000:3000"
environment:
DATABASE_URL: postgres://app:secret@db:5432/tasks
depends_on:
db:
condition: service_healthy
db:
image: postgres:17
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: secret
POSTGRES_DB: tasks
volumes:
- dbdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app"]
interval: 5s
retries: 10
volumes:
dbdata:
Then:
docker compose up
Three things worth noticing. Services reach each other by name — the API connects to host db, because compose puts them on a shared network with DNS. The volume dbdata persists Postgres data across container restarts — containers are disposable, volumes are not. And the healthcheck plus depends_on.condition means the API waits for Postgres to actually accept connections, not merely to have started — the fix for the classic “connection refused on first boot” race.
The quiet superpower: this file is your onboarding documentation. New teammate, day one: git clone, docker compose up, working environment. No “install Postgres 17 but not 18, then…” wiki page slowly rotting.
The commands you’ll actually use
| Command | What it does |
|---|---|
docker ps | Running containers (add -a for stopped ones) |
docker logs -f <name> | Follow a container’s output |
docker exec -it <name> sh | Open a shell inside a running container |
docker compose up -d | Start everything, detached |
docker compose down | Stop and remove containers (volumes survive) |
docker build -t name . | Build an image from the Dockerfile |
docker image prune | Reclaim disk from dangling images |
docker exec -it <name> sh deserves a highlight — when something’s weird, shelling into the container and looking around (“is the file there? can it reach the DB?”) beats guessing from outside every time.
Habits that pay off early
Pin your bases. FROM node:22-slim, not FROM node:latest. latest means “surprise me at the worst moment.”
Prefer slim over alpine (at first). Alpine images are smaller but use musl instead of glibc, which occasionally breaks native modules in confusing ways. -slim is 90% of the size win with none of the mystery.
One process per container. Resist the urge to cram the API and the database into one image; composing single-purpose containers is the entire design.
Multi-stage builds when you have a build step. Compile with the heavy toolchain in one stage, copy only artifacts into a slim runtime stage:
FROM node:22 AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:22-slim
WORKDIR /app
COPY --from=build /app/dist ./dist
COPY package*.json ./
RUN npm ci --omit=dev
USER node
CMD ["node", "dist/server.js"]
TypeScript, bundlers, frontend builds — the pattern is identical, and the runtime image never carries compilers.
Config via environment, secrets never in images. Anything in an image layer is effectively public to anyone who can pull it. .env files stay in .dockerignore; production secrets come from your platform’s secret store.
Frequently Asked Questions
Do I need Kubernetes after learning Docker? Not nearly as soon as the internet implies. A single server running compose (or a platform like Fly.io, Railway or a cloud container service) comfortably runs most small-to-medium production workloads. Kubernetes earns its complexity when you have many services, many machines and a team to operate it. Learn Docker deeply first; you’ll recognize the moment orchestration stops being optional — it feels like writing compose files with fifteen services and wishing for autoscaling.
How is a container different from a virtual machine? A VM virtualizes hardware and boots a whole guest operating system — minutes to start, gigabytes each, strong isolation. Containers share the host’s kernel and isolate at the process level — milliseconds to start, megabytes each, good-but-lighter isolation. That’s why you can run twenty containers on a laptop that would struggle with three VMs, and why containers won the packaging war for application software.
Why is my image 1.5 GB and what do I do about it?
The usual suspects, in order: a fat base image (use -slim), missing .dockerignore (shipping node_modules and .git), build tools left in the final image (use multi-stage builds), and dev dependencies installed in production (npm ci --omit=dev). Run docker history <image> to see which layer holds the weight. A typical Node API that starts at 1.5 GB lands at 200–300 MB with the fixes above — faster pulls, cheaper storage, smaller attack surface.
Can I use Docker for development, not just deployment?
Yes — many teams develop inside containers. Bind mounts (-v ./src:/app/src) let code changes on your host hot-reload in the container, and VS Code’s Dev Containers extension formalizes the whole thing: the repo carries a devcontainer config, and every teammate gets an identical, disposable environment with one click. It’s the strongest cure known for works-on-my-machine syndrome during development, not just after it.
Final Thoughts
Docker’s learning curve is real but front-loaded: three concepts, one Dockerfile pattern, one compose file, and you’ve covered what most developers use daily for years. The payoff arrives immediately — reproducible environments, one-command onboarding, dev/prod parity — and compounds as your stack grows. Everything past this point (Kubernetes, registries, CI pipelines) is the same ideas at larger scale.
The best next step is ruthless practice: containerize the last project you built. You’ll hit one weird error, fix it, and never fear the whale again. And if the terminal you’re doing all this in feels clunky, that’s a solvable problem too — see our developer terminal setup guide.