Deploy a Laravel App with GitLab CI/CD to a VPS

Khimananda Oli 7 min read DevOps
Deploy a Laravel App with GitLab CI/CD to a VPS

By Khimananda Oli | Last reviewed: August 2026

You need to deploy a Laravel app with GitLab CI/CD to a VPS reliably without manual FTP uploads or fragile shell scripts. While managed platforms simplify hosting, many teams in Nepal and globally prefer VPS environments for cost control, data sovereignty, and full root access. This guide provides a battle-tested pipeline configuration that automates testing, building, and deploying your Laravel application securely via SSH.

GitLab RunnerTest StageDeploy StageProduction VPStriggerpassSSH
High-level flow when you deploy a Laravel app with GitLab CI/CD to a VPS using SSH-based automation

How do you prepare the VPS for automated Laravel deployment?

Before configuring any pipeline, the target server must be hardened and structured for non-interactive deployments. A common mistake is deploying directly as root or mixing application files with system directories. Create a dedicated deployment user with restricted sudo access and proper SSH key authentication. If you are starting fresh, follow this initial Ubuntu server setup guide to establish a secure baseline.

Create a deployment user and directory structure

sudo adduser --disabled-password deployer
sudo mkdir -p /var/www/laravel-app/{releases,shared}
sudo chown -R deployer:deployer /var/www/laravel-app
sudo chmod 755 /var/www/laravel-app

The releases folder holds timestamped deployment artifacts, while shared persists environment files, storage, and logs across deploys. This separation prevents accidental data loss during rollbacks.

Configure SSH key authentication for CI

Generate a dedicated ED25519 key pair for GitLab CI. Never reuse personal keys or embed passwords in pipelines.

ssh-keygen -t ed25519 -C "gitlab-ci-deployer" -f ~/.ssh/gitlab_deploy_key -N ""
cat ~/.ssh/gitlab_deploy_key.pub | ssh deployer@your-vps-ip "mkdir -p ~/.ssh && cat >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys"

Add the private key content to GitLab under Settings → CI/CD → Variables as SSH_PRIVATE_KEY (type: File, protected). Add the VPS IP or hostname as DEPLOY_HOST and the username as DEPLOY_USER. Mark all sensitive variables as masked and protected to prevent leakage in logs.

How do you configure .gitlab-ci.yml for Laravel testing and deployment?

A minimal but production-safe pipeline includes three stages: test, build, and deploy. Testing must always precede deployment to catch regressions early. For deeper context on structuring pipelines specifically for Laravel, see this step-by-step GitLab CI Laravel tutorial.

stages:
  - test
  - build
  - deploy

variables:
  APP_DIR: "/var/www/laravel-app"
  PHP_VERSION: "8.3"

test:
  stage: test
  image: php:${PHP_VERSION}-cli
  cache:
    paths:
      - vendor/
  before_script:
    - apt-get update && apt-get install -y git unzip libzip-dev
    - docker-php-ext-install zip pdo_mysql
    - curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer
  script:
    - composer install --prefer-dist --no-interaction --no-progress
    - php artisan test --parallel

deploy:
  stage: deploy
  image: alpine:latest
  only:
    - main
  before_script:
    - apk add --no-cache openssh-client bash
    - eval $(ssh-agent -s)
    - echo "$SSH_PRIVATE_KEY" | tr -d '\r' | ssh-add -
    - mkdir -p ~/.ssh
    - chmod 700 ~/.ssh
    - ssh-keyscan -H $DEPLOY_HOST >> ~/.ssh/known_hosts
  script:
    - ssh $DEPLOY_USER@$DEPLOY_HOST "cd $APP_DIR && ./deploy.sh"

This configuration uses Alpine Linux for the deploy stage to minimize attack surface and image size. The ssh-keyscan command prevents interactive host key verification prompts that would hang the pipeline. Always pin PHP versions explicitly—floating tags like php:latest cause silent breakage when upstream updates land.

GitLab RunnerVPS Deploy ScriptLaravel AppSSH execpull + installgit pull origin maincomposer installphp artisan migratecache:clear + optimize
Execution sequence when deploying Laravel via GitLab CI SSH session on the VPS

What deployment strategy prevents downtime during Laravel releases?

Directly overwriting live files causes brief errors as PHP-FPM serves partially updated code. Use atomic symlinks instead. Create a new release directory per deploy, install dependencies there, then swap the current symlink. This approach enables instant rollback by repointing the symlink to the previous release.

#!/bin/bash
# deploy.sh — run on VPS as deployer user
set -euo pipefail

RELEASES_DIR="/var/www/laravel-app/releases"
SHARED_DIR="/var/www/laravel-app/shared"
CURRENT_LINK="/var/www/laravel-app/current"
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
NEW_RELEASE="$RELEASES_DIR/$TIMESTAMP"

mkdir -p "$NEW_RELEASE"
git clone --depth 1 --branch main [email protected]:your-org/laravel-app.git "$NEW_RELEASE"

ln -nfs "$SHARED_DIR/.env" "$NEW_RELEASE/.env"
ln -nfs "$SHARED_DIR/storage" "$NEW_RELEASE/storage"

cd "$NEW_RELEASE"
composer install --no-dev --optimize-autoloader --no-interaction
php artisan migrate --force
php artisan config:cache
php artisan route:cache
php artisan view:cache

ln -nfs "$NEW_RELEASE" "$CURRENT_LINK"
echo "Deployed: $TIMESTAMP"

This script assumes Nginx points its root to /var/www/laravel-app/current/public. If you haven't configured the web server yet, reference this Laravel Nginx deployment guide for correct virtual host setup including SSL termination.

StrategyDowntime RiskRollback SpeedComplexityBest For
Direct overwriteHighSlow (manual restore)LowDev/staging only
Atomic symlinkNear-zeroInstant (symlink swap)MediumProduction VPS
Blue-green containersZeroFast (traffic shift)HighKubernetes/Docker
Deployer.php toolNear-zeroInstant (built-in)MediumTeams wanting abstraction

How do you handle secrets, caching, and post-deploy validation safely?

Never commit .env files or database credentials to version control. Store production secrets in GitLab CI variables or an external vault, then inject them during the build phase if needed. On the VPS, keep the canonical .env in the shared directory and symlink it into each release as shown above.

  • Dependency caching: Cache vendor/ between pipeline runs using GitLab’s native cache directive keyed by composer.lock hash. This reduces test stage duration from minutes to seconds.
  • OpCache reset: After swapping symlinks, send USR2 signal to PHP-FPM master process or hit a dedicated opcache-reset endpoint to invalidate stale bytecode. Without this, users may see old code despite successful deployment.
  • Health checks: Add a final pipeline step that curls /api/health on the deployed domain. Fail the job if HTTP 200 isn’t returned within 30 seconds. This catches misconfigurations before they impact real users.
  • Migration safety: Run migrate --force only after confirming backups exist. Wrap destructive migrations in feature flags or maintenance mode toggles for high-traffic applications.

In practice, teams often skip post-deploy validation because “it worked locally.” That assumption breaks under real load. Automate the check so failures surface in Merge Requests, not customer support tickets.

