Back to Blog

Laravel Docker Configuration: Dev & Production

Vignesh Saravanan · Aug 16, 2026

LaravelDockerNginxDevOps

Running Laravel in Docker gives you an identical environment on every machine — no more "it works on my laptop". The tricky part is keeping development fast (hot reload, Xdebug) while shipping a lean, optimized image to production (built assets, no dev deps, queue workers). The answer is a multi-stage Dockerfile plus two compose overrides. Here's the full setup I use.

The Multi-Stage Dockerfile

A single Dockerfile with a base stage that both dev and prod build from. The base installs PHP extensions, Node, and Composer once:

Dockerfile
FROM php:8.3-fpm AS base

WORKDIR /var/www/html/laravel-app

RUN apt-get update && apt-get install -y \
    git curl unzip zip \
    libonig-dev libzip-dev libpng-dev \
    libjpeg62-turbo-dev libfreetype6-dev \
    libicu-dev supervisor \
    && rm -rf /var/lib/apt/lists/*

RUN docker-php-ext-configure gd --with-freetype --with-jpeg \
    && docker-php-ext-install pdo_mysql mbstring bcmath zip gd exif pcntl opcache intl

RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
    && apt-get install -y nodejs \
    && rm -rf /var/lib/apt/lists/*

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

EXPOSE 9000

CMD ["php-fpm"]

Dev Stage: Xdebug Only in Dev

The dev target installs Xdebug but does not enable it globally. It's loaded only via the config mounted by docker-compose.dev.yml — production never mounts that file, so prod runs without any Xdebug overhead.

Dockerfile (dev stage)
FROM base AS dev

# Build Xdebug but DO NOT enable it globally.
RUN pecl install xdebug \
    && rm -rf /tmp/pear

Prod Stage: Lean and Optimized

The prod target copies manifests first so Docker layer caching is preserved, installs production-only dependencies, builds frontend assets, and hands control to Supervisor (PHP-FPM + queue workers) as PID 1.

Dockerfile (prod stage)
FROM base AS prod

COPY composer.json composer.lock package.json package-lock.json .
COPY app/Helpers/ ./app/Helpers/

# --no-scripts: skip artisan package:discover (no .env during build)
RUN composer install --no-dev --optimize-autoloader --no-scripts \
    && npm ci

COPY . .

RUN rm -f bootstrap/cache/packages.php bootstrap/cache/services.php \
    && php artisan package:discover --ansi || rm -f bootstrap/cache/packages.php

RUN npm run build

COPY docker/supervisor/supervisord.conf /etc/supervisor/supervisord.conf

RUN chown -R www-data:www-data /var/www/html/laravel-app

CMD ["supervisord", "-c", "/etc/supervisor/supervisord.conf"]

Base Compose File

The shared docker-compose.yml defines the app, nginx, MySQL (with a healthcheck), Redis, and phpMyAdmin:

docker-compose.yml
services:
  app:
    build:
      context: .
      dockerfile: Dockerfile
      target: dev
    image: laravel-app
    container_name: laravel-app
    restart: unless-stopped
    working_dir: /var/www/html/laravel-app
    networks: [laravel]
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_started

  nginx:
    image: nginx:alpine
    container_name: laravel-nginx
    restart: unless-stopped
    depends_on: [app]
    networks: [laravel]

  db:
    image: mysql:8.0
    container_name: laravel-db
    restart: unless-stopped
    ports:
      - "127.0.0.1:3307:3306"
    environment:
      MYSQL_DATABASE: ${DB_DATABASE}
      MYSQL_ROOT_PASSWORD: ${DB_PASSWORD}
      MYSQL_USER: ${DB_USERNAME}
      MYSQL_PASSWORD: ${DB_PASSWORD}
    volumes:
      - dbdata:/var/lib/mysql
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "--protocol=tcp"]
      start_period: 300s
      interval: 10s
      timeout: 5s
      retries: 10
    networks: [laravel]

  redis:
    image: redis:alpine
    container_name: laravel-redis
    restart: unless-stopped
    networks: [laravel]

  phpmyadmin:
    image: phpmyadmin/phpmyadmin
    container_name: laravel-phpmyadmin
    restart: unless-stopped
    ports:
      - "127.0.0.1:8082:80"
    environment:
      PMA_HOST: db
      PMA_PORT: 3306
      PMA_USER: ${DB_USERNAME}
      PMA_PASSWORD: ${DB_PASSWORD}
    depends_on: [db]
    networks: [laravel]

networks:
  laravel:
    driver: bridge

volumes:
  dbdata:
    name: laravel-app_dbdata
    driver: local

Dev Overrides

docker-compose.dev.yml bind-mounts the source code so changes appear instantly, mounts the Xdebug INI, maps nginx to port 8000, and spins up a Vite container for HMR:

docker-compose.dev.yml
services:
  app:
    build:
      target: dev
    volumes:
      - ./:/var/www/html/laravel-app
      - ./docker/php/dev/php.ini:/usr/local/etc/php/conf.d/local.ini
      - ./docker/php/dev/php-xdebug.ini:/usr/local/etc/php/conf.d/php-xdebug.ini
    environment:
      APP_ENV: local
      APP_DEBUG: "true"
  nginx:
    ports:
      - "8000:80"
    volumes:
      - ./:/var/www/html/laravel-app
      - ./docker/nginx/dev/default.conf:/etc/nginx/conf.d/default.conf
  db:
    ports:
      - "127.0.0.1:3307:3306"
  redis:
    ports:
      - "6380:6379"
  vite:
    image: node:20
    container_name: laravel-vite
    working_dir: /var/www/html/laravel-app
    command: sh -c "[ -d node_modules ] || npm ci && npm run dev -- --host 0.0.0.0"
    volumes:
      - ./:/var/www/html/laravel-app
    ports:
      - "5173:5173"
    networks: [laravel]

Prod Overrides

In production the code is baked into the image. A named volume app_code is seeded from the built image and shared read-only with nginx so it can serve the compiled assets. Only the .env, PHP config, and worker config are mounted in. nginx handles HTTP→HTTPS redirects plus Let's Encrypt certificates.

docker-compose.prod.yml
services:
  app:
    build:
      target: prod
    volumes:
      - app_code:/var/www/html/laravel-app
      - ./.env:/var/www/html/laravel-app/.env
      - ./docker/php/prod/php.ini:/usr/local/etc/php/conf.d/local.ini
      - ./docker/supervisor.d/laravel-worker.ini:/etc/supervisor/conf.d/laravel-worker.conf
    environment:
      APP_ENV: production
      ENABLE_LARAVEL_WORKER: "true"
  nginx:
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - app_code:/var/www/html/laravel-app:ro
      - ./docker/nginx/prod/default.conf:/etc/nginx/conf.d/default.conf
      - /etc/letsencrypt:/etc/letsencrypt:ro
      - /var/www/certbot:/var/www/certbot:ro

volumes:
  app_code:
    driver: local
⚠️ Deploy tip: recreate the app_code volume on each deploy to pick up the new image contents — but don't use -v with the shared dbdata volume or you'll lose your database.

Nginx: Dev vs Prod

Dev nginx simply forwards PHP to the app container and serves Laravel from the mounted public directory:

docker/nginx/dev/default.conf
server {
    listen 80;
    index index.php index.html;
    root /var/www/html/laravel-app/public;
    client_max_body_size 200M;

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

    location ~ \.php$ {
        try_files $uri =404;
        fastcgi_split_path_info ^(.+\.php)(/.+)$;
        fastcgi_pass app:9000;
        fastcgi_index index.php;
        include fastcgi_params;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
        fastcgi_param PATH_INFO $fastcgi_path_info;
    }
}

Prod nginx forces HTTPS, redirects non-www to www, proxies phpMyAdmin, caches static files for 30 days, and denies hidden files:

docker/nginx/prod/default.conf
server {
    listen 80;
    server_name example.com www.example.com;
    location /.well-known/acme-challenge/ { root /var/www/certbot; }
    return 301 https://www.example.com$request_uri;
}

server {
    listen 443 ssl;
    server_name www.example.com;
    ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;

    client_max_body_size 512M;
    root /var/www/html/laravel-app/public;
    index index.php index.html index.htm;

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

    location ~ \.php$ {
        try_files $uri =404;
        set $backend "app:9000";
        fastcgi_pass $backend;
        include fastcgi_params;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
    }

    location ~* \.(jpg|jpeg|gif|png|css|js|ico|svg|woff2?)$ {
        expires 30d;
        add_header Cache-Control "public, no-transform";
    }

    location ~ /\.ht { deny all; }
}

Xdebug in Dev

The dev Xdebug config connects back to your IDE on host.docker.internal and listens on port 9001:

docker/php/dev/php-xdebug.ini
zend_extension=xdebug
xdebug.mode=debug
xdebug.start_with_request=yes
xdebug.client_host=host.docker.internal
xdebug.client_port=9001
xdebug.idekey=PHPSTORM

Supervisor: PHP-FPM + Queue Workers

In production, Supervisor keeps PHP-FPM running and spawns 8 Laravel queue workers. The worker config is only mounted in prod, and it starts only when ENABLE_LARAVEL_WORKER is true:

supervisord.conf
[supervisord]
nodaemon=true
user=root

[program:php-fpm]
command=php-fpm -F
autostart=true
autorestart=true

[include]
files = /etc/supervisor/conf.d/*.conf
laravel-worker.ini
[program:laravel-worker]
process_name=%(program_name)s_%(process_num)02d
command=php /var/www/html/laravel-app/artisan queue:work --sleep=3 --tries=3 --timeout=180
autostart=%(ENV_ENABLE_LARAVEL_WORKER)s
autorestart=true
numprocs=8
redirect_stderr=true
stdout_logfile=/var/www/html/laravel-app/storage/logs/worker.log

.dockerignore

Keep the build context small so prod images build fast and never leak secrets or local dependencies:

.dockerignore
.git
.env
node_modules
vendor
storage/*.key
storage/logs/*
storage/framework/cache/*
storage/framework/sessions/*
storage/framework/views/*
.idea
.vscode
*.log
*.md

Daily Workflow

  • Start / stop: docker compose up -d and docker compose down (data persists).
  • Rebuild after Dockerfile changes: docker compose up -d --build.
  • Run artisan: docker compose exec app php artisan migrate.
  • Shell in: docker compose exec app bash.
  • Tail logs: docker compose logs -f app.
terminal
# First-time setup
docker compose up -d --build
docker compose exec app composer install
docker compose exec app cp .env.example .env
docker compose exec app php artisan key:generate
docker compose exec app php artisan migrate --seed

Why This Setup Works

  • Dev speed: bind mounts + Vite HMR give instant feedback; Xdebug is isolated to dev.
  • Tiny prod image: no dev dependencies, no dev tools, built assets baked in.
  • One Dockerfile: dev and prod share the base layer, so only the differences are rebuilt.
  • Production ready: Supervisor manages php-fpm and queue workers, and HTTPS is handled by nginx + Let's Encrypt.