Table of Contents
- Introduction
- Why Dockerize Your Laravel Application?
- The Foundation: Your Dockerfile and Entrypoint Script
- Automated Deployment with GitHub Actions
- Setting Up Your DigitalOcean VM
- Best Practices and SEO Considerations
- 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
andRUN 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 ...
andchown ...
,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
entrypoint.sh
The Entrypoint Script: 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
towww-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
): Thewait
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.
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 anubuntu-latest
runner.- Checkout code: Uses
actions/checkout@v3
to get the repository code. - Log in to Docker Hub: Uses
docker/login-action@v2
withDOCKER_USERNAME
andDOCKER_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
anddocker 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
-
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 theauthorized_keys
file on your VM for theSSH_USER
.
- Add the public part of the SSH key (corresponding to your
-
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.
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!