Kokil Thapa - Professional Web Developer in Nepal
Freelancer Web Developer in Nepal with 15+ Years of Experience

Kokil Thapa is an experienced full-stack web developer focused on building fast, secure, and scalable web applications. He helps businesses and individuals create SEO-friendly, user-focused digital platforms designed for long-term growth.

Reduce Docker Image Size: Best Practices

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.

Why Image Size MattersBuild TimeLayer cache hitsRegistryStorage costPull SpeedCI and K8s nodesSecurityFewer packagesSmaller Images = Faster DeploysLess bandwidth, quicker rollbacks, lower CVE count800 MB image~4 min pull on slow link180 MB image~55 sec pull same link
Reduce Docker image size to speed pulls across CI runners, registry mirrors, and production nodes.

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.

Multi-Stage Build FlowBuilder StageNode + ComposerCompile assetsArtifactspublic/buildvendor/autoloadRuntime Stagephp:8.3-fpm-alpineCOPY --from onlyDiscarded: node_modules, git, dev deps, build toolsBefore: 920 MBSingle-stage DockerfileAfter: 165 MBMulti-stage + alpine
Multi-stage builds discard compilers and dev dependencies, keeping only runtime artifacts in the final image.

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 ImageTypical SizeBest ForTrade-offs
ubuntu:24.04~78 MB compressedTeams needing apt packages and glibc compatibilityLarger than Alpine; more packages to patch
alpine:3.20~3 MB compressedPHP-FPM, Nginx, Go binaries, static sitesUses musl libc; some PHP extensions need extra work
distroless/static~2 MB compressedCompiled Go or Rust binariesNo shell, no package manager; harder to debug
gcr.io/distroless/phpVariesPHP apps needing minimal attack surfaceLimited extension support; see our distroless images guide
php:8.3-fpm (Debian)~450 MBQuick prototypes, extension-heavy appsConvenient but heavy for production
php:8.3-fpm-alpine~110 MBProduction PHP/Laravel workloadsInstall 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.

  1. Copy lock files only
  2. Install dependencies
  3. Copy source code
  4. Run build commands
  5. 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.

Layer Bloat vs OptimizedBloated ImageOptimized Imageubuntu base 78 MBapt cache 120 MBnode_modules 340 MBgit + vim 45 MBapp code 80 MBalpine php 110 MBvendor prod 35 MBcompiled assets 8 MBruntime libs 12 MBTotal: ~663 MBTotal: ~165 MB
Dockerfile optimizations remove cached package indexes, dev dependencies, and debug tools from final image layers.

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.

CI Size Gate PipelineGit PushTrigger CIBuildMulti-stageMeasuredocker inspectGateSize checkPushRegistryFail: Image exceeds 200 MB budgetBlock push, notify team, inspect docker historyPass: Scan with Trivy, push to registryDeploy via GitLab CI or Deployer workflow
Enforce Reduce Docker Image Size best practices in CI with byte-limit gates before registry push.

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-alpine instead of full Debian images unless glibc compatibility forces otherwise.
  • Maintain a strict .dockerignore and combine RUN steps 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 history and 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

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.

Every megabyte must be pulled across the network on CI runners and every production node, so oversized images slow deploys and burn pipeline time. On a three-node cluster, a 1.2 GB image costs three times the bandwidth of a 400 MB one. Smaller images also shrink the attack surface because fewer installed packages mean fewer CVEs for scanners like Trivy to flag. On budget-sensitive VPS plans—around Rs 3,000/month (~USD 22)—a bloated image can push disk usage into pressure territory, leaving little room for logs, database dumps, and backups.

Multi-stage builds are the single highest-impact technique. You compile, test, and bundle in builder stages, then copy only runtime artifacts into a clean final stage. On a Laravel 13 app with PHP 8.3, a typical pattern uses node:26-alpine for Vite assets, composer:2.10 for vendor output, and php:8.3-fpm-alpine as runtime. Node.js, npm, Composer, Git, dev dependencies, and your full source tree never reach the production image. Name stages explicitly—AS frontend, AS vendor, AS runtime—and copy compiled assets and autoload output, not node_modules or test suites.

Your base image sets the floor. alpine:3.20 compresses to roughly 3 MB and works well for PHP-FPM and static sites. php:8.3-fpm-alpine sits around 110 MB and hits the sweet spot for most Laravel 12/13 workloads. The full php:8.3-fpm Debian image is convenient but weighs roughly 450 MB—too heavy for production unless you need many extensions quickly. Distroless images strip shells and package managers for maximum minimalism. ubuntu:24.04 compresses to about 78 MB when you need apt packages and glibc compatibility without going full Debian PHP images.

