# Your Docker Image Is 1.2 GB. Here's How to Get It Under 100 MB

Your CI spends a minute pulling a 1.2 GB image. Deploys wait on it. A fresh node has to download it before it can serve a single request.

Most bloated images share the same handful of causes, and each one has a cheap fix. This post walks through them in order of payoff, using one realistic Python image.

First, a definition, because "image size" means two different things.

## Two sizes, and how to see both

-   **Compressed size** is what the registry stores and what you download. Docker Hub shows this number.
    
-   **Unpacked size** is what the image takes on disk once it's pulled.
    

The unpacked number is always bigger. Docker's own docs show a small image at 12.8 MB unpacked and 3.99 MB of compressed content.

On Docker Engine 29 and later, `docker image ls` shows both, as **DISK USAGE** (unpacked) and **CONTENT SIZE** (compressed). Older engines show a single size, which is the unpacked one.

```bash
docker image ls myapp                      # sizes, in two columns on Docker 29 and later
docker history myapp:before                # how much each layer added
docker save myapp:before | gzip | wc -c   # a rough compressed size on older engines
```

For a visual tour of every layer and its files, try the open-source tool [dive](https://github.com/wagoodman/dive).

In this post, **"under 100 MB" means compressed**, the size you push and pull. Unpacked will be larger, so always look at both.

## Where 1.2 GB comes from

Here's a small FastAPI service. Two files:

```python
# main.py
from fastapi import FastAPI

app = FastAPI()

@app.get("/health")
def health():
    return {"status": "ok"}
```

```text
# requirements.txt  (pin your versions in a real project)
fastapi
uvicorn[standard]
```

And the Dockerfile that most of us write first:

```dockerfile
FROM python:3.14
WORKDIR /app
COPY . .
RUN apt-get update && apt-get install -y build-essential libpq-dev curl
RUN pip install -r requirements.txt
RUN apt-get clean
EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
```

It works. It's also carrying a lot of weight:

-   **A full base image.** `python:3.14` is a complete Debian system with compilers and headers.
    
-   **Build tools in the final image.** `build-essential` and `libpq-dev` are needed to compile things, not to run them.
    
-   `COPY . .` **with no** `.dockerignore`**.** Your `.git` folder, virtualenv and local files all ride along.
    
-   **No** `--no-install-recommends`, so `apt` adds suggested extras.
    
-   **The package lists and pip's cache** stay in the image.
    
-   **A cleanup in its own layer.** `RUN apt-get clean` on its own line shrinks nothing, as you'll see below.
    

How big is the base alone? These are the compressed sizes Docker Hub lists for the `linux/amd64` builds of the 3.14 tags, checked on October 31, 2026:

| Base image | Compressed size |
| --- | --- |
| python:3.14 (the full image) | about 400 MB |
| python:3.14-slim | about 42 MB |
| python:3.14-alpine | about 17 MB |

Published comparisons put full Python images at roughly 800 MB to 1 GB once unpacked. Add the toolchain, the caches and your whole project folder, and 1.2 GB arrives quickly.

> **Which Python?** The examples use 3.14. Python 3.15 is out now, and `latest` points to it. Use the newest version your dependencies publish wheels for, because a missing wheel means compiling from source, and a slim image has no compiler.

### A scoreboard before you start

Instead of guessing, run a checker over the Dockerfile. The one at the end of this post looks only at the habits that cost size or build time:

```bash
python dockerfile_check.py Dockerfile
```

```text
Dockerfile:0  no-dockerignore  No .dockerignore next to the Dockerfile, so .git, virtualenvs and local files all go into the build context.
Dockerfile:1  heavy-base  python:3.14 is the full image. Use a -slim or -alpine tag for the stage that ships.
Dockerfile:4  apt-lists  The apt package lists stay in the image. End the same RUN with rm -rf /var/lib/apt/lists/*.
Dockerfile:4  apt-recommends  apt-get install without --no-install-recommends pulls in suggested extras.
Dockerfile:4  build-tools-in-final-stage  Build tools in the shipped image: build-essential, libpq-dev. Install them in a builder stage.
Dockerfile:5  copy-before-install  Dependencies install after COPY . (line 3), so any code change reinstalls them. Copy the dependency files first.
Dockerfile:5  pip-cache  pip keeps its download cache in the layer. Add --no-cache-dir, or use a cache mount.
Dockerfile:6  cleanup-in-own-layer  Deleting files in a later layer doesn't shrink the image. Do the cleanup in the same RUN that made the mess.
8 thing(s) to fix
```

Eight findings. Each one maps to a fix below, and by the end the output will read `Nothing found`.

## Fix 1: Start from a smaller base

This is the biggest single win, and it's one line. The Docker docs say a smaller base brings portability and fast downloads, shrinks your image, and cuts the number of vulnerabilities you inherit through dependencies.

Your options for a Python app:

-   **Full (**`python:3.14`**).** Everything included. Fine for a builder stage, wasteful for the image you ship.
    
-   **Slim (**`python:3.14-slim`**).** Debian with the extras removed. It's the sensible default.
    
-   **Alpine (**`python:3.14-alpine`**).** Smallest, but it uses musl instead of glibc. The official image page recommends it when final size is your primary concern, warns that software can hit libc compatibility issues, and notes that tools such as git or bash usually aren't included.
    
-   **Distroless or** `scratch`**.** No shell, no package manager. Best for static binaries like Go.
    

![Mermaid Diagram](https://mermaid.ink/img/pako:eNpVkMtu20AMRX_lluvRokBXXiSIo7rNIl3EfaDweEFJlEVkpBGGo7iq438v5KAJsiRxD-8BT1THRmhFbYjHuuOU8b30AwDc7Dz96rTuULEJ2piQO4H2fBDkjjOs09GuPe1RFFdYnzzdGTSDYZmz1qh04DQ7BH0UfImICQ-T5WtP55eO9UI-e_ot5ukZtztPjVpOMYjZ_ysxwerEue487d9z3-KClSdPZQSHgDlOCY2MMjQy1Cp2kUQ_WcCxEwnmwEMDNciTpBm9HLias2BMUmuc7E2ufC_3eeeJw6iDvGqUrxoZNiVZYpudJwvae9qTo15Sz9rQ6kS5k375dCMtTyGTe9n85KRcBbEl08Yhb7jXMNOKCh7HIIXNlqV3WAcdHu-53l7mTRyyg6etHKLgx50nh4dYxRwdvkp4kqw1O9wk5eBgPFhhkrQldynZ6t_F5eOn8Q-dz46qw20MMdGKPhw7zULnf1kVsic?type=png)

Two caveats from the official sources. The Python image page itself recommends the full image unless you have space constraints, because slim has no compilers, so `pip install` can fail for packages that need building from source. And Docker's own docs point to Alpine as a small general-purpose base.

For a Python app where size matters, the builder stage in Fix 2 is where you add a compiler, or switch to the full image, if a dependency needs building from source. None of it reaches the image you ship.

And slim avoids the musl issues behind the second caveat. So when in doubt, use **slim**. Alpine saves a few more megabytes, but it's the one most likely to turn a two-minute build into an afternoon of debugging, as the gotchas section shows.

## Fix 2: Build in one stage, ship from another

Multi-stage builds are the other big lever. You build in a heavy image, then copy only the results into a clean one. Docker's docs say it reduces the size of the final image by separating the build from the output, and note that a production image often doesn't need compilers or other build tools.

![Mermaid Diagram](https://mermaid.ink/img/pako:eNplUF1Lw0AQ_CvrPl8QxaeghX5QLChKi4Lk-rBJNs3q5S7cXVpr2_8uaaGi7tsMszPD7LBwJWOKlXGboiYf4WGuLQBA6PKVp7aGUSemZJ9pXERaMVylkJ8ojcuTtr9hpnFmQyRjoOSWbcm2EA63ub8ciI0OCNbiY0eG7fr8yrb8kzcVS-acdp1CrBmk6cHWdRBqaX8FjzONQygMk4VgpIGcAmtcQpIMYJJpHLt2C2LhvQvxaPZT41iObNk7e-i3-F9s2BvtNY6fnt8gSSrvmrvzAHuYoMKGfUNSYrrDWHPTD1pyRZ2JqE7MK3mh3HDoNZWzcUqNmC2mmFDbGk7CNkRuFIyM2I9HKhZHPHU2KtC44JVjeJlpVDB3uYtOwT2bNUcpSMHQCxkFgWxIAnupUB1DFvLVd7m6aT_xcFCYr8bOOI8pXmxqiYyHb8T3qO8?type=png)

For Python the trick is a virtualenv. Create it in the builder, install into it, then copy the whole folder across. Everything the build needed stays behind: compilers, headers, pip's cache, intermediate files.

Two rules keep it working. Use the same Python version and the same OS family in both stages, so the interpreter and system libraries match. For example, build on `python:3.14` and ship on `python:3.14-slim`. And keep the virtualenv at the same path in both.

## Fix 3: Clean up in the same layer

Every instruction creates a layer, and layers only add. A file deleted in a later layer is hidden from the container, but it's still stored in the earlier layer, and you still ship and pull it.

![Mermaid Diagram](https://mermaid.ink/img/pako:eNpVj8tqw0AMRX9F1XpMSenKlEAcCC00XeTRTZyF7JFtUXnGzEySpiH_XuJAoUuJq3OPLlh7y5hjo_5UdxQSbIrSAQDMdiW-05kDTHJYbT-AhpS1nEBcTKQK1UHUZhwjuySkL1V4nJK1EQgaPkF3cDawhWVR4h6ybArFH_HpP7FWJjfed2I5QiPK0UDg3h85gvOpE9eWuL-bFSNtvitx0zFITy1DTKIKtXeJxEXQuziIg-agWuIeDfYcehKL-QVTx_3tbcsNHTShuW8-KQhVyvGWabxLC-pFz5hjRsOgnMVzTNwbKFTc15Lq9TgvvEsGSlxz6xm2byUaWPnKJ2_glfXISWoyMAtCaiCSi1nkIA2asWQtPzeXyfPwjderwaqde_UBc3w4dZIYr78iFY0v?type=png)

That's what the `cleanup-in-own-layer` finding is about. The fix is to clean up in the same `RUN` that made the mess. The Docker docs show this pattern for `apt`:

```dockerfile
RUN apt-get update && apt-get install -y --no-install-recommends curl \
    && rm -rf /var/lib/apt/lists/*
```

`--no-install-recommends` skips suggested packages. The `rm` runs before the layer is saved, so the lists never reach it.

For pip, either pass `--no-cache-dir`, or use a BuildKit cache mount, which keeps pip's cache outside the image and makes rebuilds faster too:

```dockerfile
RUN --mount=type=cache,target=/root/.cache/pip \
    pip install -r requirements.txt
```

## Fix 4: Add a .dockerignore

Without one, `COPY . .` sends your entire folder to the builder and copies it into the image. That includes `.git`, `.venv`, test data, and a `.env` file with secrets you'd rather not bake into a layer.

A `.dockerignore` uses patterns much like `.gitignore`. Here's a starting point:

```text
.git
.gitignore
.venv/
venv/
__pycache__/
*.pyc
.env
.pytest_cache/
tests/
docs/
*.md
Dockerfile*
.dockerignore
```

It also makes the build faster, because there's less to send to the builder before anything starts.

## Fix 5: Copy dependency files first

This one doesn't shrink the image, but it saves your afternoon. Docker caches each layer, and a layer is rebuilt when anything above it changes.

If you `COPY . .` first and install dependencies second, every code edit invalidates the install step. Copy only `requirements.txt`, install, and copy the code last. That's the `copy-before-install` finding.

## Fix 6: Ship only what you run

Dev dependencies, test frameworks and docs belong in CI, not production. Most tools have a switch:

| Tool | In the image |
| --- | --- |
| pip | Keep a runtime-only requirements.txt |
| uv | uv sync --frozen --no-dev |
| npm | npm ci --omit=dev |

If you use uv, its Docker guide covers the pieces below. `UV_COMPILE_BYTECODE=1` compiles bytecode, which tends to improve startup time. A cache mount keeps uv's cache out of the image. And `--no-install-project` installs dependencies in their own layer.

Pin the uv version in real projects, as the guide advises. The two-stage layout is the same idea as the Python example above:

```dockerfile
FROM python:3.14-slim AS builder
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/   # pin a specific uv version in real projects
ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy
WORKDIR /app
COPY pyproject.toml uv.lock ./
RUN --mount=type=cache,target=/root/.cache/uv \
    uv sync --frozen --no-install-project --no-dev
COPY . .
RUN --mount=type=cache,target=/root/.cache/uv \
    uv sync --frozen --no-dev

FROM python:3.14-slim
COPY --from=builder /app /app
ENV PATH="/app/.venv/bin:$PATH"
WORKDIR /app
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
```

## Fix 7: Static binaries can go much smaller

Compiled languages like Go can produce a single static binary, and then the base image barely matters. Build in the full toolchain image, ship from distroless:

```dockerfile
# syntax=docker/dockerfile:1

FROM golang:1.27 AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /server .

FROM gcr.io/distroless/static-debian13:nonroot
COPY --from=builder /server /server
EXPOSE 8080
ENTRYPOINT ["/server"]
```

Three details do the work:

-   `CGO_ENABLED=0` makes the binary fully static, with no C library needed.
    
-   `-ldflags="-s -w"` strips the symbol table and debug information.
    
-   `gcr.io/distroless/static` holds the bare minimum to run a static binary: CA certificates, timezone data and a `/tmp` directory. The `:nonroot` tag runs as an unprivileged user.
    

Check the distroless project for the current tag names. Docker's docs also describe pinning a base image by digest, which makes builds reproducible but opts you out of automatic security updates, unless a tool like Dependabot bumps the digest for you.

The trade-off is debugging. Distroless images have no shell and no package manager. For local debugging there's a `:debug` variant that includes a BusyBox shell, which you should never ship.

## Put it together

Here's the Python image after the fixes:

```dockerfile
# syntax=docker/dockerfile:1

FROM python:3.14-slim AS builder
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
COPY requirements.txt .
RUN --mount=type=cache,target=/root/.cache/pip \
    pip install -r requirements.txt

FROM python:3.14-slim
RUN useradd --create-home --uid 10001 app
COPY --from=builder /opt/venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH" \
    PYTHONDONTWRITEBYTECODE=1 \
    PYTHONUNBUFFERED=1
WORKDIR /app
COPY --chown=app:app main.py .
USER app
EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
```

Compare it with where we started:

-   **The base is slim**, in both stages.
    
-   **The builder holds the toolchain.** Only a copy of the virtualenv crosses over.
    
-   **There's no** `COPY . .`**.** Just `main.py`, owned by a non-root user.
    
-   **Dependencies install from** `requirements.txt` **alone**, so code changes don't reinstall them.
    
-   **pip's cache lives in a cache mount**, not a layer.
    

Add the `.dockerignore` from Fix 4 and run the checker again:

```text
Nothing found
```

Then build both versions and compare:

```bash
docker build -f Dockerfile.before -t myapp:before .
docker build -f Dockerfile.after  -t myapp:after  .
docker image ls myapp
```

The slim base is about 42 MB compressed, and FastAPI with its dependencies adds to that. For a service like this, the total should land comfortably under 100 MB compressed, and well under the full-image version. Your exact numbers depend on your dependencies and platform, so use the two columns from `docker image ls` to find out.

## Gotchas

| Gotcha | What goes wrong | What to do |
| --- | --- | --- |
| Alpine and wheels | Alpine uses musl, not glibc. Python wheels built for it follow PEP 656, and a package without one is compiled from source, which needs compilers and slows the build | Check your dependencies publish musllinux wheels, or stay on slim |
| Missing runtime libraries | A slim image has fewer system libraries, so an import can fail at startup | Install the runtime package (libpq5), not the -dev one, in the final stage |
| Copying a virtualenv | It breaks if the Python version or OS family differs between stages, or if the path changes | Same Python and OS family in both stages, same path |
| No shell | docker exec ... sh and a curl-based HEALTHCHECK don't work in distroless or scratch | Use the :debug tag locally, and a health endpoint that the orchestrator probes |
| scratch has nothing | No CA certificates, no timezone data, no user database | Use distroless static, or copy those files in |
| File ownership | Files copied as root can't be written by a non-root user | COPY --chown=app:app |
| Smaller isn't patched | A small image can still be out of date | Pin versions, rebuild on a schedule, and use docker build --pull to fetch fresh base images |

## Run a real linter too

My checker is a teaching tool. It's small enough to read in one sitting, and it covers size and caching only. For CI, use [hadolint](https://github.com/hadolint/hadolint), which parses the Dockerfile properly and runs ShellCheck on your `RUN` lines:

```bash
hadolint Dockerfile
```

Several of the rules overlap with hadolint's:

| This post's rule | hadolint rule |
| --- | --- |
| apt-recommends | DL3015: avoid additional packages by specifying --no-install-recommends |
| apt-lists | DL3009: delete the apt-get lists after installing something |
| pip-cache | DL3042: avoid the cache directory with pip install --no-cache-dir |
| unpinned-tag | DL3006 and DL3007: always tag the image version, and don't use latest |

## The checker and its tests

Here's the whole checker. It splits a Dockerfile into stages and applies size rules only to what ships. A mess in a builder stage is fine, because it never reaches the final image.

```python
import re
import sys
from dataclasses import dataclass, field
from pathlib import Path

HEAVY_IMAGES = {"python", "node", "ruby", "php", "golang", "rust", "openjdk"}
LIGHT_MARKERS = ("slim", "alpine", "minimal")
BUILD_PACKAGES = {"build-essential", "gcc", "g++", "make", "cmake", "pkg-config"}
INSTALL_STEP = re.compile(r"\b(pip3?\s+install|npm\s+(ci|install)|yarn\s+install|uv\s+sync|poetry\s+install|go\s+mod\s+download|bundle\s+install)\b")
APT_INSTALL = re.compile(r"\bapt(?:-get)?\s+(?:-\S+\s+)*install\s+([^&;|]*)")
CLEANUP = re.compile(r"^(rm\s+-\S+\s+\S+.*|apt-get\s+(clean|autoremove)\b.*|pip\s+cache\s+purge)$")

@dataclass(frozen=True)
class Finding:
    line: int
    rule: str
    message: str

@dataclass
class Stage:
    name: str | None
    line: int
    image: str
    steps: list = field(default_factory=list)  # (line, KEYWORD, arguments)

def parse(text):
    """Instructions as (line, KEYWORD, arguments), with continuation lines joined.
    Heredocs (RUN <<EOF) aren't supported."""
    steps, buffer, start = [], "", 0
    for number, raw in enumerate(text.splitlines(), start=1):
        line = raw.rstrip()
        if not buffer and (not line.strip() or line.lstrip().startswith("#")):
            continue
        if not buffer:
            start = number
        if line.endswith("\\"):
            buffer += line[:-1] + " "
            continue
        buffer += line
        keyword, _, args = buffer.strip().partition(" ")
        steps.append((start, keyword.upper(), args.strip()))
        buffer = ""
    return steps

def split_stages(text):
    stages = []
    for line, keyword, args in parse(text):
        if keyword == "FROM":
            parts = [p for p in args.split() if not p.startswith("--")]
            name = parts[2].lower() if len(parts) >= 3 and parts[1].upper() == "AS" else None
            stages.append(Stage(name, line, parts[0]))
        elif stages:
            stages[-1].steps.append((line, keyword, args))
    return stages

def parse_image(ref):
    """'ghcr.io/org/app:1.2' -> ('app', '1.2', False). The last flag says whether a digest pins it."""
    pinned = "@" in ref
    ref = ref.split("@", 1)[0]
    name, colon, tag = ref.rpartition(":")
    if not colon or "/" in tag:  # no tag, or that colon was a registry port
        name, tag = ref, ""
    return name.rsplit("/", 1)[-1], tag, pinned

def image_chain(stages):
    """The final stage, then every earlier stage it builds on (FROM builder ...)."""
    by_name = {s.name: s for s in stages if s.name}
    chain = [stages[-1]]
    while chain[-1].image.lower() in by_name and by_name[chain[-1].image.lower()] not in chain:
        chain.append(by_name[chain[-1].image.lower()])
    return chain

def is_copy_all(args):
    sources = [a for a in args.split() if not a.startswith("--")]
    return bool(sources) and "--from" not in args and sources[0] in (".", "./")

def check(text, has_dockerignore=True):
    stages = split_stages(text)
    if not stages:
        return []
    findings, aliases = [], {s.name for s in stages if s.name}

    if not has_dockerignore:
        findings.append(Finding(0, "no-dockerignore", "No .dockerignore next to the Dockerfile, so .git, virtualenvs and local files all go into the build context."))

    for stage in stages:
        repo, tag, pinned = parse_image(stage.image)
        if stage.image.lower() in aliases or stage.image.lower() == "scratch" or "$" in stage.image:
            continue
        if not pinned and tag in ("", "latest"):
            findings.append(Finding(stage.line, "unpinned-tag", f"{stage.image} has no version tag, so the base can change under you."))

    chain = image_chain(stages)  # only the final image ships: leftovers in a builder stage don't count
    repo, tag, _ = parse_image(chain[-1].image)
    if repo in HEAVY_IMAGES and "$" not in chain[-1].image and not any(marker in tag for marker in LIGHT_MARKERS):
        findings.append(Finding(chain[-1].line, "heavy-base", f"{chain[-1].image} is the full image. Use a -slim or -alpine tag for the stage that ships."))

    for stage in stages:  # build-cache order matters in every stage
        copied_all = None
        for line, keyword, args in stage.steps:
            if keyword == "COPY" and is_copy_all(args):
                copied_all = line
            elif keyword == "RUN" and copied_all and INSTALL_STEP.search(args):
                findings.append(Finding(line, "copy-before-install", f"Dependencies install after COPY . (line {copied_all}), so any code change reinstalls them. Copy the dependency files first."))
                copied_all = None

    pip_cache_off, previous = False, None
    for stage in reversed(chain):
        for line, keyword, args in stage.steps:
            if keyword == "ENV" and "PIP_NO_CACHE_DIR" in args:
                pip_cache_off = True
            if keyword == "RUN":
                install = APT_INSTALL.search(args)
                if install:
                    if "--no-install-recommends" not in args and "Install-Recommends=false" not in args:
                        findings.append(Finding(line, "apt-recommends", "apt-get install without --no-install-recommends pulls in suggested extras."))
                    if "/var/lib/apt/lists" not in args:
                        findings.append(Finding(line, "apt-lists", "The apt package lists stay in the image. End the same RUN with rm -rf /var/lib/apt/lists/*."))
                    tools = [p.split("=")[0] for p in install.group(1).split() if not p.startswith("-")]
                    tools = [p for p in tools if p in BUILD_PACKAGES or p.endswith("-dev")]
                    if tools:
                        findings.append(Finding(line, "build-tools-in-final-stage", f"Build tools in the shipped image: {', '.join(tools)}. Install them in a builder stage."))
                if re.search(r"(?<!uv )pip3?\s+install", args) and not (pip_cache_off or "--no-cache-dir" in args or "--mount=type=cache" in args):
                    findings.append(Finding(line, "pip-cache", "pip keeps its download cache in the layer. Add --no-cache-dir, or use a cache mount."))
                if previous in ("RUN", "COPY", "ADD") and all(CLEANUP.match(part.strip()) for part in args.split("&&")):
                    findings.append(Finding(line, "cleanup-in-own-layer", "Deleting files in a later layer doesn't shrink the image. Do the cleanup in the same RUN that made the mess."))
            previous = keyword
    return sorted(findings, key=lambda f: (f.line, f.rule))

def main(argv):
    path = Path(argv[1] if len(argv) > 1 else "Dockerfile")
    findings = check(path.read_text(), (path.parent / ".dockerignore").exists())
    for f in findings:
        print(f"{path}:{f.line}  {f.rule}  {f.message}")
    print(f"{len(findings)} thing(s) to fix" if findings else "Nothing found")
    return 1 if findings else 0

if __name__ == "__main__":
    sys.exit(main(sys.argv))
```

Its limits are worth knowing. It doesn't understand heredocs (`RUN <<EOF`), and it skips `FROM` lines that use build arguments instead of expanding them. It's a set of heuristics, so treat a finding as a prompt to look, not a verdict.

The tests use the Dockerfiles from this post as fixtures. The `before` file must trigger every rule, and the `after` and Go files must come out clean:

```python
import contextlib
import io
import tempfile
from pathlib import Path

from dockerfile_check import check, main, parse, parse_image

HERE = Path(__file__).parent / "examples"

def rules(text, dockerignore=True):
    return sorted({f.rule for f in check(text, dockerignore)})

def test_parse_joins_continued_lines_and_skips_comments():
    text = "# a comment\nFROM python:3.14-slim\n\nRUN apt-get update && \\\n    apt-get install -y curl\nCMD [\"x\"]\n"
    assert parse(text) == [
        (2, "FROM", "python:3.14-slim"),
        (4, "RUN", "apt-get update &&      apt-get install -y curl"),
        (6, "CMD", '["x"]'),
    ]

def test_parse_image():
    assert parse_image("python:3.14-slim") == ("python", "3.14-slim", False)
    assert parse_image("python") == ("python", "", False)
    assert parse_image("registry:5000/team/app") == ("app", "", False)  # that colon is a port, not a tag
    assert parse_image("ghcr.io/astral-sh/uv:latest") == ("uv", "latest", False)
    assert parse_image("python:3.14-slim@sha256:abc") == ("python", "3.14-slim", True)

# ---- the examples used in the post ---------------------------------------------------------------

def test_the_before_dockerfile_trips_every_size_rule():
    found = check((HERE / "Dockerfile.before").read_text())
    by_rule = {f.rule: f.line for f in found}
    assert by_rule == {
        "heavy-base": 1,
        "apt-lists": 4,
        "apt-recommends": 4,
        "build-tools-in-final-stage": 4,
        "copy-before-install": 5,
        "pip-cache": 5,
        "cleanup-in-own-layer": 6,
    }

def test_the_after_and_go_dockerfiles_are_clean():
    assert check((HERE / "Dockerfile.after").read_text()) == []
    assert check((HERE / "Dockerfile.go").read_text()) == []

def test_a_missing_dockerignore_is_flagged():
    assert rules((HERE / "Dockerfile.after").read_text(), dockerignore=False) == ["no-dockerignore"]

# ---- only what ships counts ------------------------------------------------------------------------

BUILDER_MESS = """FROM python:3.14 AS builder
RUN apt-get update && apt-get install -y build-essential libpq-dev
RUN pip install -r requirements.txt
RUN apt-get clean
FROM python:3.14-slim
COPY --from=builder /opt/venv /opt/venv
"""

def test_a_messy_builder_stage_is_fine_because_it_does_not_ship():
    assert rules(BUILDER_MESS) == []

def test_building_on_top_of_a_stage_inherits_its_mess():
    text = BUILDER_MESS.replace("FROM python:3.14-slim\n", "FROM builder\n")
    assert rules(text) == ["apt-lists", "apt-recommends", "build-tools-in-final-stage", "cleanup-in-own-layer", "heavy-base", "pip-cache"]

# ---- individual rules ------------------------------------------------------------------------------------

def test_unpinned_tags():
    assert rules("FROM python:3.14-slim\n") == []
    assert rules("FROM python\n") == ["heavy-base", "unpinned-tag"]
    assert rules("FROM python:latest\n") == ["heavy-base", "unpinned-tag"]
    assert rules("FROM python:3.14-slim@sha256:abc\n") == []
    assert rules("FROM debian@sha256:abc\n") == []  # a digest pins it, even without a tag
    assert rules("FROM debian:trixie-slim AS a\nFROM scratch\nFROM a\n") == []  # scratch and stage names are fine
    assert rules("FROM debian AS a\nFROM a\n") == ["unpinned-tag"]

def test_which_base_images_count_as_heavy():
    assert rules("FROM golang:1.25\n") == ["heavy-base"]
    assert rules("FROM golang:1.25-alpine\n") == []
    assert rules("FROM node:24-bookworm-slim\n") == []
    assert rules("FROM gcr.io/distroless/static-debian13:nonroot\n") == []
    assert rules("FROM golang:1.25 AS builder\nFROM gcr.io/distroless/static-debian13:nonroot\n") == []  # a fat builder is the point

def test_copying_from_another_stage_is_not_copying_the_whole_project():
    text = "FROM node:24-slim AS b\nFROM node:24-slim\nCOPY --from=b . .\nRUN npm ci\n"
    assert rules(text) == []

def test_plain_apt_is_checked_like_apt_get():
    assert rules("FROM debian:trixie-slim\nRUN apt update && apt install -y curl\n") == ["apt-lists", "apt-recommends"]

def test_uv_pip_is_not_told_to_use_a_pip_flag():
    assert rules("FROM python:3.14-slim\nRUN uv pip install --system fastapi\n") == []

def test_a_build_argument_in_from_is_left_alone():
    assert rules("ARG BASE=python:3.14-slim\nFROM ${BASE}\n") == []
    assert rules("ARG PY=3.14\nFROM python:${PY}\n") == []  # can't tell whether that tag is slim

def test_apt_done_properly_is_clean():
    ok = ("FROM debian:trixie-slim\nRUN apt-get update && apt-get install -y --no-install-recommends curl "
          "&& rm -rf /var/lib/apt/lists/*\n")
    assert rules(ok) == []
    by_option = ok.replace("--no-install-recommends", "-o APT::Install-Recommends=false")
    assert rules(by_option) == []

def test_dev_packages_and_compilers_are_flagged_in_the_shipped_image():
    text = "FROM debian:trixie-slim\nRUN apt-get install -y --no-install-recommends gcc libpq-dev curl && rm -rf /var/lib/apt/lists/*\n"
    found = check(text)
    assert [f.rule for f in found] == ["build-tools-in-final-stage"]
    assert "gcc, libpq-dev" in found[0].message and "curl" not in found[0].message

def test_three_ways_to_keep_pip_quiet():
    base = "FROM python:3.14-slim\n"
    assert rules(base + "RUN pip install flask\n") == ["pip-cache"]
    assert rules(base + "RUN pip install --no-cache-dir flask\n") == []
    assert rules(base + "RUN --mount=type=cache,target=/root/.cache/pip pip install flask\n") == []
    assert rules(base + "ENV PIP_NO_CACHE_DIR=1\nRUN pip install flask\n") == []

def test_cleanup_only_helps_in_the_same_layer():
    base = "FROM debian:trixie-slim\n"
    assert rules(base + "RUN make\nRUN rm -rf /tmp/build\n") == ["cleanup-in-own-layer"]
    assert rules(base + "RUN make\nRUN make install && rm -rf /tmp/build\n") == []

def test_copying_everything_before_installing_dependencies():
    base = "FROM node:24-slim\n"
    assert rules(base + "COPY . .\nRUN npm ci\n") == ["copy-before-install"]
    assert rules(base + "COPY package*.json ./\nRUN npm ci\nCOPY . .\n") == []

# ---- the command line ------------------------------------------------------------------------------------------

def run_cli(dockerfile_text, dockerignore):
    folder = Path(tempfile.mkdtemp())
    (folder / "Dockerfile").write_text(dockerfile_text)
    if dockerignore:
        (folder / ".dockerignore").write_text(".git\n")
    out = io.StringIO()
    with contextlib.redirect_stdout(out):
        code = main(["dockerfile_check.py", str(folder / "Dockerfile")])
    return code, out.getvalue()

def test_cli_exit_codes_and_output():
    code, output = run_cli((HERE / "Dockerfile.before").read_text(), dockerignore=True)
    assert code == 1 and "heavy-base" in output and output.strip().endswith("7 thing(s) to fix")
    code, output = run_cli((HERE / "Dockerfile.after").read_text(), dockerignore=True)
    assert code == 0 and output.strip() == "Nothing found"
```

Save the three Dockerfiles from this post in an `examples/` folder next to the tests, with these names: `Dockerfile.before`, `Dockerfile.after` and `Dockerfile.go`. Then run `pytest -q`.

## Checklist

-   **Measure first.** Look at compressed and unpacked size, and `docker history` for the heavy layers.
    
-   **Use a slim base** for the stage that ships.
    
-   **Build in one stage, ship from another.**
    
-   **Clean up in the same** `RUN` that creates the mess.
    
-   **Add a** `.dockerignore`**.**
    
-   **Copy dependency files before code.**
    
-   **Leave dev dependencies and build tools out of the final image.**
    
-   **Lint the Dockerfile in CI,** and rebuild images regularly.

---

*Published via [ZyVOP](https://zyvop.com/your-docker-image-is-1-2-gb-here-s-how-to-get-it-under-100-mb-9oiu3?utm_source=hashnode&utm_medium=crosspost&utm_campaign=syndication) — Write once in Markdown, auto-backup to GitHub, and syndicate to Dev.to, Medium & Hashnode in 1 click.*
