Sandro Turriate

Coder, cook, explorer

How to Dockerfile

Oct 3, 2020

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:

  1. A base image that builds all the nasty dependencies exactly once.
  2. 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
# base/Dockerfile — build the hard-to-compile deps once, then forget them
FROM golang:alpine

# system packages plus the toolchain you need to compile
RUN apk --no-cache add build-base cmake git curl vips-dev sqlite-dev

# vendor one or two things from source that the package manager gets wrong,
# then clean up the build-only tools you no longer need
RUN wget .../libtiff-....tar.gz && \
    cmake ... && make -j$(nproc) && make install && \
    apk --no-cache del build-base cmake

# a code-generator you only need at build time
RUN go install example.com/tool@latest

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
# Dockerfile — the fast, repeatable deploy image (multi-stage)
# ---- build stage: compile the binary ----
FROM my-base AS build
COPY . /app
WORKDIR /app
RUN make build

# ---- prod stage: ship only the binary ----
FROM my-base AS prod
RUN apk --no-cache del build-base vips-dev  # strip -dev packages at runtime
RUN addgroup -S app && adduser -S app -G app -h /app
RUN mkdir -p /app
COPY --from=build /app/app.bin /app/
WORKDIR /app

ENV ENV=production
USER app
EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
  CMD wget -q --spider http://localhost:8000/ || exit 1

CMD ["/app/app.bin"]

A few things worth noticing in the prod stage:

  • COPY --from=build pulls the binary out of the other stage — multi-stage at work.
  • It removes the -dev packages 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 app with its home set to the app directory. Never run your process as root in a container.
  • HEALTHCHECK gives 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.

InstructionWhat it doesUse it for
FROM imageSets the base image for a stageEvery single stage starts here
WORKDIR /pathSets the working directoryKeep file paths predictable & relative
COPY src dstCopies files from build context into imageBringing in source, config, or an APKBUILD
COPY --from=stage src dstCopies a file out of another stageThe core of multi-stage builds
RUN cmdRuns a command, committing the result as a layerInstalling packages, building, generating
ENV K=VSets an environment variable in the imageConfig that's the same everywhere
USER nameSwitches the user the container runs asRunning as non-root — always do this
EXPOSE portDocuments a port the app listens onDocumentation; doesn't actually publish
CMD [...]Default command to run when the container startsThe process your app runs
HEALTHCHECKDefines how to check if the container is healthyLetting 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 literally alias docker=podman and 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
# Remove dangling images, stopped containers, and unused networks/cache
docker system prune

# Also remove unused images (not just dangling ones)
docker system prune -a

# Podman's equivalent
podman system prune

# Don't leak containers — name them and remove on exit
docker run --rm --name myapp myimage

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 -dev packages and run as a non-root user in prod.
  • Mount secrets at runtime — never bake credentials into an image.
  • Use HEALTHCHECK so 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.