Backend••10 min read•20 views

Deploy Laravel with Docker: Step By Step Guide

Docker is no longer just a trend, but the de facto standard of software architecture today. Mastering containerization makes your development workflow much more consistent, secure, and easy to deploy to any cloud provider without surprise errors in the middle of the night.

Have you ever heard this classic sentence in the office or developer's hangout: "Well, my laptop runs smoothly, but when I go to the server it gets an error of 500!"?

If you have experienced this, don't worry, you are not alone. Problems with different PHP versions, database extensions that are forgotten to be activated on the VPS, or MySQL versions with different minor releases are daily bread if we still deploy applications manually.

In the world of modern software engineering, that problem is solved with containerization, and Docker is the standard. With Docker, our Laravel application is neatly packaged with all its dependencies—starting from the PHP runtime, Nginx web server, MySQL database, to caching layers like Redis. Once run on your laptop, guaranteed behavior is 100% the same when moving to the staging or production server.

As a software engineer, understanding the Docker workflow not only makes the deployment process smooth without staying up late, but also keeps the application architecture modular, scalable, and easy to maintain. Come on, let's thoroughly examine the step-by-step guide to deploying Laravel using Docker from zero to live.

1. Stack Architecture That We Will Build

Don't rush into typing the command line before understanding the system architecture. We won't cram Laravel, Nginx, and MySQL into one container like ancient virtual machines (VMs).

Docker's core philosophy is "One container, one concern" (one container for one responsibility). The architectural structure that we design includes:

  • Service app (PHP-FPM): PHP 8.3/8.2-FPM based container that specifically executes PHP scripts and the Laravel framework. Here we install extensions like pdo_mysql, mbstring, zip, and opcache.
  • Service webserver (Nginx): High performance reverse proxy and web server.Nginx handles static files (CSS, JS, images) directly and forwards .php file requests to the app service via FastCGI port 9000.
  • Service db (MySQL 8.0): Relational database management system whose data we store outside the lifecycle container using Docker Named Volume, so that data is not lost when the container is restarted or updated.
  • Service redis (Optional/Best Practice): Acts as an in-memory driver for caching, session storage, and Laravel Queue processing.
  • Custom Bridge Network: Isolated internal network so that these containers can chat with each other using their respective service names as hostnames.
 Internet / User
                           │
                           ▼
                 ┌───────────────────┐
                 │ Nginx (Port 80) │
                 └─────────┬─────────┘
        Static Files │ FastCGI Request
        (CSS/JS/Img) ▼ (Port 9000)
                 ┌───────────────────┐
                 │ PHP-FPM (Laravel)│
                 └────┬─────────┬────┘
                      │ │
    SQL Queries (3306)│ │ Cache / Queue (6379)
                      ▼ ▼
             ┌──────────┐ ┌───────────┐
             │ MySQL DB │ │ Redis Pods │
             └──────────┘ └───────────┘

2. Project Directory Structure

The key to neat containerization is a clean folder structure. Place all Docker configurations in a separate folder named docker/ in the root directory of your Laravel project.

The arrangement will look like this:

Plaintext
my-laravel-docker/
├── app/
├── bootstrap/
├── config/
├── database/
├── docker/
│ ├── nginx/
│ │ └── default.conf
│ └── php/
│ ├── Dockerfiles
│ └── local.ini
├── public/
├── resources/
├── routes/
├── storage/
├── .env
├── .env.example
├── .dockerignore
├── composer.json
├── docker-compose.yml
└── package.json

3. Docker Component Configuration

Let's create the configuration files one by one. Make sure you are careful in this section, because the container operating system configuration has a big impact on performance and security.

A. Securing Build Context: .dockerignore

Before Docker reads the contents of the project, create a .dockerignore file in the root directory. The aim is to prevent heavy or sensitive local files from being copied into the Docker image during the build process.

Create file .dockerignore:

Plaintext
.git
.github
node_modules
vendors
storage/logs/*
storage/framework/cache/data/*
storage/framework/sessions/*
storage/framework/views/*
.env
.env.backup
.phpunit.result.cache
npm-debug.log
yarn-error.log

B. Runtime Brain: docker/php/Dockerfile

The Dockerfile file is responsible for compiling the Debian/Alpine base image, installing OS-level Linux dependencies, compiling the PHP extensions required by Laravel, installing Composer, and setting user permissions.

Create file docker/php/Dockerfile:

Dockerfile
# Uses the official stable PHP 8.3 FPM base image
FROM php:8.3-fpm

# Define arguments for user host UID/GID flexibility
ARG USER_ID=1000
ARG GROUP_ID=1000# Set the working directory inside the container
WORKDIR /var/www

# Install OS level system dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
    build-essential\
    libpng-dev\
    libjpeg62-turbo-dev\
    libfreetype6-dev\
    locales\
    zip\
    jpegoptim optipng pngquant gifsicle \
    vim\
    unzip\
    git\
    curl\
    libzip-dev\
    libonig-dev\
    libxml2-dev\
    && apt-get clean && rm -rf /var/lib/apt/lists/*

# Install and configure core PHP extensions for Laravel
RUN docker-php-ext-configure gd --with-freetype --with-jpeg \
    && docker-php-ext-install -j$(nproc) gd \
    && docker-php-ext-install pdo_mysql mbstring zip exif pcntl bcmath opcache

# Download the latest version of the Composer binary directly from the official Composer image
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer

# Setup non-root user so that Laravel storage & cache permissions do not conflict with the host
RUN groupadd -g ${GROUP_ID} appuser && \
    useradd -u ${USER_ID} -ms /bin/bash -g appuser appuser

# Move working directory ownership to appuser
RUN chown -R appuser:appuser /var/www

# Use a non-root user to run the process
USER appuser

# Expose default FastCGI port
EXPOSE 9000

CMD ["php-fpm"]

C. PHP Configuration Optimization: docker/php/local.ini

The default PHP default usually limits RAM allocation and file uploads. We set this custom configuration to match enterprise application standards.

Create file docker/php/local.ini:

This, TOML
upload_max_filesize = 40M
post_max_size = 40M
memory_limit = 512M
max_execution_time = 300
date.timezone = Asia/Jakarta

; Opcache configuration for high performance
opcache.enable = 1
opcache.enable_cli = 1
opcache.memory_consumption = 128
opcache.interned_strings_buffer = 8
opcache.max_accelerated_files = 10000
opcache.revalidate_freq = 2
opcache.fast_shutdown = 1

D. Reverse Proxy Web Server: docker/nginx/default.conf

Nginx is responsible for handling HTTP/HTTPS routing and forwarding PHP execution to the app container via FastCGI.

Create file docker/nginx/default.conf:

Nginx
server {
    listen 80;
    server_name localhost;
    root /var/www/public;

    index index.php index.html index.htm;
    charset utf-8;

    # Logging access and errors
    error_log /var/log/nginx/error.log;access_log /var/log/nginx/access.log;

    # Default security header settings
    add_header X-Frame-Options "SAMEORIGIN";
    add_header X-Content-Type-Options "nosniff";

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

    location = /favicon.ico { access_log off; log_not_found off; }
    location = /robots.txt { access_log off; log_not_found off; }

    error_page 404 /index.php;

    # Forward PHP script execution to the service app on port 9000
    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;
        fastcgi_hide_header X-Powered-By;
    }

    # Block direct access to hidden files such as .env and .git
    location ~ /\.(?!well-known).* {
        deny all;
    }
}

4. Orchestration with docker-compose.yml

Now we combine all the above puzzles into one orchestration conductor file: docker-compose.yml. This file turns on and connects the webserver service, Laravel application, MySQL database, and Redis.

Create the file docker-compose.yml in the root of the project directory:

YAML
services:
  # Service 1: PHP-FPM Application Container
  app:
    build:
      context: .
      dockerfile: docker/php/Dockerfile
      args:USER_ID: 1000
        GROUP_ID: 1000
    image: laravel-app:latest
    container_name: laravel_app
    restart: unless-stopped
    working_dir: /var/www
    volumes:
      - ./:/var/www
      - ./docker/php/local.ini:/usr/local/etc/php/conf.d/local.ini:ro
    networks:
      - laravel_network
    depends_on:
      - db
      - redis

  # Service 2: Nginx Web Server
  webserver:
    image: nginx:alpine
    container_name: laravel_webserver
    restart: unless-stopped
    ports:
      - "80:80"
    volumes:
      - ./:/var/www
      - ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
    networks:
      - laravel_network
    depends_on:
      - app

  # Service 3: MySQL Database
  db:
    image: mysql:8.0
    container_name: laravel_db
    restart: unless-stopped
    environment:
      MYSQL_DATABASE: ${DB_DATABASE:-laravel}
      MYSQL_ROOT_PASSWORD: ${DB_PASSWORD:-secret}
      MYSQL_USER: ${DB_USERNAME:-laravel_user}MYSQL_PASSWORD: ${DB_PASSWORD:-secret}
    ports:
      - "3306:3306"
    volumes:
      - dbdata:/var/lib/mysql
    networks:
      - laravel_network

  # Service 4: Redis Cache & Queue Manager
  redis:
    image: redis:alpine
    container_name: laravel_redis
    restart: unless-stopped
    ports:
      - "6379:6379"
    volumes:
      - redisdata:/data
    networks:
      - laravel_network

# Definition of Persistent Storage
volumes:
  dbdata:
    driver: local
  redisdata:
    driver: local

# Internal Network Definition
networks:
  laravel_network:
    driver: bridge

5. Environment Variable Adjustment (.env)

This is a crucial point that often makes juniors scratch their heads. In non-Docker environments, we usually redirect DB_HOST to 127.0.0.1 or localhost.

But in Docker networking, 127.0.0.1 in the app container refers to itself, not to the database container. So that the app container can contact the database and cache, replace the host with the service name that we registered in docker-compose.yml.

Open your Laravel .env file and adjust the connection variables:

Code snippet
APP_NAME="Laravel Docker App"
APP_ENV=local
APP_KEY=
APP_DEBUG=true
APP_TIMEZONE=Asia/Jakarta
APP_URL=http://localhost

LOG_CHANNEL=stack
LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug

# Database Configuration (Host redirects to service name: db)
DB_CONNECTION=mysql
DB_HOST=db
DB_PORT=3306
DB_DATABASE=laravel
DB_USERNAME=laravel_user
DB_PASSWORD=secret

# Configure Cache & Queue Driver to Redis
BROADCAST_CONNECTION=log
FILESYSTEM_DISK=local
QUEUE_CONNECTION=redis
CACHE_STORE=redis
CACHE_PREFIX=

# Redis configuration (Host redirects to service name: redis)
REDIS_CLIENT=phpredis
REDIS_HOST=redis
REDIS_PASSWORD=null
REDIS_PORT=6379

6. Running and Initializing the System

The configuration collection is complete. It's time to fire up the entire stack and initialize the Laravel framework in the container.

Run these commands sequentially in the terminal:

Bash
# 1. Build image and start the container in the background (detached mode)
docker compose up -d --build

# 2. Check the status to see whether all containers are Up / Healthy
docker compose ps
After all four containers are running (laravel_app, laravel_webserver, laravel_db, laravel_redis), we execute Laravel maintenance commands through the app:
container
Bash
# 3. Install the entire PHP Composer library in the container
docker compose exec app composer install

# 4. Generate Application Encryption Key
docker compose exec app php artisan key:generate

# 5. Run the database migration
docker compose exec app php artisan migrate

# 6.Set permissions for the Laravel writable folder
docker compose exec app chmod -R 775 storage bootstrap/cache

# 7. Create a storage symlink to the public folder
docker compose exec app php artisan storage:link
Open your browser and navigate to http://localhost. Happy! Laravel's welcome page now runs seamlessly on top of your Docker stack.

7. General Troubleshooting: Senior Flying Hours

Even experienced engineers have encountered problems when dealing with Docker. Here are some quick solutions to the most frequently occurring problems:

Constraint 1: "The stream or file .../storage/logs/laravel.log could not be opened: Permission denied"

  • Cause: The user on your host computer has a different User ID (UID) from the user in the Linux container, so the PHP-FPM process is not allowed to write to disk.
  • Solution: Run permission repair directly via container:

    Bash
    docker compose exec app chown -R appuser:appuser /var/www/storage /var/www/bootstrap/cache
    docker compose exec app chmod -R 775 /var/www/storage /var/www/bootstrap/cache
    

Constraint 2: "SQLSTATE[HY000] [2002] Connection refused"

  • Cause: Container app starts up faster than the MySQL engine which needs to initialize database tables when first built.
  • Solution: Wait 15–30 seconds until the MySQL engine completely finishes startup. Check the MySQL log with the command:

    Bash
    docker compose logs db
    
    Once the log displays "ready for connections", repeat the command docker compose exec app php artisan migrate.

Obstacle 3: Config Cache Locks Out Old Settings

  • Cause: Laravel caches old configuration settings from outside Docker (for example DB_HOST=127.0.0.1).
  • Solution: Clear all cache configuration:

    Bash
    docker compose exec app php artisan config:clear
    docker compose exec app php artisan cache:clear
    

8. Transition to Production Checklist

The setup we created above is ideal for local development. However, when you want to take this image to production level (such as AWS ECS, DigitalOcean App Platform, or Kubernetes), pay attention to the following points:

  • Multi-Stage Build Dockerfile: Don't bring compilers and dev tools into production. Use multi-stage build to compile front-end assets (Vite/Tailwind) and Composer dependencies (composer install --no-dev --optimize-autoloader), then copy only the final artifacts to the production image to keep the image file size slim (under 150MB).
  • Separate Databases: Don't run database containers on the same server as web apps in production unless the budget is very tight. Use a managed database service such as AWS RDS or DigitalOcean Managed MySQL for automatic backup, multi-AZ failover, and scaling.
  • Run a Separate Queue Worker: If your application processes jobs (such as sending emails or generating PDFs), create a new service at docker-compose.yml with the same image but with the command php artisan queue:work so as not to burden the main web request process.
  • Enable Caching Framework: In production, always run php artisan config:cache, php artisan route:cache, and php artisan view:cache in your CI/CD pipeline to reduce framework boot time.
Docker is no longer just a trend, but the de facto standard of software architecture today. Mastering containerization makes your development workflow much more consistent, secure, and easy to deploy to any cloud provider without surprise errors in the middle of the night.

Good luck, explore the configuration, and goodbye to the drama of "it works on my machine!"
 
Sigit Wasis Subekti

Sigit Wasis Subekti

Software Engineer & Tech Educator

Software Engineer and Tech Educator sharing insights on web development and software architecture.