· 2 min read · Docker, Laravel, PHP, DevOps

Dockerising a Laravel app with Docker Compose

A practical Docker Compose setup for Laravel: PHP-FPM, Nginx, MySQL and a queue worker in separate containers, with persistent data, environment files and a production checklist.

"It works on my machine" stops being a problem once the machine is part of the project. Docker Compose lets you describe a Laravel app's whole environment, the PHP version, the web server, the database and the background workers, in one file that runs the same on a laptop and on a server.

This is the setup I start from. Each part of the app gets its own container, so each one can be updated, restarted or scaled without touching the others.

The four services

  • app: PHP-FPM running your Laravel code.
  • web: Nginx, serving static files and passing PHP requests to app.
  • db: MySQL, with its data on a named volume so it survives rebuilds.
  • queue: the same image as app, running php artisan queue:work.

The Dockerfile

FROM php:8.3-fpm-alpine

RUN apk add --no-cache icu-dev libzip-dev \
 && docker-php-ext-install pdo_mysql intl zip opcache

COPY --from=composer:2 /usr/bin/composer /usr/bin/composer

WORKDIR /var/www/html
COPY composer.json composer.lock ./
RUN composer install --no-dev --no-scripts --prefer-dist --no-interaction

COPY . .
RUN composer dump-autoload --optimize \
 && chown -R www-data:www-data storage bootstrap/cache

USER www-data

Copying composer.json and composer.lock before the rest of the code means Docker can reuse the dependency layer. Changing a controller doesn't trigger a full composer install, so rebuilds take seconds.

docker-compose.yml

services:
  app:
    build: .
    env_file: .env
    depends_on:
      db:
        condition: service_healthy
    volumes:
      - storage:/var/www/html/storage

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

  queue:
    build: .
    env_file: .env
    command: php artisan queue:work --tries=3 --max-time=3600
    restart: unless-stopped
    depends_on:
      - db

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

volumes:
  mysql:
  storage:

Inside the Compose network, containers reach each other by service name. In Laravel's .env, DB_HOST is simply db, not 127.0.0.1.

The Nginx config

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$fastcgi_script_name;
        include fastcgi_params;
    }
}

Everyday commands

docker compose up -d --build        # start everything
docker compose exec app php artisan migrate
docker compose logs -f queue        # watch the worker
docker compose restart queue        # pick up new code in the worker

Queue workers keep the app in memory, so they don't see new code until they restart. Add docker compose restart queue (or php artisan queue:restart) to every deploy.

Before it goes to production

  • Set APP_ENV=production and APP_DEBUG=false, and run php artisan config:cache and route:cache on start-up.
  • Don't publish the MySQL port. Only Nginx needs to be reachable from outside.
  • Put a reverse proxy with HTTPS in front, such as Caddy or Traefik, or terminate TLS in Nginx.
  • Back up the mysql volume on a schedule with mysqldump, and test a restore.
  • Add a scheduler service running php artisan schedule:work if the app has scheduled tasks.

For moving existing sites onto a server like this, I've written up the full process in moving from shared hosting to a Dockerised VPS.

Checklist

  • One container per job: PHP-FPM, Nginx, MySQL, queue worker
  • Copy Composer files first so dependency layers are cached
  • Named volumes for the database and storage
  • A health check so the app waits for MySQL
  • Restart queue workers on every deploy
  • Keep the database port private and back it up

Want a Laravel app containerised or moved to a VPS? Tell me about it.

Keep reading