How to Dockerfile
At this point most of my container definitions get written by an LLM. But the fundamentals still matter — knowing why a Dockerfile is structured the way it is lets me catch mistakes the LLM won't, and lets me give it much better instructions. This started as a personal cheatsheet for writing Dockerfiles; it ended up being a reference I keep coming back to. This is the generalized version.
Most of what follows is plain Dockerfile/Docker knowledge. But the last section covers a shift I've made that's worth talking about: moving from Docker to Podman.
The pattern that matters: a base image + a deploy image
Almost every realistic project needs two things built into the container, and they have very different lifecycles:
- Dependencies — big, weird, takes forever to build, and almost never changes.
- Your application — the thing you rebuild constantly as you ship new code.
If you cram both into a single Dockerfile, every deploy re-compiles your painful dependencies even though nothing about them changed, wasting minutes and CI time. The fix is to split them:
- A base image that builds all the nasty dependencies exactly once.
- A deploy image that
FROMs the base and only layers your latest app binary on top.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | |
This base image is slow to build — but you only pay for it once. From here on, every deploy is a thin downstream image.
As a concrete template, the base stage might pull a language base image (say golang:alpine), add the system packages you need, and compile one or two vendors from source (a fork of libjpeg, an image library, libtiff — the slow, fiddly stuff). All of that happens in RUN steps and gets cached as layers. Because nothing in this stage changes between deploys, those layers stay warm.
The deploy side uses a multi-stage Dockerfile: a build stage compiles the Go binary, and a prod stage COPYs only that binary in (never the toolchain, source, or build tools).
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 | |
A few things worth noticing in the prod stage:
COPY --from=buildpulls the binary out of the other stage — multi-stage at work.- It removes the
-devpackages once the compile is done. You don't need headers or build tooling at runtime, so strip them to shrink the image and reduce attack surface. - It runs as a non-root
USER appwith its home set to the app directory. Never run your process as root in a container. HEALTHCHECKgives the orchestrator a way to know the service is actually alive.- Secrets (AWS credentials) are mounted at runtime, never baked into the image.
The command table
The core of the cheatsheet. These are the Dockerfile instructions you'll actually reach for.
| Instruction | What it does | Use it for |
|---|---|---|
FROM image | Sets the base image for a stage | Every single stage starts here |
WORKDIR /path | Sets the working directory | Keep file paths predictable & relative |
COPY src dst | Copies files from build context into image | Bringing in source, config, or an APKBUILD |
COPY --from=stage src dst | Copies a file out of another stage | The core of multi-stage builds |
RUN cmd | Runs a command, committing the result as a layer | Installing packages, building, generating |
ENV K=V | Sets an environment variable in the image | Config that's the same everywhere |
USER name | Switches the user the container runs as | Running as non-root — always do this |
EXPOSE port | Documents a port the app listens on | Documentation; doesn't actually publish |
CMD [...] | Default command to run when the container starts | The process your app runs |
HEALTHCHECK | Defines how to check if the container is healthy | Letting orchestrators restart dead services |
Podman instead of Docker
I switched to Podman — both on my local machine and on my VPS. Part of the push was practical, and part was that Docker went increasingly commercial and the direction of the project didn't sit right.
The headline difference is that Podman is daemonless. Docker runs a central background daemon (dockerd) that you talk to via a client; Podman has no daemon at all — each podman command talks to a per-user, on-demand process (using conmon and crun) and then disappears. That means:
- Rootless by default. Containers run as your own user, not as root. Docker needed root (or a rootful daemon) to work out of the box; Podman just works unprivileged. Better security, no escalation.
- Drop-in compatible.
podman build,podman run,podman ps… the CLI mirrors Docker's. In most cases you can literallyalias docker=podmanand keep going. BuildKit-style multi-stage builds work through it too. - No surprise resource hog. Because there's no always-on daemon, nothing is eating CPU and RAM in the background when you're not using it.
- You can manage a cluster of containers with
podman-compose, and it maps naturally to Pods (groups of containers sharing networking) when you need that.
Why Podman can feel fast (virtio)
On Linux, Podman runs your container natively through the kernel — there's no VM hop at all, so it's essentially as fast as the hardware. Where the "fast" reputation comes in is on macOS and Windows, where a Linux VM is required (Linux containers can't run natively on those hosts).
Both Docker Desktop and Podman spin up a Linux VM under the hood for this. A big piece of the speed on macOS and Windows comes from virtio: the VM exposes paravirtualized devices (disk via virtio-blk, networking via virtio-net), which let the guest talk to the host with far less overhead than fully-emulated hardware. And both tools use virtiofs for sharing files between the host and the VM — the fast shared filesystem that replaced the old, slow file-sharing (Docker's legacy gRPC-FUSE, and Podman's Plan 9/9P predecessors). Mounting your project directory into a container is dramatically snappier with virtiofs, especially when there are lots of small files.
So the "virtio advantage" isn't really a Podman-vs-Docker differentiator anymore — Docker Desktop has used virtiofs by default since version 4.6 (2022), so both platforms get roughly the same virtualization and file-sharing performance on the same hardware. The genuine differences are elsewhere:
- Daemonless & rootless. Podman has no central background daemon and runs containers as your own user, not root. Less running in the background means less constant CPU/RAM tax when idle, and better isolation by default.
- No daemon in the guest. Because Podman is daemonless, less is running inside the Linux VM, which keeps it a bit leaner than Docker's daemon-bearing VM.
- Fewer moving parts on Linux. On a Linux host, Podman needs no VM or daemon at all — it's native, which is where you'll see the real speed payoff.
If you're on macOS or Windows, moving to Podman isn't going to magically speed up your virtio builds, because Docker enjoys the same virtiofs machinery. The honest reasons to switch are rootless security, the lack of a background daemon, and the Linux-native experience — not a file-sharing performance edge. On a Linux host, though, both tools run natively and neither carries a meaningful virtualization overhead.
Cleanup & good practice
Cheatsheets love a good cleanup command, and containers love to accumulate junk.
1 2 3 4 5 6 7 8 9 10 11 | |
Name your containers so you stop creating new ones on every run, or use --rm to delete the container automatically when it exits. Both are small habits that keep docker ps / podman ps from becoming a graveyard.
The takeaways
- Keep the pattern simple: a base image for rarely-changing dependencies, a multi-stage deploy image that
COPY --from=builds only the finished binary. - Strip
-devpackages and run as a non-root user in prod. - Mount secrets at runtime — never bake credentials into an image.
- Use
HEALTHCHECKso your platform knows when the app is dead. - Podman is a daemonless, rootless, mostly drop-in replacement for Docker. On macOS/Windows both tools share the same virtiofs file-sharing machinery, so the honest reasons to switch are rootless security and the lack of a background daemon — not a speed edge; on Linux both run natively with no VM overhead.