← العودة إلى المدونة
·18 دقائق قراءة

How I Deploy Laravel and Next.js to a VPS: FastPanel, GitHub Actions and Zero-Downtime Releases

The setup behind my client apps: a VPS from Hetzner or netcup, FastPanel to host several sites on one server, and GitHub Actions deploys with a releases folder, shared files and an atomic switch, for Laravel (with Vue or React) and Next.js.

deploymentvpslaravelnextjsgithub-actionsdevops

Most of the apps I build and look after don't run on a platform that bills per app. They run on a few VPSs I manage myself, several sites per server, deployed by GitHub Actions. It costs a fraction of a managed plan per app, and in exchange I own the server: updates, backups and the deploy pipeline are my job.

This post is that setup, end to end: how I pick a VPS provider, how FASTPANEL turns one server into a small hosting platform, and the deploy pipeline that ships Laravel and Next.js apps without a second of downtime.

Picking a VPS provider

The cheapest VPS isn't the one that costs least per month. It's the one you don't have to think about. What I look for, in order:

  • Reliability. Stable network, few unplanned reboots, and support that answers when something does break.
  • Location. Data centers in Europe, close to both my European clients and Algeria.
  • Snapshots and backups from the provider's dashboard, so a bad upgrade is a restore, not a rebuild.
  • Honest resources. The CPU and disk you pay for, not a number shared with too many neighbours.

The two I recommend are Hetzner and netcup. I've been a netcup client for almost two years now, and it's where most of my servers run. Before that I used Contabo. It's hard to beat on price, but in my experience it's less reliable, so I'd only put things there that are allowed to go down.

Prices change often, so I won't quote them. Compare the plans on each provider's site at the size you need.

FASTPANEL: one server, many sites

A bare VPS gives you SSH and nothing else. You could set up nginx, PHP-FPM, databases, SSL and users by hand for every site, and keep them all updated. Or you can put a control panel on top. I use FASTPANEL: it's light, it runs on Debian, Ubuntu, AlmaLinux and Rocky Linux, and it does what I need without getting in the way.

FASTPANEL dashboard: the server's CPU, RAM and disk usage at the top, and a grid of hosted sites
The FASTPANEL dashboard: every site on the server, with live CPU, RAM and disk usage. Screenshot: FASTPANEL.

What I use it for:

  • Many sites on one VPS. Each site gets its own folder, /var/www/<user>/data/www/<domain>, its own system user if you want one, and its own settings.
  • A PHP version per site. PHP 5.3 to 8.5 can be installed side by side from the Applications menu, so an old Laravel 8 app and a new Laravel 13 app live on the same server, each on its own version. That's what makes upgrading old apps one at a time possible.
  • Databases with a web admin. MySQL and PostgreSQL (9.6 to 17), with phpMyAdmin and phpPgAdmin installed from the same menu. If you prefer the pgAdmin desktop app, connect it to PostgreSQL through an SSH tunnel.
  • The rest of the stack. Redis, Memcached, Composer, Git and Fail2ban, a scheduler for cron jobs, and SSL certificates from each site's card.
A FASTPANEL site card: site user, root directory, PHP mode and version, and shortcuts to files, databases, DNS, SSL certificates and the scheduler
A site's card: its user, root directory and PHP version, with files, databases, DNS, SSL and the scheduler one click away. Screenshot: FASTPANEL.

FASTPANEL comes as a Standard and an Extended license. Installing Node.js from the panel is an Extended feature. I install Node myself with nvm, per user, which also lets each app pin its own Node version with an .nvmrc file. Check the current license terms before you pick one.

Node, Python and anything else that listens on a port

A Next.js server, a Python API or a websocket service isn't served by PHP. It listens on a local port, and the site in front of it forwards requests there. In FASTPANEL, open the site's Settings, choose Reverse proxy under Backend (PHP, Reverse proxy, etc.) and enter the app's address, for example http://127.0.0.1:3000. The panel keeps handling the domain, SSL and static files; your app only answers on its port. You can also list several addresses, and requests are balanced between them.

FASTPANEL site settings: Basic, Static content, HTTPS, Redirects, Site directory, Log settings, HTTP authorization, PHP settings and Security
Site settings: where the site directory, PHP version and backend mode are set. Screenshot: FASTPANEL.

More on each setting in the FASTPANEL knowledge base.

The classic deploy is git pull inside the live folder, then composer install and a build. For a minute or two, visitors hit a half-updated app: new code with old dependencies, or a build that isn't finished. If the build fails, the site stays broken until someone fixes it.

