Laravel Docker Deployment - Automated CI/CD with GitHub Actions

Table of Contents

  1. Introduction
  2. Why Dockerize Your Laravel Application?
  3. The Foundation: Your Dockerfile and Entrypoint Script
  4. Automated Deployment with GitHub Actions
  5. Setting Up Your DigitalOcean VM
  6. Docker Healthchecks
  7. Best Practices and SEO Considerations
  8. Conclusion

Laravel Docker Deployment: Automated CI/CD with GitHub Actions

Deploying Laravel applications can often be a complex dance of server configurations, dependency management, and environment variables. You're constantly seeking ways to simplify this process, ensure consistency, and accelerate deployments. This post will guide you through Dockerizing your Laravel application and setting up an automated deployment pipeline to a DigitalOcean VM using GitHub Actions, triggered by Git tags.

Why Dockerize Your Laravel Application?

Dockerizing your Laravel application offers a multitude of benefits, central to modern DevOps practices:

  • Environment Consistency: Eliminate "it works on my machine" issues. Docker containers package your application and all its dependencies into a single, isolated unit, ensuring it runs identically across development, testing, and production environments.
  • Simplified Dependency Management: No more manual PHP version management, extension installations, or system library configurations on your server. Docker handles it all within the image.
  • Portability: Your Dockerized Laravel app can run on any system with Docker installed, whether it's a local machine, a VM, or a cloud platform.
  • Scalability: While not the primary focus of this specific deployment, Docker lays the groundwork for easily scaling your application horizontally with container orchestration tools like Kubernetes.
  • Faster Rollbacks: If a new deployment introduces issues, rolling back to a previous, stable Docker image is significantly faster and more reliable than manual code rollbacks.

The Foundation: Your Dockerfile and Entrypoint Script

Let's break down the core components that make your Laravel application Docker-ready.

Dockerfile

Your Dockerfile defines the environment for your Laravel application. This Dockerfile uses a multi-stage build process for efficiency and a smaller final image:

# Stage 1: Build dependencies
FROM composer:latest AS builder
WORKDIR /app
COPY . .
RUN composer install --no-dev --optimize-autoloader

# Stage 2: Build PHP application
FROM php:8.4-fpm-alpine

# Install required system dependencies
RUN apk add --no-cache \
    nginx \
    supervisor \
    icu-dev \
    libzip-dev \
    zlib-dev \
    oniguruma-dev \
    shadow \
    bash \
    sqlite-dev \
    nodejs \
    npm

# Install required PHP extensions
RUN docker-php-ext-install \
        bcmath \
        pdo_mysql \
        pdo_sqlite \
        zip \
        intl \
        opcache

# Set working directory
WORKDIR /var/www/html

# Copy application files
COPY . .

# Build frontend assets
RUN npm install
RUN npm run build

# Copy dependencies from builder stage
COPY --from=builder /app/vendor ./vendor

# Set permissions
RUN set -e \
  && PHP_LOG_DIR="/var/log/php" \
  && NGINX_LOG_DIR="/var/log/nginx" \
  && NGINX_LIB_DIR="/var/lib/nginx" \
  && install -d -o www-data -g www-data -m 775 /var/www/html/storage /var/www/html/bootstrap/cache $NGINX_LIB_DIR/logs $NGINX_LIB_DIR/tmp /run/nginx \
  && install -d -o www-data -g www-data -m 755 /var/run/php $PHP_LOG_DIR $NGINX_LOG_DIR $NGINX_LIB_DIR /run/nginx \
  && touch $PHP_LOG_DIR/php-fpm.log $PHP_LOG_DIR/php-fpm.err $NGINX_LOG_DIR/error.log $NGINX_LOG_DIR/access.log \
  && chown www-data:www-data $PHP_LOG_DIR/php-fpm.log $PHP_LOG_DIR/php-fpm.err $NGINX_LOG_DIR/error.log $NGINX_LOG_DIR/access.log \
  && chmod 664 $PHP_LOG_DIR/php-fpm.log $PHP_LOG_DIR/php-fpm.err $NGINX_LOG_DIR/error.log $NGINX_LOG_DIR/access.log

