Tutorials

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

Cargo ship made of circuit boards carrying glowing software containers across a digital ocean

“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.

The whole lifecycle: a Dockerfile builds an image; an image runs as containers; compose orchestrates the lot.

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.

Why containers won app packaging: process-level isolation at a fraction of a VM's weight.

The commands you’ll actually use

CommandWhat it does
docker psRunning containers (add -a for stopped ones)
docker logs -f <name>Follow a container’s output
docker exec -it <name> shOpen a shell inside a running container
docker compose up -dStart everything, detached
docker compose downStop and remove containers (volumes survive)
docker build -t name .Build an image from the Dockerfile
docker image pruneReclaim 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.