
Table of Contents
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.
main and the site updates with zero downtime.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/vendorgitignored — it is rebuilt per release. - Committed build assets. If the server runs no
npm, compile your CSS/JS locally and commitpublic/*.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.
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:
SSH_PRIVATE_KEY— the private key (Protected; it cannot be Masked because it is multiline).SSH_KNOWN_HOSTS— the output ofssh-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.
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.