Welcome to HowToShipIt — practical how-to guides for developers: code, AI tools, and servers, explained step by step.

How to Deploy a Laravel App with Docker: Step-by-Step Guide (2026)

Most tutorials that teach you how to deploy Laravel with Docker are frozen in 2023: they still use the old docker-compose v1 syntax, pin PHP 8.2, and show a dev-only setup with bind-mounted code that would never survive production. In this guide you will build a production-ready Docker setup for a real Laravel app from scratch: a multi-stage Dockerfile on PHP 8.4, a separate Nginx container, MySQL and Redis services, a dedicated queue worker, the Laravel scheduler, health checks, and a repeatable deploy script you can run on any VPS. It is written for Laravel 13 (the current release, which requires PHP 8.3 or newer), but the pattern works on Laravel 12 too.

By the end, a deploy is three commands: pull, build, migrate. Nothing is copied by hand, nothing is “works on my machine”.

What You Are Building

Five containers, each with one job:

  • app — your Laravel code on php:8.4-fpm, built with a multi-stage Dockerfile so the final image contains no compilers or dev tools.
  • webserver — Nginx, serving /var/www/public and forwarding PHP requests to the app container over FastCGI.
  • mysql — MySQL 8.4 with a named volume, so your data survives container restarts.
  • redis — cache and queue driver.
  • worker and scheduler — the same app image, started with different commands: one runs queue:work, the other runs schedule:run every minute.

This separation is the whole point. Docker’s own official Laravel guide recommends exactly this shape — a production image built with multi-stage builds, plus separate Compose files for development (compose.dev.yaml) and production (compose.prod.yaml) — and the queue worker and scheduler live in their own containers rather than being bolted onto the web container.

Why not just use Laravel Sail?

Laravel Sail is Laravel’s official Docker setup and it is excellent — for local development. You install it with composer require laravel/sail --dev, run php artisan sail:install, and start everything with ./vendor/bin/sail up. But Sail is deliberately a dev tool: it bind-mounts your code, ships dev dependencies, and is not hardened for production. Production deployments need a custom Dockerfile with no bind mounts, cached config, an OPcache-tuned PHP, and a real process layout. Sail gets your team coding; this guide gets your app shipped.

Prerequisites

  • A Laravel project (a fresh composer create-project laravel/laravel myapp works fine, so does an existing app).
  • Docker Engine 24+ and Docker Compose v2 on your machine and on the server. Modern Compose is invoked as docker compose (a space, not a hyphen); the old docker-compose binary and the version: '3.8' key at the top of Compose files are obsolete — if you see them in an old tutorial, that tutorial is out of date.
  • A VPS or any machine that can run Docker, with ports 80 and 443 open.
  • Your app’s Composer dependencies installable with composer install (Composer 2.x).

Step 1: Lay Out the Docker Files

Keep every Docker file inside a docker/ directory at the project root so the project stays clean:

myapp/
├── app/
├── bootstrap/
├── docker/
│   ├── php/
│   │   ├── Dockerfile
│   │   └── entrypoint.sh
│   └── nginx/
│       └── default.conf
├── compose.prod.yaml
├── compose.dev.yaml        # optional, for local parity
└── .dockerignore

Step 2: Write a Multi-Stage Dockerfile for PHP

The Dockerfile has three stages. The vendor stage installs Composer dependencies, the assets stage builds your Vite frontend, and the final app stage copies only the finished artefacts into a slim runtime image. Nothing from the build toolchain (git, npm, compiler headers) survives into production.

Laravel 13 requires PHP 8.3 or newer, and PHP 8.4 is the safest choice right now: every mainstream package already supports it, and you avoid the occasional brand-new-release hiccup of 8.5. The extension list below covers everything Laravel needs (Ctype, cURL, DOM, Fileinfo, Mbstring, OpenSSL, PDO, Tokenizer, XML and friends) plus the drivers for MySQL queues and image handling.

