Guide

Images and Dockerfiles: Build a Small App Image

Part 2 of 7By Amith Kumar6 min read
Part 2 of 7Docker in Production: A Hands-On Guide
  1. 1The Docker Engine and Your First Container
  2. 2Images and Dockerfiles: Build a Small App Image
  3. 3Compose a Multi-Service App
  4. 4Volumes and Networks That Persist and Connect
  5. 5Resource Limits and Restart Policies
  6. 6Logging and Observability
  7. 7Security and Hardening
Verified 22 Jul 2026 · Ubuntu 24.04 LTS · Docker 29.1.3

Build your own Docker image from a Dockerfile

A Docker image is built from a Dockerfile, a short recipe of steps the engine runs top to bottom, each one adding a layer. This chapter builds a Docker image for a small app on Ubuntu 24.04 LTS, reads back the layers with docker history, and then rewrites the Dockerfile as a multi-stage build that drops the final image from 477 MB to under 20 MB. Every size and step below is what the live server reported.

Key takeaways
  • A Dockerfile is an ordered recipe; each instruction adds a cached layer, so the order of your steps decides rebuild speed.
  • docker build -t name:tag . turns the recipe into a tagged image you can run or push.
  • docker history shows every layer and its size, which is how you find the fat in an image.
  • A multi-stage build keeps the compiler in a throwaway stage and copies only the finished binary out, so the shipped image is a fraction of the size.

The app here is a small service written in Go called cadence, a stand-up board that later reads a counter from Redis. Go suits this chapter because a compiled binary needs a toolchain to build and nothing but itself to run, which is exactly the split a multi-stage build is designed for.

Prerequisites

Before you start
  • An Ubuntu 24.04 LTS server with Docker 29.1.3 and a sudo-capable user.
  • A project directory holding your source and the Dockerfile.
  • The container basics from the first chapter.

Write the Dockerfile

A first, honest attempt often builds inside the toolchain image and stops there. It works, and it is large, because the finished image carries the entire compiler. Here is the recipe done properly, in two stages.

# syntax=docker/dockerfile:1
FROM golang:1.23-alpine AS build
WORKDIR /src
COPY go.mod ./
COPY app.go ./
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /out/cadence .

FROM alpine:3.20
RUN addgroup -S app && adduser -S -G app app
COPY --from=build /out/cadence /usr/local/bin/cadence
USER app
EXPOSE 3000
ENTRYPOINT ["/usr/local/bin/cadence"]

The first stage, named build, pulls the Go toolchain and compiles a static binary. The second stage starts fresh from a small Alpine base, creates a non-root user, and copies only the finished binary across with COPY --from=build. The compiler never reaches the final image. Copying go.mod before the source is deliberate: Docker caches each layer, so dependency steps only re-run when their inputs change.

Build it and read the layers

Build the image and tag it, then ask Docker to describe what it produced.

docker build -t cadence:1.0 .
docker build running the multi-stage Dockerfile step by step and tagging the finished image cadence:1.0

The build runs each instruction in order, reporting the stages and the final image ID. Now read the layers back.

docker history cadence:1.0
docker history listing each layer of the image newest first, with the Alpine base and copied binary carrying the size and the metadata steps adding zero bytes

docker history lists every layer newest first, with the instruction that created it and its size. The bulk is the Alpine base and the copied binary; the metadata steps like USER and EXPOSE add zero bytes. This view is how you spot a needlessly large layer, for example a package install that was never cleaned up in the same step.

Checkpoint

docker build should end with the image tagged cadence:1.0, and docker history cadence:1.0 should show the base and the binary carrying the size while the metadata steps read 0B. A build stops at the first instruction that fails, so if it errored, read that line, fix it, and build again before moving on.

Prove the multi-stage saving

The saving is easy to see when you build the naive single-stage version alongside it and compare. Build the fat variant, then list both.

docker build -f Dockerfile.fat -t cadence:fat .
docker images cadence
docker images comparing the single-stage cadence:fat at 477 MB against the multi-stage cadence:1.0 at 19.5 MB, the same app roughly twenty-four times smaller

The single-stage cadence:fat image weighs 477 MB, because it keeps the whole Go toolchain and build cache. The multi-stage cadence:1.0 is 19.5 MB, since it holds only a small Alpine base and one stripped binary. That is roughly a twenty-fourfold reduction from the same source, for the price of one extra FROM line.

Checkpoint

docker images cadence should list cadence:1.0 near 19.5 MB beside the fat build near 477 MB. If your final image is close to the fat number instead, the last stage is still carrying the toolchain, so confirm the second FROM starts from a clean base and only COPY --from=build brings the binary across.

Why a small image matters
  • It pulls faster on every deploy and every new node, so rollouts are quicker.
  • It carries less software, so there is less that a scanner can flag and less to patch.
  • It starts sooner, because there is less to unpack onto the writable layer.

A smaller image is not a vanity metric. It is fewer bytes over the wire on every deploy and a smaller surface to keep patched, which the security chapter returns to.

Tag on purpose

The tag after the colon is a label you choose, and it is worth choosing well. Use a real version like cadence:1.0 for something you deploy, so you always know which build is running. Reserve latest for local convenience, never for production, because it is a moving pointer that turns a rollback into guesswork. You can attach several tags to one image ID, which is how a single build becomes both 1.0 and a release name.

Frequently asked questions

What does multi-stage actually save?

It keeps your build tools out of the shipped image. The compiler, headers, and package caches live in an early stage that is discarded, and only the finished artifact is copied into a clean final stage. On this app that took the image from 477 MB to 19.5 MB with no change to the source code.

Why copy go.mod before the rest of the source?

Docker caches layers by their inputs. If dependency files are copied and resolved before your application code, then editing a source file does not invalidate the dependency layer, so rebuilds reuse it and finish faster. Copying everything at once would re-resolve dependencies on every code change.

Should I always use an Alpine base?

Alpine is small and works for statically linked binaries like this Go app, which is why the final image is tiny. Some language runtimes need glibc and are happier on a slim Debian base. Pick the smallest base your app actually runs on, and confirm it starts before you commit to it.

Where do the layers go on disk?

Under /var/lib/docker, stored by the overlay storage driver and shared between images that use the same base. That sharing is why pulling a second Alpine-based image is quick: the base layers are already present, and only the layers unique to the new image are downloaded.

What you have, and what comes next

You wrote a Dockerfile, built a tagged Docker image, read its layers with docker history, and cut the shipped image from 477 MB to 19.5 MB with a multi-stage build. You now have a small, reproducible image of your own app.

One image on its own is not an application, though. Real apps have a datastore beside them. The next chapter describes the whole stack, the app plus Redis, in a single Compose file and brings it up with one command.


Run your containers on a slice

Run Docker in production on a slice

Containers on a server, not just your laptop, need an always-on host. Spin up a slice, run your Compose stack with the volumes, limits and hardening from this guide.

Get early access

This guide is provided for educational purposes and offered as is, without warranty of any kind. The commands change the configuration of a server you control, and some can lock you out if run out of order. Test on a non-production machine, keep a second session open, and take a snapshot or backup before you begin. You are responsible for changes made to your own systems; ServerCake accepts no liability for any loss, downtime, lockout, or damage arising from following this guide or running the accompanying script.

Was this guide helpful?
Share

Spotted something out of date or have a question?

Built by the ServerCake team

Cloud that speaks India.

ServerCake is India's KYC-verified cloud: VMs and managed databases priced in ₹, billed with GST, on India-resident infrastructure. Reserve your spot for early access.

Reserve your spot