CI/CD Pipeline with GitLab CI for Laravel (2026 Step-by-Step)

Khimananda Oli 6 min read Database
CI/CD Pipeline with GitLab CI for Laravel (2026 Step-by-Step)

By Khimananda Oli | Last reviewed: August 2026

Deploying Laravel by SSH-ing in and running git pull works until the day a half-finished pull, a missed composer install, or a broken migration takes the site down mid-request. A GitLab CI/CD pipeline for Laravel removes that risk: every push to main is linted, built on a clean runner, and shipped to your server with an atomic, zero-downtime release swap. This guide builds that pipeline end to end — the exact .gitlab-ci.yml and deploy.php included.

git pushmain branchverifycomposer + php -lsyntax gatedeployDeployer clonemigrate + cacheproductionatomic symlinkzero downtime
The Laravel CI/CD pipeline: push to main triggers verify, then Deployer ships an atomic release to production with zero downtime.

What do you need before building a GitLab CI/CD pipeline for Laravel?

Four prerequisites keep the pipeline reproducible and the deploy safe:

  • A committed composer.lock. The runner installs from the lock with --no-dev, so the lock must be in version control. Keep /vendor gitignored — it is rebuilt per release.
  • Committed build assets. If the server runs no npm, compile your CSS/JS locally and commit public/*.min.* so releases are self-contained.
  • SSH access from GitLab CI to your server via a dedicated deploy key (never your personal key).
  • A matching PHP version in the runner image and on the server — mismatched runtimes cause cache and extension surprises.

How do you write the .gitlab-ci.yml for a Laravel project?

The pipeline has two stages. The verify stage is a fast safety net — it proves Composer resolves and that your key controllers and console commands parse before anything touches the server. It is not a full test suite; think of it as a pre-flight check.

stages:
  - verify
  - deploy

lint:
  stage: verify
  image: php:8.4-cli          # match production FPM version
  before_script:
    - apt-get update && apt-get install -y git unzip libzip-dev libicu-dev libonig-dev
    - docker-php-ext-install zip intl bcmath
    - curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer
  script:
    - composer install --no-dev --no-interaction --prefer-dist --optimize-autoloader --no-scripts
    - php -l app/Http/Controllers/HomeController.php
    - php -l app/Http/Controllers/BlogController.php
  only:
    - main

Two details matter. --no-scripts skips post-autoload-dump (which runs artisan package:discover and would try to boot the app and hit the database — unnecessary for a syntax check). And only: main keeps the pipeline focused on your production branch.

/srv/app/releases/41 (previous)42 (live)43 (building)shared/.envstorage/currentreleases/42shared/ is symlinked into every release — uploads and secrets persist across deploys
Deployer keeps timestamped releases; current is an atomic symlink, and shared/ holds the real .env and storage/.

How does the deploy stage ship Laravel with zero downtime?

The deploy stage loads the SSH key, installs Composer (so the dep binary is available), and runs Deployer. Deployer clones the repository into a fresh, timestamped release directory, runs composer install --no-dev, executes php artisan migrate --force, warms the config/route/view caches, then flips the current symlink in a single atomic operation. Because traffic only ever hits current, a broken build never reaches visitors, and rollback is an instant symlink swap back.

deploy_production:
  stage: deploy
  image: php:8.4-cli
  needs: [lint]
  before_script:
    - eval $(ssh-agent -s)
    - echo "$SSH_PRIVATE_KEY" | tr -d '\r' | ssh-add -
    - mkdir -p ~/.ssh && chmod 700 ~/.ssh
    - echo "$SSH_KNOWN_HOSTS" > ~/.ssh/known_hosts
    - composer install --no-interaction --prefer-dist --no-scripts
  script:
    - vendor/bin/dep deploy production --verbose
  environment:
    name: production
  rules:
    - if: '$CI_COMMIT_BRANCH == "main" && $SSH_PRIVATE_KEY'

The rules line is a small but useful trick: the deploy job only appears once the SSH_PRIVATE_KEY variable exists. Before you configure secrets, pushes still run the lint stage but never show a failed deploy — the job self-activates the moment the variable is set.

How do you configure Deployer for Laravel?

Deployer ships a Laravel recipe. A minimal deploy.php declares the host, the shared files that persist across releases, and pins the PHP binary so cache warming runs on the same runtime that serves requests:

<?php
namespace Deployer;
require 'recipe/laravel.php';

set('application', 'example.com');
set('repository', '[email protected]:you/example.git');
set('keep_releases', 3);
add('shared_files', ['.env']);
add('shared_dirs', ['storage']);
set('bin/php', '/usr/bin/php8.4');

host('production')
    ->set('hostname', '203.0.113.10')
    ->set('remote_user', 'deploy')
    ->set('deploy_path', '/srv/example.com')
    ->set('branch', 'main');

after('deploy:failed', 'deploy:unlock');

Migrations run automatically — the Laravel recipe includes artisan:migrate before the symlink swap, so a failed migration aborts the release while the previous one keeps serving. For the full server-side story, pair this with a guide on deploying Laravel on an Ubuntu VPS and review your DevOps and cloud services setup so responsibilities are clear before you automate them.

How do you store SSH secrets safely in GitLab CI/CD?

Never commit keys. Generate a dedicated deploy keypair, add the public half to the server's ~/.ssh/authorized_keys, and store the private half in GitLab under Settings → CI/CD → Variables:

  1. SSH_PRIVATE_KEY — the private key (Protected; it cannot be Masked because it is multiline).
  2. SSH_KNOWN_HOSTS — the output of ssh-keyscan -H your.server.ip, so the runner trusts the host without an interactive prompt.

Mark both Protected and confirm main is a protected branch — protected variables are only exposed to jobs on protected branches. A dedicated key also means you can revoke CI access by removing one line from authorized_keys without touching your personal login.

deployrelease 41release 42current42rollbackrelease 41release 42current41 (instant)
Rollback is not a rebuild — it is an instant symlink flip back to the previous release.

How do you roll back a bad Laravel deploy?

Because each release is a complete, already-built checkout, rolling back never recompiles anything. From your machine:

vendor/bin/dep rollback production      # flip current to the previous release
vendor/bin/dep releases production      # list releases and see which is live

Keep keep_releases at 3 or more so you always have a known-good release to flip back to. If a deploy fails mid-flight and leaves a lock, clear it with vendor/bin/dep deploy:unlock production.

Conclusion

A two-stage GitLab CI/CD pipeline turns Laravel deploys from a nervous manual ritual into a boring, repeatable event: push to main, watch the pipeline lint and ship, and trust the atomic swap to keep the site up. Start with the verify stage today, add Deployer when you are ready for zero-downtime releases, and layer tests into the pipeline as your suite grows. If you want this set up and audited for your team, get in touch or explore the DevOps case studies for examples of pipelines in production.

Frequently Asked Questions

A CI/CD pipeline for Laravel is an automated workflow that tests and ships your code on every push. Continuous integration verifies the build (Composer install, syntax checks, tests); continuous deployment releases the verified code to your server without manual SSH steps.

A manual git pull can leave the app in a half-updated state — new code with stale dependencies or an unrun migration — and any error is visible to live users. GitLab CI builds each release on a clean runner and swaps it in atomically, so visitors only ever see a complete, working release.

No. You can start with a verify stage that only runs composer install and php -l syntax checks, which already catches broken dependencies and parse errors. Add PHPUnit or Pest tests to the same stage later; the pipeline structure does not change.

Use an official php:X.Y-cli image that matches the PHP-FPM version on your production server. Matching versions prevents subtle differences in extensions, cached config, and language behaviour between the build and the live runtime.

The post-autoload-dump script runs artisan package:discover, which boots the framework and may connect to the database. During a lint or build step you only need the dependencies on disk, so --no-scripts avoids an unnecessary — and often failing — application boot.

Deployer is a PHP deployment tool with a built-in Laravel recipe. It creates timestamped releases, runs migrations and cache warming, and flips an atomic symlink to activate a release, giving you zero-downtime deploys and instant rollbacks without writing custom shell scripts.

Each deploy builds a brand-new release directory while the current one keeps serving traffic. Only after the build, migrations, and cache warming succeed does Deployer repoint the current symlink to the new release — an atomic operation, so no request ever hits a half-built app.

In a shared directory outside the releases, declared via shared_files. Deployer symlinks the same .env into every release, so your secrets and configuration persist across deploys and are never part of the cloned code.

The Deployer Laravel recipe runs php artisan migrate --force automatically on the new release before the symlink swap. If a migration fails, the release is aborted and the previous release keeps serving, so a bad migration never takes the site down.

Generate a dedicated deploy keypair, put the public key in the server's authorized_keys, and store the private key as a Protected CI/CD variable named SSH_PRIVATE_KEY. Add SSH_KNOWN_HOSTS from ssh-keyscan so the runner trusts the host non-interactively.

GitLab only masks single-line values, and an SSH private key is multiline. Mark it Protected instead, and ensure your deploy branch is a protected branch so the variable is exposed only to trusted pipelines.

Run vendor/bin/dep rollback production. Because every release is a complete pre-built checkout, rollback just repoints the current symlink to the previous release instantly — nothing is recompiled. Keep at least three releases so a good one is always available.

Yes. The pipeline installs dependencies from the lock file to guarantee identical versions on every build. Commit composer.lock and keep the vendor directory gitignored, since it is rebuilt inside each release on the server.

Build your assets locally with your bundler and commit the compiled files (for example public/css/app.min.css and the mix or vite manifest). Releases then contain ready-to-serve assets, and the server never needs npm or a build step.

Yes. The concepts are identical — a verify job and a deploy job that runs Deployer over SSH. Only the YAML syntax and the secrets UI differ; the deploy.php file and the zero-downtime release model stay exactly the same.