# ---------- Stage 1: Composer dependencies ----------
FROM composer:2 AS vendor
WORKDIR /app
COPY composer.json composer.lock ./
# --no-dev and --optimize-autoloader: production only, classmap built once
RUN composer install --no-dev --optimize-autoloader --no-scripts --no-progress

# ---------- Stage 2: frontend assets ----------
FROM node:22-alpine AS assets
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci
COPY . .
RUN npm run build

# ---------- Stage 3: the production runtime ----------
FROM php:8.4-fpm

# System libraries Laravel's extensions compile against
RUN apt-get update && apt-get install -y --no-install-recommends \
        git curl zip unzip \
        libpng-dev libonig-dev libxml2-dev libzip-dev \
    && docker-php-ext-install -j$(nproc) \
        pdo_mysql mbstring zip exif pcntl bcmath gd \
    && pecl install redis \
    && docker-php-ext-enable redis \
    && apt-get clean && rm -rf /var/lib/apt/lists/*

# Production PHP tuning: OPcache on, errors off
RUN { \
        echo 'opcache.enable=1'; \
        echo 'opcache.memory_consumption=256'; \
        echo 'opcache.max_accelerated_files=20000'; \
        echo 'opcache.validate_timestamps=0'; \
        echo 'display_errors=Off'; \
        echo 'log_errors=On'; \
    } > /usr/local/etc/php/conf.d/production.ini

WORKDIR /var/www

# Copy the finished artefacts from the build stages
COPY --chown=www-data:www-data . /var/www
COPY --chown=www-data:www-data --from=vendor /app/vendor /var/www/vendor
COPY --chown=www-data:www-data --from=assets /app/public/build /var/www/public/build

# Laravel needs to write here at runtime
RUN chmod -R 775 /var/www/storage /var/www/bootstrap/cache

COPY docker/php/entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh

ENTRYPOINT ["entrypoint.sh"]
CMD ["php-fpm"]

Two things worth noticing. First, opcache.validate_timestamps=0 means PHP never re-checks files for changes — that is a real speed win in production, but it also means every deploy must restart the PHP container (our deploy script does that automatically). Second, the code is copied into the image, not bind-mounted. In production you never want volumes: - .:/var/www: a bind mount means whatever is on the server’s disk wins over whatever you built, which defeats the entire purpose of an image.

Step 3: Add a .dockerignore File

Without this, your build context ships .git, local .env, and node_modules into every build — slow and a leak risk if a real .env with secrets ever ends up in an image layer:

.git
.github
.env
.env.*
!/.env.example
node_modules
vendor
storage/logs/*
storage/framework/cache/*
storage/framework/sessions/*
storage/framework/views/*
bootstrap/cache/*
.phpunit.result.cache
/tests

Step 4: Configure Nginx

Nginx serves static files directly and passes PHP to the app container at app:9000. Docker’s internal DNS resolves the service name app automatically — never use localhost for inter-container traffic, because inside a container localhost is the container itself.

server {
    listen 80;
    server_name example.com;
    root /var/www/public;
    index index.php;

    # Laravel's built-in health route — handy for health checks
    location /up {
        try_files $uri $uri/ /index.php?$query_string;
    }

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

    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 $realpath_root$fastcgi_script_name;
        fastcgi_param PATH_INFO $fastcgi_path_info;
    }

    location ~ /\.ht {
        deny all;
    }

    # Long cache on versioned Vite assets
    location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ {
        expires 1y;
        add_header Cache-Control "public, immutable";
    }
}

Step 5: Write compose.prod.yaml

This is the heart of the setup. Note what is not here: no port mapping for MySQL or Redis (they are only reachable inside the Compose network), no version: key, and the queue worker and scheduler reuse the same app image with a different command.

services:
  app:
    build:
      context: .
      dockerfile: docker/php/Dockerfile
    image: myapp:latest
    restart: unless-stopped
    env_file: .env
    depends_on:
      mysql:
        condition: service_healthy
      redis:
        condition: service_started
    healthcheck:
      test: ["CMD", "php", "-v"]
      interval: 30s
      timeout: 5s
      retries: 3

  webserver:
    image: nginx:alpine
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
      # static assets must exist in both containers
      - app-public:/var/www/public:ro
    depends_on:
      app:
        condition: service_started
    healthcheck:
      test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost/up"]
      interval: 30s
      timeout: 5s
      retries: 3

  mysql:
    image: mysql:8.4
    restart: unless-stopped
    env_file: .env
    environment:
      MYSQL_DATABASE: ${DB_DATABASE}
      MYSQL_USER: ${DB_USERNAME}
      MYSQL_PASSWORD: ${DB_PASSWORD}
      MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
    volumes:
      - mysql-data:/var/lib/mysql
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p$${MYSQL_ROOT_PASSWORD}"]
      interval: 10s
      timeout: 5s
      retries: 10

  redis:
    image: redis:alpine
    restart: unless-stopped
    command: ["redis-server", "--appendonly", "yes"]
    volumes:
      - redis-data:/data

  worker:
    image: myapp:latest
    restart: unless-stopped
    env_file: .env
    command: ["php", "artisan", "queue:work", "--sleep=3", "--tries=3", "--max-time=3600"]
    depends_on:
      app:
        condition: service_started
      redis:
        condition: service_started

  scheduler:
    image: myapp:latest
    restart: unless-stopped
    env_file: .env
    command: >
      sh -c "while true; do php artisan schedule:run --no-interaction; sleep 60; done"
    depends_on:
      app:
        condition: service_started

volumes:
  app-public:
  mysql-data:
  redis-data:

A few deliberate choices here:

  • One image, three roles. The worker and scheduler are the exact same image as the web app, so a queue worker can never run different code than the web tier. This kills an entire class of “but it worked on the web container” bugs.
  • The scheduler is a loop, not cron. Laravel’s scheduler is designed to run once a minute, and a tiny shell loop inside a container does that with zero extra tooling. (For heavy cron needs, a dedicated supercronic sidecar is the cleaner long-term answer.)
  • Named volumes for state. mysql-data and redis-data live outside any container, so docker compose down never deletes your database. Code, meanwhile, lives inside the image — the two are updated on different rhythms, exactly as they should be.

One wrinkle: the Nginx container needs your compiled public/build assets, which live inside the app image. The app-public volume shared between them solves it — add a line to the entrypoint script that copies /var/www/public into the shared volume on boot (shown in Step 6).

Step 6: The Entrypoint Script

The entrypoint runs every time the app container starts. It waits for the database, runs migrations, warms the caches, syncs public assets, then hands control to PHP-FPM. Migrations run here — not at build time — because build time has no database to migrate against.

#!/bin/sh
set -e

# Sync public assets (Vite build output, storage link target) to the shared volume
if [ -d /var/www-public ]; then
    cp -r /var/www/public/. /var/www-public/
    php artisan storage:link --force || true
fi

# Wait for MySQL to accept connections (up to ~60s)
for i in $(seq 1 30); do
    if php artisan db:show >/dev/null 2>&1; then
        break
    fi
    echo "Waiting for database... ($i)"
    sleep 2
done

php artisan migrate --force
php artisan optimize

exec "$@"

Mount the shared volume at /var/www-public by adding this to the app service in compose.prod.yaml:

    volumes:
      - app-public:/var/www-public

php artisan optimize caches your config, routes, and views in one shot — the single biggest free performance win on a Laravel deploy, and the reason you must keep APP_DEBUG=false in production: cached config means a debug flag flipped at runtime is silently ignored.

Step 7: Production .env (On the Server, Never in Git)

Your .env lives on the server only — it is in .dockerignore and .gitignore, and env_file: .env in Compose injects it into every container. The critical Docker-specific values:

APP_ENV=production
APP_DEBUG=false
APP_URL=https://example.com

DB_CONNECTION=mysql
DB_HOST=mysql            # the Compose service name, NOT localhost
DB_PORT=3306
DB_DATABASE=myapp
DB_USERNAME=myapp
DB_PASSWORD=use-a-long-random-password-here

CACHE_STORE=redis
SESSION_DRIVER=redis
QUEUE_CONNECTION=redis
REDIS_HOST=redis        # again: the service name
REDIS_PORT=6379

# Generate once: php artisan key:generate --show
APP_KEY=base64:...

The number-one “it works locally but not in Docker” bug is DB_HOST=127.0.0.1. Inside a container, 127.0.0.1 is the container itself, and your app container is not running MySQL. Service names are DNS names on the Compose network — use them.

Step 8: The Deploy Script

Save this as deploy.sh on the server. It is deliberately boring: pull, rebuild, start, clean up old images. Because the code is baked into the image, a deploy is atomic-ish — the old containers keep serving until the new ones are healthy.

#!/bin/bash
set -e
cd /opt/myapp

git fetch origin main
git reset --hard origin/main

# Rebuild only what changed, start everything fresh
docker compose -f compose.prod.yaml up -d --build

# Drop dangling images so the disk doesn't fill up over months
docker image prune -f

docker compose -f compose.prod.yaml ps

Run your first deploy with it, then verify:

chmod +x deploy.sh && ./deploy.sh

# Watch the app container run its migrations
docker compose -f compose.prod.yaml logs -f app

# Smoke-test the health route
curl -f http://localhost/up && echo "OK"

# Run a one-off Artisan command inside the running app container
docker compose -f compose.prod.yaml exec app php artisan about

If you prefer zero-downtime deploys rather than a few seconds of restart, put this whole stack behind a reverse proxy (Traefik or Caddy) and do blue-green swaps — but for most side projects and small client apps, the restart window here is seconds, and the simplicity is worth it.

Step 9: Add HTTPS

You have two honest options, and both are fine:

  • Cloudflare in front (easiest). Point your DNS at the server, enable the orange-cloud proxy, and set SSL mode to Full (strict). Cloudflare terminates TLS and your Nginx keeps serving plain HTTP on port 80. Free, and you get a CDN and DDoS protection thrown in.
  • Certbot on the host. Install Certbot, run certbot --nginx against your domain, and let it manage certificates and renewal. Slightly more moving parts, but no third party in front of your traffic.

What you should not do is serve a production Laravel app over plain HTTP in 2026 — browsers flag it, and any login form on it is a liability.

Troubleshooting the Usual Suspects

Symptom Usual cause Fix
SQLSTATE[HY000] [2002] Connection refused DB_HOST is 127.0.0.1 or localhost Set DB_HOST=mysql (the service name)
502 Bad Gateway from Nginx PHP-FPM not reachable Check fastcgi_pass app:9000 matches the service name; confirm the app container is running with docker compose ps
Blank pages, nothing in the log APP_DEBUG=false with cached config, or permissions chmod -R 775 storage bootstrap/cache; check storage/logs/laravel.log
Changes don’t appear after deploy OPcache validate_timestamps=0 or old containers still running Deploys rebuild the image; verify with docker compose images that the new image is live
Queue jobs sit unprocessed Worker not running or wrong queue connection docker compose logs worker; confirm QUEUE_CONNECTION=redis and REDIS_HOST=redis
Build is painfully slow No layer caching; huge build context Check .dockerignore; order Dockerfile steps from least- to most-frequently-changed

When This Setup Is Overkill

Honesty matters: if your app is a weekend side project with no queues and fifty visitors a day, this whole stack is more machinery than you need. A single container running ServerSideUp’s PHP images (which bundle PHP-FPM and Nginx in one variant) or even php artisan serve behind a process manager will do. Reach for the five-container layout when you have background jobs, scheduled tasks, real traffic, or a team that needs everyone’s local environment to match production exactly.

Further Reading & References

Published 3 October 2026. Verified against Docker’s official Laravel guide, the Laravel 13.x deployment docs, and the Laravel release timeline on the same date. Commands target Docker Engine 24+ with Compose v2.

Leave a Comment