No. Alpine is smaller but uses musl libc instead of glibc. Some proprietary PHP extensions, Oracle clients, and legacy binaries expect glibc and break on Alpine. Use Alpine when your stack supports it; switch to slim Debian variants like php:8.3-fpm-bookworm when compatibility issues appear.

Not directly for HTTP request handling. Smaller images speed registry pulls, reduce disk usage, and shrink CVE exposure. Faster deploys mean you recover from incidents sooner—that is the practical performance win.

Exclude anything the runtime never needs: .git, node_modules, vendor, tests, storage/logs, storage/framework/cache, storage/framework/sessions, storage/framework/views, .env files, docker-compose files, README and markdown files, and PHPUnit cache. Every file in the build context can end up in a layer if COPY grabs it. On a client project, a proper .dockerignore dropped context upload from 480 MB to 12 MB, saving minutes on every CI build. Pair this with copying lock files before application code so dependency installs cache across builds.

Combine RUN instructions and clean up in the same layer—use apk add --no-cache on Alpine and chain apt-get clean with rm -rf /var/lib/apt/lists/* on Debian in the same step. Order instructions from least to most frequently changed: copy composer.lock and package-lock.json first, install dependencies, then copy source and run builds. Do not install vim, curl, git, or netcat in production images. Use BuildKit cache mounts for package managers so caches live on the builder host and never get committed to image layers. Enable BuildKit with DOCKER_BUILDKIT=1 after installing Docker on Ubuntu.

Build locally, then inspect with docker image ls, docker history myapp:local --human --no-trunc, and docker inspect myapp:local --format='{{.Size}}'. docker history shows which COPY or RUN layers consume space. For a deeper interactive breakdown, use dive. Look for unexpectedly large layers before pushing to your registry. When size jumps between tags, investigate before it reaches production, and remember multi-architecture Buildx builds may differ slightly between amd64 and arm64 variants.

Treat image size like any other build metric because optimizations drift when teams add utilities over time. In GitLab CI, build the image, read size with docker inspect --format='{{.Size}}', compare against a byte budget, and fail the job if it exceeds the limit before push. A Laravel API might allow 250 MB; a static Nginx site should stay under 50 MB. Document budgets in your pipeline standards. Run Trivy or similar scanners on every build since size and security overlap, and track size over time in your registry UI or monitoring stack.

Run composer install --no-dev --optimize-autoloader in a builder stage using composer:2.10, never copy tests/, phpunit.xml, or IDE config into runtime, and use php:8.3-fpm-alpine or php:8.5-fpm-alpine as the final base. Run php artisan config:cache and route:cache at deploy time rather than bake time unless your pipeline expects warmed images. Build separate slim images for queue workers without Nginx or frontend assets—targets under 200 MB for web and under 100 MB for workers. On booking platforms, splitting web, queue, and scheduler kept each container under 180 MB while scaling workers independently.

Put Node 26 LTS in the builder stage only, not the PHP runtime image. Use npm ci instead of npm install for reproducible lean installs. Set NODE_ENV=production before npm run build with Vite 8.x. Only copy compiled output—dist/ or public/build/—into the runtime stage. The runtime image should never contain node_modules, the npm binary, or your full resources tree. This pairs with multi-stage builds where the frontend stage is discarded after assets are copied to php:8.3-fpm-alpine.

WordPress 7.1 and WooCommerce 11.1 images grow fast because of plugins and media. Use multi-stage builds to copy a curated plugin set rather than an entire development tree. Mount wp-content/uploads as a persistent volume instead of baking media into the image. Exclude development themes and unused language packs from the final layer. Audit what actually needs to ship inside the container versus what belongs on persistent storage—uploads, logs, and user-generated content should almost always live outside the image.

No. Do not bundle MySQL 9.7 or PostgreSQL 18 inside your application image. Run databases as separate containers or managed services. Coupling a database to your app Dockerfile bloats the image, complicates scaling, and mixes concerns that belong in dedicated database setups. For local development, use a separate PostgreSQL in Docker configuration rather than embedding database binaries alongside PHP-FPM or Nginx in the same Dockerfile.

Compose itself does not shrink images. It helps indirectly through profiles and overrides that build different targets for development and production. Use a dev profile that includes debug tools and a production profile that builds only the slim runtime stage with docker build --target runtime. That keeps convenience during local work without shipping vim, curl, or git into registry images tagged for production deploys.

Share this article

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.

Quick Contact Options
Choose how you want to connect me: