Optimizing Laravel Forge Deployments with Docker: Advanced Strategies for Scalability and Resilience
Leveraging Docker for Enhanced Laravel Forge Deployments
While Laravel Forge excels at simplifying server provisioning and deployment for PHP applications, integrating Docker can elevate these deployments to a new level of consistency, scalability, and resilience. This approach moves beyond Forge’s default PHP-FPM setup, enabling more granular control over the application environment and its dependencies. We’ll explore advanced strategies for building robust Docker images, managing multi-container applications, and optimizing deployment pipelines when using Forge.
Crafting Production-Ready Dockerfiles for Laravel
A well-structured Dockerfile is the cornerstone of a successful Dockerized Laravel application. The goal is to create lean, secure, and reproducible images. We’ll start with a multi-stage build to minimize the final image size and include only necessary production artifacts.
Consider the following Dockerfile structure:
# Stage 1: Builder
FROM composer:latest as builder
WORKDIR /app
# Copy composer files and install dependencies
COPY composer.json composer.lock ./
RUN composer install --no-dev --optimize-autoloader --no-interaction
# Copy application code
COPY . .
# Build frontend assets (if applicable)
# RUN npm install && npm run build
# Stage 2: Production Image
FROM php:8.2-fpm-alpine
# Install necessary PHP extensions and system packages
RUN apk update && apk add --no-cache \
git \
zip \
unzip \
libzip-dev \
libpng-dev \
libjpeg-turbo-dev \
freetype-dev \
icu-dev \
postgresql-dev \
# Add other necessary extensions here, e.g., for Redis, Memcached
&& docker-php-ext-configure gd --with-freetype --with-jpeg \
&& docker-php-ext-install -j$(nproc) gd \
&& docker-php-ext-install pdo pdo_mysql zip intl opcache \
&& apk del --no-cache libzip-dev
# Copy optimized autoloader and vendor directory from builder stage
COPY --from=builder /app/vendor /var/www/html/vendor
COPY --from=builder /app /var/www/html
# Set application directory
WORKDIR /var/www/html
# Ensure correct permissions for storage and bootstrap/cache
RUN chown -R www-data:www-data /var/www/html/storage /var/www/html/bootstrap/cache \
&& chmod -R 775 /var/www/html/storage /var/www/html/bootstrap/cache
# Expose port 9000 for PHP-FPM
EXPOSE 9000
# Set the entrypoint to run PHP-FPM
CMD ["php-fpm"]
Key considerations in this Dockerfile:
- Multi-stage builds: The `builder` stage uses a Composer image to install dependencies and potentially build assets. The final stage uses a lean `php:8.2-fpm-alpine` image, copying only the necessary `vendor` directory and application code, significantly reducing the final image size.
- Essential Extensions: We explicitly install and enable common PHP extensions like `gd`, `pdo`, `zip`, and `intl`. Ensure you add any other extensions your application relies on (e.g., `redis`, `memcached`).
- Permissions: Correctly setting permissions for `storage` and `bootstrap/cache` is crucial for Laravel to function correctly.
- `php-fpm` as CMD: This is standard for running PHP applications behind a web server like Nginx.
Orchestrating with Docker Compose for Development and Staging
For local development and staging environments, Docker Compose is invaluable for defining and running multi-container applications. This allows you to easily spin up your Laravel application alongside databases, Redis, queues, and other services.
A typical `docker-compose.yml` might look like this:
version: '3.8'
services:
app:
build:
context: .
dockerfile: Dockerfile
ports:
- "8000:9000" # Map host port 8000 to container port 9000 (PHP-FPM)
volumes:
- .:/var/www/html # Mount application code for live changes
depends_on:
- db
- redis
environment:
DB_HOST: db
DB_PORT: 3306
DB_DATABASE: laravel_db
DB_USERNAME: user
DB_PASSWORD: password
REDIS_HOST: redis
REDIS_PORT: 6379
APP_ENV: local
APP_DEBUG: true
nginx:
image: nginx:alpine
ports:
- "80:80" # Map host port 80 to container port 80
volumes:
- .:/var/www/html # Mount application code
- ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf # Nginx configuration
depends_on:
- app
db:
image: mysql:8.0
ports:
- "33066:3306" # Map host port 33066 to container port 3306
volumes:
- db_data:/var/lib/mysql
environment:
MYSQL_ROOT_PASSWORD: rootpassword
MYSQL_DATABASE: laravel_db
MYSQL_USER: user
MYSQL_PASSWORD: password
redis:
image: redis:alpine
ports:
- "63790:6379" # Map host port 63790 to container port 6379
volumes:
db_data:
And the corresponding Nginx configuration (`docker/nginx/default.conf`):
server {
listen 80;
server_name localhost;
root /var/www/html/public;
index index.php index.html index.htm;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ \.php$ {
fastcgi_split_path_info ^(.+\.php)(/.+)$;
fastcgi_pass app:9000; # Connect to the PHP-FPM service
fastcgi_index index.php;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_param PATH_INFO $fastcgi_path_info;
}
location ~ /\.ht {
deny all;
}
# Serve static files directly
location ~* \.(css|js|jpg|jpeg|gif|png|ico|svg|webp)$ {
expires 1y;
add_header Cache-Control "public";
}
}
This setup provides a self-contained development environment. The `app` service builds from your Dockerfile, the `nginx` service acts as the web server proxying requests to `php-fpm`, and `db` and `redis` provide essential services. The volume mounts allow for hot-reloading of code during development.
Integrating Docker with Laravel Forge Deployments
Integrating Docker with Forge requires a shift in how deployments are managed. Instead of Forge directly installing PHP and dependencies on the server, Forge will be used to deploy your Docker image and manage the container orchestration. This typically involves:
1. Building and Pushing Docker Images
You’ll need a Docker registry (like Docker Hub, AWS ECR, or GitLab Container Registry) to store your built images. A CI/CD pipeline (e.g., GitHub Actions, GitLab CI, Jenkins) is the ideal place to automate this process. The pipeline should:
- Checkout your application code.
- Build the Docker image using your `Dockerfile`.
- Tag the image appropriately (e.g., with the Git commit SHA or a semantic version).
- Push the tagged image to your chosen Docker registry.
A simplified GitHub Actions workflow for this:
name: Build and Push Docker Image
on:
push:
branches:
- main # Or your production branch
jobs:
build_and_push:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Log in to Docker Hub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build and push Docker image
uses: docker/build-push-action@v4
with:
context: .
file: ./Dockerfile
push: true
tags: your-dockerhub-username/your-app-name:${{ github.sha }} # Tag with commit SHA
cache-from: type=registry,ref=your-dockerhub-username/your-app-name:buildcache
cache-to: type=registry,ref=your-dockerhub-username/your-app-name:buildcache,mode=max
2. Configuring Forge for Docker Deployments
On your Forge-provisioned server, you’ll need Docker and Docker Compose installed. Forge can handle this via its “Custom Commands” or by selecting a server image that already includes Docker. The deployment process in Forge will then involve:
- SSHing into the server.
- Pulling the latest Docker image from your registry.
- Stopping and removing the old container.
- Starting a new container with the updated image.
You can achieve this by setting up a deployment script on the server and triggering it via Forge’s “Deploy Script” or “Post-Deployment” hooks. Alternatively, you can use Forge’s “Custom Deploy Script” feature.
Example of a deployment script (`deploy.sh`) to be placed on the server:
#!/bin/bash
set -e # Exit immediately if a command exits with a non-zero status.
# --- Configuration ---
IMAGE_NAME="your-dockerhub-username/your-app-name"
CONTAINER_NAME="laravel_app"
REGISTRY_URL="docker.io" # Or your specific registry URL
DOCKER_REGISTRY_USER="${DOCKER_REGISTRY_USER:-}" # Set via Forge environment variables
DOCKER_REGISTRY_PASS="${DOCKER_REGISTRY_PASS:-}" # Set via Forge environment variables
APP_ENV="${APP_ENV:-production}" # Set via Forge environment variables
DB_HOST="${DB_HOST:-db}"
DB_PORT="${DB_PORT:-3306}"
DB_DATABASE="${DB_DATABASE:-laravel_db}"
DB_USERNAME="${DB_USERNAME:-user}"
DB_PASSWORD="${DB_PASSWORD:-password}"
REDIS_HOST="${REDIS_HOST:-redis}"
REDIS_PORT="${REDIS_PORT:-6379}"
# --- End Configuration ---
echo "Starting deployment..."
# Login to Docker registry if credentials are provided
if [ -n "$DOCKER_REGISTRY_USER" ] && [ -n "$DOCKER_REGISTRY_PASS" ]; then
echo "Logging into Docker registry..."
echo "$DOCKER_REGISTRY_PASS" | docker login $REGISTRY_URL -u "$DOCKER_REGISTRY_USER" --password-stdin
fi
# Pull the latest image
LATEST_TAG=$(curl -s "https://registry.hub.docker.com/v2/repositories/${IMAGE_NAME}/tags/" | jq -r '.results[0].name') # Example for Docker Hub, adapt for others
if [ -z "$LATEST_TAG" ]; then
echo "Could not determine latest tag. Exiting."
exit 1
fi
echo "Pulling image: ${IMAGE_NAME}:${LATEST_TAG}"
docker pull ${IMAGE_NAME}:${LATEST_TAG}
# Stop and remove existing container if it exists
if [ "$(docker ps -q -f name=${CONTAINER_NAME})" ]; then
echo "Stopping existing container: ${CONTAINER_NAME}"
docker stop ${CONTAINER_NAME}
fi
if [ "$(docker ps -aq -f status=exited -f name=${CONTAINER_NAME})" ]; then
echo "Removing existing container: ${CONTAINER_NAME}"
docker rm ${CONTAINER_NAME}
fi
# Start new container
echo "Starting new container: ${CONTAINER_NAME}"
docker run -d \
--name ${CONTAINER_NAME} \
-p 80:80 \
-e APP_ENV=${APP_ENV} \
-e APP_DEBUG=false \
-e DB_HOST=${DB_HOST} \
-e DB_PORT=${DB_PORT} \
-e DB_DATABASE=${DB_DATABASE} \
-e DB_USERNAME=${DB_USERNAME} \
-e DB_PASSWORD=${DB_PASSWORD} \
-e REDIS_HOST=${REDIS_HOST} \
-e REDIS_PORT=${REDIS_PORT} \
--restart unless-stopped \
${IMAGE_NAME}:${LATEST_TAG}
# Clean up old images (optional, use with caution)
# docker image prune -f
echo "Deployment complete."
In Forge, navigate to your site’s deployment settings. Under “Deployment Script” or “Post-Deployment,” you can set this script to execute. Ensure you configure the necessary environment variables (like `DOCKER_REGISTRY_USER`, `DOCKER_REGISTRY_PASS`, `APP_ENV`, `DB_HOST`, etc.) in Forge’s environment settings for the server. The `jq` command is used here to parse JSON from the Docker Hub API to get the latest tag; you might need to install `jq` on your server (`apt-get update && apt-get install -y jq`).
Advanced Strategies for Scalability and Resilience
1. Multi-Container Orchestration with Docker Compose on Server
For more complex setups, especially if you’re not using a managed Kubernetes service, you can deploy your application using Docker Compose directly on the Forge server. This requires a `docker-compose.yml` file that is tailored for production (e.g., no volume mounts for code, proper network configurations).
Your production `docker-compose.yml` might look like this:
version: '3.8'
services:
app:
image: your-dockerhub-username/your-app-name:${CI_COMMIT_SHA:-latest} # Use CI_COMMIT_SHA from CI or fallback to latest
ports:
- "9000" # Expose PHP-FPM port internally
volumes:
- /var/www/html/storage:/var/www/html/storage # Persist storage
- /var/www/html/bootstrap/cache:/var/www/html/bootstrap/cache # Persist cache
environment:
APP_ENV: production
APP_DEBUG: false
DB_HOST: db
DB_PORT: 3306
DB_DATABASE: laravel_db
DB_USERNAME: user
DB_PASSWORD: password
REDIS_HOST: redis
REDIS_PORT: 6379
networks:
- app-network
restart: unless-stopped
nginx:
image: nginx:alpine
ports:
- "80:80"
- "443:443" # For SSL
volumes:
- ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf # Production Nginx config
- /etc/letsencrypt:/etc/letsencrypt:ro # For SSL certificates
- /var/www/html/public:/var/www/html/public # Mount public dir for Nginx
depends_on:
- app
networks:
- app-network
restart: unless-stopped
db:
image: mysql:8.0
ports:
- "33066:3306" # Expose for potential external access or debugging
volumes:
- db_data:/var/lib/mysql
environment:
MYSQL_ROOT_PASSWORD: rootpassword
MYSQL_DATABASE: laravel_db
MYSQL_USER: user
MYSQL_PASSWORD: password
networks:
- app-network
restart: unless-stopped
redis:
image: redis:alpine
ports:
- "63790:6379" # Expose for potential external access or debugging
networks:
- app-network
restart: unless-stopped
networks:
app-network:
driver: bridge
volumes:
db_data:
In this scenario, your Forge deployment script would:
- SSH into the server.
- Pull the latest image (e.g., `docker pull your-dockerhub-username/your-app-name:latest`).
- Navigate to the directory containing your production `docker-compose.yml`.
- Run `docker-compose pull` to pull all service images.
- Run `docker-compose up -d –remove-orphans` to bring up the services.
This provides a more robust way to manage multiple interconnected services. Forge’s role is reduced to ensuring Docker is installed and then executing these `docker-compose` commands.
2. Health Checks and Rollbacks
Implementing health checks is crucial for resilience. Your Docker image can include a health check command that Docker can periodically run to determine if the container is healthy. For example, adding to your Dockerfile:
# In your Dockerfile HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ CMD curl -f http://localhost:9000/health || exit 1
This assumes you have a `/health` endpoint in your Laravel application that returns a 200 OK status if the application is running correctly. This health check can be integrated with orchestration tools or your deployment script to automatically roll back if a new deployment fails.
For automated rollbacks, your deployment script would need to track previous image tags. If the new deployment fails its health checks, the script can revert to the previous known-good image.
3. Optimizing for CI/CD and Forge Integration
The most effective strategy is to decouple the build process from the deployment process. Your CI/CD pipeline is responsible for building and pushing images. Forge’s role becomes purely orchestration: pulling the image and running the containers.
Forge’s “Custom Deploy Script” is ideal for this. It allows you to execute arbitrary shell scripts on your server after a successful Git pull (or in your case, after a manual trigger or a simple webhook). You can configure a webhook in your CI/CD pipeline to trigger a deployment on Forge once a new Docker image is ready.
The webhook payload could contain the new image tag, which your Forge deployment script then uses to pull and deploy the specific version.
Conclusion
By integrating Docker with Laravel Forge, you gain significant advantages in consistency, portability, and manageability. Moving from Forge’s default PHP-FPM deployments to a Docker-centric approach, especially when combined with robust CI/CD practices, allows for more sophisticated scaling and resilience strategies. The key is to leverage Dockerfiles for environment definition, Docker Compose for orchestration, and a CI/CD pipeline for automated image building and pushing, with Forge acting as the final orchestrator on your production servers.