DEV Community

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

Posted on • Originally published at dineshstack.com

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/scp-action@v0.1.7
  • 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" ] &amp;&amp; source "${NVM_DIR}/nvm.sh"

        # npm, node, pm2 are now available
        npm ci --omit=dev --ignore-scripts</code></pre><h2>Error 8: script_stop Is Not a Valid Input for ssh-action@v1</h2><p><strong>Symptom:</strong></p><pre><code class="language-plaintext">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:

> visa-saas-web@0.1.0 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

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" ] &amp;&amp; source "${NVM_DIR}/nvm.sh"

        BASE=/var/www/visa-saas
        STAGING="$BASE/web-staging"

        echo "→ Extracting to staging"
        rm -rf "$STAGING" &amp;&amp; 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&gt;/dev/null || true
        pm2 start ecosystem.config.js
        pm2 save

        rm -f /tmp/web-release.tar.gz
        echo "✓ Web deployed"</code></pre><p>And the complete Laravel 13 API deploy script:</p><pre><code class="language-yaml">          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 &amp;&amp; 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"</code></pre><h2>Key Lessons</h2><p><strong>Override every service connection in CI.</strong> If <code>.env.example</code> 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 <code>services:</code> block.</p><p><strong>Git doesn't track empty directories.</strong> Every directory Laravel needs at boot time must have a <code>.gitignore</code> placeholder committed so it exists in a fresh checkout.</p><p><strong>Non-interactive SSH ignores your shell profile.</strong> 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.</p><p><strong>PM2 has a bug with stopped processes.</strong> Never use <code>pm2 restart</code> or <code>pm2 reload</code> 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.</p><p><strong>paths: filters are silent.</strong> 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 <code>workflow_dispatch</code>.</p><p>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.</p><h2>Related Posts in This Series</h2><ul><li data-list-item-id="e9e04d3612ea86545d33fe463f528b777"><a href="/en/zero-downtime-deploy-vps-github-actions-laravel">Post 6: Zero-Downtime Deploy to VPS with GitHub Actions and Laravel 13</a> — the deploy pattern this post debugs in production</li><li data-list-item-id="e08d3c9c0d55b96b6579d0e61e8a4262f"><a href="/en/github-actions-secrets-env-vars-laravel">Post 5: Managing Secrets and Environment Variables in GitHub Actions</a> — set up VPS_HOST, VPS_USER, VPS_SSH_KEY correctly</li><li data-list-item-id="eba1431ff0f6c59c7d67a91a6bd1b7f3f"><a href="/en/laravel-pest-tests-mysql-github-actions">Post 3: Run Laravel Pest Tests Against MySQL in GitHub Actions</a> — covers the CI test service setup behind errors 4 and 5</li></ul>

Originally published at dineshstack.com — read the full version with code samples and updates there.

Top comments (1)

Collapse
 
nexusshell profile image
Nexus Shell

Useful breakdown, especially the non-interactive shell + nvm issue and the silent paths filter. A few production hardening details would make the final scripts safer:

  • Pin third-party Actions to a full commit SHA instead of a mutable @v1 tag.
  • Set ssh-action's fingerprint input so the runner verifies the VPS host key.
  • Give the deploy account only the narrow sudo permission needed to reload PHP-FPM; avoid a general-purpose privileged account.
  • Keep versioned releases and switch an api/current (or web/current) symlink only after health checks pass. Removing the live directory before mv leaves a failure window and makes rollback harder.

That changes a failed deployment from a partially replaced production tree into a release you can reject or roll back cleanly.