Direct Overwriterm -rf public/*copy new filesrun migrations⚠ Downtime window activeAtomic Symlinkclone to releases/timestampinstall deps + migrateswap current symlink✓ Zero downtimevs
Why atomic symlinks outperform direct file replacement when you deploy a Laravel app with GitLab CI/CD to a VPS

Secure and Reliable Laravel Deployment Next Steps

To successfully deploy a Laravel app with GitLab CI/CD to a VPS, prioritize security hygiene over convenience: dedicated users, scoped SSH keys, secret isolation, and atomic releases. Test your pipeline against a staging environment identical to production before promoting to live traffic. Monitor deployment frequency and failure rates as key DevOps metrics—they reveal process health better than uptime alone. If your team needs help designing compliant, auditable CI/CD workflows for regulated environments, reach out to discuss your infrastructure requirements.

Frequently Asked Questions

You need a GitLab repository, a VPS running Ubuntu 24.04 or Debian 13, SSH key access, PHP 8.4, Composer, Nginx, and a configured .gitlab-ci.yml file in your project root defining deployment stages and runner tags for automated execution.

Generate an ED25519 key pair locally, add the public key to the VPS authorized_keys file, and store the private key as a masked CI/CD variable named SSH_PRIVATE_KEY. Never commit keys to version control or expose them in pipeline logs during deployment scripts.

Use a Docker executor runner installed on a separate host or the same VPS for isolation. Shell executors work but risk environment contamination. Register the runner with gitlab-runner register and tag it specifically for deployment jobs to prevent unintended builds on shared infrastructure.

Usually two to five minutes depending on asset compilation speed and server resources. Caching vendor and node_modules directories between pipelines reduces this significantly. Network latency between the runner and VPS also affects total transfer time during rsync or scp operations.

Yes, but GitLab offers superior self-hosted runner support and integrated container registry. GitHub Actions requires third-party SSH actions for VPS deploys. For teams already using GitLab for source control, staying within the platform reduces context switching and credential management overhead significantly.

Run php artisan migrate --force in a post-deployment script after code sync completes. Always backup the database first using mysqldump stored as a CI artifact. Use maintenance mode via php artisan down before migrating and up after verification to prevent user errors during schema changes.

Store secrets like APP_KEY and DB_PASSWORD as protected CI/CD variables scoped to production branches. Inject them during deployment using envsubst or direct echo commands into the .env file. Never hardcode values in .gitlab-ci.yml or commit sensitive configuration to the repository under any circumstances.

Maintain release symlinks pointing to timestamped deployment directories. Create a manual rollback job in .gitlab-ci.yml that switches the current symlink to the previous release directory and restarts PHP-FPM. This atomic approach ensures instant recovery without re-running the full pipeline or risking partial states.

No. Create a dedicated deploy user with sudo privileges limited to systemctl restart php-fpm and nginx. Configure passwordless sudo for specific commands only. Running deployments as root increases security risk and makes audit trails harder to maintain across multiple team members and automated processes.

GitLab Free tier includes 400 compute minutes monthly which suffices for small projects. Self-hosted runners eliminate minute costs entirely, requiring only VPS expenses around five to twenty dollars monthly. Paid tiers start at twenty-nine dollars per user monthly for additional minutes and advanced features.

Verify the SSH private key variable has correct newline formatting and the deploy user owns the release directory. Check that the runner has network access to the VPS port 22. Ensure authorized_keys permissions are 600 and the .ssh directory is 700 on the target server.

Cache node_modules using GitLab cache artifacts keyed by package-lock.json hash. Run npm ci instead of npm install for deterministic builds. Compile assets in the build stage and transfer only the public/build directory to the VPS, avoiding redundant compilation on every deployment cycle.

Achieve near-zero downtime using atomic symlink swaps between release directories. Restart PHP-FPM gracefully with service php8.4-fpm reload rather than restart. Queue workers should be restarted separately via supervisorctl. True zero-downtime requires multiple servers behind a load balancer for seamless request draining.

Use gitlab-ci-lint API endpoint or the CI Lint tool in GitLab UI to validate syntax. Run pipeline on a staging branch first with identical job definitions. Test SSH connectivity and permissions in a dedicated debug job before enabling actual deployment steps to catch configuration issues early.

Restrict SSH to key-based authentication only, disable password login, and limit firewall rules to runner IP addresses. Rotate deploy keys quarterly. Enable GitLab branch protection requiring merge requests. Audit pipeline logs regularly and mask all sensitive variables to prevent accidental exposure in job outputs.