Kokil Thapa - Professional Web Developer in Nepal
Freelancer Web Developer in Nepal with 15+ Years of Experience

Kokil Thapa is an experienced full-stack web developer focused on building fast, secure, and scalable web applications. He helps businesses and individuals create SEO-friendly, user-focused digital platforms designed for long-term growth.

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

By Kokil Thapa | Last reviewed: August 2026

You need to deploy a Laravel app with GitLab CI/CD to a VPS reliably without taking your site offline during updates. Manual SSH deployments and FTP uploads introduce human error, inconsistent environments, and inevitable downtime that breaks user trust and hurts SEO. A properly configured automated pipeline eliminates these risks by testing, building, and releasing your application through a repeatable, version-controlled process every time you push code.

How Do You Prepare a VPS Before You Deploy a Laravel App with GitLab CI/CD?

Before writing any pipeline configuration, your target server must be correctly provisioned. I have seen countless deployment failures on client projects caused not by bad CI code, but by servers missing basic prerequisites. When you hire a Laravel developer in Nepal or manage infrastructure yourself, verify these foundations first.

Server Software Requirements for 2026

Laravel 12.x requires PHP 8.2 minimum, though PHP 8.4 is the current stable release recommended for new deployments. Your VPS should run Ubuntu 22.04 or 24.04 LTS with the following stack:

  • PHP 8.2+ FPM: Install php-fpm along with required extensions (mbstring, xml, curl, zip, bcmath, intl, mysql/pgsql)
  • Nginx: Configure as reverse proxy to PHP-FPM socket
  • Composer 2.7+: Required for dependency installation on the runner or server
  • Node.js 22 LTS + NPM 10+: Only needed if building assets on-server (prefer committing built assets)
  • MySQL 8.0/8.4 or PostgreSQL 16/17: Match your application database driver
  • Redis 7.x: For caching, queues, and sessions in production

User and Permission Architecture

Create a dedicated deployment user separate from root. This user owns the release directories and must have passwordless sudo only for specific commands like reloading PHP-FPM:

# Create deploy user
sudo adduser --disabled-password deploy

# Set up SSH key authentication
sudo mkdir -p /home/deploy/.ssh
sudo nano /home/deploy/.ssh/authorized_keys
sudo chown -R deploy:deploy /home/deploy/.ssh
sudo chmod 700 /home/deploy/.ssh
sudo chmod 600 /home/deploy/.ssh/authorized_keys

# Allow FPM reload without password
echo "deploy ALL=(ALL) NOPASSWD: /usr/sbin/service php8.4-fpm reload" | sudo tee /etc/sudoers.d/deploy-php

The web root should point to /var/www/your-app/current/public, where current is a symlink managed by your deployment tool. Never point Nginx directly to a release directory; the symlink swap is what enables atomic deployments.

/var/www/your-app/releases/20260817103000/20260816091500/20260815142200/shared/.envstorage/node_modules/current → symlinkpoints to latest releaseNginx root: /var/www/your-app/current/public
Directory layout when you deploy a Laravel app with GitLab CI/CD using atomic symlinked releases

What Is the Correct Pipeline Structure to Deploy a Laravel App with GitLab CI/CD?

Your .gitlab-ci.yml defines stages that execute sequentially. For Laravel, use four stages: test, build, staging, and production. Each stage fails fast before reaching the next, preventing broken code from ever touching production.

Base Configuration and Variables

Define environment variables in GitLab CI/CD Settings > Variables, never in the YAML file itself. Store SSH private keys, database passwords, and APP_KEY as masked, protected variables.

image: php:8.4-cli

variables:
  COMPOSER_CACHE_DIR: "$CI_PROJECT_DIR/.composer-cache"
  FF_USE_FASTZIP: "true"

cache:
  key: ${CI_COMMIT_REF_SLUG}
  paths:
    - .composer-cache/
    - vendor/

stages:
  - test
  - build
  - staging
  - production

Test Stage Configuration

Run PHPUnit or Pest tests against an in-memory SQLite database for speed. If your application depends on MySQL-specific features, use a service container instead.

