TechEarl

How to Dockerize a PHP / Laravel App

A Dockerfile for PHP-FPM + Nginx, the Composer build step, OPcache config, and the Laravel storage/cache permission flip that catches every PHP developer once.

Ishan KarunaratneIshan Karunaratne⏱️ 6 min readUpdated
Share thisCopied

The standard production-shape Docker for a PHP app is php-fpm in one container and Nginx in another, talking via a Docker network. The Dockerfile is straightforward; the bits that catch people are the Composer build step, the OPcache config that doesn't ship enabled, and the Laravel-specific permissions on storage/ and bootstrap/cache/.

A working Dockerfile (Laravel-shaped)

dockerfile
# Composer install (build stage)
FROM composer:2 AS vendor
WORKDIR /app
COPY composer.json composer.lock ./
RUN composer install --no-dev --no-scripts --no-autoloader --prefer-dist
COPY . .
RUN composer dump-autoload --optimize --no-dev

# Runtime
FROM php:8.4-fpm-alpine
RUN docker-php-ext-install pdo_mysql opcache
RUN docker-php-ext-enable opcache
COPY ./docker/opcache.ini /usr/local/etc/php/conf.d/opcache.ini
WORKDIR /var/www/html
COPY --from=vendor --chown=www-data:www-data /app /var/www/html
RUN chown -R www-data:www-data storage bootstrap/cache && \
    chmod -R 775 storage bootstrap/cache
USER www-data
EXPOSE 9000
CMD ["php-fpm"]

With a matching nginx.conf (next section) and Compose to wire them together.

Pick the right PHP image

  • php:8.4-fpm-alpine — PHP-FPM on Alpine. Smallest (~80 MB). The default I reach for.
  • php:8.4-fpm — PHP-FPM on Debian-slim. Bigger but glibc and more extensions install cleanly.
  • php:8.4-apache — PHP + Apache + mod_php in one container. Easier (one container, no Nginx-side config), but less flexible. Use for small apps where the simpler shape is worth more than the configurability.
  • php:8.4-cli — CLI only, for one-off commands and queue workers. See Run a Specific PHP Version in Docker.

Pin to a minor version (8.4); avoid latest.

Install PHP extensions

The base image doesn't ship most extensions. Install with docker-php-ext-install:

dockerfile
RUN docker-php-ext-install pdo_mysql opcache bcmath

Extensions that need libraries: install the dev package, install the extension, then remove the dev package:

dockerfile
RUN apk add --no-cache --virtual .build-deps libpng-dev jpeg-dev freetype-dev && \
    docker-php-ext-configure gd --with-freetype --with-jpeg && \
    docker-php-ext-install gd && \
    apk del .build-deps && \
    apk add --no-cache libpng jpeg freetype

The --virtual group lets you remove the build-time packages in one shot while keeping the runtime libraries.

OPcache config

OPcache is bundled with PHP since 5.5 but off by default. Without it, Laravel/Symfony performance is terrible — PHP parses every file on every request. Enable and tune:

docker/opcache.ini:

ini
opcache.enable=1
opcache.enable_cli=0
opcache.memory_consumption=128
opcache.interned_strings_buffer=16
opcache.max_accelerated_files=10000
opcache.validate_timestamps=0
opcache.revalidate_freq=0

validate_timestamps=0 means OPcache trusts its cache and never re-stats files. Critical for production — re-stating thousands of files per request is what kills perf. Do not use this in dev because then file edits don't take effect without a container restart.

Nginx config

Nginx in its own container, talking to PHP-FPM via the Compose network:

docker/nginx.conf:

nginx
server {
    listen 80;
    server_name _;
    root /var/www/html/public;
    index index.php index.html;

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

    location ~ \.php$ {
        fastcgi_pass app:9000;
        fastcgi_index index.php;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
        include fastcgi_params;
    }

    location ~ /\. {
        deny all;
    }
}

fastcgi_pass app:9000app is the PHP-FPM service name in the Compose file. Both containers need the same /var/www/html/public mounted (Nginx serves the static files; PHP-FPM runs the PHP).

Compose

