Your Dockerfile Is Probably Too Big for Production
You just finished writing your application. It compiles, it runs locally, and you are ready to ship it. You write a Dockerfile, build an image, push it to a registry, and deploy it to a server. The deployment works, but something feels off. The image is 1.2 GB. Pulling it on the server takes three minutes. Storage costs are creeping up. And somewhere in the back of your mind, you wonder: do I really need a full operating system with a package manager and a shell just to run my Go binary?
This is the moment where most teams realize that building an image and building an image fit for delivery are two different things. The Dockerfile you write for local testing is not the same Dockerfile you should use in production. If your image is large, slow to build, or full of unnecessary tools, your entire pipeline suffers. Every pull takes longer. Every build wastes time. Every vulnerability scanner finds more issues than it should.
The good news is that fixing this does not require a complete rewrite of your infrastructure. It requires understanding three things: how Dockerfiles actually work, how to keep images small, and how to make builds repeatable.
How a Dockerfile Becomes an Image
A Dockerfile is a text file with instructions. Each instruction creates one layer. When you rebuild the image, Docker checks which layers have not changed and reuses them from cache. This is why the second build is faster than the first: unchanged layers are not rebuilt.
The problem is that most people write Dockerfiles as if they are writing a setup script. They install everything, copy everything, and only later realize that the final image contains compilers, debug tools, and system utilities that have nothing to do with running the application. Every extra tool in the image is extra weight during pull, extra surface area for vulnerabilities, and extra complexity when you try to understand what is actually in the image.
The fix is not about writing less code. It is about separating the build environment from the runtime environment.
Multi-Stage Builds: The Single Most Important Pattern
Multi-stage builds let you define multiple stages in one Dockerfile. The first stage contains everything needed to compile the application: the full SDK, compilers, build tools, and dependencies. The second stage contains only what is needed to run the compiled artifact: the binary, runtime libraries, and nothing else.
Here is what that looks like for a Go application:
The diagram below illustrates how the two stages work together:
# Stage 1: Build
FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go build -o myapp .
# Stage 2: Run
FROM alpine:3.19
COPY --from=builder /app/myapp /myapp
CMD ["/myapp"]
The first stage uses the full Go SDK. The second stage uses a minimal Alpine image that contains only the bare essentials. The Go SDK, the source code, and the build cache never make it into the final image. The result is an image that is often 20 to 30 megabytes instead of over a gigabyte.
The same pattern applies to any compiled language. For interpreted languages like Python or Node.js, you can use multi-stage builds to install dependencies in one stage and copy only the installed packages into the final stage, leaving behind the package manager cache and build tools.
Security Starts With What You Leave Out
Every tool in your image is a potential vulnerability. A shell like Bash or a package manager like apt might seem harmless, but if an attacker gains access to your container, those tools become weapons. They allow the attacker to install new software, download payloads, or pivot to other systems.
The principle is simple: do not include anything that is not required to run the application. If you need to debug a running container occasionally, do not keep debugging tools in the production image. Instead, create a separate debugging image or use ephemeral containers that mount the necessary tools at runtime.
For production images, consider using distroless base images. These images contain only the runtime and libraries your application needs. No shell, no package manager, no utilities. Google maintains distroless images for several languages, including Go, Python, Java, and Node.js. They are small, minimal, and significantly reduce the attack surface.
If distroless feels too restrictive, Alpine is a reasonable alternative. It is small and includes a shell, but it uses musl libc instead of glibc, which can cause compatibility issues with some applications. Test your application on Alpine before committing to it.
Reproducibility Is Not Optional
An image that cannot be rebuilt identically six months from now is a liability. The most common cause of unreproducible builds is the use of the latest tag for base images. latest changes whenever the maintainer pushes a new version. Today's build might use Go 1.22, but next month's build might use Go 1.23, and you will not know until something breaks.
Always pin your base image to a specific version tag or, even better, to a digest hash. A digest hash is the cryptographic checksum of the image content. It never changes. If you use golang:1.22-alpine@sha256:abc123..., you are guaranteed to get the exact same base image every time, regardless of when or where you build.
FROM golang:1.22-alpine@sha256:abc123def456 AS builder
This applies to every image you pull, including intermediate images used in multi-stage builds. If you cannot find the digest for a specific version, use the most specific tag available, such as golang:1.22.0-alpine3.19 instead of golang:1.22-alpine.
Layer Ordering Affects Build Speed
Docker caches layers based on the order of instructions. If you change a file that is copied early in the Dockerfile, all subsequent layers are invalidated and rebuilt. If you change a file that is copied late, only the layers after that point are rebuilt.
The practical rule is: put instructions that change rarely at the top, and instructions that change frequently at the bottom.
- Install system dependencies first.
- Copy dependency manifests (like
go.modandgo.sum) and run the dependency download step. - Copy the source code last.
This way, if you change only your source code, the dependency installation step is reused from cache. The build is faster because Docker does not re-download packages that have not changed.
A Practical Checklist for Your Dockerfile
Before you push your next image to production, run through this checklist:
- Does the final image contain only what is needed to run the application?
- Are build tools, SDKs, and source code excluded from the final stage?
- Is the base image pinned to a specific version or digest, not
latest? - Is the base image minimal (distroless or Alpine) rather than a full OS image?
- Are rarely-changed instructions placed before frequently-changed instructions?
- Is there no shell or package manager in the production image unless absolutely necessary?
What Matters Most
A Dockerfile is not just a build script. It is a contract between your build process and your production environment. A good Dockerfile produces an image that is small, secure, and reproducible. A bad Dockerfile produces an image that is slow to pull, hard to debug, and impossible to rebuild with confidence.
The next time you write a Dockerfile, start with the final image in mind. Ask yourself: what does this application actually need to run? Then leave everything else out. Your pipeline will be faster, your deployments will be smoother, and your security team will have one less thing to worry about.