DEV Community

Cloud Frontier
Cloud Frontier

Posted on

Docker Images Without the Bloat

The Problem with Fat Images

We've all been there: you pull an image, run docker images, and see a 1.2GB monster staring back at you. That's not just disk space wasted; it's slower pulls, slower deploys, and a larger attack surface. The usual suspects? Base images with unnecessary tools, build dependencies left behind, and layers that contain temp files or caches.

I've been guilty of shipping images that could have been 20x smaller. Over time, I've adopted a few techniques that make a real difference. Here's what actually works.

Start Small: Choose the Right Base

Your base image sets the floor. ubuntu:latest is around 78MB, but alpine:latest is under 5MB. If you're running a Go binary, you don't even need a full OS: scratch is literally empty. For Python, consider python:3.12-slim instead of python:3.12 (which includes compilers and headers you likely don't need at runtime).

For example, a simple Python app:

FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["python", "app.py"]
Enter fullscreen mode Exit fullscreen mode

That's already a fraction of the size of the full image.

Multi-Stage Builds: The Game Changer

If you need build tools, don't ship them. Multi-stage builds let you compile in one stage and copy only the artifacts to a clean final stage.

Here's a Go example:

# Build stage
FROM golang:1.22-alpine AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /app/myapp

# Final stage
FROM scratch
COPY --from=builder /app/myapp /myapp
ENTRYPOINT ["/myapp"]
Enter fullscreen mode Exit fullscreen mode

The final image is just the binary. For a Go app, that's typically 10-20MB. For a Node.js app, you can do the same: install dependencies in a builder, then copy node_modules and your code to a slim runtime image.

Clean Up in the Same Layer

Every RUN command creates a layer. If you install packages and then delete them in a separate RUN, the deletion doesn't remove the data from the previous layer; it just adds a new layer on top. The size remains.

Always combine cleanup in the same RUN:

RUN apt-get update && apt-get install -y --no-install-recommends build-essential \
    && pip install --no-cache-dir -r requirements.txt \
    && apt-get purge -y build-essential \
    && rm -rf /var/lib/apt/lists/*
Enter fullscreen mode Exit fullscreen mode

Also, use --no-install-recommends for apt and --no-cache-dir for pip to avoid pulling in extras.

.dockerignore: Stop Copying Junk

If you're copying your entire project directory, you might be including .git, node_modules, test files, or local caches. A minimal .dockerignore can save megabytes:

.git
node_modules
__pycache__
*.pyc
.env
Dockerfile
.dockerignore
Enter fullscreen mode Exit fullscreen mode

This also speeds up the build context transfer.

Use Distroless Images

If you need a runtime but not a shell, consider Google's distroless images. They contain only your runtime (e.g., Python, Node) and necessary libraries, no package manager or shell. This reduces attack surface and size. For example:

FROM gcr.io/distroless/python3-debian12
WORKDIR /app
COPY . .
CMD ["app.py"]
Enter fullscreen mode Exit fullscreen mode

Note: you can't docker exec into a distroless container (no shell), so debugging is trickier. That's a trade-off.

Check Your Layers

After building, inspect your image with docker history to see what's taking up space:

docker history myimage
Enter fullscreen mode Exit fullscreen mode

You'll see the size each layer added. If you see a huge layer for something you thought you cleaned up, you know you missed combining commands.

Also, docker images shows the total size, but docker system df gives a breakdown of all your images, containers, and build cache.

Realistic Example: Python API

Let's put it together. A FastAPI app with a couple of dependencies:

FROM python:3.12-slim AS builder
WORKDIR /app
COPY requirements.txt .
RUN pip install --prefix=/install --no-cache-dir -r requirements.txt

FROM python:3.12-slim
WORKDIR /app
COPY --from=builder /install /usr/local
COPY . .
EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
Enter fullscreen mode Exit fullscreen mode

This avoids installing build dependencies in the final image. You can even go distroless if you're comfortable.

Final Thoughts

Shrinking images isn't just about aesthetics. Smaller images pull faster, start faster, and have fewer vulnerabilities. The techniques are straightforward: pick a slim base, use multi-stage builds, clean up in the same layer, and ignore unnecessary files.

Next time you build an image, run docker images and ask yourself: do I need all that? The answer is usually no.

Top comments (0)