# How do I shrink Docker images with multi-stage builds and distroless?

> Leave the build tools in the builder stage: make the final stage distroless/static or scratch for Go and fpm-alpine for PHP, and run as non-root.

- Asked: 2026-06-11
- Answered: 2026-06-12
- Asked by: Korhan
- Tags: ci-cd, docker, altyapi
- Source: https://www.muhammetsafak.com.tr/en/just-ask/shrinking-docker-images-with-multi-stage-builds-and-distroless/
- Language: en-US
- Author: Muhammet Şafak

---
**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?


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.

The root of the problem is singular: things that shouldn't be there (compiler, package manager, shell) leak into production.

1. **The multi-stage idea: a `builder` stage 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=builder` only the binary/app + the runtime it needs to run. Build tools never enter the image.
2. **For Go, the final stage can be `scratch` or `distroless/static`.** The Go binary is self-contained (statically linked), so you get an image of a few MB. Distroless has no shell or package manager — smaller attack surface; when you need to debug, use the `:debug` variant.
3. **PHP is more nuanced: the runtime is unavoidable.** There's no compiled binary in PHP; you need the PHP runtime + extensions. Target `php:8.x-fpm-alpine`, run `composer install --no-dev` in the build stage, and copy only `vendor/` + the app into the final stage. You won't hit a few MB, but you drop from hundreds to tens of MB.
4. **Security matters 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

- [Docker containerization: installation and basic usage](/en/blog/docker-containerization-installation-and-basic-usage/) — Blog
- [How do I isolate self-hosted CI/CD runners and build an ephemeral, clean-after-each-run environment?](https://www.muhammetsafak.com.tr/en/just-ask/isolating-and-securing-self-hosted-ci-cd-runners/) — Just Ask
- [How do I set up secretless AWS access in CI/CD with OIDC and IAM roles?](https://www.muhammetsafak.com.tr/en/just-ask/secretless-ci-cd-with-aws-iam-roles-and-oidc/) — Just Ask
- [How do I safely manage Terraform state with S3 + DynamoDB locking?](https://www.muhammetsafak.com.tr/en/just-ask/terraform-remote-state-and-locking-with-s3-and-dynamodb/) — Just Ask