The fix is to never touch the live folder. Every deploy goes into a new folder, and the site is switched to it only when it's ready:

example.com/
├── current -> releases/20261024-71e5b3d   # what the web server serves
├── releases/
│   ├── 20261020-8b1d4e7
│   ├── 20261022-c4a90f2
│   └── 20261024-71e5b3d
└── shared/
    ├── .env          # one config for every release
    └── storage/      # uploads, logs, sessions: survive every deploy
  1. Upload the new code into releases/<date>-<commit>.
  2. Link the shared files into it: .env and storage/ point to shared/, so uploads and config survive every deploy.
  3. Install, build, migrate and cache inside the new release, while the current one keeps serving.
  4. Switch current to the new release with one atomic rename.
  5. Prune old releases, keeping the last few for rollbacks.

If any step before the switch fails, the new folder is deleted and visitors never notice. Try it:

example.com/

current→releases/20261022-c4a90f2

releases/

  • 20261018-3f9c2a1
  • 20261020-8b1d4e7
  • 20261022-c4a90f2live

shared/

  • .env
  • storage/
Press deploy. The live release keeps serving the whole time.downtime: 0 s

The switch is the only moment the live site changes, so it has to be atomic. Depending on the version, ln -sfn on an existing link can remove it before creating the new one, which leaves a tiny gap where current doesn't exist. Creating the new link under a temporary name and renaming it over the old one never does, because a rename is atomic:

ln -sfn "$RELEASE_DIR" "$APP_DIR/current.tmp"
mv -Tf "$APP_DIR/current.tmp" "$APP_DIR/current"

A rollback is the same two lines, pointed at the previous release. Nothing is rebuilt, so it takes a second.

SSH: a key just for deploys

GitHub Actions deploys over SSH, with a key that does nothing else:

# On your machine: a dedicated key pair, no passphrase
ssh-keygen -t ed25519 -C "github-deploy" -f ./deploy_key -N ""

# On the server, as the site's user: allow the public key
cat deploy_key.pub >> ~/.ssh/authorized_keys

# Pin the server's host key instead of trusting it blindly on every run
ssh-keyscan -H your-server.example.com > known_hosts

In the repository's Settings → Secrets and variables → Actions, store the private key as SSH_PRIVATE_KEY and the contents of known_hosts as SSH_KNOWN_HOSTS. The host, user and app folder go in as variables: SSH_HOST, SSH_USER, APP_DIR. Then delete the local copy of the private key.

The deploy user is the site's own user, not root. If a key ever leaks, it reaches one user's sites, not the whole server.

Laravel, with Vue or React

For Laravel I build everything in GitHub Actions: Composer dependencies and the Vite build for the Vue or React front end. The server receives a release that's ready to run, so it doesn't need Node at all. That keeps it lean, and a broken npm run build fails in CI before anything reaches the server.

.github/workflows/deploy.yml:

name: Deploy

on:
  push:
    branches: [main]
  workflow_dispatch:

# One deploy at a time; a newer push waits instead of cancelling one mid-switch.
concurrency:
  group: deploy-production
  cancel-in-progress: false

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: production
    env:
      SSH_TARGET: ${{ vars.SSH_USER }}@${{ vars.SSH_HOST }}
      APP_DIR: ${{ vars.APP_DIR }} # e.g. /var/www/fastuser/data/www/example.com
    steps:
      - uses: actions/checkout@v7

      - uses: shivammathur/setup-php@v2
        with:
          php-version: "8.4"
          tools: composer:v2
          coverage: none

      - uses: actions/setup-node@v7
        with:
          node-version-file: .nvmrc
          cache: npm

      - name: Install dependencies and build the front end
        run: |
          composer install --no-dev --no-interaction --prefer-dist --optimize-autoloader
          npm ci
          npm run build # Vite: compiles Vue or React into public/build

      - name: Configure SSH
        env:
          SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
          SSH_KNOWN_HOSTS: ${{ secrets.SSH_KNOWN_HOSTS }}
        run: |
          install -m 700 -d ~/.ssh
          printf '%s\n' "$SSH_PRIVATE_KEY" > ~/.ssh/id_deploy
          chmod 600 ~/.ssh/id_deploy
          printf '%s\n' "$SSH_KNOWN_HOSTS" > ~/.ssh/known_hosts
          printf 'Host *\n  IdentityFile ~/.ssh/id_deploy\n  IdentitiesOnly yes\n' > ~/.ssh/config

      - name: Upload the release
        run: |
          RELEASE="$(date -u +%Y%m%d%H%M%S)-${GITHUB_SHA::7}"
          echo "RELEASE=$RELEASE" >> "$GITHUB_ENV"
          tar --exclude=./node_modules --exclude=./.git --exclude=./storage -czf - . \
            | ssh "$SSH_TARGET" "mkdir -p '$APP_DIR/releases/$RELEASE' && tar -xzf - -C '$APP_DIR/releases/$RELEASE'"

      - name: Activate the release
        run: ssh "$SSH_TARGET" "bash '$APP_DIR/releases/$RELEASE/scripts/deploy.sh'"

