Docker killed "it works on my machine." Package the app together with its dependencies, and it starts the same way everywhere. That's the whole idea, and it turned out to matter a great deal.
Where it came from

It started as an internal tool at dotCloud, a PaaS startup — a wrapper around LXC (Linux Containers). Solomon Hykes demoed it publicly at PyCon US 2013, and once it was open-sourced it went far past anything an internal tool was scoped for.
Why this and not VMs
VMware and VirtualBox were the standard at the time. The difference is straightforward:
- VM: carries a full guest OS. Gigabytes, minutes to boot.
- Container: shares the host kernel. Megabytes, milliseconds to boot.
Containers weren't Docker's invention — LXC and cgroups came first. What Docker did was compress all of it into one file (Dockerfile) and a handful of commands. That changed how many people could actually use it.
What to install
You need a runtime on macOS or Windows.
- Docker Desktop: the official one. Complete, but not light on resources while it's running.
- OrbStack: macOS only. Starts faster, uses less memory, and existing Docker workflows carry over more or less unchanged. Worth trying if you're on a Mac.
The commands to learn first
Check it works
docker run hello-world
Finding no local image, it pulls one from Docker Hub, runs it, prints a message and exits. A few seconds, start to finish.
Run a web server
docker run -d -p 8080:80 --name my-nginx nginx
-d runs it in the background; -p 8080:80 connects your port 8080 to the container's port 80. Open http://localhost:8080 and Nginx answers — without Nginx being installed on your machine.
Look at it, get inside it
docker ps docker exec -it my-nginx bash
docker ps shows only running containers; add -a when you're hunting for one that died. For debugging, docker logs my-nginx usually gets you there faster than a shell.
Clean up
docker stop my-nginx docker rm my-nginx
Dockerfile
Commands typed by hand aren't reproducible. Describe the image in a file instead.
FROM node:20-alpine WORKDIR /app COPY package.json . RUN npm install COPY . . CMD ["npm", "start"]
FROM picks the base. The alpine variant is built on Alpine Linux and produces a much smaller image.
WORKDIR sets the directory for everything after it, creating it if it doesn't exist.
The split between COPY package.json . and COPY . . is the important part of this file. Docker caches layer by layer, and when one layer's input changes, every layer after it is rebuilt. Copy the source first and a one-character edit sends you back through npm install. Put the things that rarely change earlier — that's the reason for the order.
CMD runs when the container starts, and the container exits when it exits.
Build it:
docker build -t my-app .
More than one container
Real applications need a database and a cache. Managing that with a row of docker run commands doesn't hold up, so it goes in a Compose file.
# compose.yaml services: web: build: . ports: - "3000:3000" db: image: postgres environment: POSTGRES_PASSWORD: password
docker compose up brings all of it up. Service names double as hostnames, so web reaches Postgres at db.
One caveat: that example is for local use. The password is sitting in the file, so don't carry it into production as-is.