If containers have always felt like magic you copy-paste and pray over, this is the guide that makes them click. No hand-waving — you'll understand why each command does what it does, and walk out able to Dockerize a real app and ship it small and secure.
What's inside
1. What Docker is (and the problem it kills)2. The mental model: image → container → registry3. Install & verify4. Your first real container5. Managing containers & images6. Writing your own Dockerfile7. Build it & run it8. .dockerignore (do this early)9. Data that survives: volumes10. Networking: letting containers talk11. Docker Compose: your whole stack in one file12. Optimization: smaller, faster images13. Security & production best practices14. Cleanup (reclaim your disk)15. The cheat sheet16. Troubleshooting the usual suspects1. What Docker is (and the problem it kills)
Docker packages your app and everything it needs to run — the runtime, libraries, system tools, config — into one portable unit called a container. That container runs identically on your laptop, a teammate's machine, and production.
It exists to kill one specific sentence: "but it works on my machine." That happens because your machine has a different Node version, a missing library, a different OS. A container carries its own environment, so "your machine" stops mattering.
Container vs virtual machine: a VM boots a whole guest operating system (heavy, slow, gigabytes). A container shares the host's kernel and only packages your app and its dependencies (light, fast, megabytes). That's why containers start in milliseconds and you can run dozens on one machine.
2. The mental model: image → container → registry
Four words unlock everything. Get these and the commands make sense:
- Dockerfile — a recipe. A text file of instructions for how to build your app's environment.
- Image — the built, frozen result of that recipe. Read-only. Like a class in code, or a cake recipe fully prepped into a mix.
- Container — a running instance of an image. Like an object created from a class. You can run many containers from one image.
- Registry — where images live and get shared. Docker Hub is the default public one; you push and pull images from it like git for images.
The flow is always: write a Dockerfile → build it into an image → run the image as a container. Everything below is variations on that loop.
3. Install & verify
Mac / Windows: install Docker Desktop from docker.com. It bundles the engine, CLI, and a dashboard. On Windows it runs on WSL2 under the hood — accept that when prompted.
Linux: install Docker Engine (no Desktop needed). The official convenience script:
curl -fsSL https://get.docker.com | sh
# run docker without sudo (log out/in after):
sudo usermod -aG docker $USER
Verify it's working — this pulls a tiny test image and runs it:
docker --version
docker run hello-world
If hello-world prints a welcome message, your install is good. That single command already did the whole loop: pulled an image from a registry and ran it as a container.
4. Your first real container
Run an actual web server in one line — the Nginx web server, in the background, on port 8080:
docker run -d --name web -p 8080:80 nginx
Open http://localhost:8080 and you'll see the Nginx page. Breaking down those flags, because you'll use them constantly:
-d— detached. Run in the background instead of taking over your terminal.--name web— give it a friendly name instead of a random one.-p 8080:80— map host port 8080 → container port 80. Left is your machine, right is inside the container. This is how the outside world reaches the app.nginx— the image to run (pulled from Docker Hub automatically).
Two more flags you'll live in:
-it— interactive terminal, for shells:docker run -it --rm ubuntu bashdrops you inside a fresh Ubuntu.--rm— auto-delete the container when it stops. Great for throwaway runs so you don't pile up dead containers.-e KEY=value— set an environment variable, e.g.-e POSTGRES_PASSWORD=secret.
5. Managing containers & images
Your day-to-day control panel:
docker ps # running containers
docker ps -a # ALL containers, including stopped
docker logs web # see a container's output
docker logs -f web # follow logs live (Ctrl+C to stop watching)
docker exec -it web bash # open a shell INSIDE a running container
docker stop web # stop it
docker start web # start it again
docker rm web # delete it (must be stopped first)
docker rm -f web # force-stop and delete in one go
And for images:
docker images # list local images
docker pull postgres:16 # download an image ahead of time
docker rmi nginx # remove an image
docker tag myapp me/myapp:1.0 # give an image another name/tag
The #1 beginner confusion: docker ps only shows running containers. If a container "disappeared," it probably exited — use docker ps -a to find it, then docker logs to see why it died.
6. Writing your own Dockerfile
Now you package your app. A Dockerfile is the recipe. Here's a complete one for a Node app — every line explained after:
# Start from an official base image (pinned version, slim variant)
FROM node:20-slim
# Set the working directory inside the container
WORKDIR /app
# Copy dependency manifests FIRST (this line is the key to fast builds)
COPY package*.json ./
# Install only production dependencies
RUN npm ci --omit=dev
# Now copy the rest of your source code
COPY . .
# Document which port the app listens on
EXPOSE 3000
# The command that runs when the container starts
CMD ["node", "server.js"]
FROM— the base you build on. Someone already made a Node image; you extend it.WORKDIR— sets and creates the folder all following commands run in.COPY— copies files from your project into the image.RUN— runs a command at build time (installing deps, compiling). Each RUN creates a layer.EXPOSE— documentation of the port (doesn't publish it; you still need-pwhen running).CMD— the default command run at container start. Use the JSON array form["node","server.js"].
Why copy package.json before the source? Docker caches each layer. If your dependencies don't change, Docker reuses the cached npm ci layer and skips reinstalling — turning a 2-minute build into 2 seconds. If you copied everything at once, changing one line of code would reinstall every dependency. This ordering is the single biggest build-speed trick.
7. Build it & run it
Build your Dockerfile into an image (the -t tags it with a name, the . is the build context — the current folder):
docker build -t myapp .
docker run -d --name myapp -p 3000:3000 myapp
Change your code, rebuild, rerun. That's the whole inner loop. To rebuild from scratch ignoring cache:
docker build --no-cache -t myapp .
8. .dockerignore (do this early)
Create a .dockerignore file next to your Dockerfile. It keeps junk out of the build context — which makes builds faster and images smaller, and stops you from accidentally baking secrets into an image:
node_modules
npm-debug.log
.git
.env
dist
*.md
Dockerfile
.dockerignore
Without this, COPY . . would drag your local node_modules, .git history, and any .env secrets into the image. Always add it.
9. Data that survives: volumes
Containers are ephemeral — delete one and its internal data is gone. For databases, uploads, anything you need to keep, you mount storage from outside. Two kinds:
- Named volumes — Docker manages the storage. Best for databases and production data.
- Bind mounts — map a folder on your machine into the container. Best for local development (edit code on your host, see it live inside).
# Named volume — Postgres data persists across container restarts/deletes
docker run -d --name db \
-e POSTGRES_PASSWORD=secret \
-v pgdata:/var/lib/postgresql/data \
postgres:16
# Bind mount — your local ./src shows up live inside the container
docker run -d --name dev -p 3000:3000 \
-v $(pwd)/src:/app/src \
myapp
The pattern is -v SOURCE:TARGET. A plain name (pgdata) is a named volume; a path ($(pwd)/src) is a bind mount. Inspect volumes with docker volume ls.
10. Networking: letting containers talk
By default each container is isolated. To let two containers talk (say an app and its database), put them on the same user-defined network — then they can reach each other by container name as a hostname:
docker network create appnet
docker run -d --name db --network appnet postgres:16
docker run -d --name app --network appnet -p 3000:3000 myapp
# inside 'app', the database is reachable at host db:5432
You rarely wire this by hand though — Docker Compose (next) creates a shared network for you automatically. That's the normal way to run multi-container apps.
11. Docker Compose: your whole stack in one file
Typing long docker run commands for every service gets old fast. Compose defines your entire multi-container app in one compose.yaml file and runs it with a single command.
Here's an app + Postgres database, fully wired:
services:
web:
build: .
ports:
- "3000:3000"
environment:
- DATABASE_URL=postgres://user:pass@db:5432/app
depends_on:
- db
db:
image: postgres:16
environment:
- POSTGRES_USER=user
- POSTGRES_PASSWORD=pass
- POSTGRES_DB=app
volumes:
- db-data:/var/lib/postgresql/data
volumes:
db-data:
Notice web reaches the database at db:5432 — Compose put both on a shared network and used the service name as the hostname. Run the whole thing:
docker compose up -d # build + start everything in the background
docker compose ps # what's running
docker compose logs -f # follow logs from all services
docker compose down # stop and remove everything
docker compose down -v # ...and delete the volumes too (wipes data)
Modern Docker uses docker compose (with a space, built in). The old docker-compose (hyphen) is a separate legacy binary — if a tutorial uses the hyphen, the space version is the current one.
12. Optimization: smaller, faster images
This is where beginners' 1.2GB images become 90MB. Four techniques, in order of impact:
A. Order layers by how often they change
Covered above but it's the biggest win: copy dependency files and install before copying source. Stable things first, frequently-changing things last, so the cache does the work.
B. Multi-stage builds — drop the build tools
Build your app in one stage (with all the compilers and dev deps), then copy only the finished output into a clean final image. The build tools never ship:
# ---- Build stage ----
FROM node:20-slim AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# ---- Runtime stage (this is what ships) ----
FROM node:20-slim
WORKDIR /app
ENV NODE_ENV=production
COPY package*.json ./
RUN npm ci --omit=dev
COPY --from=build /app/dist ./dist
EXPOSE 3000
USER node
CMD ["node", "dist/server.js"]
COPY --from=build pulls the compiled dist out of the first stage. The final image has no source, no dev dependencies, no build toolchain.
C. Pick a small base image
node:20— full, ~1GB. Avoid for production.node:20-slim— trimmed Debian, a good default.node:20-alpine— tiny (~50MB base), but uses musl libc which occasionally breaks native modules — test it.- distroless — Google's images with no shell or package manager at all. Smallest attack surface, but harder to debug.
D. Collapse RUN layers and clean up
Each RUN is a layer, and deleting files in a later layer doesn't shrink an earlier one. Do the install and cleanup in a single RUN:
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*
Bonus: with BuildKit (on by default) you can cache package downloads across builds:
RUN --mount=type=cache,target=/root/.npm npm ci
13. Security & production best practices
- Don't run as root. Add a user in your Dockerfile and switch to it. If the app is compromised, the attacker isn't root inside the container:
RUN addgroup --system app && adduser --system --ingroup app app USER app - Pin your base image. Use
node:20-slim, nevernode:latest— "latest" silently changes and breaks reproducibility. - Never bake secrets into images. Don't
COPY .envor hardcode keys. Pass secrets at runtime with-e, an env file, or your platform's secret manager. Anything in an image layer is permanent and extractable. - Scan your images for known vulnerabilities:
docker scout cves myapp - Add a healthcheck so Docker knows if the app is actually alive, not just running:
HEALTHCHECK --interval=30s --timeout=3s \ CMD curl -f http://localhost:3000/health || exit 1 - One process per container. App in one, database in another. Don't cram a whole stack into a single container — that's what Compose is for.
14. Cleanup (reclaim your disk)
Docker quietly eats disk with old images, stopped containers, and dangling layers. Check and reclaim:
docker system df # see what's using space
docker container prune # remove all stopped containers
docker image prune # remove dangling (untagged) images
docker system prune # containers + networks + dangling images
docker system prune -a --volumes # NUCLEAR: everything unused, incl. volumes
Be careful with -a --volumes — it deletes unused images and volumes, which can wipe database data. Read what it lists before confirming.
15. The cheat sheet
The commands you'll actually reach for, in one place:
# Run
docker run -d --name NAME -p HOST:CONTAINER IMAGE # background service
docker run -it --rm IMAGE bash # throwaway shell
# Inspect
docker ps # running docker ps -a # all
docker logs -f NAME # follow logs docker exec -it NAME bash # get inside
# Lifecycle
docker stop NAME | docker start NAME | docker rm -f NAME
# Images
docker build -t NAME . | docker images | docker rmi NAME
docker pull IMAGE | docker push USER/IMAGE
# Compose
docker compose up -d | docker compose down | docker compose logs -f
# Cleanup
docker system df | docker system prune
16. Troubleshooting the usual suspects
- "port is already allocated" — another process (or container) owns that host port. Use a different left-hand port (
-p 8081:80) or stop the conflicting container. - Container exits immediately — run
docker logs NAME. Usually the main process crashed, or yourCMDbackgrounded itself. A container lives only as long as its foreground process; that process must stay in the foreground. - "COPY failed: no such file or directory" — the file is outside the build context or excluded by
.dockerignore. Paths in COPY are relative to where you rundocker build. - Code changes don't show up — you rebuilt but cached an old layer, or you're not mounting the source. For dev, bind-mount your code (
-v $(pwd):/app); for prod, rebuild the image. - Image is enormous — you skipped multi-stage builds or used a full base. Switch to
-slim/alpineand a multi-stage build (section 12). - "permission denied" on a mounted volume — the container's user ID doesn't match the file owner. Either run as a matching user or fix ownership on the host.
When stuck, the debugging trio is always: docker ps -a (is it running / why did it stop) → docker logs (what did it say) → docker exec -it NAME sh (go inside and look around).
Want the next one first?
The Input Daily — a 5-minute AI brief every weekday. 3 stories that matter, 1 tool worth trying, 1 prompt to steal.
Free. Every morning. Unsubscribe in one click. Privacy Policy.