test:
  stage: test
  before_script:
    - apt-get update && apt-get install -y libzip-dev unzip git
    - docker-php-ext-install zip pdo_mysql
    - composer install --prefer-dist --no-progress --no-interaction
    - cp .env.testing .env
    - php artisan key:generate
  script:
    - php artisan test --parallel
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
    - if: $CI_COMMIT_BRANCH == "main"

Build Stage for Asset Compilation

In my experience working on production Laravel applications, building frontend assets on the VPS wastes server resources and slows deployments. Build once in CI, then transfer compiled artifacts:

build:
  stage: build
  image: node:22-bookworm
  cache:
    key: node-${CI_COMMIT_REF_SLUG}
    paths:
      - node_modules/
  script:
    - npm ci
    - npm run build
  artifacts:
    paths:
      - public/build/
    expire_in: 1 week
  rules:
    - if: $CI_COMMIT_BRANCH == "main"

How Do You Implement Zero-Downtime Deployment When You Deploy a Laravel App with GitLab CI/CD?

Zero-downtime means users never see errors or maintenance pages during releases. Achieve this through atomic symlink swaps and careful handling of stateful operations. On legal-tech portals I maintain, even seconds of downtime during document submission workflows cause real business problems, making this non-negotiable.

Using Deployer for Atomic Releases

Deployer 7 is the standard PHP deployment tool. It creates timestamped release directories, symlinks shared files, runs migrations, and swaps the current symlink atomically. Add it to your project:

composer require deployer/deployer --dev

Create deploy.php in your project root:

<?php
namespace Deployer;

require 'recipe/laravel.php';

set('application', 'your-app');
set('repository', 'git@gitlab.com:your-org/your-app.git');
set('php_fpm_service', 'php8.4-fpm');
set('keep_releases', 5);

host('production')
    ->set('remote_user', 'deploy')
    ->set('hostname', 'your-vps-ip')
    ->set('deploy_path', '/var/www/your-app')
    ->set('labels', ['stage' => 'production']);

task('deploy:npm_build', function () {
    // Skip if assets built in CI and transferred
    writeln('Assets pre-built in CI pipeline');
});

after('deploy:symlink', 'artisan:optimize');
after('deploy:symlink', 'php-fpm:reload');

GitLab CI Production Deploy Job

The production job downloads built artifacts, transfers them via rsync, and triggers Deployer. Use manual confirmation for production to prevent accidental deploys:

deploy_production:
  stage: production
  image: alpine:latest
  before_script:
    - apk add --no-cache openssh-client rsync bash
    - eval $(ssh-agent -s)
    - echo "$SSH_PRIVATE_KEY" | ssh-add -
    - mkdir -p ~/.ssh && chmod 700 ~/.ssh
    - ssh-keyscan -H $PRODUCTION_HOST >> ~/.ssh/known_hosts
  script:
    - rsync -avz --delete public/build/ deploy@$PRODUCTION_HOST:/var/www/your-app/shared/public/build/
    - ./vendor/bin/dep deploy production --tag=$CI_COMMIT_TAG
  environment:
    name: production
    url: https://yourdomain.com
  when: manual
  only:
    - tags
TESTPHPUnit / PestBUILDVite + ArtifactsSTAGINGAuto-deployPRODUCTIONManual gateMANUALPipeline fails fast at each stage — broken code never reaches productionArtifacts passed between stages via GitLab artifact storage
Four-stage pipeline when you deploy a Laravel app with GitLab CI/CD with manual production approval

Which Common Mistakes Break Production When You Deploy a Laravel App with GitLab CI/CD?

After maintaining multiple sister sites sharing the same Deployer 7 + GitLab CI pipeline on shared EC2 infrastructure, I have catalogued recurring failure modes. Avoiding these saves hours of debugging at 2 AM.

