Deploy to VPS with GitHub Actions: 12 Real Errors Fixed (Laravel 13 + Next.js 16)

GitHub Actions VPS deployments fail in predictable ways — and almost none of the tutorials warn you about them in advance. We deployed a Laravel 13 API and a Next.js 16 frontend to an existing Ubuntu VPS using GitHub Actions and hit 12 separate errors across six CI runs before everything went green.
This post documents every one of them: the symptom, the root cause, and the exact fix. If you're setting up the same stack, you'll hit most of these too.
The Stack
- Laravel 13 modular monolith (API only, no Blade views)
- Next.js 16 (App Router) with PM2 process manager
- GitHub Actions CI/CD with
appleboy/ssh-action@v1andappleboy/[email protected] - Ubuntu VPS with Nginx, PHP 8.4-FPM, nvm-managed Node.js 22
- Releases/symlink pattern for zero-downtime Laravel API deploys
Error 1: GitHub Actions Can't Find the Action Repository
Symptom:
Unable to resolve action aglipanci/laravel-forge-deploy, repository not foundCause: The workflow referenced a GitHub Actions action whose repository doesn't exist. Actions can be deleted, renamed, or simply mistyped — and GitHub gives no warning until the job actually runs.
Fix: Verify every uses: line points to a real, public repository before committing. If the deployment target isn't configured yet, replace the step with a stub:
- name: Deployment not yet configured
run: echo "Add VPS_HOST and VPS_SSH_KEY secrets to enable this."A passing stub is better than a broken action that blocks all future CI runs while you set up the target.
Error 2: Required Secret Not Supplied
Symptom:
Error: Input required and not supplied: vercel-tokenCause: The deploy job referenced ${{ secrets.VERCEL_TOKEN }} but the secret wasn't added to the GitHub environment yet.
Fix: Go to GitHub → repo → Settings → Environments → production → Add environment secret and add every secret the job needs. The order matters: configure secrets before enabling the deploy job.
VPS_HOST — your server IP or domain
VPS_USER — SSH username (e.g. deploy_user)
VPS_SSH_KEY — private SSH key (ed25519 recommended)Error 3: Editing workflow YAML Doesn't Retrigger GitHub Actions
Symptom: You edit .github/workflows/web-ci.yml, push to main, and nothing appears in the Actions tab.
Cause: The workflow has a paths: filter:
on:
push:
paths: ['web/**']Changing a workflow file doesn't match web/**, so the workflow never fires — silently.
Fix: Add the workflow file itself to the paths filter and add workflow_dispatch for manual runs:
on:
push:
branches: [main, develop]
paths: ['web/**', '.github/workflows/web-ci.yml']
pull_request:
branches: [main, develop]
paths: ['web/**', '.github/workflows/web-ci.yml']
workflow_dispatch:workflow_dispatch gives you a "Run workflow" button in the GitHub UI — essential for re-deploying without making a code change.
Error 4: Broadcast Events Cause 500s in CI (No Reverb Server Running)
Symptom: 17 feature tests return HTTP 500 with no obvious error. Digging into the log reveals:
cURL error 7: Failed to connect to 0.0.0.0 port 8080: Couldn't connect to serverCause: .env.example ships with BROADCAST_CONNECTION=reverb as the default since Laravel 11. CI copies .env.example and never overrides it. Any test that fires a broadcast event — invoice paid, application status changed, work permit updated — tries to open a TCP connection to a Reverb WebSocket server at 0.0.0.0:8080. There is no Reverb server in CI. The entire request returns 500.
Fix: Add one line to your CI environment setup step:
- name: Set test environment variables
run: |
echo "BROADCAST_CONNECTION=log" >> .env.testing
echo "QUEUE_CONNECTION=sync" >> .env.testing
echo "MAIL_MAILER=array" >> .env.testingThe log driver routes all broadcast events to the log file. No server required. Apply the same principle to every service that doesn't run in CI.
Error 5: Laravel Storage Directories Missing in CI Checkout
Symptom:
Please provide a valid cache pathOccurs during composer install's post-autoload-dump hook, which boots Laravel and requires storage/framework/views/, storage/framework/sessions/, and bootstrap/cache/ to exist.
Cause: Git doesn't track empty directories. These folders exist on your machine but were never committed.
Fix: Add .gitignore placeholder files so git tracks the directory structure:
for dir in \
storage/framework/views \
storage/framework/sessions \
storage/framework/cache/data \
storage/logs \
bootstrap/cache; do
mkdir -p "api/$dir"
printf "*\n!.gitignore" > "api/$dir/.gitignore"
done
git add api/storage api/bootstrap/cache
git commit -m "chore: add storage skeleton so CI checkout has required directories"Error 6: Pest Exits Code 2 — Test Directory Not Found
Symptom:
INFO Test directory ".../api/tests/Unit" not found.
Error: Process completed with exit code 2.Cause: Pest exits with code 2 when a configured test directory doesn't exist. tests/Unit/ was empty on the development machine and never committed — so CI checkout had no directory there at all.
Fix: Every directory listed in phpunit.xml must contain at least one .php test file. A .gitkeep doesn't help — Pest needs a real test file to recognise the directory:
<?php
it('sanity check', function () {
expect(true)->toBeTrue();
});Error 7: npm Not Found When Deploying to VPS via SSH
Symptom:
bash: line 15: npm: command not found
Process exited with status 127Cause: Node.js was installed via nvm. When you SSH in manually, your shell sources ~/.bashrc which loads nvm and adds node/npm to PATH. But appleboy/ssh-action opens a non-interactive, non-login shell — ~/.bashrc is never sourced, so nvm's PATH is missing entirely.
Fix: Source nvm explicitly at the top of your deploy script before any node/npm/pm2 command:
script: |
set -euo pipefail
# Non-interactive SSH doesn't source ~/.bashrc — load nvm manually
export NVM_DIR="${HOME}/.nvm"
[ -s "${NVM_DIR}/nvm.sh" ] && source "${NVM_DIR}/nvm.sh"
# npm, node, pm2 are now available
npm ci --omit=dev --ignore-scriptsError 8: script_stop Is Not a Valid Input for ssh-action@v1
Symptom:
Warning: Unexpected input(s) 'script_stop', valid inputs are
['host', 'port', 'key', 'script', ...]Cause: script_stop: true existed in an older version of appleboy/ssh-action and was removed in v1.
Fix: Remove script_stop: true from the action inputs. Identical behaviour is achieved by set -euo pipefail at the top of the script, which makes the shell exit immediately on any error:
- name: Deploy on server
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.VPS_HOST }}
username: ${{ secrets.VPS_USER }}
key: ${{ secrets.VPS_SSH_KEY }}
script: |
set -euo pipefail
# your deploy commands hereError 9: Husky Runs During Production npm ci and Crashes
Symptom:
> [email protected] prepare
> husky
sh: 1: husky: not found
npm error code 127Cause: package.json has a prepare lifecycle script that runs husky. Husky is a devDependency. npm ci --omit=dev skips installing it but still runs the prepare script — which immediately fails because the husky binary doesn't exist.
Fix: Add --ignore-scripts to skip all lifecycle scripts. This is safe on the server because the app is already built by CI before being uploaded:
npm ci --omit=dev --ignore-scriptsError 10: resources/views Directory Missing on a Laravel 13 API-Only App
Symptom:
In Finder.php line 648:
The ".../resources/views" directory does not exist.Cause: This is a Laravel 13 API-only application — there are no Blade views, so resources/views was never created and is not in git. When the deploy script runs php artisan view:cache, Laravel's Finder class tries to scan that directory and throws a fatal exception.
Fix: Create the directory before running the command:
mkdir -p resources/views && php artisan view:cacheAn empty resources/views is valid. view:cache reports zero views compiled and exits cleanly.
Error 11: PM2 Crashes Restarting a Stopped Process on VPS
Symptom:
[PM2] Applying action restartProcessId on app [visa-saas](ids: [ 2 ])
Error: [ERROR] Process 2 not found
TypeError: Cannot read properties of undefined (reading 'pm2_env')Cause: The PM2 process was in a stopped state — not online. PM2 has a bug where reload and restart on a stopped process access proc.pm2_env internally, which is undefined for stopped processes. The result is a TypeError crash.
Confirm the state with:
pm2 listA red stopped status confirms the issue.
Fix: Delete the process from PM2's registry first, then start fresh from the ecosystem config:
pm2 delete visa-saas 2>/dev/null || true
pm2 start ecosystem.config.js
pm2 save2>/dev/null || true makes the delete a no-op on first deploy when no process exists yet. pm2 save persists the list so it survives a server reboot.
Error 12: pm2 reload Loses the Process After a Directory Swap on VPS
Symptom: Even with the process running, pm2 reload throws the same "Process not found" error immediately after the mv web-staging/ web/ directory swap.
Cause: pm2 reload performs a graceful zero-downtime restart — it forks a new process, waits for it to become ready, then kills the old one. When you mv the entire working directory mid-reload, PM2 loses track of the original process ID during the fork phase and the same null-access bug fires.
Fix: Same pattern as Error 11. Delete then start:
pm2 delete visa-saas 2>/dev/null || true
pm2 start ecosystem.config.js
pm2 saveThis causes 1–2 seconds of downtime on each deploy, which is acceptable for most projects. For true zero-downtime Next.js deploys, use the releases/symlink pattern for the web directory so PM2's cwd never moves — only the symlink target changes.
The Complete Working Deploy Script
Here is the final working SSH deploy script for the Next.js 16 frontend incorporating all fixes from this post:
- name: Deploy on server
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.VPS_HOST }}
username: ${{ secrets.VPS_USER }}
key: ${{ secrets.VPS_SSH_KEY }}
script: |
set -euo pipefail
# Non-interactive SSH doesn't source ~/.bashrc — load nvm manually
export NVM_DIR="${HOME}/.nvm"
[ -s "${NVM_DIR}/nvm.sh" ] && source "${NVM_DIR}/nvm.sh"
BASE=/var/www/visa-saas
STAGING="$BASE/web-staging"
echo "→ Extracting to staging"
rm -rf "$STAGING" && mkdir -p "$STAGING"
tar -xzf /tmp/web-release.tar.gz -C "$STAGING"
echo "→ Linking shared .env.local"
ln -sfn "$BASE/shared/.env.local" "$STAGING/.env.local"
echo "→ Installing production dependencies"
cd "$STAGING"
npm ci --omit=dev --ignore-scripts
echo "→ Activating release"
rm -rf "$BASE/web"
mv "$STAGING" "$BASE/web"
echo "→ Restarting PM2"
cd "$BASE/web"
pm2 delete visa-saas 2>/dev/null || true
pm2 start ecosystem.config.js
pm2 save
rm -f /tmp/web-release.tar.gz
echo "✓ Web deployed"And the complete Laravel 13 API deploy script:
script: |
set -euo pipefail
DEPLOY=/var/www/visa-saas
RELEASE="$DEPLOY/releases/$(date +%Y%m%d%H%M%S)"
SHARED="$DEPLOY/shared"
mkdir -p "$RELEASE"
tar -xzf /tmp/api-release.tar.gz -C "$RELEASE"
cd "$RELEASE"
rm -rf storage
ln -sfn "$SHARED/storage" storage
ln -sfn "$SHARED/.env" .env
composer install --no-dev --optimize-autoloader --no-interaction --quiet
php artisan migrate --force
php artisan config:cache
php artisan route:cache
mkdir -p resources/views && php artisan view:cache
php artisan event:cache
php artisan queue:restart || true
ln -sfn "$RELEASE" "$DEPLOY/api"
sudo systemctl reload php8.4-fpm
ls -1dt "$DEPLOY/releases/"* | tail -n +6 | xargs rm -rf || true
rm -f /tmp/api-release.tar.gz
echo "✓ API deployed: $RELEASE"Key Lessons
Override every service connection in CI. If .env.example connects to anything — Reverb, Redis, Pusher, Mailpit, Typesense — explicitly set a CI-safe alternative in your test environment step. Assume nothing runs in CI unless you started it in a services: block.
Git doesn't track empty directories. Every directory Laravel needs at boot time must have a .gitignore placeholder committed so it exists in a fresh checkout.
Non-interactive SSH ignores your shell profile. Any binary installed via a version manager (nvm, pyenv, rbenv) is invisible in SSH deploy scripts unless you source the manager explicitly at the top of the script.
PM2 has a bug with stopped processes. Never use pm2 restart or pm2 reload in automated scripts without guarding for the stopped state. The delete-then-start pattern is slightly slower but works every time regardless of prior process state.
paths: filters are silent. A workflow file change that doesn't match its own paths filter simply does nothing. Always include the workflow file in its own paths list and add workflow_dispatch.
None of these are edge cases. Every one of them comes from the gap between a tutorial environment and a real VPS that already has other apps, an existing PM2 setup, and environment variables that were never designed with CI in mind.
Related Posts in This Series
- Post 6: Zero-Downtime Deploy to VPS with GitHub Actions and Laravel 13 — the deploy pattern this post debugs in production
- Post 5: Managing Secrets and Environment Variables in GitHub Actions — set up VPS_HOST, VPS_USER, VPS_SSH_KEY correctly
- Post 3: Run Laravel Pest Tests Against MySQL in GitHub Actions — covers the CI test service setup behind errors 4 and 5
Frequently Asked Questions
Why does npm say "command not found" in my GitHub Actions SSH deploy?
Why do my Laravel tests return 500 in CI but pass locally?
Why does pm2 restart fail with "Process not found"?
Why does php artisan view:cache fail on a Laravel API?
Why doesn't my GitHub Actions workflow trigger when I edit the workflow file?
Senior Full Stack Developer · Building SaaS products & teaching Laravel/React · 10+ years experience · Founder of Orion360 · Based in Dubai, UAE.
Was this post helpful?
Reviews & Ratings
Sign in to leave a review.