The second half runs on the server, from inside the release that was just uploaded. scripts/deploy.sh, committed with the app:

#!/usr/bin/env bash
# Runs on the server from inside the uploaded release: APP_DIR/releases/<id>/scripts/deploy.sh
set -euo pipefail

RELEASE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
APP_DIR="$(cd "$RELEASE_DIR/../.." && pwd)"
SHARED_DIR="$APP_DIR/shared"
KEEP_RELEASES="${KEEP_RELEASES:-5}"
PHP="${PHP_BIN:-php}" # the same PHP version the site uses in FASTPANEL

# A failure before the switch removes the release; the live site never moved.
cleanup_failed_release() {
  if [ "$(readlink -f "$APP_DIR/current" 2>/dev/null)" != "$RELEASE_DIR" ]; then
    echo "Deploy failed, removing $RELEASE_DIR" >&2
    rm -rf "$RELEASE_DIR"
  fi
}
trap cleanup_failed_release ERR

[ -f "$SHARED_DIR/.env" ] || { echo "Missing $SHARED_DIR/.env" >&2; exit 1; }

# One .env and one storage folder, shared by every release.
mkdir -p "$SHARED_DIR"/storage/{app/public,framework/{cache,sessions,views},logs}
ln -sfn "$SHARED_DIR/.env" "$RELEASE_DIR/.env"
rm -rf "$RELEASE_DIR/storage"
ln -sfn "$SHARED_DIR/storage" "$RELEASE_DIR/storage"

cd "$RELEASE_DIR"
"$PHP" artisan storage:link
"$PHP" artisan migrate --force
"$PHP" artisan optimize

# The switch: atomic, and the only moment the live site changes.
ln -sfn "$RELEASE_DIR" "$APP_DIR/current.tmp"
mv -Tf "$APP_DIR/current.tmp" "$APP_DIR/current"

# Queue workers keep the old code in memory until they restart.
"$PHP" artisan queue:restart # with Horizon: "$PHP" artisan horizon:terminate

# Keep the newest releases for rollbacks. Names start with a timestamp, so they sort by date.
cd "$APP_DIR/releases"
ls -1 | sort -r | tail -n +"$((KEEP_RELEASES + 1))" | xargs -r rm -rf

echo "Deployed $(basename "$RELEASE_DIR")"

On the FASTPANEL side, two settings:

  • Site directory: point the site at …/current/public, so the web server always serves whatever current points to. Do the first deploy before you change it, so the folder exists.
  • PHP version: pick the same one you use in CI. FASTPANEL installs PHP versions side by side, so the php on the command line may not be the site's. Set PHP_BIN to that version's binary in the deploy script.

Three things worth knowing:

  • Migrations run before the switch, so for a few seconds the previous release runs on the new schema. Keep migrations backward compatible: add a column in one deploy, start using it in the next, drop the old one later.
  • PHP caches resolved paths for a short time (realpath_cache_ttl, 120 seconds by default). Some PHP-FPM workers can keep serving the previous release for up to that long after the switch. It's harmless, because the previous release is still on disk. If you need the switch to be instant, reload PHP-FPM after it.
  • Inertia SSR: if your Vue or React app renders on the server, build it with vite build --ssr in CI, and restart the SSR process after the switch with php artisan inertia:stop-ssr (your process manager starts it again from the new release).

Next.js

A Next.js app isn't a folder of PHP files the web server reads on each request. It's a Node process that has to be replaced. The release layout stays the same, plus a process manager that can swap processes without dropping requests: PM2 in cluster mode.

I build Next.js on the server rather than in CI. Its build reads environment variables and bakes the NEXT_PUBLIC_ ones into the bundle, and those live in shared/.env on the server. GitHub Actions only uploads the code, a clean git archive of the commit, and runs the deploy script:

      - name: Upload the release
        run: |
          RELEASE="$(date -u +%Y%m%d%H%M%S)-${GITHUB_SHA::7}"
          echo "RELEASE_DIR=$APP_DIR/releases/$RELEASE" >> "$GITHUB_ENV"
          git archive --format=tar HEAD | ssh "$SSH_TARGET" \
            "mkdir -p '$APP_DIR/releases/$RELEASE' '$APP_DIR/shared' && tar -x -C '$APP_DIR/releases/$RELEASE'"

      - name: Build and switch
        run: ssh "$SSH_TARGET" "bash '$RELEASE_DIR/scripts/deploy.sh'"

