Docker: The Commands I Actually Use
I spent a good chunk of time avoiding Docker. Every tutorial I opened threw a wall of flags at me before explaining why any of it mattered. So this is the writeup I wish I'd had — not a full manual, just the commands and concepts that actually come up when you're building and shipping something real.
Why Docker in the first place
"Works on my machine" is not a deployment strategy. Docker packages an app together with everything it needs to run — the runtime, libraries, system tools, config — into a single unit called an image. Spin that image up anywhere and you get the exact same environment every time. No more chasing down a missing dependency at 11pm before a demo.
Two words you'll see everywhere:
- Image — a frozen snapshot of your app and its environment. Think of it as a blueprint.
- Container — a running instance of that image. You can spin up several containers from one image.
The flow, in short
Before getting into the details, here's the chain those pieces fit into:
Dockerfile → docker build → Image → docker run → Container
(instructions) (frozen package) (live process, executing)
The Dockerfile is just instructions. docker build follows them and produces an image — a finished, frozen package sitting on disk, doing nothing on its own. docker run takes that image and spins up a live process from it, and that live process is the container. The CMD line in your Dockerfile is what actually executes at that point.
One thing worth catching early: running an image doesn't use it up. It stays exactly as it was, and you can run it again and again, each time creating a fresh, separate container. Build once, run as many times as you need.
Images vs containers, one more time
This confused me for longer than I'd like to admit. An image is read-only and doesn't change once built. A container is what you get when you actually run that image — it has its own writable layer on top, its own process, its own lifecycle. Delete the container, the image is still sitting there untouched, ready to spin up another one.
An image is layers, stacked
When Docker builds an image, it doesn't create one solid blob. Each instruction in your Dockerfile — FROM, RUN, COPY, and so on — creates its own layer, and those layers stack on top of each other, read-only. That FROM node:20-alpine line pulls in a whole set of layers someone else already built: the OS pieces, Node itself. Your RUN npm install adds another layer on top with your dependencies. Your COPY . . adds your source code as yet another layer.
This is why the caching trick mentioned earlier works. Docker checks each layer, and if nothing changed since the last build, it reuses that layer instead of rebuilding it. Change your source code but not your package.json, and the npm install layer gets reused untouched — only the layers after it rebuild. That's the entire reason for copying package.json before your source code, instead of just doing COPY . . once.
You can actually see these layers:
docker history myapp:latestA container is an image plus one writable layer
When you run docker run myapp, Docker takes all those read-only image layers and adds a thin writable layer on top, just for that container. Anything the running process does — writing a log file, creating a temp file, modifying something in /app — happens in that writable layer, not in the image itself.
This has a real consequence: if you don't mount a volume, anything written during runtime vanishes the moment the container is removed, because that writable layer goes with it. The image underneath never changes — it's still frozen exactly as it was built. This is also why you can spin up ten containers from the same image and each one gets its own independent writable layer, with no risk of them stepping on each other.
One image, many containers
Think of the image as a class and the container as an instance of it, if that helps — you write the class once, but you can create as many instances as you want, each with its own state, each disposable.
docker run -d --name web1 myapp:latest
docker run -d --name web2 myapp:latest
docker run -d --name web3 myapp:latestThree containers, one image on disk. docker images still shows just one entry for myapp:latest, while docker ps shows all three running.
Containers have a lifecycle, images mostly don't
An image just sits there until you delete it or rebuild it. A container goes through states — created, running, paused, stopped, removed. This is why docker ps only shows running containers by default and you need docker ps -a to see stopped ones sitting around. They still exist, they're just not doing anything, and they still take up disk space until you docker rm them.
Containers are meant to be disposable
The instinct when starting out is to treat a container like a VM — start it, exec into it, install something manually, keep using that same container forever. That works, but it defeats the point. If the container dies or you need to scale to five of them, whatever you installed by hand is gone or missing on the others. The intended pattern is: if something needs to persist or be installed, it goes in the Dockerfile or a mounted volume, and the container itself stays replaceable.
Dockerfile basics
A Dockerfile is just a recipe — a list of steps Docker follows to build your image.
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
EXPOSE 3000
CMD ["node", "index.js"]Quick breakdown, because the instructions look cryptic until you've written a few:
FROM— the base image you're building on top of. Almost always the first line.WORKDIR— sets the working directory inside the container for everything after it.COPY— copies files from your machine into the image.RUN— executes a command during the build (installing packages, for example).EXPOSE— documents which port the app listens on. Doesn't actually publish it — that happens at run time.CMD— the command that runs when the container starts.
One habit that saves real build time: copy package.json and run npm install before copying the rest of your source code. Docker caches each layer, so if your dependencies haven't changed, it skips reinstalling them even when your code has.
Building and running
# build an image from a Dockerfile in the current directory
docker build -t myapp:latest .
# run a container from that image
docker run -d -p 3000:3000 --name myapp-container myapp:latestFlags worth actually knowing:
-t— tags the image with a name (and optionally a version), instead of a random hash you'll never remember.-d— detached mode, runs the container in the background so it doesn't lock up your terminal.-p 3000:3000— maps a port on your machine to a port inside the container. Format ishost:container.--name— gives the container a name you can actually reference later instead of a generated string.-v— mounts a volume, more on that below.-e— sets an environment variable inside the container.
docker run -d -p 3000:3000 -e NODE_ENV=production -v $(pwd):/app --name myapp myapp:latestManaging containers
# list running containers
docker ps
# list all containers, including stopped ones
docker ps -a
# stop a running container
docker stop myapp-container
# start a stopped container back up
docker start myapp-container
# restart a container
docker restart myapp-container
# remove a container (has to be stopped first, or add -f)
docker rm myapp-container
# view logs
docker logs myapp-container
# follow logs live, like tail -f
docker logs -f myapp-container
# get a shell inside a running container
docker exec -it myapp-container shdocker exec -it is the one I reach for most when debugging. It drops you inside the running container so you can poke around, check if a file actually got copied, or see what environment variables are set.
Managing images
# list images on your machine
docker images
# remove an image
docker rmi myapp:latest
# pull an image from a registry without running it
docker pull node:20-alpine
# push your image to a registry
docker push myusername/myapp:latest
# tag an existing image
docker tag myapp:latest myusername/myapp:latestVolumes, because containers forget everything
Here's the part that catches people off guard: when a container is removed, everything written inside it during runtime disappears too. That's fine for a stateless API, disastrous for a database. Volumes solve this by storing data outside the container's own filesystem.
# create a named volume
docker volume create mydata
# run a container with that volume attached
docker run -d -v mydata:/var/lib/postgresql/data --name mydb postgres
# list volumes
docker volume ls
# remove a volume
docker volume rm mydataThere's also bind mounts, which map a folder on your actual machine into the container — useful during development when you want code changes to reflect instantly without rebuilding the image:
docker run -d -v $(pwd):/app --name dev-container myappCleaning up
Docker will happily eat your disk space with stopped containers, dangling images, and unused networks if you let it.
# remove all stopped containers
docker container prune
# remove all unused images
docker image prune
# remove everything unused - containers, networks, images, build cache
docker system prune
# same as above, but including unused volumes too (be careful with this one)
docker system prune -a --volumesI run docker system prune roughly once a week. It's saved me from a few "why is my disk full" panics.
Docker Compose
Most real projects aren't a single container — you've got an app, a database, maybe Redis, maybe a reverse proxy. Compose lets you define all of that in one file instead of typing out a dozen docker run commands by hand.
# docker-compose.yml
services:
app:
build: .
ports:
- "3000:3000"
environment:
- NODE_ENV=development
depends_on:
- db
volumes:
- .:/app
db:
image: postgres:16
environment:
- POSTGRES_PASSWORD=devpassword
- POSTGRES_DB=myapp
volumes:
- pgdata:/var/lib/postgresql/data
volumes:
pgdata:# start everything defined in the compose file
docker compose up
# start in detached mode
docker compose up -d
# rebuild images before starting
docker compose up --build
# stop everything
docker compose down
# stop and also remove volumes
docker compose down -v
# view logs across all services
docker compose logs -f
# run a command inside a specific service
docker compose exec app shdepends_on controls startup order, not readiness — your app container might start before Postgres is actually ready to accept connections. If you hit connection errors on startup, that's usually why. A retry loop or a healthcheck in the compose file fixes it properly.
Networking, briefly
Containers on the same Docker network can talk to each other using their service name as the hostname. That's why, in the compose file above, the app can reach the database just by connecting to db:5432 instead of needing an IP address.
# list networks
docker network ls
# create a custom network
docker network create mynetwork
# run a container attached to it
docker run -d --network mynetwork --name myapp myapp:latestA few things that tripped me up early on
Rebuild after changing the Dockerfile. Editing the Dockerfile itself doesn't update a running container — you need to rebuild the image and start a new container from it.
Alpine images are smaller, not always simpler. They strip out a lot of standard tools, so some npm packages that rely on native bindings can fail to install on Alpine but work fine on the regular node image. If a build fails mysteriously on -alpine, that's the first thing to check.
.dockerignore matters as much as .gitignore. Without one, your node_modules and .git folder get copied into the build context, which slows builds down and bloats your image for no reason.
node_modules
.git
.env
dist
Detached mode hides errors. If a container exits immediately after docker run -d, check docker logs <container> before assuming something's broken with Docker itself — it's almost always the app crashing on startup.
Closing thought
None of this clicked for me until I actually broke something and had to figure out why. Reading about volumes is one thing; losing a database's worth of test data because I forgot to mount one is what actually made it stick. If you're learning this too, build something small, deploy it badly a few times, and the commands stop feeling like memorization.