# Exclude SQLite database file
RUN rm -f /var/www/html/database/database.sqlite

# Environment variables
ENV SERVER_NAME={HOSTNAME}

# Copy configuration files
COPY docker/php/php-fpm.conf /usr/local/etc/php-fpm.conf
COPY docker/nginx/nginx.conf /etc/nginx/nginx.conf
COPY docker/supervisor/supervisord.conf /etc/supervisord.conf
COPY docker/entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh

# Expose ports
EXPOSE 80

CMD ["/entrypoint.sh"]

Here's a detailed explanation of what this Dockerfile is doing:

Multi-Stage Build: This Dockerfile uses a multi-stage build, which is a best practice for creating smaller, more secure production images.

  • Stage 1: builder

    • FROM composer:latest AS builder: Starts with a composer image. This stage is dedicated solely to installing PHP dependencies.
    • WORKDIR /app: Sets the working directory inside this stage.
    • COPY . .: Copies your application's source code into the builder stage.
    • RUN composer install --no-dev --optimize-autoloader: Installs Composer dependencies, excluding development dependencies and optimizing the autoloader.
  • Stage 2: Build PHP application

    • FROM php:8.4-fpm-alpine: Starts with a lean Alpine-based PHP-FPM image (PHP 8.4).
    • RUN apk add --no-cache ...: Installs necessary system-level packages using Alpine's apk package manager. This includes nginx, supervisor, development libraries like icu-dev, libzip-dev, zlib-dev, oniguruma-dev, utility tools shadow and bash, sqlite-dev, nodejs, and npm.
    • RUN docker-php-ext-install ...: Installs specific PHP extensions required by your Laravel application: bcmath, pdo_mysql, pdo_sqlite, zip, intl, and opcache.
    • WORKDIR /var/www/html: Sets the primary working directory for your Laravel application within the container.
    • COPY . .: Copies your entire application's source code into the /var/www/html directory of the final image.
    • RUN npm install and RUN npm run build: Installs Node.js dependencies and then runs your frontend build script.
    • COPY --from=builder /app/vendor ./vendor: Copies only the vendor directory from the builder stage to the final image.
  • Permissions Setup

    • RUN mkdir -p ...: Creates necessary directories for PHP-FPM, Nginx, and their logs.
    • chown -R www-data:www-data ...: Changes ownership of the application directory and various log/runtime directories to www-data.
    • chmod -R 775 ...: Sets read, write, and execute permissions for the www-data group on Laravel's storage and bootstrap/cache directories, as well as Nginx related directories.
    • touch ... and chown ..., chmod ...: Creates log files and sets their ownership and permissions for PHP-FPM and Nginx logs.
    • RUN rm -f /var/www/html/database/database.sqlite: Removes the SQLite database file from the image.
    • ENV SERVER_NAME=: Sets an environment variable.
  • Copy Configuration Files

    • COPY docker/php/php-fpm.conf /usr/local/etc/php-fpm.conf: Copies a custom PHP-FPM configuration.
    • COPY docker/nginx/nginx.conf /etc/nginx/nginx.conf: Copies a custom Nginx configuration.
    • COPY docker/supervisor/supervisord.conf /etc/supervisord.conf: Copies a custom Supervisor configuration.
    • COPY docker/entrypoint.sh /entrypoint.sh: Copies the entrypoint script into the image.
    • RUN chmod +x /entrypoint.sh: Makes the entrypoint script executable.
  • Expose and Start

    • EXPOSE 80: Informs Docker that the container will listen on port 80.
    • CMD ["/entrypoint.sh"]: Specifies the command that will be executed when the container starts, running the entrypoint.sh script.

PHP-FPM and Nginx Configuration

