How do I shrink Docker images with multi-stage builds and distroless?
Question
Our Go or PHP Docker images we ship to production reach 1.2GB. This slows CI/CD (upload/download) and creates an attack surface, since the image carries unnecessary libraries like compilers and bash. How do I use a multi-stage build to leave the build tools in the first stage and ship only a minimal distroless or alpine-based image (say 50MB) to production?
Answer
Short answer: a 1.2GB image ships your whole build toolchain to production — both slow and a big attack surface. A multi-stage build fixes both; you put only the artifact and the runtime in the final stage.
Short answer
The root of the problem is singular: things that shouldn’t be there (compiler, package manager, shell) leak into production. I explain how layers are formed and the .dockerignore logic from the ground up in the Docker post; this optimization sits on top of that groundwork.
Why
-
The cost is twofold: speed and attack surface. A 1.2GB image inflates upload/download time on every build; the compiler, bash, and package manager it carries leave an attack surface production never needs.
-
The multi-stage idea: a
builderstage produces, the final stage only copies. Put the compiler and dependencies in the first stage and produce the artifact. In the final stage,COPY --from=builderonly the binary/app + the runtime it needs to run. Build tools never enter the image. -
Go and PHP don’t bottom out at the same place. The Go binary is self-contained (statically linked), so you get an image of a few MB. In PHP there’s no compiled binary; you need the PHP runtime + extensions, so the target size has to be different too.
What to do
-
For Go, make the final stage
scratchordistroless/static. Distroless has no shell or package manager — smaller attack surface; when you need to debug, use the:debugvariant. -
For PHP, target
php:8.x-fpm-alpine. Runcomposer install --no-devin the build stage, and copy onlyvendor/+ the app into the final stage. You won’t hit a few MB, but you drop from hundreds to tens of MB. -
Tighten security as much as size. Run the container as non-root, exclude junk with
.dockerignore, pin the base image by digest, and let the registry layer-cache the dependency stage so it isn’t refetched on every build.
Bottom line: I’d use multi-stage + distroless/static/scratch for Go, fpm-alpine for PHP, run non-root, and keep .dockerignore tight. That turns a 1.2GB image into tens of MB — faster CI/CD, smaller attack surface. I explain how image layers work and the .dockerignore logic from the ground up in the hub post; that setup lays the groundwork for this optimization.
Related Reading
Comments
Sign in with your GitHub account to join the discussion. Comments are stored in GitHub Discussions.