The costs in a containerised setup are compute, registry storage, egress and build minutes. All four are controllable, and image size affects three of them at once.
A multi-stage build separates the toolchain from the runtime, so compilers, headers and package caches never reach the final layer. A Go binary in a distroless or scratch base is a few megabytes; the same binary in golang:latest is close to a gigabyte.
FROM golang:1.23 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /app ./cmd/server
FROM gcr.io/distroless/static-debian12
COPY --from=build /app /app
USER nonroot:nonroot
ENTRYPOINT ["/app"]
For interpreted languages, -slim variants are usually the right default; Alpine is smaller still but uses musl, which breaks some Python wheels and can cause hard-to-diagnose DNS and threading differences. Order layers so rarely-changing steps come first — copying the lockfile and installing dependencies before copying source means a code change does not re-run installation.
Registries charge for stored gigabytes, and every image tag you have ever pushed is still there unless something deletes it. Two fixes: set a lifecycle policy that keeps the last N tags and expires untagged manifests, and stop pushing an image per commit on branches. On a busy repository, tag retention alone frequently removes the majority of registry storage. Also check egress — pulling large images into CI repeatedly can cost more than storing them.
At the small end, a plain virtual machine running Docker Compose is dramatically cheaper than any managed container platform. A VPS with 2 vCPU and 4 GB from Hetzner, DigitalOcean, Vultr or Linode sits in the range where a $50 monthly budget covers the machine plus backups with room left. Managed Kubernetes adds a control-plane fee plus per-node cost before you run anything, and serverless container products bill per request and per GB-second, which is excellent for spiky traffic and poor for a service that is always on.
The rule of thumb: constant load favours a fixed-price VM; bursty or near-zero load favours scale-to-zero serverless. Paying for idle serverless capacity is the most common way a small deployment gets expensive.
BuildKit cache mounts keep package manager caches out of layers while still reusing them:
RUN --mount=type=cache,target=/root/.cache/pip pip install -r requirements.txt
RUN --mount=type=cache,target=/root/.npm npm ci
In CI, push a cache image with --cache-to type=registry,mode=max and pull it with --cache-from so ephemeral runners start warm. Build minutes are billed; a build that drops from eight minutes to ninety seconds is a direct saving as well as a faster loop.
docker logs plus logrotate instead of a per-GB log vendor for a single-host setup.Set memory and CPU limits on every container so one leak does not force a larger instance. Configure log rotation in the daemon (max-size and max-file in daemon.json) — unbounded JSON logs filling a disk is the single most common self-hosted outage. Run docker system prune -af --volumes on a schedule, carefully, since it removes unused volumes too. And put a billing alert on whatever provider you use at half your intended budget, because the alert is what turns a bad month into a small one.