The PHP-FPM and Nginx configurations are essential for running your Laravel application efficiently within the Docker container. They ensure that PHP requests are handled correctly and that static assets are served efficiently.

php-fpm.conf

This file configures PHP-FPM, the FastCGI Process Manager for PHP. It sets up the process management and logging settings for PHP-FPM. Here's a basic example of what it might look like:

[global]
error_log = /var/log/php-fpm.log

[www]
listen = /var/run/php/php-fpm.sock
listen.owner = www-data
listen.group = www-data
listen.mode = 0660
user = www-data
group = www-data
pm = dynamic
pm.max_children = 5
pm.start_servers = 2
pm.min_spare_servers = 1
pm.max_spare_servers = 3

nginx.conf

This file configures Nginx, the web server that will serve your Laravel application. It sets up the server blocks, location directives, and other settings necessary for serving your application efficiently. Here's a basic example:

worker_processes auto;

pid /run/nginx/nginx.pid;

events {
    worker_connections 1024;
}

http {
    include       mime.types;
    default_type  application/octet-stream;

    sendfile        on;
    keepalive_timeout 65;

    server {
        listen 80;
        listen [::]:80;
        server_name ${SERVER_NAME:localhost};
        server_tokens off;
        root /var/www/html/public;

        add_header X-Frame-Options "SAMEORIGIN";
        add_header X-XSS-Protection "1; mode=block";
        add_header X-Content-Type-Options "nosniff";

        index index.php index.html;

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

        charset utf-8;
        error_log  /var/log/nginx/error.log;
        access_log off;
        error_page 404 /index.php;

        location ~ \.php$ {
            include fastcgi_params;
            fastcgi_pass unix:/var/run/php/php-fpm.sock;
            fastcgi_index index.php;
            fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
            fastcgi_split_path_info ^(.+\.php)(/.+)$;
        }
    }
}

Supervisor Configuration

Supervisor is a process control system that allows you to manage and monitor processes within your Docker container.

The supervisord.conf file is used to configure Supervisor to manage Nginx and PHP-FPM processes. Here's a basic example of what it might look like:

[supervisord]
nodaemon=true
logfile=/var/www/html/storage/logs/supervisord.log

[program:php-fpm]
command=/usr/local/sbin/php-fpm --nodaemonize
user=www-data
autostart=true
autorestart=true
stdout_logfile=/var/log/php-fpm.log
stderr_logfile=/var/log/php-fpm.err

[program:nginx]
command=/usr/sbin/nginx -g "daemon off;"
user=www-data
autostart=true
autorestart=true
stdout_logfile=/var/log/nginx/access.log
stderr_logfile=/var/log/nginx/error.log

The Entrypoint Script: entrypoint.sh

This script is crucial for initializing your Laravel application within the Docker container. It ensures your application is ready to serve requests upon container startup. Let's analyze the provided entrypoint.sh:

#!/bin/sh

# Exit script on error
set -e

echo "Starting Laravel application..."

# Create database file
if [ ! -f /var/www/html/database/database.sqlite ]; then
  echo "Database file not found. Creating a new one..."
  touch /var/www/html/database/database.sqlite
fi

# Set correct permissions
chown -R www-data:www-data /var/www/html/database/database.sqlite
chown -R www-data:www-data /var/www/html/storage /var/www/html/bootstrap/cache

# Start Supervisor in the background
supervisord -c /etc/supervisord.conf &

# Run database migrations and other artisan commands
echo "Running Laravel artisan commands..."

# echo "Running migrations..."
# php artisan migrate --force

echo "Clearing caches..."
php artisan optimize

echo "Restart queue workers..."
php artisan queue:restart

