Why Docker for Laravel Deployment?
Moving from traditional deployment methods to Docker offers significant advantages for Laravel applications. Instead of managing PHP versions, extensions, and dependencies directly on servers, Docker allows you to package everything into consistent, reproducible containers. This means no more “it works on my machine” problems and easier maintenance across staging and production environments.
For a recent project, we migrated from a traditional deployment setup using Deployer to a fully containerized Docker solution. This article walks through the implementation, focusing on three key challenges: accessing private package repositories during builds, running separate containers for web, queue, and scheduled tasks, and automating deployments with Portainer and Watchtower.
Docker Architecture: Multi-Stage Builds
The foundation of our setup is a multi-stage Dockerfile that efficiently builds our Laravel application. We use ServerSideUp’s PHP Docker images as our base, which come pre-configured with PHP-FPM and Nginx.
Base Image Setup
The base stage installs all necessary PHP extensions:
FROM serversideup/php:8.3-fpm-nginx AS base
USER root
RUN install-php-extensions intl bcmath gd exif imagick
Development vs Production Stages
For local development, we create a stage that maps user IDs to avoid permission issues:
FROM base AS development
ARG USER_ID
ARG GROUP_ID
USER root
RUN docker-php-serversideup-set-id www-data $USER_ID:$GROUP_ID && \
docker-php-serversideup-set-file-permissions --owner $USER_ID:$GROUP_ID --service nginx
For CI and production, we use different configurations to ensure proper permissions and security.
Accessing Private Repositories During Build
One of the trickiest parts of containerizing a Laravel application is accessing private packages during the build process. Our project requires access to three types of private repositories:
- GitHub private repositories (for custom packages)
- Repman private registry (for internal packages)
- Font Awesome Pro (via npm)
Composer Dependencies with Build Secrets
Docker BuildKit’s secret mounting feature allows us to securely pass tokens during build without baking them into the image:
FROM base AS vendor
WORKDIR /var/www/html
USER root
# Install git for composer
RUN apt-get update && apt-get install -y git && rm -rf /var/lib/apt/lists/*
COPY composer.json composer.lock ./
RUN --mount=type=secret,id=GITHUB_TOKEN \
--mount=type=secret,id=REPMAN_TOKEN \
git config --global url."https://x-access-token:$(cat /run/secrets/GITHUB_TOKEN)@github.com/".insteadOf "git@github.com:" && \
composer config --global --auth http-basic.org.repman.org token "$(cat /run/secrets/REPMAN_TOKEN)" && \
composer install --no-dev --no-scripts --optimize-autoloader --no-interaction
This approach configures git to use the GitHub token for HTTPS access and sets up authentication for the Repman registry, all without exposing tokens in the final image.
NPM Private Packages
For Font Awesome Pro, we use a similar approach in the assets build stage:
FROM node:21 AS assets
WORKDIR /var/www/html
COPY package.json package-lock.json ./
RUN --mount=type=secret,id=FONT_AWESOME_TOKEN \
npm config set "@fortawesome:registry" https://npm.fontawesome.com/ && \
npm config set "//npm.fontawesome.com/:_authToken" "$(cat /run/secrets/FONT_AWESOME_TOKEN)" && \
npm ci
COPY . .
COPY --from=vendor /var/www/html/vendor ./vendor
RUN npm run build
GitHub Actions Integration
In your GitHub Actions workflow, you pass these secrets during the build:
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
file: ./Dockerfile
target: deploy
push: true
tags: ${{ steps.meta.outputs.tags }}
secrets: |
FONT_AWESOME_TOKEN=${{ secrets.FONT_AWESOME_TOKEN }}
REPMAN_TOKEN=${{ secrets.REPMAN_TOKEN }}
GITHUB_TOKEN=${{ secrets.GITHUB_TOKEN }}
Three Container Architecture: Web, Queue, and Scheduler
A common pattern in Laravel deployments is running separate processes for different responsibilities. Our setup uses three containers, all built from the same image but with different entry points.
Container 1: Web Server
The main PHP container runs PHP-FPM with Nginx:
services:
php:
image: ghcr.io/org/project-production-php:latest
container_name: project-production-php
ports:
- 8080:8080
environment:
AUTORUN_ENABLED: true
OPCACHE_ENABLED: true
AUTORUN_LARAVEL_ROUTE_CACHE: false
AUTORUN_LARAVEL_VIEW_CACHE: false
volumes:
- storage:/var/www/html/storage
- cache:/var/www/html/bootstrap/cache
- lang:/var/www/html/lang
This container handles all HTTP requests and serves your Laravel application.
Container 2: Queue Worker
The queue container runs Laravel’s queue worker for background job processing:
queue:
image: ghcr.io/org/project-production-php:latest
container_name: project-production-queue
command: ["php", "/var/www/html/artisan", "queue:work", "--tries=3"]
stop_signal: SIGTERM
volumes:
- storage:/var/www/html/storage
- cache:/var/www/html/bootstrap/cache
- lang:/var/www/html/lang
healthcheck:
test: ["CMD", "healthcheck-queue"]
start_period: 10s
The SIGTERM signal ensures graceful shutdown, allowing jobs to complete before stopping. The ServerSideUp image includes a built-in healthcheck-queue command that monitors queue worker health.
Container 3: Scheduler
The scheduler container runs Laravel’s schedule worker for cron tasks:
task:
image: ghcr.io/org/project-production-php:latest
container_name: project-production-task
command: ["php", "/var/www/html/artisan", "schedule:work"]
stop_signal: SIGTERM
volumes:
- storage:/var/www/html/storage
- cache:/var/www/html/bootstrap/cache
- lang:/var/www/html/lang
healthcheck:
test: ["CMD", "healthcheck-schedule"]
start_period: 10s
This eliminates the need for cron configuration on the host system. The schedule:work command runs continuously and executes scheduled tasks at the right time.
Shared Environment Variables
All three containers share the same environment configuration using YAML anchors:
x-env: &default-env
APP_NAME: ${APP_NAME}
APP_ENV: ${APP_ENV}
APP_KEY: ${APP_KEY}
DB_CONNECTION: ${DB_CONNECTION}
DB_HOST: ${DB_HOST}
# ... other environment variables
services:
php:
environment:
<<: *default-env
# Additional web-specific variables
queue:
environment:
<<: *default-env
task:
environment:
<<: *default-env
This ensures consistency across all containers while keeping your docker-compose file DRY.
Shared Volumes
All three containers share persistent volumes for data that needs to survive container restarts:
volumes:
storage: # Laravel storage directory
cache: # Bootstrap cache
lang: # Language files
These volumes are mapped on the host system by your container orchestration tool (in our case, Portainer).
Automated Deployment with Portainer and Watchtower
While the PR focused on the Docker setup, deployment automation is achieved through Portainer for management and Watchtower for automatic updates.
Portainer Setup
Portainer provides a web interface for managing Docker containers. We have two stacks:
project-staging- for the staging environmentproject-production- for the production environment
Each stack contains the respective docker-compose file (staging or production). Since GitHub access from Portainer was problematic, we copied the docker-compose files directly into Portainer’s stack editor.
Read more about Portainer stacks here.
Important: When you update docker-compose files in your repository, remember to update them in Portainer as well.
Environment Variables in Portainer
Each Portainer stack has its own environment variables configured through the UI.
When adding new environment variables:
- Add them to the docker-compose file in your repository
- Add them to the Portainer stack environment variables
- Trigger a rebuild (more on this below)
Volume Mapping
Portainer manages persistent volumes for each stack. The volumes are mapped by your infrastructure team to ensure data persistence:
storage- Laravel’s storage directorycache- Bootstrap cache directorylang- Language files directory
These volumes survive container updates, preserving your application data across deployments.
Watchtower: Automated Image Updates
Watchtower is a container that monitors your running containers and automatically updates them when new images are available. In our setup, Watchtower checks for new images daily at 2:00 AM.
When you push to the staging branch or create a new git tag (for production):
- GitHub Actions builds a new Docker image
- The image is pushed to GitHub Container Registry (
ghcr.io) - Watchtower detects the new image during its next check
- Watchtower pulls the new image
- Watchtower gracefully stops the old containers
- Watchtower starts new containers with the updated image
This provides zero-downtime deployments without manual intervention.
Manual Deployment via Portainer
If you need to deploy immediately without waiting for Watchtower:
- Navigate to your stack in Portainer
- Click “Update the stack”
- Check the “Pull latest image” checkbox in the modal
- Click “Update”
Portainer will pull the latest image from the registry and restart your containers.
Key Takeaways
Migrating to Docker for Laravel deployment offers several benefits:
Pros:
- Consistent environments across development, staging, and production
- Easy PHP and extension version management
- Separation of concerns with dedicated containers
- Automated deployments with minimal manual intervention
- Secure handling of private repository access during builds
Challenges:
- Initial setup complexity with multi-stage builds
- Managing secrets across GitHub Actions and container registries
- Keeping docker-compose files synchronized between repository and Portainer
- Understanding volume persistence and container networking
Best Practices:
- Use multi-stage builds to keep final images small and secure
- Never bake secrets into images; use BuildKit secret mounting
- Run separate containers for web, queue, and scheduler responsibilities
- Implement health checks for non-web containers
- Use Watchtower for automated updates with scheduled checks
- Maintain documentation about manual deployment procedures
The transition from traditional deployment to Docker requires upfront investment, but the payoff in reliability, consistency, and ease of maintenance makes it worthwhile for Laravel applications of any significant size.
Files
.github/workflows/docker-build-staging.yml
name: Build and Push Docker Image
on:
push:
branches:
- staging
env:
REGISTRY: ghcr.io
IMAGE_NAME: org/project-staging-php
jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Log in to the Container registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=sha,prefix={{date 'YYYYMMDD'}}-
type=raw,value=latest,enable=${{ github.ref == format('refs/heads/{0}', 'staging') }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
file: ./Dockerfile
target: deploy
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
secrets: |
FONT_AWESOME_TOKEN=${{ secrets.FONT_AWESOME_TOKEN }}
REPMAN_TOKEN=${{ secrets.REPMAN_TOKEN }}
GITHUB_TOKEN=${{ secrets.GITHUB_TOKEN }}
docker-compose.staging.yml
x-env: &default-env
APP_NAME: ${APP_NAME}
APP_ENV: ${APP_ENV}
APP_KEY: ${APP_KEY}
APP_DEBUG: ${APP_DEBUG}
APP_URL: ${APP_URL}
# More environment variables
services:
php:
image: ghcr.io/org/project-staging-php:latest
container_name: project-staging-php
ports:
- 8080:8080
environment:
<<: *default-env
AUTORUN_ENABLED: true
OPCACHE_ENABLED: true
AUTORUN_LARAVEL_ROUTE_CACHE: false
AUTORUN_LARAVEL_VIEW_CACHE: false
volumes:
- storage:/var/www/html/storage
- cache:/var/www/html/bootstrap/cache
- lang:/var/www/html/lang
task:
image: ghcr.io/org/project-staging-php:latest
container_name: project-staging-task
environment:
<<: *default-env
volumes:
- storage:/var/www/html/storage
- cache:/var/www/html/bootstrap/cache
- lang:/var/www/html/lang
command: [ "php", "/var/www/html/artisan", "schedule:work" ]
stop_signal: SIGTERM # Set this for graceful shutdown if you're using fpm-apache or fpm-nginx
healthcheck:
# This is our native healthcheck script for the scheduler
test: [ "CMD", "healthcheck-schedule" ]
start_period: 10s
queue:
image: ghcr.io/org/project-staging-php:latest
container_name: project-staging-queue
environment:
<<: *default-env
volumes:
- storage:/var/www/html/storage
- cache:/var/www/html/bootstrap/cache
- lang:/var/www/html/lang
command: [ "php", "/var/www/html/artisan", "queue:work", "--tries=3" ]
stop_signal: SIGTERM # Set this for graceful shutdown if you're using fpm-apache or fpm-nginx
healthcheck:
# This is our native healthcheck script for the queue
test: [ "CMD", "healthcheck-queue" ]
start_period: 10s
volumes:
storage:
cache:
lang:
Dockerfile
############################################
# Base Image
############################################
# Learn more about the Server Side Up PHP Docker Images at:
# https://serversideup.net/open-source/docker-php/
FROM serversideup/php:8.4-fpm-nginx AS base
# Switch to root before installing our PHP extensions
USER root
RUN install-php-extensions intl bcmath gd exif imagick
############################################
# Development Image
############################################
FROM base AS development
# We can pass USER_ID and GROUP_ID as build arguments
# to ensure the www-data user has the same UID and GID
# as the user running Docker.
ARG USER_ID
ARG GROUP_ID
# Switch to root so we can set the user ID and group ID
USER root
RUN docker-php-serversideup-set-id www-data $USER_ID:$GROUP_ID && \
docker-php-serversideup-set-file-permissions --owner $USER_ID:$GROUP_ID --service nginx
############################################
# CI image
############################################
FROM base AS ci
# Sometimes CI images need to run as root
# so we set the ROOT user and configure
# the PHP-FPM pool to run as www-data
USER root
RUN echo "user = www-data" >> /usr/local/etc/php-fpm.d/docker-php-serversideup-pool.conf && \
echo "group = www-data" >> /usr/local/etc/php-fpm.d/docker-php-serversideup-pool.conf
############################################
# Vendor Build Stage
############################################
FROM base AS vendor
WORKDIR /var/www/html
USER root
# Install git for composer
RUN apt-get update && apt-get install -y git && rm -rf /var/lib/apt/lists/*
COPY composer.json composer.lock ./
RUN --mount=type=secret,id=GITHUB_TOKEN \
--mount=type=secret,id=REPMAN_TOKEN \
git config --global url."https://x-access-token:$(cat /run/secrets/GITHUB_TOKEN)@github.com/".insteadOf "git@github.com:" && \
composer config --global --auth http-basic.org.repman.com token "$(cat /run/secrets/REPMAN_TOKEN)" && \
composer install --no-dev --no-scripts --optimize-autoloader --no-interaction
############################################
# Assets Build Stage
############################################
FROM node:21 AS assets
WORKDIR /var/www/html
COPY package.json package-lock.json ./
# Configure FontAwesome registry with token and install dependencies
RUN --mount=type=secret,id=FONT_AWESOME_TOKEN \
npm config set "@fortawesome:registry" https://npm.fontawesome.com/ && \
npm config set "//npm.fontawesome.com/:_authToken" "$(cat /run/secrets/FONT_AWESOME_TOKEN)" && \
npm ci
# Copy application files and vendor from previous stage
COPY . .
COPY --from=vendor /var/www/html/vendor ./vendor
# Build assets
RUN npm run build
############################################
# Production Image
############################################
FROM base AS deploy
WORKDIR /var/www/html
# Copy application files
COPY --chown=www-data:www-data . /var/www/html
# Copy vendor from vendor stage
COPY --from=vendor /var/www/html/vendor ./vendor
# Copy built assets from assets stage
COPY --from=assets /var/www/html/public/build ./public/build
# Run post-autoload-dump scripts and optimize
USER root
RUN composer dump-autoload --optimize --classmap-authoritative && \
mkdir -p /var/www/html/lang && \
chown -R www-data:www-data /var/www/html/lang
# Switch back to www-data user
USER www-data