
September 09, 2026
13 min read
By Kokil Thapa | Last reviewed: September 2026
Large Docker images slow every part of your pipeline. Pulls take longer on CI runners and production nodes. Registry storage adds up fast when you tag every commit. A bloated image also ships extra packages an attacker can exploit. If you want to Reduce Docker Image Size: Best Practices that survive real deployments, start with multi-stage builds and a minimal runtime base—not a single giant Dockerfile copied from a tutorial. I've used these patterns on Linux production servers and Laravel apps where deploy speed and disk headroom actually matter.
Why does Docker image size matter in production?
Image size is not a vanity metric. It directly affects how fast you ship fixes and how much you pay to run infrastructure.
Every megabyte in an image must be pulled across the network. On a three-node cluster, a 1.2 GB image costs three times the bandwidth of a 400 MB one. CI pipelines that pull fresh images on every job feel this pain daily. I've seen teams burn ten extra minutes per deploy because nobody trimmed a Node build stage left inside a PHP runtime image.
Smaller images also mean a smaller attack surface. Fewer installed packages means fewer CVEs for scanners like Trivy to flag. That ties into broader Ubuntu server hardening and routine container image scanning workflows.
On budget-sensitive projects—common for Nepal-based clients—a single oversized image can push a Rs 3,000/month VPS (~USD 22) into disk-pressure territory. Smaller images leave room for logs, database dumps, and backup retention without emergency upgrades.
For Laravel and PHP workloads I maintain, the goal is usually under 200 MB for a web app image and under 100 MB for a queue worker. WordPress and WooCommerce images run larger because of extensions, but you can still cut fat from build tools left in the final layer.
How do you reduce Docker image size with multi-stage builds?
Multi-stage builds are the single highest-impact technique. You compile, test, and bundle in one stage, then copy only what the runtime needs into a clean final stage. The official Docker documentation describes this pattern clearly in their multi-stage build guide.
On a production Laravel 13 app with PHP 8.3, a typical pattern looks like this:
# syntax=docker/dockerfile:1
FROM node:26-alpine AS frontend
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY resources/ resources/
COPY vite.config.js ./
RUN npm run build
FROM composer:2.10 AS vendor
WORKDIR /app
COPY composer.json composer.lock ./
RUN composer install --no-dev --no-scripts --prefer-dist --optimize-autoloader
COPY . .
RUN composer dump-autoload --optimize
FROM php:8.3-fpm-alpine AS runtime
RUN apk add --no-cache libpng libzip icu
COPY --from=vendor /app /var/www/html
COPY --from=frontend /app/public/build /var/www/html/public/build
WORKDIR /var/www/html
USER www-data
Notice what never reaches the runtime image: Node.js, npm, Composer binary, Git, and your full source tree of dev dependencies. For deeper walkthroughs, see our guides on multi-stage Docker image reduction and small multi-stage images.
Name your stages explicitly
Use AS frontend, AS vendor, and AS runtime labels. Named stages make Dockerfiles readable and let you target specific stages with docker build --target runtime during local debugging.
Copy artifacts, not toolchains
The runtime stage should receive compiled assets, vendor/autoload output, and maybe a trimmed config. It should not receive node_modules, .git, or test suites. A common mistake is copying the entire build context with COPY . . in every stage without a .dockerignore.
For Symfony 8.1 apps requiring PHP 8.4.1, the same idea applies: run composer install --no-dev in a builder, warm caches if needed, then copy into php:8.4-fpm-alpine. Symfony's Docker setup documentation follows a similar split between build and runtime concerns.
Which base images produce the smallest Docker containers?
Your base image sets the floor. You cannot shrink below what the OS and runtime require. Choosing the wrong base is the most common reason a "optimized" Dockerfile still weighs 600 MB.
| Base Image | Typical Size | Best For | Trade-offs |
|---|---|---|---|
ubuntu:24.04 | ~78 MB compressed | Teams needing apt packages and glibc compatibility | Larger than Alpine; more packages to patch |
alpine:3.20 | ~3 MB compressed | PHP-FPM, Nginx, Go binaries, static sites | Uses musl libc; some PHP extensions need extra work |
distroless/static | ~2 MB compressed | Compiled Go or Rust binaries | No shell, no package manager; harder to debug |
gcr.io/distroless/php | Varies | PHP apps needing minimal attack surface | Limited extension support; see our distroless images guide |
php:8.3-fpm (Debian) | ~450 MB | Quick prototypes, extension-heavy apps | Convenient but heavy for production |
php:8.3-fpm-alpine | ~110 MB | Production PHP/Laravel workloads | Install extensions via docker-php-ext-install |
For most Laravel 12/13 projects I work on, php:8.3-fpm-alpine or php:8.5-fpm-alpine hits the sweet spot. You get official PHP images, a small footprint, and enough flexibility for common extensions like pdo_mysql, redis, and intl.
When Alpine is the wrong choice
Alpine uses musl instead of glibc. Some proprietary PHP extensions, Oracle clients, or legacy binaries expect glibc. In those cases, use a slim Debian variant like php:8.3-fpm-bookworm and still apply multi-stage builds. You trade some size for compatibility.
Distroless for maximum minimalism
Distroless images contain your app and runtime libraries—nothing else. No sh, no apt, no curl. That makes emergency debugging harder. I reserve distroless for services where observability is handled externally and the binary is fully static or bundled.
What Dockerfile optimizations cut image layers and bloat?
Base image choice and multi-stage builds do the heavy lifting. These layer-level habits squeeze out the rest.
Write a strict .dockerignore
Every file in your build context can end up in a layer if a COPY instruction grabs it. Exclude what the runtime never needs:
.git
.gitignore
node_modules
vendor
tests
storage/logs
storage/framework/cache
storage/framework/sessions
storage/framework/views
.env
.env.*
docker-compose*.yml
README.md
*.md
.phpunit.result.cache
On a client project, adding a proper .dockerignore dropped context upload from 480 MB to 12 MB. That alone saved minutes on every CI build.
Combine RUN instructions and clean up in the same layer
Each instruction creates a layer. Package manager caches left in one layer stay forever unless you delete them in the same RUN:
RUN apk add --no-cache \
libpng-dev libzip-dev \
&& docker-php-ext-install gd zip \
&& apk del libpng-dev libzip-dev
The --no-cache flag on Alpine avoids storing the apk index. On Debian-based images, chain apt-get clean && rm -rf /var/lib/apt/lists/* in the same layer.
Order instructions for cache efficiency
Put rarely changing instructions first. Copy composer.lock before application code so dependency installs cache across builds. The same applies to package-lock.json for frontend assets built with Vite 8.x.
- Copy lock files only
- Install dependencies
- Copy source code
- Run build commands
- Copy artifacts to runtime stage
Do not install debugging tools in production images
vim, curl, git, and netcat are convenient during development. They have no place in a production runtime image. If you need shell access for incidents, use docker exec against a debug sidecar or a dedicated troubleshooting container—not your live app image.
Use BuildKit cache mounts for package managers
BuildKit cache mounts speed builds without bloating final images:
RUN --mount=type=cache,target=/root/.composer/cache \
composer install --no-dev --prefer-dist --optimize-autoloader
The cache lives on the builder host. It never gets committed to an image layer. Enable BuildKit with DOCKER_BUILDKIT=1 or configure it permanently after you install Docker on Ubuntu.
These habits pair well with limiting Docker container resources so your runtime constraints match your slim image philosophy.
How do you measure and enforce Docker image size limits in CI?
Optimizations drift without measurement. Teams add "just one utility" and six months later the image doubles. Treat image size like any other build metric.
Inspect locally before pushing
docker build -t myapp:local .
docker image ls myapp:local
docker history myapp:local --human --no-trunc
docker inspect myapp:local --format='{{.Size}}'
docker history shows which layers consume space. Look for unexpectedly large COPY or RUN steps. Tools like dive give an interactive layer breakdown if you want more detail than history provides.
Fail builds that exceed a size budget
In GitLab CI—a pipeline I use on several production sites—you can gate on image size before push:
build:
script:
- docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA .
- |
SIZE=$(docker inspect $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA --format='{{.Size}}')
MAX=200000000
if [ "$SIZE" -gt "$MAX" ]; then
echo "Image ${SIZE} bytes exceeds ${MAX} byte limit"
exit 1
fi
- docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
Adjust the byte limit per service. A Laravel API might allow 250 MB. A static Nginx site should stay under 50 MB. Document the budget in your team's build pipeline automation standards.
Scan and track over time
Size and security overlap. Run Trivy or similar scanners on every build. Track image size in your registry UI or export metrics to your monitoring stack. When size jumps between tags, investigate before it reaches production.
For multi-architecture builds with Docker Buildx, remember each platform variant has its own size profile. An arm64 Alpine image may differ slightly from amd64 because of architecture-specific packages.
What language-specific tricks shrink PHP, Node, and WordPress images?
Generic Dockerfile rules get you most of the way. These stack-specific habits finish the job.
PHP and Laravel
Run composer install --no-dev --optimize-autoloader in the build stage. Never copy tests/, phpunit.xml, or IDE config. Use php artisan config:cache and route:cache at deploy time, not bake time, unless your deploy pipeline expects a fully warmed image. For queue workers, build a separate slim image without Nginx or frontend assets. Our modern Laravel architecture guide covers how service boundaries affect container design.
On booking platforms like Adventure Third Pole Trek, splitting web, queue, and scheduler into separate images kept each container under 180 MB while scaling workers independently.
Node.js frontend builds
Use npm ci instead of npm install for reproducible, lean installs. Set NODE_ENV=production before npm run build. Only the compiled output—usually dist/ or public/build/—belongs in the runtime image. Node 26 LTS belongs in the builder stage only for PHP apps that use Vite.
WordPress and WooCommerce
WordPress 7.1 images grow fast because of plugins and uploads. Use multi-stage to copy a curated plugin set. Mount wp-content/uploads as a volume instead of baking media into the image. For WooCommerce 11.1 shops, exclude development themes and unused language packs. Our WordPress development service often starts with an audit of what actually needs to ship inside the container versus what belongs on persistent storage.
Database sidecars
Do not bundle MySQL 9.7 or PostgreSQL 18 inside your app image. Run databases as separate containers or managed services. If you need Postgres locally, follow a dedicated PostgreSQL in Docker for development setup rather than coupling it to your app Dockerfile.
Key Takeaways
- Use multi-stage builds to keep compilers, dev dependencies, and build tools out of the runtime image.
- Pick slim bases like
php:8.3-fpm-alpineinstead of full Debian images unless glibc compatibility forces otherwise. - Maintain a strict
.dockerignoreand combineRUNsteps so package caches never persist in layers. - Order Dockerfile instructions from least to most frequently changed to maximize BuildKit layer cache hits.
- Measure with
docker historyand enforce byte-limit gates in CI before images reach your Docker registry. - Split web, worker, and scheduler into separate images when a monolith container carries assets nothing else needs.
People Also Ask
What is a good Docker image size for a web application?
For a PHP or Laravel API with compiled frontend assets, aim for 150–250 MB compressed. Static Nginx sites should stay under 50 MB. If your image exceeds 500 MB, you almost certainly have build tools or dev dependencies in the final stage.
Does a smaller Docker image improve runtime performance?
Smaller images do not directly make your app faster at serving requests. They speed up pulls, reduce disk usage, and shrink the CVE surface. Faster deploys mean you recover from incidents sooner, which is the practical performance win.
Is Alpine always better than Ubuntu for Docker?
Alpine is smaller, but it uses musl libc. Some binaries and PHP extensions expect glibc and break on Alpine. Use Alpine when your stack supports it. Use slim Debian or Ubuntu bases when compatibility issues appear.
Can Docker Compose help reduce image size?
Compose itself does not shrink images, but profiles and overrides let you build different targets for dev and production. Use a dev profile with debug tools and a production profile that builds only the slim runtime stage. See Docker Compose profiles and overrides for the pattern.
Ship smaller containers with confidence
Reducing Docker image size is not a one-time cleanup. It is a build discipline: multi-stage Dockerfiles, slim bases, tight .dockerignore files, and CI gates that reject bloat before it ships. The payoff shows up in faster deploys, lower registry bills, and fewer packages to patch on every release cycle.
If your team is migrating legacy apps into containers or tuning a Laravel pipeline that has outgrown its infrastructure, the same principles apply whether you deploy to a single VPS or a multi-node cluster. Start by measuring your current image, then apply Reduce Docker Image Size: Best Practices one layer at a time.
Need help auditing Dockerfiles, setting up GitLab CI size gates, or containerizing a production Laravel app? Contact us for a practical review. For ongoing deploy support, see our support and maintenance service or explore related testing and optimization work. You can also validate JSON configs from your pipeline with our free JSON formatter tool.
Frequently Asked Questions
0 Comments
Leave a comment
Your email is not published. Comments appear once they have been read. Sign in to have your details filled in.

