DEV Community

Cover image for Multi-Stage Dockerfiles for PHP & Node: 70% Smaller Images
Muhammad Ali Mughal
Muhammad Ali Mughal

Posted on

Multi-Stage Dockerfiles for PHP & Node: 70% Smaller Images

A typical single-stage Dockerfile for a Laravel or Node.js application starts from a full Debian base image, installs gcc, g++, make, python3, git, composer, and npm, compiles native extensions and Vite/Next.js frontend assets, and then ships that entire build kitchen straight to production. The result is a 950MB to 1.4GB container image that takes minutes to pull during Kubernetes autoscaling, wastes registry storage, and hands attackers a full C compiler and package manager inside your running container. By splitting your build into multi-stage Dockerfiles, you can drop production image sizes by 70% to 85% while speeding up CI builds. Below are the exact production patterns we use for PHP/Laravel and Node.js workloads.

Multi-Stage Dockerfiles for PHP & Node: 70% Smaller Images

Why Bloated Single-Stage Images Hurt Both Autoscaling and Security

When our engineering team at Loop Origin audits containerized SaaS platforms, single-stage Dockerfiles are almost always the #1 cause of slow deployments and sluggish horizontal pod autoscaling. Whenever a traffic spike hits your API or your background worker pool—such as the Redis and Supervisor workers covered in my guide on running Laravel queues in production—fresh cluster nodes have to pull and extract your container image over the network before a single new worker can start processing jobs.

Shipping a 1.1GB image instead of a 185MB image creates three compounding problems:

  1. Slow Cold Starts on New Nodes: Pulling and unpacking 1.1GB of uncompressed layers across multiple nodes adds 45 to 90 seconds to every deployment or autoscaling event.
  2. Broken Layer Caching in CI/CD: If you COPY . . before running composer install or npm ci, changing a single line in a controller invalidates the dependency installation layer and forces a full re-download on every pull request.
  3. Expanded Runtime Attack Surface: Leaving composer, npm, git, curl, and .env.example or test fixtures inside the final image makes post-exploitation privilege escalation trivial if an attacker ever finds an RCE vulnerability.

Comparison Table: Single-Stage vs. Multi-Stage Docker Builds (PHP & Node.js)

Here are real image sizes and cold-pull benchmarks from a production Laravel + Vite application and a Node.js TypeScript API:

Stack & Build Strategy Base Image Final Image Size Included in Final Runtime Image Average Node Cold Pull Time
Laravel Single-Stage (Naive) php:8.3-fpm (Debian Bookworm) 1,140 MB PHP + Node.js + npm + Composer + node_modules + C headers (libpng-dev, gcc) + .git ~58s
Laravel 3-Stage Build (Optimized) php:8.3-fpm-alpine3.20 182 MB (84% smaller) Only compiled PHP extensions (opcache, pdo_pgsql, redis), production vendor/, and compiled public/build assets ~9s
Node.js Single-Stage (Naive) node:22 (Debian Bookworm) 1,090 MB Full OS toolchain + TypeScript source + devDependencies (jest, eslint, typescript) ~52s
Node.js Multi-Stage Build (Optimized) node:22-alpine3.20 164 MB (85% smaller) Only node binary, pruned production node_modules, and compiled dist/ JavaScript ~7s

Pattern 1: Production 3-Stage Dockerfile for PHP 8.3 & Laravel

A modern Laravel application actually has two separate build pipelines: Composer for PHP dependencies and Node/Vite for frontend assets (public/build). Neither Composer nor Node.js belongs in your final PHP-FPM runtime container.

Here is a complete 3-stage Dockerfile that compiles assets in isolated stages and copies only the immutable artifacts into a slim Alpine runtime running as non-root www-data:

# ==========================================
# Stage 1: Composer Vendor Dependencies
# ==========================================
FROM composer:2.8 AS vendor
WORKDIR /app
COPY composer.json composer.lock ./
RUN composer install     --no-dev     --no-interaction     --no-Plugins     --no-scripts     --prefer-dist     --optimize-autoloader

# ==========================================
# Stage 2: Frontend Asset Build (Vite / Tailwind)
# ==========================================
FROM node:22-alpine3.20 AS frontend
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY resources/ ./resources/
COPY public/ ./public/
COPY vite.config.js postcss.config.js tailwind.config.js ./
RUN npm run build

# ==========================================
# Stage 3: Lean Production PHP-FPM Runtime
# ==========================================
FROM php:8.3-fpm-alpine3.20 AS runtime
WORKDIR /var/www/html

# Install runtime shared libraries ONLY; purge -dev build headers in the same layer
RUN apk add --no-cache         libpq         libzip         icu-libs         fcgi     && apk add --no-cache --virtual .build-deps         $PHPIZE_DEPS         postgresql-dev         libzip-dev         icu-dev     && docker-php-ext-install -j$(nproc)         pdo_pgsql         intl         zip         opcache         pcntl     && pecl install redis     && docker-php-ext-enable redis     && apk del .build-deps

# Copy production OPcache & PHP-FPM tuning
COPY docker/php/opcache.ini /usr/local/etc/php/conf.d/opcache.ini

# Copy application code & pre-built artifacts from Stages 1 & 2
COPY --chown=www-data:www-data . .
COPY --from=vendor --chown=www-data:www-data /app/vendor ./vendor
COPY --from=frontend --chown=www-data:www-data /app/public/build ./public/build

# Drop root privileges
USER www-data
EXPOSE 9000
CMD ["php-fpm"]
Enter fullscreen mode Exit fullscreen mode

