You've written a Node.js app. It works on your laptop. You push it. CI passes. Then it hits staging and everything breaks. Missing system dependency. Wrong version of node. Some environment variable that was set on your machine but nowhere else. You spend two hours tracking down something that has nothing to do with your actual code.
This whole class of problem exists because the environment your code runs in isn't part of your code. Your laptop has one set of libraries, CI has another, production has a third. They drift apart silently, and things break at the worst possible time.
Docker fixes this by packaging your app and its entire environment into a single portable unit. That's basically the pitch. But the details of how it does this are worth understanding properly.
๐ง Containers are not VMs
People say "containers are like lightweight VMs" and it's close enough to get you started, but it's wrong in a way that matters.
A virtual machine runs a full guest operating system on top of a hypervisor. It boots its own kernel. A container doesn't do any of that. Containers share the host machine's kernel and use Linux features (namespaces and cgroups) to isolate processes from each other. There's no second kernel. No boot sequence.
This has real consequences:
| VM | Container | |
|---|---|---|
| What's virtualized | Full hardware + OS | Just the process environment |
| Boot time | 30-60 seconds | Milliseconds |
| Typical size | Gigabytes | Megabytes |
| Isolation strength | Strong (separate kernel) | Good (shared kernel, namespace isolation) |
So containers are fast and small because they're not pretending to be a whole computer. They're isolated processes with their own filesystem view.
Images vs containers
This is the thing most people get confused about early on.
An image is a read-only template. It's built in layers (more on that in a second) and it contains everything your app needs to run: the OS base, your code, your dependencies, your config. But it doesn't run. It just sits there.
A container is what you get when you actually run an image. It takes the image's layers, adds a thin writable layer on top (using copy-on-write), and starts a process inside it. You can run ten containers from the same image and they each get their own writable layer.
| Image | Container | |
|---|---|---|
| State | Read-only | Running (or stopped) with a writable layer |
| Created by | docker build |
docker run |
| Analogy | A class definition | An instance of that class |
| Reusable | Yes, many containers from one image | Each container is its own thing |
| Persists after stop | Always exists until deleted | Writable layer lost on removal |
Think of it like this: the image is the class, the container is the instance. Same image, same result, every time.
๐ ๏ธ Layers and the build cache
Every instruction in a Dockerfile creates a layer. Layers are cached. And this is where Docker gets clever, but also where people shoot themselves in the foot.
Here's the gotcha: Docker caches each layer and reuses it as long as nothing changed from that point upward. The moment one layer is invalidated, every layer below it rebuilds too.
So if you do this:
# Bad: copies ALL source files before installing dependencies
COPY . .
RUN npm install
Every time you change any source file, Docker sees a different COPY . . result, invalidates that layer, and re-runs npm install from scratch. Even if your dependencies haven't changed. On a big project that's minutes of wasted time on every build.
The fix:
# Good: copy package files first, install, then copy source
COPY package.json package-lock.json ./
RUN npm install
COPY . .
Now npm install only re-runs when your package files actually change. Your source code changes don't bust the dependency cache. This one reordering can take your build from 4 minutes to 15 seconds.
And here's something that trips people up: deleting a file in a later layer doesn't shrink the image. The data is still there in the earlier layer. Layers are additive. This matters a lot for secrets, which I'll come back to.
Anatomy of a Dockerfile
Here's a realistic Dockerfile for a Node.js app:
FROM node:20-slim
WORKDIR /app
# Install dependencies first (layer caching)
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
# Copy source code
COPY src/ ./src/
EXPOSE 3000
CMD ["node", "src/index.js"]
Quick notes: FROM picks your base image. WORKDIR sets the directory for everything after it. npm ci is better than npm install for reproducible builds. EXPOSE is documentation (it doesn't actually publish the port). CMD is what runs when the container starts.
One sentence on CMD vs ENTRYPOINT: CMD is the default command that can be overridden at runtime, ENTRYPOINT is the fixed executable that CMD arguments get appended to.
โก The everyday workflow
Here's what working with Docker actually looks like day-to-day:
# Build an image from a Dockerfile (tag it with a name)
docker build -t my-app .
# Run a container from that image
docker run -d -p 3000:3000 --name app my-app
# See what's running
docker ps
# Check logs
docker logs app
# Get a shell inside the running container
docker exec -it app sh
# Stop and remove
docker stop app
docker rm app
# Push to a registry
docker push my-app:1.0.0
docker run is actually docker create + docker start combined. And docker exec runs a command inside an already running container. Different things.
But here's the thing you need to internalize: containers are ephemeral. When you remove a container, its writable layer is gone. Any files you wrote inside it, gone. This isn't a bug. It's the design. If you need data to survive, you use volumes.
Volumes, networks, and Compose
Volumes come in two flavors. Named volumes are managed by Docker and live in Docker's storage area. Bind mounts map a specific host path into the container. Named volumes are better for data you want Docker to manage (like database files). Bind mounts are better for development when you want live code reloading.
For networking: containers on the same user-defined network can reach each other by container name. Basically, Docker runs an internal DNS for you. But this only works on user-defined networks. The default bridge network doesn't give you name resolution. You have to create your own.
Here's a quick Compose file that ties it together:
services:
app:
build: .
ports:
- "3000:3000"
depends_on:
- db
environment:
DATABASE_URL: postgres://user:pass@db:5432/mydb
db:
image: postgres:16
volumes:
- pgdata:/var/lib/postgresql/data
environment:
POSTGRES_USER: user
POSTGRES_PASSWORD: pass
POSTGRES_DB: mydb
volumes:
pgdata:
Notice the app service connects to db by name. That works because Compose creates a user-defined network for you automatically.
Pitfalls that will bite you
Honestly, these are mistakes almost everyone makes at least once:
-
Running as root: The default user in most base images is root. If someone breaks out of your app, they're root on the container. Add a
USER nodeinstruction. -
Using
latesttag:latestisn't a version. It's whatever happened to be pushed most recently. Pin your versions.node:20.11-slim, notnode:latest. -
Giant images: A full
node:20image is over 1GB. Switch tonode:20-slimornode:20-alpine. Or use multi-stage builds where you build in a fat image and copy the output to a minimal one. -
Baking secrets into layers: If you
COPY .env .or pass secrets viaARG, they're baked into the image history permanently. Layers are additive, remember? Use runtime environment variables or Docker secrets instead. - Assuming the filesystem persists: I said it before but it's worth repeating. When the container is removed, everything in the writable layer is gone. Use volumes for anything that needs to survive.
๐ Takeaways
- Containers share the host kernel. They're isolated processes, not VMs.
- Images are read-only layer stacks. Containers add a thin writable layer on top.
- Layer order in your Dockerfile determines cache efficiency. Put things that change rarely at the top.
- Containers are disposable. Anything that needs to persist goes in a volume.
- Use slim/alpine base images, pin your versions, and don't bake secrets into layers.
Top comments (0)