MistakeSymptomFix
Running migrations before symlink swapNew columns referenced by old code cause 500 errorsMake migrations backward-compatible; run after symlink
Missing opcache invalidationOld PHP bytecode served after deployReload PHP-FPM or call opcache_reset() post-deploy
Building assets on production serverMemory exhaustion, slow deploys, Node version driftBuild in CI, transfer only compiled artifacts
Stale cron paths after releaseScheduled tasks reference deleted release directoryPoint crontab to /current/artisan, not timestamped path
SSH key stored unencrypted in repoSecurity breach, credential exposureUse GitLab CI/CD masked variables, never commit keys
No rollback strategyExtended downtime while fixing forwardKeep 3-5 releases; use dep rollback for instant revert

Database Migration Safety

Never run destructive migrations during deployment. If you must rename a column, do it in three releases: add new column, migrate data, remove old column. This pattern prevents downtime regardless of deployment timing. For more on safe schema changes, see guidance on MySQL optimization for SaaS applications.

Cache and Queue Considerations

After deploying new code, cached config, routes, and views may reference stale data. Always run php artisan optimize:clear followed by php artisan optimize after the symlink swap. If you use Redis queues, ensure workers restart gracefully using php artisan queue:restart so they pick up new code without dropping jobs.

❌ UNSAFE: Single ReleaseRename columnCode uses new nameDOWNTIME: Old code + new schema = errors✅ SAFE: Three-Release StrategyRelease 1Add new_col, keep old_colRelease 2Write to both, backfill dataRelease 3Drop old_col, use new_col onlyZERO DOWNTIME: Every release works with previous schema state
Backward-compatible migration strategy prevents downtime when you deploy a Laravel app with GitLab CI/CD

How Do You Secure Secrets and SSH Keys in Your Deployment Pipeline?

Security failures in CI/CD pipelines expose credentials faster than almost any other vector. Treat your pipeline configuration with the same rigor as application code. For teams exploring broader security posture, review cybersecurity trends developers need to know in 2026.

GitLab Variable Best Practices

  1. Mask all sensitive values: Enable the "Masked" option so secrets never appear in job logs
  2. Protect production variables: Mark as "Protected" so they only inject on protected branches/tags
  3. Use file-type variables for SSH keys: Store private keys as File type, not Variable, to preserve formatting
  4. Rotate keys regularly: Generate new deploy keys quarterly; revoke old ones immediately
  5. Audit variable access: Review who can view/edit CI/CD settings monthly

SSH Key Hygiene

Generate a dedicated ED25519 key pair for each environment. Never reuse your personal SSH key or share keys between staging and production:

# Generate deploy-specific key
ssh-keygen -t ed25519 -C "gitlab-ci-production-deploy" -f deploy_prod_ed25519 -N ""

# Add public key to VPS authorized_keys for deploy user
cat deploy_prod_ed25519.pub | ssh deploy@your-vps "cat >> ~/.ssh/authorized_keys"

# Store private key content in GitLab CI/CD Variables as SSH_PRIVATE_KEY_PROD
# Store public key fingerprint in KNOWN_HOSTS via ssh-keyscan

Restrict the deploy key on the VPS side using command= in authorized_keys to limit what the CI runner can execute. This defense-in-depth measure contains damage if the key leaks.

Conclusion: Ship Confidently Every Time You Deploy a Laravel App with GitLab CI/CD

When you deploy a Laravel app with GitLab CI/CD to a VPS correctly, releases become boring, predictable events rather than anxiety-inducing rituals. The combination of atomic symlinked releases, pre-built assets, backward-compatible migrations, and secured secrets gives you confidence to ship frequently without fear. Start with the four-stage pipeline outlined here, validate your VPS prerequisites first, and add monitoring before optimizing further. If you need hands-on help configuring your pipeline or auditing an existing deployment workflow, reach out through my contact page to discuss your specific infrastructure needs.

Frequently Asked Questions

Ubuntu 22.04 or 24.04 LTS, PHP 8.2+, Composer, Git, and SSH access. Node.js is only needed if building assets on the server; I typically build locally and commit artifacts to keep VPS specs minimal.

