Infrastructure

A pragmatic guide to Docker for small teams

You don't need a platform team to get real value from containers. A no-nonsense setup that fits in one file, one registry, and a Friday afternoon.

One file, one registry, one afternoon.

Most Docker advice is written for organisations with a platform team, a service mesh, and a dedicated on-call rotation. If you’re four people shipping a product, that advice will cost you a month and give you back a system nobody fully understands.

Here’s the version that actually pays for itself in an afternoon.

What you’re buying

Be honest about the goal. For a small team, containers are worth it for exactly three things:

Everything else (orchestration, autoscaling, dynamic service discovery) is a solution to problems you probably don’t have yet. You can adopt it later; the image you build today will still be the right input.

The Dockerfile

Multi-stage, non-root, and small enough that pulling it isn’t an event:

# ---- build ----------------------------------------------------
FROM golang:1.24-alpine AS build
WORKDIR /src

# Dependencies first: this layer caches until go.mod/go.sum change.
COPY go.mod go.sum ./
RUN go mod download

COPY . .
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/app ./cmd/app

# ---- run ------------------------------------------------------
FROM alpine:3.20
RUN apk add --no-cache ca-certificates tzdata \
 && adduser -D -u 10001 app
COPY --from=build /out/app /usr/local/bin/app
USER app
EXPOSE 8080
ENTRYPOINT ["/usr/local/bin/app"]
The dependency copy above the source copy is the whole trick: it turns a two-minute rebuild into a five-second one.

Two details worth defending. Copying go.mod/go.sum before the rest of the source means a code change doesn’t invalidate the dependency layer. And running as a non-root user costs you one line and removes an entire category of container-escape write-up from your future.

The compose file

One file, checked in, used by everyone:

services:
  app:
    build: .
    ports: ["8080:8080"]
    environment:
      DATABASE_URL: postgres://app:app@db:5432/app?sslmode=disable
    depends_on:
      db: { condition: service_healthy }

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

volumes:
  pgdata:

depends_on with a real healthcheck is the difference between “it works” and “it works on the second try.” Without it, your app starts talking to a Postgres that hasn’t finished initialising, fails, and teaches everyone on the team to run the command twice out of superstition.

build → push → pull → run
The entire pipeline. Add stages when a real problem asks for them, not before.

Things worth doing, in order

  1. Pin base images to a minor version. alpine:3.20, not alpine:latest. Reproducibility is the point; latest throws it away.
  2. Add a .dockerignore. Without it you ship .git, node_modules, and your local .env into the build context. Start with .git, node_modules, *.md, .env*.
  3. Use one registry and tag with the commit SHA. app:a1b2c3d tells you exactly what’s running. app:latest in production is a question you can’t answer during an incident.
  4. Scan the image in CI. docker scout cves or trivy image takes seconds and catches the base-image CVE you’d otherwise learn about from a customer.
  5. Keep secrets out of the image. Environment at runtime, not ENV at build time; anything baked into a layer is readable by anyone who can pull it.

What to skip, for now

Kubernetes, unless you have a concrete reason someone can state in one sentence. A private base-image registry. Building your own CI runners. Compose profiles for six environments. Each of these is a real tool for a real problem, and each will quietly consume the time you meant to spend on the product.

The right amount of infrastructure is the smallest amount that makes deploys boring. Boring is the whole feature.

Start with a Dockerfile you can read in one screen and a compose file that gets a new hire running before lunch. When something hurts, fix that specific thing. Small teams don’t lose to missing infrastructure. They lose to infrastructure nobody has time to maintain.


M

Mo Jahani

Software architect working on systems for millions of users, from embedded devices to web platforms. I write about the unglamorous engineering decisions that decide whether software lasts.

Get in touch