yaml
services:
  app:
    build: .
    volumes:
      - app-storage:/var/www/html/storage
    environment:
      APP_ENV: production
      APP_KEY: ${APP_KEY}
      DB_HOST: db
      DB_DATABASE: laravel
      DB_USERNAME: laravel
      DB_PASSWORD: ${DB_PASSWORD}
    depends_on:
      db:
        condition: service_healthy

  web:
    image: nginx:alpine
    ports:
      - "8080:80"
    volumes:
      - ./docker/nginx.conf:/etc/nginx/conf.d/default.conf:ro
    depends_on:
      - app

  db:
    image: mysql:8.4
    environment:
      MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
      MYSQL_DATABASE: laravel
      MYSQL_USER: laravel
      MYSQL_PASSWORD: ${DB_PASSWORD}
    volumes:
      - db-data:/var/lib/mysql
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-p${DB_ROOT_PASSWORD}"]
      interval: 5s
      timeout: 3s
      retries: 5

volumes:
  app-storage:
  db-data:

Critical detail: Nginx and PHP-FPM both need access to the application files. In the Dockerfile, the app image includes the full Laravel codebase; Nginx serves it from /var/www/html/public. For that to work, either:

  • Mount the source code into both containers (more common in dev).
  • Build the source into the app image AND mount that same volume into Nginx.

For the production shape above, the simplest setup is to bake the source into the app image and use a shared volume to expose it to Nginx. The exact mechanics vary by setup; the canonical Laravel-in-Docker community pattern is the laravel-docker template.

Laravel storage and bootstrap/cache

dockerfile
RUN chown -R www-data:www-data storage bootstrap/cache && \
    chmod -R 775 storage bootstrap/cache

Laravel writes to storage/ (logs, sessions, uploads, cache files) and bootstrap/cache/ (compiled config, route cache). Without write access for the www-data user, your app boots once and then crashes with permission errors. Set ownership in the Dockerfile.

In Compose, the storage volume keeps user uploads and logs across container restarts:

yaml
services:
  app:
    volumes:
      - app-storage:/var/www/html/storage

Queue workers and scheduler

PHP-FPM handles web requests. Queue workers and the scheduler run in separate containers using the same image but different commands:

yaml
services:
  app:
    # web (php-fpm) — as before

  queue:
    build: .
    command: php artisan queue:work --tries=3 --timeout=90
    environment:
      APP_ENV: production
      # same as web
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_started

  scheduler:
    build: .
    command: sh -c "while true; do php artisan schedule:run --verbose --no-interaction; sleep 60; done"
    environment:
      APP_ENV: production
      # same as web
    depends_on:
      db:
        condition: service_healthy

Each is a sibling container with the same image but a different command: override.

Common pitfalls

  • OPcache off in production. Performance is terrible. Enable it via the opcache.ini recipe above and set validate_timestamps=0.
  • Storage and bootstrap/cache permissions. Set ownership in the Dockerfile so the www-data user can write.
  • PHP-FPM unable to reach the host. Inside the app container, localhost is the container itself. To reach the database container, use the service name (db). To reach the host, use host.docker.internal (Docker Desktop) or set up an explicit host mapping.
  • Forgot to install extensions. Errors like could not find driver mean pdo_mysql (or whichever DB driver) wasn't installed. Add it via docker-php-ext-install.
  • .env baked into the image. Don't COPY .env; pass env vars via Compose or --env-file at runtime.
  • Apache image + complex Nginx config attempted. The php:apache and php:fpm images serve different setups. If you want fine-grained Nginx control, start from php:fpm-alpine and a separate Nginx container.

What to do next

FAQ

Sources

Authoritative references this article was fact-checked against.

TagsDockerPHPLaravelPHP-FPMNginxComposerDevOps

Found this useful? Pass it on.

Copied
Ishan Karunaratne

Ishan Karunaratne

Tech Architect · Software Engineer · AI/DevOps

Tech architect and software engineer with 20+ years building software, Linux systems, and DevOps infrastructure, and lately working AI into the stack. Currently Chief Technology Officer at a healthcare tech startup, which is where most of these field notes come from.

Keep reading

Related posts

H

How to Dockerize a Node.js App

A Dockerfile for a real Node.js app: multi-stage build, npm ci for deterministic dependencies, the node_modules-volume trick that makes bind-mounted source fast on Mac, and the non-root user that most tutorials skip.