Building a Modern CI/CD Pipeline for Laravel with GitHub Actions and Docker
Manual FTP uploads and SSH deployment commands are recipes for production outages. Here is how to build an automated, zero-downtime CI/CD pipeline for Laravel using GitHub Actions and Docker.
Deploying software manually — whether by dragging files over FTP, running git pull directly on production servers, or manually executing database migrations over SSH — is one of the most common causes of downtime and human error in engineering teams.
A robust Continuous Integration and Continuous Deployment (CI/CD) pipeline ensures that every commit is tested, linted, analyzed for type safety, and deployed automatically without interrupting active users.
In this guide, I share the GitHub Actions pipeline I use to deliver zero-downtime deployments for production Laravel applications.
Pipeline Architecture Overview
A professional deployment pipeline consists of two stages:
- Continuous Integration (CI): Runs on every Pull Request.
- Code formatting check (Laravel Pint)
- Static analysis (PHPStan / Larastan at Level 6+)
- Automated test suite execution (Pest / PHPUnit)
- Continuous Deployment (CD): Runs on merge to
main.- Build optimized production Docker image
- Push image to GitHub Container Registry (GHCR)
- Trigger zero-downtime rolling update on VPS / Kubernetes
Developer Push
│
▼
┌──────────────────┐
│ GitHub Actions CI│ ── Lint (Pint) & Static Analysis (PHPStan)
└────────┬─────────┘ ── Automated Test Suite (Pest/PHPUnit)
│ All Pass
▼
┌──────────────────┐
│ Docker Build │ ── Multi-stage build with OPcache enabled
└────────┬─────────┘
│
▼
┌──────────────────┐
│ Production VPS │ ── Zero-downtime deployment & DB migrations
└──────────────────┘
Part 1: GitHub Actions CI Workflow
Create .github/workflows/ci.yml:
name: Continuous Integration on: push: branches: [main] pull_request: branches: [main] jobs: laravel-tests: runs-on: ubuntu-latest services: postgres: image: postgres:16-alpine env: POSTGRES_DB: testing POSTGRES_USER: user POSTGRES_PASSWORD: password ports: - 5432:5432 options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 steps: - name: Checkout code uses: actions/checkout@v4 - name: Setup PHP uses: shivammathur/setup-php@v2 with: php-version: '8.3' extensions: mbstring, pdo, pdo_pgsql, bcmath, redis, zip coverage: none - name: Cache Composer dependencies uses: actions/cache@v3 with: path: vendor key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }} restore-keys: ${{ runner.os }}-composer- - name: Install Dependencies run: composer install --prefer-dist --no-interaction --no-progress - name: Check Code Style (Pint) run: ./vendor/bin/pint --test - name: Static Analysis (PHPStan) run: ./vendor/bin/phpstan analyse --memory-limit=2G - name: Run Test Suite env: DB_CONNECTION: pgsql DB_HOST: 127.0.0.1 DB_PORT: 5432 DB_DATABASE: testing DB_USERNAME: user DB_PASSWORD: password run: php artisan test --parallel
Part 2: Production Multi-Stage Dockerfile
A production PHP Docker image must be lean, secure, and pre-warmed with OPcache. Use a multi-stage Dockerfile:
# Stage 1: Build assets and vendor FROM php:8.3-fpm-alpine AS base RUN apk add --no-cache postgresql-dev libzip-dev zip unzip linux-headers \ && docker-php-ext-install pdo pdo_pgsql opcache COPY --from=composer:2 /usr/bin/composer /usr/bin/composer WORKDIR /var/www/html # Install production dependencies only COPY composer.json composer.lock ./ RUN composer install --no-dev --optimize-autoloader --no-scripts # Copy application files COPY . . # Run production optimizations RUN php artisan config:cache \ && php artisan route:cache \ && php artisan view:cache EXPOSE 9000 CMD ["php-fpm"]
Part 3: Automated Zero-Downtime Deployment
Once tests pass and the Docker image is pushed to GHCR, GitHub Actions securely triggers the deployment script on the production server via SSH:
# Zero-downtime deployment script on server docker compose pull app docker compose up -d --no-deps --build app docker compose exec -T app php artisan migrate --force docker compose exec -T app php artisan queue:restart
Because Docker Compose replaces containers only when the new container passes health checks, users never experience dropped requests or maintenance screens during release.
For testing best practices, read my Laravel Unit and Feature Testing Guide and Laravel vs Django vs .NET Comparison.
Need an expert DevOps and backend engineer to automate your deployment pipelines or modernize your infrastructure? Learn more about my backend development services or reach out to discuss your CI/CD setup.