Notice the .build-deps virtual package trick in Stage 3: we install $PHPIZE_DEPS and -dev header packages, compile pdo_pgsql, intl, opcache, and redis, and immediately delete .build-deps within the exact same RUN instruction. That keeps gcc and header files out of the committed image layer while leaving the tiny runtime shared libraries (libpq, icu-libs) intact. And once your database connection is live, make sure your queries take advantage of proper index column order as detailed in my guide on composite indexes in PostgreSQL and MySQL.

Pattern 2: Production Multi-Stage Dockerfile for Node.js & TypeScript

In a TypeScript service (NestJS, Express, or Fastify), devDependencies (typescript, @types/*, ts-node, eslint, prettier, jest) often account for 65%+ of the size of node_modules. Using a 3-stage Dockerfile separates production dependencies from build-time dependencies cleanly:

# ==========================================
# Stage 1: Production Dependencies Only
# ==========================================
FROM node:22-alpine3.20 AS prod-deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev && npm cache clean --force

# ==========================================
# Stage 2: TypeScript Build Stage
# ==========================================
FROM node:22-alpine3.20 AS builder
WORKDIR /app
COPY package.json package-lock.json tsconfig.json ./
RUN npm ci
COPY src/ ./src/
RUN npm run build

# ==========================================
# Stage 3: Minimal Non-Root Production Image
# ==========================================
FROM node:22-alpine3.20 AS runtime
WORKDIR /app
ENV NODE_ENV=production

# Copy only pruned node_modules and compiled JS output
COPY --from=prod-deps --chown=node:node /app/node_modules ./node_modules
COPY --from=builder --chown=node:node /app/dist ./dist
COPY --chown=node:node package.json ./

USER node
EXPOSE 3000
CMD ["node", "dist/main.js"]
Enter fullscreen mode Exit fullscreen mode

Never Skip .dockerignore: The Silent Layer Killer

Even the cleanest multi-stage Dockerfile will leak secrets or bust your build cache if you forget a .dockerignore file in the repository root. Without .dockerignore, every COPY . . instruction transfers your local .git history, local node_modules, .env files, and logs into the Docker build context:

.git
.github
.env
.env.*
node_modules
vendor
storage/logs/*
storage/framework/cache/*
tests
docker-compose*.yml
*.md
Enter fullscreen mode Exit fullscreen mode

6-Step Production Docker Image Audit Checklist

Before pushing your next container image to AWS ECR, GitHub Container Registry, or Docker Hub, verify these six items:

  • [ ] Enforce a Strict .dockerignore File: Confirm .git, .env, local vendor/, node_modules/, and test artifacts are excluded from the build context.
  • [ ] Order COPY Instructions from Least-to-Most Frequently Changed: Always COPY composer.json composer.lock ./ (or package*.json) and install dependencies before copying application source code so layer caching stays warm.
  • [ ] Purge -dev Compiler Packages in the Same RUN Layer: In Alpine (apk del .build-deps) or Debian (apt-get purge -y --auto-remove), remove build compilers in the exact same RUN command that installed them.
  • [ ] Enable PHP OPcache in Production Containers: In immutable containers, set opcache.validate_timestamps=0 in opcache.ini so PHP never wastes disk I/O stat-ing files on every request.
  • [ ] Run as an Unprivileged Non-Root User: Always switch to USER www-data or USER node before CMD so container breakouts do not start with host-mapped uid 0.
  • [ ] Inspect Image Layers with docker history or dive: Verify your final image size is under 220MB and contains zero build tools or secrets. If your team needs help modernizing legacy deployments into fast, zero-downtime container pipelines, explore how we architect cloud-native systems at Loop Origin.

Frequently Asked Questions (FAQ)

Does running apk del in a separate RUN line reduce Docker image size?

No. Every RUN instruction in a Dockerfile creates a new immutable filesystem layer. If you install gcc and postgresql-dev in one RUN instruction and delete them in the next RUN instruction, the files are only marked as hidden in the upper overlay—their bytes remain inside the earlier layer forever. You must install, compile, and delete build dependencies inside a single RUN command chained with &&.

Is Alpine Linux safe for production Node.js and PHP containers?

Yes, php:8.3-fpm-alpine and node:22-alpine are battle-tested in production. The main difference is that Alpine uses musl libc instead of glibc. If your Node.js app relies on prebuilt C++ binaries that only ship glibc wheels (like certain legacy native addons) or if you see DNS resolution quirks in older Kubernetes clusters, node:22-bookworm-slim is a great glibc alternative that still saves ~750MB over the full base image.

Should I bake .env files into my production Docker image?

Never bake .env files or production secrets into a Docker image. Anyone with pull access to your container registry can inspect the image layers and extract the plaintext credentials. Inject environment variables at runtime via Kubernetes Secrets, AWS ECS Task Definitions, or Docker Compose environment mappings.

Can I use the same PHP Docker image for both web requests and Laravel queue workers?

Yes—in fact, running the exact same immutable image artifact for both your web pods (php-fpm) and your queue worker pods (php artisan queue:work) guarantees your workers never run out-of-sync code or missing PHP extensions. Simply override the container command in your Kubernetes Deployment or Compose service definition.

How much faster are CI builds with Docker BuildKit cache mounts?

Adding --mount=type=cache,target=/root/.npm or --mount=type=cache,target=/tmp/composer-cache in BuildKit keeps downloaded package tarballs warm across builds even when package-lock.json or composer.lock changes, typically cutting dependency install steps in CI by 50% to 70%.

Top comments (0)