# Keep the container running
wait
  • Error Handling (set -e): Ensures the script exits immediately if any command fails.
  • Database Initialization: Checks for and creates a database.sqlite file if it doesn't exist.
  • Permissions: Sets ownership for /var/www/html/database/database.sqlite, /var/www/html/storage, and /var/www/html/bootstrap/cache to www-data:www-data.
  • Supervisor: Starts supervisord in the background using the configuration file /etc/supervisord.conf.
  • Artisan Commands: Executes various Laravel Artisan commands:
    • php artisan migrate --force: Runs database migrations.
    • php artisan optimize: Clears caches.
    • php artisan queue:restart: Restarts any running queue workers.
  • Keep Container Running (wait): The wait command keeps the container running.

Automated Deployment with GitHub Actions

GitHub Actions provide a powerful CI/CD platform integrated directly into your GitHub repositories. Our workflow ( docker-build.yml) automates the build, push, and deployment of our Docker image.

run-tests.yml

PHPUnit is a unit testing framework for PHP that allows you to write and run automated tests for your code. By creating test cases, you can verify that individual parts of your application work as expected and catch regressions early. Using PHPUnit improves code reliability, supports test-driven development, and helps ensure that changes do not break existing functionality.

A GitHub Action running PHPUnit will automatically execute your test suite on every push or pull request. If any tests fail, the workflow will stop and report the errors, preventing untested or broken code from being merged. This enforces code quality and encourages a robust, well-tested codebase.

name: run-tests

on:
  pull_request:
    branches:
      - main

jobs:
  tests:
    name: Run tests
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Setup PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: '8.4'
          extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv
          coverage: none

      - name: Run composer install
        run: composer install -n --prefer-dist

      - name: Run tests
        run: vendor/bin/pest --ci

phpstan.yml

PHPStan is a static analysis tool for PHP that helps you identify bugs, type errors, and potential issues in your code without running it. By analyzing your codebase, PHPStan enforces strict type checks and best practices, making your code more robust and maintainable.

You use PHPStan to catch problems early in the development process, improve code quality, and reduce the risk of runtime errors. It integrates well with CI/CD pipelines, ensuring that only code passing static analysis is merged or deployed.

A GitHub Action running PHPStan automatically analyzes your code on every push or pull request. It checks for errors and fails the workflow if issues are found, preventing problematic code from being merged. This keeps your codebase clean and enforces consistent quality standards across your team.

name: PHPStan

on:
  push:
    paths:
      - '**.php'
      - 'phpstan.neon.dist'
      - '.github/workflows/phpstan.yml'
  pull_request:
    branches:
      - main

jobs:
  phpstan:
    name: phpstan
    runs-on: ubuntu-latest
    timeout-minutes: 5
    steps:
      - uses: actions/checkout@v4

      - name: Setup PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: '8.4'
          coverage: none

      - name: Install composer dependencies
        uses: ramsey/composer-install@v3

      - name: Run PHPStan
        run: ./vendor/bin/phpstan --error-format=github

docker-build.yml

name: Docker Build & Push

on:
  push:
    tags:
      - '*'
  workflow_dispatch:
    inputs:
      tag:
        description: 'Tag for the Docker image'
        required: true
        default: 'latest'
  workflow_call:
    inputs:
      tag:
        required: true
        type: string

jobs:
  build-and-push:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout code
        uses: actions/checkout@v3

      - name: Log in to Docker Hub
        uses: docker/login-action@v2
        with:
          username: ${{ secrets.DOCKER_USERNAME }}
          password: <span class="math-inline">\{\{ secrets\.DOCKER\_PASSWORD \}\}
\- name\: Build and push Docker image
uses\: docker/build\-push\-action@v4
with\:
context\: \.
file\: Dockerfile\.production
push\: true
tags\: \|
{IMAGE_REPOSITORY}/{IMAGE_NAME}\:</span>{{ github.event.inputs.tag || github.ref_name }}

      - name: Deploy via SSH
        uses: appleboy/[email protected]
        with:
          host: ${{ secrets.SSH_HOST }}
          username: ${{ secrets.SSH_USER }}
          key: <span class="math-inline">\{\{ secrets\.SSH\_KEY \}\}
