# 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](https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/qlwxev82vqgjkklvatsx.jpg)

## Why Bloated Single-Stage Images Hurt Both Autoscaling and Security

When our engineering team at [Loop Origin](https://looporigin.com/services/custom-web-application-development?utm_source=hashnode&utm_medium=article&utm_campaign=multi-stage-dockerfiles-php-laravel-nodejs-smaller-images) 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](https://dev.to/muhammadalimughal/running-laravel-queues-in-production-redis-supervisor-retries-and-failed-jobs-278m)—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`:

```dockerfile
# ==========================================
# 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"]
```

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](https://dev.to/muhammadalimughal/composite-indexes-in-postgresql-mysql-column-order-guide-22p2).

## 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:

```dockerfile
# ==========================================
# 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"]
```

## 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:

```gitignore
.git
.github
.env
.env.*
node_modules
vendor
storage/logs/*
storage/framework/cache/*
tests
docker-compose*.yml
*.md
```

## 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](https://looporigin.com/?utm_source=hashnode&utm_medium=article&utm_campaign=multi-stage-dockerfiles-php-laravel-nodejs-smaller-images).

## 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%.

