DEV Community

Cloud Frontier
Cloud Frontier

Posted on

Docker Images Without the Bloat

Stop Shipping the Kitchen Sink

We've all been there: you build a Docker image for a simple Python script, and it's 800MB. You pull it on a slow connection and watch the progress bar crawl. The image contains compilers, headers, and a full OS package manager you'll never use. It's time to trim the fat.

Start with the Right Base

Your base image sets the ceiling. ubuntu:latest is a convenience, not a goal. For most apps, you can go much smaller.

  • alpine is tiny (around 5MB) and uses musl, but sometimes native modules need extra work.
  • debian:bookworm-slim is a middle ground: Glibc, but without the bloat.
  • For Go or Rust, consider scratch or distroless.

Here's a quick comparison for a simple Python app:

# python:3.12-slim
FROM python:3.12-slim
# ~120MB

# python:3.12-alpine
FROM python:3.12-alpine
# ~50MB

# python:3.12 (full)
FROM python:3.12
# ~1GB
Enter fullscreen mode Exit fullscreen mode

Always check the official images for slim or alpine variants. They exist for a reason.

Multi-Stage Builds: The Killer Feature

If you need compilers to build, don't ship them. Use a multi-stage build: compile in one stage, copy only the artifacts to a clean final stage.

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

# Final stage
FROM alpine:latest
RUN apk add --no-cache ca-certificates
COPY --from=builder /app/myapp /usr/local/bin/myapp
CMD ["myapp"]
Enter fullscreen mode Exit fullscreen mode

The Go binary is static, so we copy it into a minimal Alpine. Final image size? Around 10MB. Compare that to shipping the Go toolchain.

Clean Up Within a Layer

If you're stuck with a single-stage build (legacy, or just quick), at least clean up in the same RUN command. Each RUN creates a layer, and files deleted in a later layer still exist in the previous one.

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

Note the && chain: this prevents intermediate layers from holding onto temporary files.

Use .dockerignore

Your build context can be huge if you're not careful. A stray node_modules or .git folder gets sent to the daemon, slowing builds and bloating cache. Add a .dockerignore file:

.git
node_modules
__pycache__
*.md
.env
Enter fullscreen mode Exit fullscreen mode

Copy Specific Files

Instead of COPY . ., copy only what you need. This also helps with layer caching: if only your code changes, you don't invalidate the layer with dependencies.

COPY package.json package-lock.json ./
RUN npm ci --only=production
COPY src ./src
Enter fullscreen mode Exit fullscreen mode

Don't Install Unnecessary Packages

That's obvious, but also think about package manager caches. pip install leaves .pyc files and caches. Use:

pip install --no-cache-dir -r requirements.txt
Enter fullscreen mode Exit fullscreen mode

For npm, npm ci --only=production skips devDependencies and uses the lockfile.

Distroless and Scratch

For production, consider gcr.io/distroless images. They have no shell, no package manager, just your app and runtime. This reduces attack surface and size.

If your app is fully static (Go, Rust), you can use scratch:

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

That's as small as it gets: just the binary.

Check Your Layers

Use docker history to see what's taking space:

docker history myimage
Enter fullscreen mode Exit fullscreen mode

You'll spot layers that are unexpectedly large. Also, docker images shows the total size, but remember that shared layers between images don't count twice.

Example: Slimming Down a Python API

Here's a before and after for a FastAPI app.

Before:

FROM python:3.12
WORKDIR /app
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY . .
CMD ["uvicorn", "main:app", "--host", "0.0.0.0"]
Enter fullscreen mode Exit fullscreen mode

Size: ~1GB

After:

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

FROM python:3.12-slim
WORKDIR /app
COPY --from=builder /usr/local/lib/python3.12/site-packages /usr/local/lib/python3.12/site-packages
COPY --from=builder /usr/local/bin /usr/local/bin
COPY . .
CMD ["uvicorn", "main:app", "--host", "0.0.0.0"]
Enter fullscreen mode Exit fullscreen mode

Size: ~150MB. The trick is copying only the installed packages, not the entire builder image.

Final Thoughts

Bloat isn't just about download speed. Smaller images mean faster startups, less disk usage, and fewer security vulnerabilities. Start with a slim base, use multi-stage builds, and always clean up. Your future self (and your CI pipeline) will thank you.

Happy trimming!

Top comments (0)