Docker Image Size Too Large, How to Reduce It
A bloated Docker image isn't usually caused by your application code — it's almost always the base image choice, unnecessary build tools left in the final image, and layer caching mistakes that pull in far more than the running application actually needs.
The Problem
You build an image and it ends up far larger than expected:
docker images
REPOSITORY TAG SIZE
your-app latest 1.2GB
For a simple API or web app, an image over a gigabyte is a strong sign something avoidable is being included — compilers, package manager caches, or entire operating system layers that only the build process needed, not the running application.
Why It Happens
Docker images accumulate size through layers, and several common patterns inflate the final image well beyond what's actually needed at runtime:
- Using a full OS base image (like
ubuntuornode:18) instead of a minimal variant, when the application doesn't need the extra tools and libraries included in the full version - Installing build-time dependencies (compilers, dev headers, build tools) that are only needed to build the application, but never removed from the final image since they were installed in the same stage as everything else
- Not cleaning up package manager caches after installing dependencies, leaving behind download caches that serve no purpose once installation is complete
- Copying the entire project directory (including
node_modules,.git, test files, and local config) into the image instead of only the files actually needed to run the application
The Fix
The single most impactful change is adopting a multi-stage build, which lets you use a full-featured image for building, then copy only the final compiled output into a much smaller runtime image:
FROM node:18 AS build
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
FROM node:18-alpine
WORKDIR /app
COPY --from=build /app/dist ./dist
COPY --from=build /app/node_modules ./node_modules
CMD ["node", "dist/server.js"]
The build tools, source files, and intermediate artifacts from the first stage never make it into the final image — only what's explicitly copied with COPY --from=build does.
Switch to a minimal base image where possible. Alpine-based images are dramatically smaller than their full counterparts for the same language runtime:
FROM node:18-alpine # ~180MB, vs node:18 at ~1GB
Be aware Alpine uses musl instead of glibc, which occasionally causes compatibility issues with certain native dependencies — test thoroughly before switching in an existing project, rather than assuming a drop-in replacement.
Clean up package manager caches within the same layer they were created in, since Docker layers are immutable — deleting a cache in a later layer doesn't reduce the size contributed by the earlier layer where it was created:
# Correct: cleanup in the same RUN command
RUN apt-get update && apt-get install -y build-essential \
&& rm -rf /var/lib/apt/lists/*
Use a .dockerignore file to prevent unnecessary files from ever being included in the build context in the first place:
# .dockerignore
node_modules
.git
*.md
test/
.env
Still Not Working?
If you've applied all the above and the image is still larger than expected, use docker history to see exactly which layer contributes the most size, rather than guessing:
docker history your-app --human --no-trunc
This lists every layer with its individual size and the command that created it, making it straightforward to identify which specific step in your Dockerfile is responsible for the largest jump in size, and target that step specifically instead of optimizing the whole file blindly.
It's also worth checking whether your application's own dependencies include something unexpectedly large. In JavaScript projects especially, a single transitive dependency pulling in native binaries or large asset files can dominate the final image size in a way that has nothing to do with your Dockerfile structure at all. Tools like du -sh node_modules/* inside the container (or on the host before building) can quickly surface a dependency that's disproportionately large compared to the rest of the project.
Finally, consider whether distroless images are a good fit for your use case — these contain only the application and its runtime dependencies, with no shell, package manager, or other OS utilities at all, producing some of the smallest possible images for compiled languages like Go. They're not always a drop-in replacement (debugging inside a distroless container is harder without a shell), but for production images where size and attack surface matter more than interactive debugging convenience, they're worth evaluating directly.