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:
- A reproducible build. The artifact that passed CI is bit-for-bit the artifact that runs in production.
- A one-command local environment. New laptop to running stack in ten
minutes, without a wiki page full of
brew install. - A boring deploy. Push an image, pull an image, restart. No rsync, no “did you remember to run migrations.”
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"]
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.
Things worth doing, in order
- Pin base images to a minor version.
alpine:3.20, notalpine:latest. Reproducibility is the point;latestthrows it away. - Add a
.dockerignore. Without it you ship.git,node_modules, and your local.envinto the build context. Start with.git,node_modules,*.md,.env*. - Use one registry and tag with the commit SHA.
app:a1b2c3dtells you exactly what’s running.app:latestin production is a question you can’t answer during an incident. - Scan the image in CI.
docker scout cvesortrivy imagetakes seconds and catches the base-image CVE you’d otherwise learn about from a customer. - Keep secrets out of the image. Environment at runtime, not
ENVat 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.