---
title: 'Dockerfile for PHP and Laravel: Best Practices | DevSense'
description: 'How to build a production Laravel image: a multi-stage Dockerfile for PHP-FPM and Vite assets, Composer and npm layer caching, OPcache, non-root, secrets, healthchecks and one image for web, workers and the scheduler.'
faq:
    - { question: 'Can you use the Laravel Sail image in production?', answer: 'No. The Sail image is built for development: it ships with Xdebug, Node.js, database clients and other tools, and the application runs via php artisan serve — a single-threaded development server. For production you build a separate minimal image on PHP-FPM (or FrankenPHP) without dev dependencies.' }
    - { question: 'Why do you need a multi-stage Dockerfile for PHP?', answer: 'Composer, Node.js, npm packages and compilers are only needed at build time. In a multi-stage build, dependencies and assets are built in separate stages, and only vendor/ and public/build are copied into the final image. The image ends up smaller, contains less vulnerable software, and its layers cache better.' }
    - { question: 'When should php artisan config:cache run — at image build time or at container start?', answer: "At container start. config:cache bakes environment variable values into a file. If you do it at build time, the image gets the build environment's variables, and the production settings passed to the container are ignored. The same goes for route:cache if your routes depend on configuration." }
    - { question: 'Which should you choose for a PHP image: Alpine or Debian?', answer: "Alpine is smaller, but it uses musl instead of glibc: some extensions take longer to build, and the behaviour and performance of certain libraries may differ. A Debian image (the default for the official php image) is larger but more predictable. For most Laravel projects Debian is the sensible choice, and Alpine is for when size is critical and you've tested your application on musl." }
published: '2026-10-03'
---
# Dockerfile for PHP and Laravel: A Production Image Without the Bloat

Locally you have Sail: `sail up` and everything works. Then production time comes, and the fastest path is to take the same image, add `COPY . .` and run it. A month later it turns out the image weighs over a gigabyte, has Xdebug and Node.js inside, the application is served by single-threaded `php artisan serve`, every build re-downloads all the Composer packages, and the `.env` with the database credentials sits right in an image layer.

A good production Laravel image is built differently: dependencies and assets are built in separate stages, the final image contains only what's needed to run, the process doesn't run as root, and configuration comes from the environment at start-up. This article walks through a complete Dockerfile with the reasoning behind every decision, plus a setup for running web, workers and the scheduler from a single image.

**Navigation:** [All tools](../) · [Sail: overview](sail) · [Sail: environment and deployment](sail-env-deploy) · [Sail: queues](sail-queues) · [Zero-downtime deployment](../architecture/zero-downtime-deployment-laravel)

## Contents

