Laravel 12

In an earlier post, CloudPanel was used to deploy a Laravel app on a VPS. That post ended with a note that CI/CD would be a topic for another day. This is that follow-up.

The short version: deploying Laravel from GitLab CI using a shell runner is very workable, but it is easy to run into confusing permission errors if you do not think carefully about which Linux user is doing what.

This problem can appear when a GitLab runner is installed and registered on a VPS while logged in as root. It is then tempting to assume the job will simply have root access. A typical first failure looks like this:

GitLab Runner Lack of Permissions

rsync: [Receiver] ERROR: cannot stat destination "/home/laravel/root/path": Permission denied (13)

This can be fixed by running the copy step with sudo, because writing into a CloudPanel-managed site directory is a privileged operation. If the runner then reports that a password is required for sudo, passwordless sudo can be granted with:

echo 'gitlab-runner ALL=(ALL) NOPASSWD:ALL' > /etc/sudoers.d/gitlab-runner
chmod 440 /etc/sudoers.d/gitlab-runner

To verify that the runner can now use sudo without a password:

su - gitlab-runner -s /bin/bash -c 'sudo rsync --version'

Then restart the runner and check its status:

systemctl restart gitlab-runner
systemctl status gitlab-runner

After making gitlab-runner a sudoer, the next failure then moves to:

cd: /home/laravel/root/path: Permission denied

The fix is not to randomly add or remove sudo. The fix is to give each step of the deployment to the right user.

The Three Users in a Laravel Deployment

When a Laravel app is deployed from GitLab CI to a VPS, there are usually at least three users involved:

This distinction matters because CloudPanel sites commonly live in paths like:

/home/staging/htdocs/staging.some-domain.net

That path usually belongs to the site user and may not be traversable by gitlab-runner. So even if rsync succeeds with sudo, a plain cd "${RELEASE_PATH}" can still fail because the shell running the job is still gitlab-runner.

The Wrong Mental Model

The first mistake is thinking:

I registered the runner as root, therefore my deploy job runs as root.

That is not always how GitLab Runner behaves in practice, especially when running as a system service. The job may still run as the gitlab-runner user. That means commands like these may fail:

script:
  - rsync -az ./ "${RELEASE_PATH}"
  - cd "${RELEASE_PATH}"
  - composer install
  - php artisan migrate --force
  - systemctl restart laravel-staging-queue.service

There are multiple problems here:

A Better Deployment Pattern

A reliable pattern looks like this:

  1. Let GitLab check out the code into the runner workspace.
  2. Use sudo rsync to copy the code into the CloudPanel site directory.
  3. Use sudo chown to make the site user own the release path.
  4. Run Laravel commands as the site user with sudo -u.
  5. Use sudo systemctl to restart services.

Here is a simplified staging deployment job:

deploy-staging:
  stage: deploy
  before_script:
    - set -euo pipefail
    - export RELEASE_PATH="/home/${STAGING_SITE_USER}/htdocs/${STAGING_DOMAIN}"
    - echo "Deploying to ${RELEASE_PATH}"
  script:
    - sudo rsync -az --no-perms --no-owner --no-group --delete --exclude '.git' --exclude 'node_modules' --exclude 'storage' --exclude .env ./ "${RELEASE_PATH}"
    - sudo chown -R ${STAGING_SITE_USER}:${STAGING_SITE_USER} "${RELEASE_PATH}"
    - |
      sudo -u "${STAGING_SITE_USER}" bash -lc "
        set -euo pipefail
        cd '${RELEASE_PATH}'
        composer install --no-dev --no-interaction --prefer-dist --optimize-autoloader
        php artisan migrate --force
        npm install
        npm run build
        php artisan config:cache
        php artisan route:cache
        php artisan view:cache
      "
    - sudo systemctl restart laravel-staging-queue.service
    - sudo systemctl restart laravel-staging-schedule.service
  tags:
    - staging
  rules:
    - if: '$CI_COMMIT_BRANCH == "staging"'
      when: on_success
    - when: never

The important line is this one:

sudo -u "${STAGING_SITE_USER}" bash -lc "..."

That is the handoff. The CI job can still use privileged commands where needed, but the application work happens as the user that owns and runs the site.

The -l flag starts a login shell. That can help if composer, php or npm are available in the site user's login environment but not in the runner's environment.

Why Not Run Everything as Root?

Running the whole deployment as root sounds simpler, but it creates a different class of problems:

For Laravel, the safer rule is:

Use root for deployment mechanics; use the site user for application commands.

Branch Guardrails

If multiple runners are in use, branch rules prevent the wrong environment from deploying accidentally:

rules:
  - if: '$CI_COMMIT_BRANCH == "staging"'
    when: on_success
  - when: never

For production, the same pattern applies with the main branch:

rules:
  - if: '$CI_COMMIT_BRANCH == "main"'
    when: on_success
  - when: never

This is especially helpful when runner tags and environments are temporarily not what their names imply. During migrations or testing, a production-tagged runner may deliberately be pointed at a staging server. Branch rules keep the deploy logic explicit.

Excluding the Right Files

When using rsync, the deploy should not blindly copy everything over the live app directory. At minimum, exclude:

--exclude '.git'
--exclude 'node_modules'
--exclude 'storage'
--exclude .env

The .env file belongs to the server. The storage directory may contain user uploads, logs, cache files and other runtime data. Those should not be deleted just because the GitLab workspace does not contain them.

Some projects also need a post-composer step to copy a generated asset from a Composer package:

php artisan app:sync-flux-assets

The broader lesson is that the deployment script should include project-specific runtime assets, not just generic Laravel commands.

Systemd Services for Queues and Schedules

If the app uses queues, the queue worker is typically restarted after deployment:

sudo systemctl restart laravel-staging-queue.service

For scheduled tasks, Laravel offers a few options. Cron with schedule:run is common, or a long-lived schedule worker can be used:

php artisan schedule:work

With systemd, that can be a normal long-running service:

[Unit]
Description=Laravel Schedule Worker (Staging)
After=network.target mysql.service

[Service]
User=staging
Group=staging
WorkingDirectory=/home/staging/htdocs/staging.some-domain.net
ExecStart=/usr/bin/php artisan schedule:work
Restart=always
RestartSec=5

[Install]
WantedBy=multi-user.target

The deploy job can then restart it just like the queue worker:

sudo systemctl restart laravel-staging-schedule.service

Server Setup Checklist

On the server, the gitlab-runner user needs enough privilege to perform the infrastructure parts of the deploy. A common setup is passwordless sudo for the runner:

echo 'gitlab-runner ALL=(ALL) NOPASSWD:ALL' > /etc/sudoers.d/gitlab-runner
chmod 440 /etc/sudoers.d/gitlab-runner

When already logged in as root, those commands do not need a sudo prefix.

The job identity can be verified by temporarily adding this to the pipeline:

script:
  - whoami
  - id

The runner service itself can be checked on the server:

ps aux | grep gitlab-runner
cat /etc/gitlab-runner/config.toml
systemctl status gitlab-runner

A Minimal Flow That Works

Putting it all together, a reliable GitLab shell-runner deployment looks like this:

# root-level deploy mechanics
sudo rsync ...
sudo chown -R "${SITE_USER}:${SITE_USER} "${RELEASE_PATH}"

# app-level work
sudo -u ${SITE_USER} bash -lc "
  cd '${RELEASE_PATH}'
  composer install --no-dev --optimize-autoloader
  php artisan migrate --force
  npm install
  npm run build
"

# root-level service management
sudo systemctl restart laravel-staging-queue.service
sudo systemctl restart laravel-staging-schedule.service

That separation is the main idea.

Conclusion

GitLab shell runners are a practical way to deploy Laravel apps to a VPS, especially when paired with CloudPanel or another site-user based hosting setup. The tricky part is not GitLab itself. The tricky part is Linux permissions.

The key takeaway:

gitlab-runner moves the deployment forward, root performs privileged operations and the site user runs the Laravel app commands.

Once those responsibilities are separated, the deployment becomes much easier to reason about. Permission errors stop moving around, file ownership stays sane and the Laravel deploy becomes repeatable.

©2025 Laravel 12