Community Article

Docker for Devs Who Don't Want to Be Sysadmins

The 80% of Docker that I actually use day to day, the layer-cache rules that cut my image builds from 4 minutes to 30 seconds, and the four mistakes that haunted my first year.

Docker for Devs Who Don't Want to Be Sysadmins

The 80% of Docker that I actually use day to day, the layer-cache rules that cut my image builds from 4 minutes to 30 seconds, and the four mistakes that haunted my first year.

backend
reliability
craftsmanship
clean-code
hannahchakraborty

By @hannahchakraborty

March 14, 2026

·

Updated May 18, 2026

234 views

5

4.2 (12)

Most of the Docker docs treat the reader as someone who is going to operate a fleet. I have not been that person in years. I am a product engineer who needs containers to do four things: run my service the same way on my laptop and in CI, give me a reliable local Postgres, ship a small image to production, and never make me think about it again. This article is the 80% of Docker that covers those four needs and stops there.

I will not cover swarm mode, docker-in-docker tricks, or the intricate dance of running Docker on a Mac (your IDE handles that). I will cover the Dockerfile patterns that have made my life calmer, the layer-cache mental model that finally clicked, the four mistakes I made in year one, and the tiny set of docker compose commands I run every day.

How layers and the cache really work

A container is a process running with its own filesystem and network namespace. The image is a stack of read-only layers; each Dockerfile instruction (RUN, COPY, ADD) creates one. When you run a container, Docker mounts a thin writable layer on top, runs your process, and the process sees the union of all those layers as /.

The one practical consequence I want you to internalize: every layer is cached by its instruction text plus the contents of the files it references. If neither changed since the last build, Docker reuses the cached layer in milliseconds. If anything changed, that layer and every layer after it are rebuilt from scratch. Order matters more than anything else in your Dockerfile.

The Dockerfile shape I write for every Node service

Here is the file I copy into every Node project. It is multi-stage, the layer order is deliberate, and it produces images around 200 MB instead of the 1.2 GB I used to ship.

# syntax=docker/dockerfile:1.7
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json yarn.lock ./
RUN --mount=type=cache,target=/root/.yarn-cache \
    yarn install --frozen-lockfile --cache-folder /root/.yarn-cache

FROM node:22-alpine AS build
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN yarn build

FROM node:22-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY --from=deps /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
COPY package.json ./
USER node
EXPOSE 3000
CMD ["node", "dist/server.js"]

Four things in that file are doing the heavy lifting. First, the deps stage copies only package.json and yarn.lock before installing. That means as long as my dependencies do not change, the cached yarn install layer is reused even when I edit application code. Second, the cache mount on yarn install keeps Yarn's package cache between builds (the --mount=type=cache syntax), which is the difference between a 90 second cold install and a 12 second warm one. Third, the build stage runs the TypeScript compiler. Its output goes into dist/. Fourth, the runtime stage starts from a clean node:22-alpine and copies in only node_modules and dist/. The build toolchain (TypeScript, ESLint, the test runner, my dev dependencies) never makes it into the final image.

The USER node line is the one I forget the most often and the one I want you to remember. Containers default to running as root. If a process inside your container has a remote-code-execution bug, root inside the container is one kernel-level escape away from root on the host. Running as a non-root user is a free hardening step.

The four mistakes I made in year one

Mistake 1: COPY . . at the top of the Dockerfile. This bust the cache on every code change, so every build reinstalled dependencies. Build time went from 30 seconds to four minutes. Fix: copy lockfile, install, then copy source.

Mistake 2: Using latest as the base image tag. Builds were reproducible until they weren't. One Tuesday morning my CI failed because node:latest had moved from Node 20 to Node 22 and a transitive dep stopped compiling. Fix: pin to a specific minor version (node:22-alpine), and bump intentionally.

Mistake 3: Running production with node_modules mounted from the host via a bind mount. This made hot-reload faster in dev and made production startup random in CI. The host volume's contents were not what I had built into the image. Fix: bind mounts in dev, never in the production container's compose stanza.

Mistake 4: One giant Dockerfile for all environments. Dev needs hot reload, watch mode, dev dependencies. Prod wants the smallest possible runtime image. I tried to make one image do both with build args and conditional logic. It was hard to debug and the prod image carried 600 MB of dev tools. Fix: one Dockerfile with multiple stages (the file above), target: build in the dev compose file, no target argument in production.

docker compose: the only commands I actually run

Local dev with docker compose up is where Docker pays for itself. Here is the compose file I use for almost every project. It runs the app, a Postgres, and a Redis, and that is the entire local environment.

services:
  app:
    build:
      context: .
      target: build
    command: yarn dev
    volumes:
      - .:/app
      - /app/node_modules
    ports:
      - "3000:3000"
    environment:
      DATABASE_URL: postgresql://app:app@postgres:5432/app
      REDIS_URL: redis://redis:6379
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_started

  postgres:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: app
      POSTGRES_PASSWORD: app
      POSTGRES_DB: app
    volumes:
      - postgres_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app"]
      interval: 5s
      timeout: 3s
      retries: 5

  redis:
    image: redis:7-alpine

volumes:
  postgres_data:

Five details from the file are worth pointing out, because each fixes a specific class of "this used to bite me" problem.

  1. The volumes: - /app/node_modules line is an anonymous volume. It shadows the bind-mounted node_modules from the host. Without it, my Mac-installed node_modules would clobber the Linux-installed one inside the container, and native modules would crash at startup.
  2. depends_on with condition: service_healthy means the app will not start until Postgres is actually accepting connections. Without it, the app's first DB query races the Postgres startup, the connection fails, and the app crashes.
  3. The Postgres data lives in a named volume (postgres_data), not a bind mount. Bind mounts to host directories and Postgres do not get along on macOS or Windows; named volumes work everywhere.
  4. I use the same Dockerfile for dev and prod, but I target the build stage in compose so the dev container has the toolchain. Prod targets runtime.
  5. Default usernames and passwords are app/app/app. This is local-only; if you find yourself copying these to staging or prod, stop.

The commands I run, in their actual frequency:

docker compose up                     # start everything, attached
docker compose up -d                  # start in background
docker compose down                   # stop and remove containers (volumes preserved)
docker compose down -v                # nuke including volumes (full reset of local DB)
docker compose logs -f app            # follow app logs
docker compose exec postgres psql -U app   # open a psql shell
docker compose restart app            # only when a tooling change demands it

That is 90% of my interaction. I have not memorized any other docker subcommand in years.

Image-size hygiene: three rules and one measurement

A bloated production image costs you: slower deploys, bigger registry bills, longer cold-start times on serverless platforms, larger attack surface for security scanners. Three rules keep my images small:

Rule 1: multi-stage builds with a slim runtime. The Dockerfile above does this. The runtime stage starts from node:22-alpine (around 50 MB) and only copies in what production needs. The build toolchain (TypeScript, dev deps, source files outside dist/) stays in the build stage and never ships.

Rule 2: alpine where it works, distroless where it doesn't. node:22-alpine works for almost everything I write. For services where Alpine's musl libc breaks a native dep (this happens, especially with bcrypt and some image-processing libs), I switch to a Debian-slim base or a distroless Node image. Distroless has no shell and no package manager; debugging is harder, but the attack surface is tiny.

Rule 3: never RUN yarn install after a COPY . .. If your Dockerfile does this, the install runs every time any source file changes, which is also the most common reason a build that should take 20 seconds takes four minutes.

The measurement that catches everything: docker history myimage:tag --no-trunc | head. It prints each layer with its size. If a single layer is 800 MB, you copied something you should not have. Often it is a stray node_modules from outside the build context (use a .dockerignore), or you committed a dist/ folder that is now duplicated into both the build and runtime stages.

A .dockerignore for Node projects that pays for itself:

Docker context exclusions for a typical Node service
  .git
  node_modules
  dist
  .next
  .env*
  *.log
  .DS_Store
  coverage
  .vscode
  .idea

Without that file, COPY . . ships your .git directory (often hundreds of MB) into the build context, your local .env, and any cached build artifacts. With it, the context is just source.

Networking inside compose: the part nobody explains

The one piece that consistently confuses people new to compose is the network model. Each service in the compose file gets a hostname equal to its service name. From the app container, postgres resolves to the postgres container; redis resolves to the redis container. There is no localhost:5432 from inside the container; that points at the container itself, not the host.

The corollary is that the host's localhost:5432 and the container's localhost:5432 are two different addresses. When my app talks to the DB, I use postgresql://app:app@postgres:5432/app (service name). When I open a psql session from the Mac terminal, I use psql -h localhost -p 5432, which works because compose maps the port. If I write localhost in the app's connection string and run it inside the container, it tries to connect to the app container's own port 5432 (which is not Postgres) and fails with a confusing "connection refused".

This took me longer to internalize than it should have. The rule that has stuck: from inside a container, refer to other services by their service name; from the host, refer to whatever port the compose file mapped.

Where I draw the line and ask an SRE

A short list of things I will not learn deeply, and gladly hand off:

  • Cluster-level orchestration (Kubernetes manifests, helm charts, autoscaling). My production runs on a managed platform; the platform team handles the rest.
  • Custom container runtimes, gVisor, kata, anything below the daemon. I have never debugged at that layer and do not want to start.
  • Image-signing pipelines (Cosign, Notary). My CI signs and pushes; I never run those commands by hand.
  • Docker swarm mode. Kubernetes won that fight years ago.

If I ever need to know any of those, I find someone whose job it is. The point of containers, for me, is to forget about the host. Knowing the layer-cache rules, writing a multi-stage Dockerfile, and running docker compose is enough to be a productive customer of the rest of the stack.

The four lines I would put on a sticky note for past-me

If I could send a single Post-it back to the version of me who first opened a Dockerfile, it would say four lines.

  1. Copy the lockfile and install before you copy the source.
  2. Multi-stage builds, always; the runtime stage starts from a clean alpine.
  3. Run as a non-root user.
  4. docker compose up, docker compose down -v, and docker compose logs -f cover 90% of what you need.

That is the file. Everything else I have learned about Docker has been worth knowing, but those four lines have done the most for my actual day-to-day. The rest is what you reach for when one of those four is not enough, which for product-engineering work is rarely.

Back to Articles