The checkout and SSH steps are the same as for Laravel. On the server, the deploy script loads the Node version from .nvmrc, links the shared files, reuses the previous build cache so builds stay fast, builds, and only then switches and reloads:

#!/usr/bin/env bash
set -euo pipefail

RELEASE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
APP_DIR="$(cd "$RELEASE_DIR/../.." && pwd)"
SHARED_DIR="$APP_DIR/shared"

# Node from the release's .nvmrc (nvm.sh isn't compatible with `set -eu`).
set +eu
. "$HOME/.nvm/nvm.sh"
nvm install "$(cat "$RELEASE_DIR/.nvmrc")" > /dev/null
set -eu

trap '[ "$(readlink -f "$APP_DIR/current")" = "$RELEASE_DIR" ] || rm -rf "$RELEASE_DIR"' ERR

ln -sfn "$SHARED_DIR/.env" "$RELEASE_DIR/.env"

# Reuse the last build cache so builds stay incremental.
if [ -d "$APP_DIR/current/.next/cache" ]; then
  mkdir -p "$RELEASE_DIR/.next"
  cp -a "$APP_DIR/current/.next/cache" "$RELEASE_DIR/.next/cache"
fi

cd "$RELEASE_DIR"
pnpm install --frozen-lockfile
pnpm run build

ln -sfn "$RELEASE_DIR" "$APP_DIR/current.tmp"
mv -Tf "$APP_DIR/current.tmp" "$APP_DIR/current"

pm2 startOrReload "$RELEASE_DIR/ecosystem.config.cjs" --update-env
pm2 save

cd "$APP_DIR/releases"
ls -1 | sort -r | tail -n +6 | xargs -r rm -rf

The zero downtime comes from the PM2 config. Two details make it work: the app runs from current, not from a fixed release, and it runs in cluster mode, so pm2 reload replaces the workers one at a time while the others keep serving:

// ecosystem.config.cjs
const path = require("node:path");
const appDir = path.resolve(__dirname, "..", "..");

module.exports = {
  apps: [
    {
      name: "example",
      // Every worker PM2 starts during a reload runs whatever release `current` points to.
      cwd: path.join(appDir, "current"),
      script: "node_modules/next/dist/bin/next",
      args: "start",
      // Cluster mode makes `pm2 reload` zero-downtime: workers are replaced one at a time.
      exec_mode: "cluster",
      instances: 2,
      kill_timeout: 10000,
      listen_timeout: 30000,
      env: { NODE_ENV: "production", PORT: "3000" },
    },
  ],
};

In FASTPANEL, the site runs in Reverse proxy mode pointing at http://127.0.0.1:3000. The panel handles the domain and SSL, and PM2 handles the app.

Two things I learned the hard way:

  • One PM2 daemon per Node version. A reload keeps the PM2 daemon, and cluster workers always run on the daemon's Node. If two apps on the server need different Node versions, give each its own daemon with PM2_HOME="$HOME/.pm2-<app>".
  • Surviving a reboot without root. A site user can't install a systemd unit, but it can add a crontab line: @reboot running pm2 resurrect brings the apps back after a restart.

Rolling back

Something slipped through anyway? Point current at the previous release:

cd /var/www/fastuser/data/www/example.com
ls -1 releases | sort -r | head -3          # newest first
ln -sfn releases/20261022-c4a90f2 current.tmp && mv -Tf current.tmp current
php artisan queue:restart                    # Laravel; for Next.js: pm2 reload example

The code goes back; the database doesn't. That's one more reason to keep migrations backward compatible: a rollback should never need a migration rolled back with it.

What this setup gets you

  • A few apps on one VPS, for the price of a single managed plan
  • A panel for the boring parts: sites, PHP versions, databases, SSL and cron
  • Deploys on every push to main, with the build checked before anything goes live
  • No downtime during deploys, and a rollback that takes a second
  • Uploads and config that survive every deploy, in shared/

If you're still deploying by FTP, or running git pull on the live folder, this is the setup I put in place when I upgrade and take over a Laravel app.

لديك مشروع مشابه؟

أخبرني أين وصلت وما الذي يعيقك. رد سريع عبر واتساب وعرض سعر مجاني.