Skip to content
TutorialLaravel

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

D

Dinesh Wijethunga

August 6, 2026Reviewed Jul 9, 2026 11 min readIntermediate 21 views
ShareX / TwitterLinkedIn
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@v1 and appleboy/[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 found

Cause: 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-token

Cause: 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 server

Cause: .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.testing

The 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 path

Occurs 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 127

Cause: 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-scripts

Error 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 here

Error 9: Husky Runs During Production npm ci and Crashes

Symptom:

> [email protected] prepare
> husky
sh: 1: husky: not found
npm error code 127

Cause: 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-scripts

Error 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:cache

An 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 list

A 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 save

2>/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 save

This 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

Frequently Asked Questions

Why does npm say "command not found" in my GitHub Actions SSH deploy?
nvm installs Node in ~/.nvm but non-interactive SSH sessions don't source ~/.bashrc. Add export NVM_DIR="${HOME}/.nvm" && [ -s "${NVM_DIR}/nvm.sh" ] && source "${NVM_DIR}/nvm.sh" at the top of your deploy script.
Why do my Laravel tests return 500 in CI but pass locally?
Your .env.example likely sets BROADCAST_CONNECTION=reverb. CI inherits this and tries to connect to a Reverb server at 0.0.0.0:8080 that doesn't exist. Add echo "BROADCAST_CONNECTION=log" >> .env.testing in your CI step.
Why does pm2 restart fail with "Process not found"?
PM2 has a bug where restart/reload on a stopped process throws TypeError: Cannot read properties of undefined (reading 'pm2_env'). Use pm2 delete visa-saas 2>/dev/null || true && pm2 start ecosystem.config.js instead.
Why does php artisan view:cache fail on a Laravel API?
API-only Laravel apps never create resources/views/. Run mkdir -p resources/views && php artisan view:cache in your deploy script.
Why doesn't my GitHub Actions workflow trigger when I edit the workflow file?
If your workflow has a paths: filter like ['web/**'], editing the workflow file itself doesn't match — so it never fires. Add .github/workflows/web-ci.yml to the paths array and add workflow_dispatch: for manual runs.
D
Dinesh Wijethunga

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.

Comments(0)

Guest comments are held for moderation.

You might also like