GitLab SaaS offers 400 free compute minutes monthly, sufficient for most small projects. Self-hosted runners on your existing VPS cost zero extra beyond server fees, typically Rs 1,500–3,000/month (~USD 11–22) for a basic DigitalOcean or Hetzner instance.

Deployer creates atomic symlinked releases with shared persistent storage and environment files. Direct git pull risks downtime during updates, leaves no rollback option, and often breaks file permissions. In my experience managing multiple legal-tech portals, Deployer prevents production outages during routine deployments.

Store the private key as a masked, protected CI/CD variable named SSH_PRIVATE_KEY. Configure the runner to write it to a temporary file with chmod 600 before deployment. Never hardcode keys in .gitlab-ci.yml or commit them to version control under any circumstances.

Build assets in the CI pipeline and upload compiled artifacts to the server. This eliminates Node.js dependencies on production, reduces VPS memory requirements, and ensures identical builds across environments. On client projects with limited server resources, this approach consistently prevents deployment failures caused by npm install timeouts.

Keep .env exclusively on the server in Deployer's shared directory, never in Git. Reference it via deploy.php shared_files configuration. Inject CI-specific secrets like database passwords through GitLab masked variables passed as environment variables during migration and cache commands, ensuring they never persist on disk or appear in logs.

The CI runner user often differs from the PHP-FPM process owner. Fix by setting correct ownership in deploy.php using writable_dirs and ensuring the deploy user belongs to the www-data group. Run sudo chown -R deploy:www-data shared/storage after initial setup. This recurs frequently when mixing manual and automated deployments on the same server.

Add php artisan optimize:clear and php artisan config:cache to your Deployer tasks post-deployment. For OPcache invalidation without restarting PHP-FPM, use cachetool or configure opcache_reset() in a dedicated endpoint called via curl immediately after symlink swap. Skipping this step causes stale configuration bugs that are difficult to diagnose in production.

Yes, using separate Deployer configurations per project and distinct GitLab CI jobs targeting different hosts or stages. Sister sites like notarykathmandu.com and translationnepal.com share infrastructure this way. Isolate each app's deploy path, shared storage, and PHP-FPM pool to prevent cross-contamination and simplify individual rollbacks.

Execute migrations via Deployer after code deployment but before symlink swap, wrapped in a maintenance mode toggle. Use backward-compatible migrations that work with both old and new code. Always backup the database pre-migration. In practice, running migrations during the release window rather than post-swap prevents partial state issues during failed deployments.

Shell executors on the target VPS itself eliminate SSH overhead and simplify authentication for single-server setups. Docker executors provide better isolation but require SSH key management between container and host. For Nepal-based clients on budget VPS instances, shell runners reduce complexity and resource consumption while maintaining reliable deployment performance.

Use Deployer's default deploy task which creates timestamped release directories, symlinks current to the new release atomically, and reloads PHP-FPM. Ensure health checks pass before finalizing. Keep at least three previous releases for instant rollback via dep rollback. True zero-downtime requires queue workers to gracefully restart after deployment completes.

VPS instances with 1GB RAM often exhaust memory during dependency resolution. Increase swap space to 2GB minimum, or set COMPOSER_MEMORY_LIMIT=-1 in CI variables. Better yet, run composer install in the CI pipeline and transfer vendor directory as an artifact. This avoids server-side memory constraints entirely and speeds up deployment significantly.

Compare PHP versions, extensions, and environment variables between local and server. Check deploy.log output in GitLab CI job traces for specific failure points. Verify file permissions, disk space, and SSH connectivity. Common culprits include missing PHP extensions, incorrect .env values, and stale OPcache serving old bytecode after symlink changes.

Not necessarily. GitLab offers integrated repository, CI, and issue tracking with generous free tier minutes. GitHub Actions has broader marketplace integrations but similar capabilities. For Nepal-based teams already using GitLab for code hosting, staying within the platform reduces context switching. Choose based on existing workflow rather than perceived superiority; both handle Laravel deployments competently.

Share this article

Quick Contact Options
Choose how you want to connect me: