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:
gitlab-runner: the user running the CI job.root: needed for privileged operations like writing into protected directories, changing ownership and restarting systemd services.- The site user: the CloudPanel or web-hosting user that owns the Laravel app directory, for example
stagingorproduction.
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:
rsyncmay not be able to write into the site directory.cdmay not be allowed becausegitlab-runnercannot traverse/home/staging.composer,php artisanandnpmmay create files owned by the wrong user.systemctlusually requires elevated privileges.
A Better Deployment Pattern
A reliable pattern looks like this:
- Let GitLab check out the code into the runner workspace.
- Use
sudo rsyncto copy the code into the CloudPanel site directory. - Use
sudo chownto make the site user own the release path. - Run Laravel commands as the site user with
sudo -u. - Use
sudo systemctlto 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:
- Laravel cache files may be owned by root.
storage/andbootstrap/cache/can become unwritable to the web user.- npm and composer artifacts may end up with ownership that breaks later deploys.
- The difference between infrastructure operations and application operations becomes blurred.
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-runnermoves 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.