script\: \|
docker pull {IMAGE_REPOSITORY}/{IMAGE_NAME}\:</span>{{ github.event.inputs.tag || github.ref_name }}

            docker stop {IMAGE_NAME} || true
            docker rm {IMAGE_NAME} || true

            docker run -d --name {IMAGE_NAME} \
            -v /var/www/html/{FOLDER_NAME}/.env:/var/www/html/.env \
            -v /var/www/html/{FOLDER_NAME}/database/database.sqlite:/var/www/html/database/database.sqlite \
            -v /var/www/html/{FOLDER_NAME}/storage/logs:/var/www/html/storage/logs \
            -p 80:80 \
            --env-file /var/www/html/{FOLDER_NAME}/.env \
            {IMAGE_REPOSITORY}/{IMAGE_NAME}:${{ github.event.inputs.tag || github.ref_name }}

Let's break down the key sections of this workflow:

  • Triggers (on):

    • push: tags: - '*': The workflow runs on pushes to any Git tag.
    • workflow_dispatch: Allows manual triggering with a tag input.
    • workflow_call: Enables calling this workflow from other workflows with a tag input.
  • Job (build-and-push):

    • runs-on: ubuntu-latest: Specifies the job runs on an ubuntu-latest runner.
    • Checkout code: Uses actions/checkout@v3 to get the repository code.
    • Log in to Docker Hub: Uses docker/login-action@v2 with DOCKER_USERNAME and DOCKER_PASSWORD secrets.
    • Build and push Docker image: Uses docker/build-push-action@v4.
      • context: .: Builds from the current directory.
      • file: Dockerfile: Specifies the Dockerfile to use.
      • push: true: Instructs the action to push the built image.
      • tags: {IMAGE_REPOSITORY}/{IMAGE_NAME}:${{ github.event.inputs.tag || github.ref_name }}: Tags the image appropriately.
    • Deploy via SSH: Uses appleboy/[email protected] to deploy via SSH.
      • host, username, key: Retrieved from GitHub Secrets (SSH_HOST, SSH_USER, SSH_KEY).
      • script: Contains commands executed on the remote VM:
        • docker pull {IMAGE_REPOSITORY}/{IMAGE_NAME}:${{ github.event.inputs.tag || github.ref_name }}: Pulls the Docker image.
        • docker stop {IMAGE_NAME} || true and docker rm {IMAGE_NAME} || true: Stops and removes any existing container named {IMAGE_NAME}.
        • docker run -d --name {IMAGE_NAME} ...: Starts a new Docker container.
          • -d: Runs in detached mode.
          • --name {IMAGE_NAME}: Assigns the name {IMAGE_NAME} to the container.
          • -v /var/www/html/{FOLDER_NAME}/.env:/var/www/html/.env: Mounts the host's .env file.
          • -v /var/www/html/{FOLDER_NAME}/database/database.sqlite:/var/www/html/database/database.sqlite: Mounts a persistent SQLite database file.
          • -v /var/www/html/{FOLDER_NAME}/storage/logs:/var/www/html/storage/logs: Mounts the Laravel logs directory.
          • -p 80:80: Maps port 80 on the host to port 80 in the container.
          • --env-file /var/www/html/{FOLDER_NAME}/.env: Loads environment variables from the host's .env file.
          • {IMAGE_REPOSITORY}/{IMAGE_NAME}:${{ github.event.inputs.tag || github.ref_name }}: Specifies the Docker image to run.

Setting Up Your DigitalOcean VM

DigitalOcean Referral Badge

  • Install Docker:
    Follow the official Docker installation guide for your VM's operating system.

  • SSH Access:
    Ensure your GitHub Actions runner has SSH access to your VM.

    • Add the public part of the SSH key (corresponding to your SSH_KEY secret) to the authorized_keys file on your VM for the SSH_USER.
  • Directory Structure:
    Create the necessary directories on your VM:

    • /var/www/html/{FOLDER_NAME}/
    • /var/www/html/{FOLDER_NAME}/database/
    • /var/www/html/{FOLDER_NAME}/storage/logs/
  • .env File:
    Create your Laravel .env file in /var/www/html/{FOLDER_NAME}/.env on your VM with your production environment variables.

  • Web Server (Nginx/Apache):
    Configure Nginx or Apache on your VM to reverse proxy requests to your Docker container's exposed port (e.g., localhost:80). This is essential for serving your application to the public.


Docker Healthchecks

A Docker health check allows you to monitor the status of your running container and ensure your Laravel application is responding as expected. By defining a health check in your Dockerfile, Docker can automatically detect if your app becomes unresponsive or fails, and mark the container as unhealthy. This is especially useful in production and automated deployment environments, as it enables orchestration tools or monitoring systems to take action (like restarting the container) if something goes wrong.

  • Automatic Recovery: If your application crashes or hangs, Docker can detect it and restart the container.
  • Improved Monitoring: Health checks provide real-time feedback on your app’s status, making it easier to spot issues early.
  • Better Orchestration: Tools like Docker Compose, Kubernetes, or cloud platforms can use health status to manage traffic and scaling.

Add the following HEALTHCHECK instruction near the end of your Dockerfile, just before the CMD or ENTRYPOINT:

HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
  CMD wget --no-verbose --tries=1 --spider http://localhost/ || exit 1

Explanation:

  • HEALTHCHECK: Defines the health check for the container.
  • --interval=30s: Runs the check every 30 seconds.
  • --timeout=5s: Fails the check if it takes longer than 5 seconds.
  • --start-period=10s: Waits 10 seconds after container start before running checks.
  • --retries=3: Marks the container as unhealthy after 3 consecutive failures.
  • The CMD uses wget to request the main page of your Laravel app. If the request fails, the container is marked as unhealthy.

Tip: For more granular checks, you can create a dedicated /health route in your Laravel app and update the URL in the health check command.

By including a health check, you make your Dockerized Laravel application more robust and production-ready.

Best Practices and SEO Considerations

  • Semantic Versioning:
    Consistently use semantic versioning (e.g., v1.0.0, v1.0.1, v2.0.0) for your Git tags to clearly communicate changes and manage deployments.

  • Security:

    • GitHub Secrets: Always use GitHub Secrets for sensitive information like Docker Hub credentials and SSH keys. Never hardcode them in your workflow files.
    • .env File Security: Protect your .env file on the VM with strict file permissions.
    • Least Privilege: Configure your SSH user on the VM with the minimum necessary permissions to perform Docker operations.
  • Logging and Monitoring:
    Ensure your Laravel logs are externalized (as shown with the volume mount) so you can monitor your application's health. Consider centralized logging solutions for production.

  • Database Strategy:
    For production, consider using a managed database service (e.g., DigitalOcean Managed Databases for MySQL/PostgreSQL) instead of SQLite within the VM for better scalability, reliability, and backup solutions.

  • This example is using SQLite for simplicity, but for larger applications, a more robust database like MySQL or PostgreSQL is recommended.

  • Nginx/Apache Configuration:
    Optimize your web server configuration for performance, including caching, gzip compression, and SSL/TLS.

  • SEO & Speed:
    Fast deployment allows for quicker iteration and fixes, indirectly helping SEO. Ensure your Laravel application is optimized for speed (e.g., optimized queries, caching, minified assets) as page load time is a significant ranking factor.

  • Error Pages:
    Implement custom 404 and 500 error pages within Laravel for a better user experience and to prevent visitors from encountering generic server errors.


Conclusion:
By embracing Docker and GitHub Actions, you can transform your Laravel deployment process from a manual, error-prone task into an automated, reliable, and efficient pipeline. This approach saves time, reduces stress, and lays the groundwork for more complex and scalable architectures in the future. Start tagging your releases and watch your Laravel application seamlessly deploy with confidence!