* [Why the Sail image isn't fit for production](#sail-vs-prod)
* [Architecture: one image, several processes](#image-layout)
* [.dockerignore: what must stay out of the build context](#dockerignore)
* [The full multi-stage Dockerfile](#multistage)
* [Layer order and the build cache](#layer-cache)
* [PHP-FPM and OPcache for production](#php-config)
* [Configuration and secrets](#config)
* [Web, workers and the scheduler from one image](#processes)
* [Healthchecks and graceful shutdown](#healthchecks)
* [Image security](#security)
* [Alpine, Debian or FrankenPHP](#base-image)
* [Common Mistakes](#common-mistakes)
* [Checklist](#checklist)
* [Self-Test Quiz](#self-test-quiz)

---

<a id="sail-vs-prod"></a>
## Why the Sail image isn't fit for production

Sail is an excellent development tool, and that's exactly why it's not suitable for a production environment:

* the application runs via `php artisan serve` under Supervisor — that's PHP's built-in development server, not PHP-FPM;
* it ships with Xdebug, Node.js, npm, MySQL/PostgreSQL clients and other utilities — a bigger image and a bigger attack surface;
* the code is mounted from the host as a volume rather than copied into the image — the image on its own doesn't contain the application;
* permissions and UID are adjusted to match the host user when the container starts.

A production image is a separate artifact with its own Dockerfile. Sail stays where it belongs — local development.

---

<a id="image-layout"></a>
## Architecture: one image, several processes

For Laravel, the "one process per container" principle means: **one application image** from which different processes are started.

```
                 ┌──────────────────────┐
  HTTP ────────► │ web (nginx)          │ static files from public/
                 └──────────┬───────────┘
                            │ FastCGI :9000
                 ┌──────────▼───────────┐
                 │ app (php-fpm)        │ ┐
                 └──────────────────────┘ │
                 ┌──────────────────────┐ │  same image,
                 │ queue (queue:work)   │ ├─ different command
                 └──────────────────────┘ │
                 ┌──────────────────────┐ │
                 │ scheduler            │ ┘
                 │ (schedule:work)      │
                 └──────────────────────┘
```

This guarantees that every process has the same code and the same extensions, while they scale independently: more workers during an import, more PHP-FPM at peak hours. nginx is built as a separate small stage into which only `public/` is copied.

---

<a id="dockerignore"></a>
## .dockerignore: what must stay out of the build context

Everything in the build directory is sent to the Docker daemon as the build context. Without a `.dockerignore`, `COPY . .` will put `.git`, your local `vendor/`, `node_modules/` and — most dangerous of all — `.env` into the image.

```gitignore
# .dockerignore
.git
.env
.env.*
!.env.example
node_modules
vendor
public/build
public/hot
public/storage
storage/logs/*
storage/framework/cache/*
storage/framework/sessions/*
storage/framework/views/*
bootstrap/cache/*.php
tests
docker-compose*.yml
*.log
```

`vendor/` and `public/build/` are excluded because they're built inside Docker — with the right PHP version, without dev packages, and reproducibly.

---

<a id="multistage"></a>
## The full multi-stage Dockerfile

```dockerfile
# syntax=docker/dockerfile:1
ARG PHP_VERSION=8.5

# ---------- base: PHP runtime with the extensions the app needs ----------
FROM php:${PHP_VERSION}-fpm AS base

COPY --from=mlocati/php-extension-installer /usr/bin/install-php-extensions /usr/local/bin/
RUN install-php-extensions pdo_pgsql redis intl zip bcmath pcntl \
 && mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini"

COPY docker/php/conf.d/ $PHP_INI_DIR/conf.d/
COPY docker/php-fpm/zz-app.conf /usr/local/etc/php-fpm.d/zz-app.conf

WORKDIR /var/www/html

# ---------- vendor: production Composer dependencies ----------
FROM base AS vendor

ENV COMPOSER_HOME=/tmp/composer \
    COMPOSER_CACHE_DIR=/tmp/composer/cache
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer

COPY composer.json composer.lock ./
RUN --mount=type=cache,target=/tmp/composer/cache \
    composer install --no-dev --no-scripts --no-autoloader \
        --prefer-dist --no-interaction --no-progress

# ---------- assets: Vite build ----------
FROM node:24-alpine AS assets
WORKDIR /app

COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci --no-audit --no-fund

COPY vite.config.* ./
COPY resources/ ./resources/
RUN npm run build

# ---------- runtime: the image that runs in production ----------
FROM base AS runtime

COPY --from=vendor /var/www/html/vendor ./vendor
COPY . .
COPY --from=assets /app/public/build ./public/build

# Composer is mounted only for this step and never ends up in the image.
RUN --mount=type=bind,from=composer:2,source=/usr/bin/composer,target=/usr/local/bin/composer \
    composer dump-autoload --optimize --no-dev --no-interaction \
 && mkdir -p storage/framework/cache storage/framework/sessions storage/framework/views \
             storage/logs bootstrap/cache \
 && chown -R www-data:www-data storage bootstrap/cache

COPY --chmod=755 docker/entrypoint.sh /usr/local/bin/entrypoint

USER www-data
EXPOSE 9000
ENTRYPOINT ["entrypoint"]
CMD ["php-fpm"]

# ---------- web: nginx with public assets only ----------
FROM nginx:stable-alpine AS web
COPY docker/nginx/default.conf /etc/nginx/conf.d/default.conf
COPY --from=runtime /var/www/html/public /var/www/html/public
```

Building two images from one file:

```bash
docker build --target runtime -t registry.example.com/shop:1.42.0 .
docker build --target web     -t registry.example.com/shop-web:1.42.0 .
```

The stages, one by one:

* **`base`** — PHP with extensions. `install-php-extensions` installs the system libraries itself and removes the compilers once the extension is built. `php.ini-production` enables production settings: errors aren't printed into the response, and `expose_php` is off. In PHP 8.5 OPcache is always part of the core; on PHP 8.4 and below, add `opcache` to the list of extensions.
* **`vendor`** — only `composer.json` and `composer.lock`, no application code. `--no-scripts --no-autoloader` are needed because Laravel's scripts (`package:discover`) require code that doesn't exist yet at this stage. The stage inherits from `base`, so Composer checks the PHP version and extension requirements — `--ignore-platform-reqs` is unnecessary and harmful.
* **`assets`** — Node.js is needed only here. If Tailwind scans Blade templates or pagination classes from `vendor/`, copy those into this stage as well.
* **`runtime`** — the final image: code, `vendor/`, built assets. `composer dump-autoload --optimize` generates the classmap and runs `package:discover`. Composer is attached via `--mount=type=bind` only for the duration of that command.
* **`web`** — nginx plus the contents of `public/`. It contains no PHP code.

nginx configuration for this setup:

```nginx
# docker/nginx/default.conf
server {
    listen 80;
    root /var/www/html/public;
    index index.php;

    location / {
        try_files $uri $uri/ /index.php?$query_string;
    }

    location ~ \.php$ {
        fastcgi_pass app:9000;
        fastcgi_param SCRIPT_FILENAME /var/www/html/public/index.php;
        include fastcgi_params;
    }

    location ~ /\.(?!well-known) {
        deny all;
    }
}
```

---

<a id="layer-cache"></a>
## Layer order and the build cache

Docker rebuilds a layer if its inputs have changed, along with every layer after it. Hence the main rule: **first what changes rarely, then what changes often**.

* `composer.json` and `composer.lock` are copied separately from the code — editing a Blade template doesn't trigger `composer install` again.
* `package.json` and `package-lock.json` — the same for npm.
* `COPY . .` comes at the very end, after all the heavy steps.

The second level is **BuildKit cache mounts**: `RUN --mount=type=cache,target=...`. Even when the dependency layer is rebuilt (you added a package), Composer and npm take already-downloaded archives from the cache rather than the network. The cache doesn't end up in the image.

In CI, the cache is preserved between runs via `docker buildx build --cache-to/--cache-from` (for example, in a registry or the GitHub Actions cache). Without it, every build on a clean runner starts from scratch.

---

<a id="php-config"></a>
## PHP-FPM and OPcache for production

```ini
; docker/php/conf.d/opcache.ini
opcache.enable=1
opcache.memory_consumption=256
opcache.interned_strings_buffer=16
opcache.max_accelerated_files=20000
; Code never changes inside a running container: skip file timestamp checks.
opcache.validate_timestamps=0
```

```ini
; docker/php/conf.d/app.ini
memory_limit=256M
upload_max_filesize=20M
post_max_size=25M
expose_php=Off
```

```ini
; docker/php-fpm/zz-app.conf
[www]
pm = dynamic
pm.max_children = 20
pm.start_servers = 4
pm.min_spare_servers = 2
pm.max_spare_servers = 8
pm.max_requests = 1000
ping.path = /ping
clear_env = no
```

* **`opcache.validate_timestamps=0`** — code inside a container is immutable, so PHP doesn't need to check file modification times on every request. New code only arrives with a new container.
* **`pm.max_children`** is calculated from memory: if the container has 1 GB and a PHP-FPM worker takes around 50 MB, it can't handle more than 15–18 processes. Measure real consumption under load.
* **`pm.max_requests`** restarts an FPM process after N requests — insurance against slow memory leaks.
* **`clear_env = no`** — by default PHP-FPM clears environment variables for its processes. If the configuration isn't cached, the application won't see them.

Application logs in a container should go to stdout/stderr rather than a file inside the container: `LOG_CHANNEL=stderr`. Docker or the orchestrator then collects them, and they aren't lost when the container is recreated.

---

<a id="config"></a>
## Configuration and secrets

**There is no `.env` in the image.** The same image must run on staging and in production — the only difference is the environment variables passed to the container. An image containing a `.env` is tied to one environment and hands out secrets to anyone with access to the registry.

**Laravel caches are built at container start, not at build time.** `config:cache` bakes the current environment variable values into `bootstrap/cache/config.php`. If you run it in the Dockerfile, the image gets the build environment's values. The same goes for routes if they depend on configuration. For example, in DevSense the IndexNow key route is registered only if `config('seo.indexnow_key')` is set — running `route:cache` during the build would freeze a set of routes without it.

```sh
#!/bin/sh
# docker/entrypoint.sh
set -e

# Build config, route, view and event caches from the runtime environment.
if [ "${LARAVEL_OPTIMIZE:-true}" = "true" ]; then
    php artisan optimize
fi

# exec replaces the shell, so PHP becomes PID 1 and receives SIGTERM directly.
exec "$@"
```

**Migrations are a separate release step, not part of the entrypoint.** If `migrate --force` runs on every container start, ten replicas will run migrations simultaneously. Run them once before switching traffic: `docker compose run --rm app php artisan migrate --force` or as a separate job in the orchestrator. How to write migrations that are compatible with the running code is covered in the article on [zero-downtime deployment](../architecture/zero-downtime-deployment-laravel#migrations).

**Build-time secrets go through `--mount=type=secret`.** If `composer install` needs a private repository token, don't pass it via `ARG` or `ENV`: those values are visible in the image history (`docker history`). BuildKit mounts the secret for the duration of a single command only:

```dockerfile
RUN --mount=type=cache,target=/tmp/composer/cache \
    --mount=type=secret,id=composer_auth,target=/tmp/composer/auth.json \
    composer install --no-dev --no-scripts --no-autoloader --prefer-dist --no-interaction
```

```bash
docker build --secret id=composer_auth,src=$HOME/.config/composer/auth.json --target runtime .
```

---

<a id="processes"></a>
## Web, workers and the scheduler from one image

```yaml
# docker-compose.prod.yml
services:
  app:
    image: registry.example.com/shop:${RELEASE}
    env_file: .env.production
    init: true
    restart: unless-stopped

  web:
    image: registry.example.com/shop-web:${RELEASE}
    ports: ["80:80"]
    depends_on: [app]
    restart: unless-stopped
    healthcheck:
      test: ["CMD", "wget", "-qO-", "http://127.0.0.1/up"]
      interval: 10s
      timeout: 3s
      retries: 3

  queue:
    image: registry.example.com/shop:${RELEASE}
    env_file: .env.production
    command: ["php", "artisan", "queue:work", "--tries=3", "--timeout=120", "--max-time=3600", "--memory=256"]
    init: true
    stop_grace_period: 180s
    restart: unless-stopped

  scheduler:
    image: registry.example.com/shop:${RELEASE}
    env_file: .env.production
    command: ["php", "artisan", "schedule:work"]
    init: true
    restart: unless-stopped
```

* `command` overrides `CMD`, while the `ENTRYPOINT` with `optimize` runs for every process.
* `restart: unless-stopped` replaces Supervisor: a `queue:work` that exited because of `--max-time` or after `queue:restart` will be started again.
* `init: true` runs a tiny init process that forwards signals and reaps "zombie" processes.
* `schedule:work` is a scheduler without cron: the command runs `schedule:run` every minute. In Kubernetes, a CronJob with `schedule:run` is often used instead.

Worker settings — timeouts, retries, memory — are covered in detail in the article on [queues in production](../architecture/laravel-queues-production).

---

<a id="healthchecks"></a>
## Healthchecks and graceful shutdown

**Health checks.** Laravel 11+ has a built-in `/up` route (the `health` parameter in `bootstrap/app.php`): it responds with `200` if the application booted and `500` if it didn't. A check through nginx (`wget` is available in the Alpine-based image) verifies the whole chain nginx → PHP-FPM → Laravel. To check PHP-FPM on its own there's `ping.path = /ping` from the pool configuration; you can query it with the `cgi-fcgi` utility from the fcgi package. Kubernetes ignores the Dockerfile `HEALTHCHECK` instruction and uses its own readiness and liveness probes — the same URLs work for them too.

**Graceful shutdown.** `docker stop` sends `SIGTERM` to the main process and then, after `stop_grace_period` (10 seconds by default), `SIGKILL`. What it takes for the shutdown to be graceful:

* the main process is PHP itself, not a shell: the entrypoint uses `exec "$@"`, and `CMD` is written in exec form (`["php-fpm"]`, not `php-fpm` as a string);
* the queue worker has the **pcntl** extension installed — `queue:work` receives `SIGTERM`, finishes its current job and exits;
* `stop_grace_period` is longer than the longest job — otherwise Docker kills the worker mid-job. It's the same principle as `stopwaitsecs` in Supervisor.

---

<a id="security"></a>
## Image security

* **Not root.** `USER www-data` at the end of the Dockerfile. If someone finds an RCE in the application, the attacker gets `www-data` privileges inside the container, not root. PHP-FPM listens on port 9000, which doesn't require privileges. FPM's warning about the `user` directive being ignored when not running as root is harmless.
* **Minimal software.** No Xdebug, Node.js, Composer, git or compilers in the final image. What isn't there can't be exploited.
* **Pinned versions.** `php:8.5-fpm` changes with every patch release. For reproducibility, pin the digest: `FROM php:8.5-fpm@sha256:...`, and automate base image updates with Dependabot or Renovate.
* **Regular rebuilds.** Vulnerabilities in OpenSSL or glibc are fixed by a new base image — even if your code hasn't changed, rebuild the image at least every week or two.
* **Scanning.** `docker scout cves` or `trivy image` in CI find known vulnerabilities in system packages and dependencies. For PHP dependencies there are still `composer audit` and `npm audit`.
* **Read-only.** At the next level, the container's root filesystem is mounted with `read_only: true`, leaving only `storage/`, `bootstrap/cache` and `/tmp` writable.

Server settings around the containers — security headers, TLS, limits — are described in the article on [server and infrastructure hardening](../security/server-and-infrastructure-hardening).

---

<a id="base-image"></a>
## Alpine, Debian or FrankenPHP

| Option | Pros | Cons |
|---------|-------|--------|
| `php:8.x-fpm` (Debian) | Predictable glibc, fast prebuilt packages, fewer surprises | Larger image |
| `php:8.x-fpm-alpine` | Small size | musl instead of glibc: slower extension builds, differences in the behaviour and performance of certain libraries |
| FrankenPHP | A single process: the Caddy web server and PHP together, HTTPS, worker mode for Laravel Octane | A different execution model: in worker mode state lives between requests, so leaks and "dirty" singletons become your problem |

For a typical Laravel project, a sensible starting point is a Debian image with PHP-FPM and nginx, as in this article. Alpine makes sense when size really matters and you've tested the application on musl. PHP execution models — FPM, workers, event loop — are compared in the article [PHP on the server](../php/runtimes).

---

<a id="common-mistakes"></a>
## Common Mistakes

**1. The Sail image or a dev image in production.**
`artisan serve`, Xdebug and Node.js in a production environment: slow and insecure.

**2. `.env` inside the image.**
Secrets in every layer and available to anyone who can pull the image. Configuration comes only through the environment.

**3. `config:cache` in the Dockerfile.**
The build environment's variables are baked into the image, and production settings are ignored.

**4. `COPY . .` before `composer install`.**
Any code change invalidates the dependency cache, and every build re-downloads all packages.

**5. `--ignore-platform-reqs` in Composer.**
The build passes, but a PHP extension is missing at runtime. Install dependencies on the same PHP as in production.

**6. Secrets via `ARG` or `ENV`.**
The values are visible in `docker history`. Use `--mount=type=secret`.

**7. Shell-form `CMD` and an entrypoint without `exec`.**
`SIGTERM` goes to the shell, not to PHP. The worker doesn't stop gracefully and gets killed after 10 seconds mid-job.

**8. `migrate --force` in every replica's entrypoint.**
Several containers run migrations at the same time. Migrations are a separate release step.

**9. Running the process as root.**
Any vulnerability in the application immediately grants root inside the container.

---

<a id="checklist"></a>
## Checklist

1. There's a `.dockerignore`: `.env`, `.git`, `vendor/`, `node_modules/` stay out of the build context.
2. Multi-stage build: Composer and Node.js only in the build stages.
3. `composer.json`/`composer.lock` and `package*.json` are copied before the code; BuildKit cache mounts are used.
4. Dependencies are installed on the same PHP with the same extensions as at runtime, without `--ignore-platform-reqs`.
5. `php.ini-production`, OPcache with `validate_timestamps=0`, `pm.max_children` calculated from memory.
6. No `.env` or secrets in the image; private tokens go through `--mount=type=secret`.
7. `php artisan optimize` runs at container start; migrations are a separate release step.
8. The process runs as `www-data`; the base image is pinned by digest and rebuilt regularly.
9. web, queue and scheduler run from one image with different commands.
10. There's a healthcheck on `/up`, `exec` in the entrypoint, pcntl, and `stop_grace_period` longer than the longest job.
11. The image is scanned for vulnerabilities in CI.

---

## Summary

A production Laravel image isn't "Sail plus COPY" — it's a separate artifact: built in several stages, containing only what it needs to run, knowing nothing about any particular environment, and running without root. Layer ordering and the BuildKit cache make builds fast, `optimize` at start-up picks up the real configuration, and a single image for web, workers and the scheduler guarantees that every process runs the same code.

---

<a id="self-test-quiz"></a>
## Self-Test Quiz

### Question 1: Why are `composer.json` and `composer.lock` copied into the image separately and before the rest of the code?
- A) Composer can't read files copied with `COPY . .`.
- B) So that the layer with installed dependencies is reused from the cache until the manifests themselves change, and code edits don't trigger `composer install` again.
- C) The multi-stage build format requires it.

<details>
<summary>Click to view the answer</summary>

**Answer: B**
Docker rebuilds a layer when its inputs change. If dependencies are installed after `COPY . .`, any template edit invalidates the `vendor/` layer. Copying the manifests separately decouples dependency installation from code changes.
</details>

### Question 2: `php artisan config:cache` runs in the Dockerfile. What happens in production?
- A) Nothing special: Laravel re-reads the environment variables at start-up.
- B) The application uses the values that were in the environment at build time and ignores the variables passed to the container.
- C) The build fails, because `config:cache` can't run without a database.

<details>
<summary>Click to view the answer</summary>

**Answer: B**
A cached configuration doesn't read `env()` again. The cache must be built at container start, once the environment variables have been passed in — for example, with `php artisan optimize` in the entrypoint.
</details>

### Question 3: On `docker stop`, a queue worker is cut off mid-job. Which of the following will NOT help?
- A) `exec "$@"` in the entrypoint and exec-form `CMD`, so that PHP receives `SIGTERM`.
- B) An installed pcntl extension and a `stop_grace_period` longer than the longest job.
- C) Increasing `memory_limit` in `php.ini`.

<details>
<summary>Click to view the answer</summary>

**Answer: C**
Graceful shutdown depends on the signal reaching the PHP process, on `SIGTERM` being handled (pcntl), and on how much time Docker gives before `SIGKILL`. The memory limit has nothing to do with it.
</details>