--- title: "Your Docker Image Is 1.2 GB. Here's How to Get It Under 100 MB" author: "Ankit Singh" published: "2026-11-01T07:35:05.677Z" updated: "2026-11-01T07:35:05.677Z" url: "https://zyvop.com/your-docker-image-is-1-2-gb-here-s-how-to-get-it-under-100-mb-9oiu3" canonical: "https://zyvop.com/your-docker-image-is-1-2-gb-here-s-how-to-get-it-under-100-mb-9oiu3" tags: ["Docker","Python","containers","Optimization","DevOps"] --- # Your Docker Image Is 1.2 GB. Here's How to Get It Under 100 MB Slim bases, multi-stage builds, a .dockerignore and a few layer habits, plus a small tested checker that spots what's bloating your Dockerfile. ![Cover image]() 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](). 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 shown on Docker Hub for the `linux/amd64` builds: | Base image | Compressed size | | --- | --- | | `python:3.14` (the full image) | about 400 MB | | `python:3.14-slim` | about 41 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. ### 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 list the benefits of a smaller base: faster downloads, a smaller image, and fewer packages to keep patched. 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 a different C library, musl. The official image page recommends it when final size is your main concern, 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 flowchart TD A["Which base for the image that ships?"] --> B{"Is it a static binary, like Go or Rust?"} B -->|"Yes"| C["distroless static or scratch"] B -->|"No"| D{"Do all your dependencies ship musl wheels, and is every megabyte precious?"} D -->|"Yes"| E["alpine"] D -->|"Not sure"| F["slim"] ``` 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 describe it as the way to cut the final image down, and they're clear that a production image shouldn't contain build tools. ```mermaid flowchart LR subgraph Builder["Stage 1: builder"] A["Install dependencies
into a virtualenv"] end subgraph Final["Stage 2: the image you ship"] C["A clean slim base"] --> D["Copy in just the virtualenv
and your code"] end A -->|"COPY --from=builder"| D ``` 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 base image tag in both stages, so the Python and the OS libraries match. 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 flowchart TB A["Layer 1: RUN apt-get install build-essential
adds a few hundred MB"] --> B["Layer 2: RUN apt-get clean
hides files, removes nothing"] B --> C["The image still contains layer 1 in full"] ``` 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 recommends the same two-stage shape. Install into a `.venv` in the builder, then copy it across. It also suggests setting `UV_COMPILE_BYTECODE=1` for faster startup, and a cache mount for uv's cache: ```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, including CA certificates. The `:nonroot` tag runs as a non-root user. Check the distroless project for the current tag names, and pin by digest in production. 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 41 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 or OS differs between stages, or if the path changes | Same base tag 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, and rebuild on a schedule | ## 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](), 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"apt-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 <= 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": 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 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"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 <