Skip to content

Deploy a Laravel app

Your Laravel app on a server of its own. git push to deploy.

Deploy on exe.dev

Shelley handles the setup. About 15 minutes.

Prefer a terminal?
curl -fsSL https://exe-staging.dev/deploy/laravel/prompt.txt | ssh exe.dev new --prompt=/dev/stdin

Laravel gives you the whole web app in one framework: routing, Eloquent, queues, the scheduler, mail and Vite for your front end. This page puts yours on an exe.dev VM of its own. git push builds and deploys it with no downtime, exe.dev serves it over HTTPS, SQLite and your uploads sit on a persistent disk, and the queue worker and scheduler come back after a reboot.

It's the server Laravel's own deployment guide describes: nginx and PHP-FPM, with php artisan optimize on every deploy. Unlike a container platform, it's a real computer: nothing sleeps between requests, files you upload stay put, and you can ssh in and run php artisan tinker.

What you get#

  • PHP 8.5, Composer and Node.js 24 installed; nginx answering on port 8000 and PHP-FPM running as app.service, both enabled to come back after a reboot.
  • A ~/app.git whose pre-receive hook builds each push into a new release (Composer, the Vite build, migrations, optimize), switches to it, and checks /up; a failed deploy rejects the push and leaves the running release alone.
  • Settings in ~/app.env (mode 600) with a generated APP_KEY, and the SQLite database and storage/ (uploads) in ~/data.
  • The queue worker as app-worker.service and the scheduler as app-schedule.timer, both enabled.
  • A nightly backup by app-backup.timer, with the first backup opened and checked in a scratch directory.
  • With REPO=new, a fresh Laravel 13 app that trusts exe.dev's proxy and greets you by your exe.dev email at /hello.
What's running, and where
Private https://my-laravel.exe.xyz/ Your app, until you make it public
Private ssh my-laravel.exe.xyz git push to deploy; shell and logs
my-laravel.exe.xyz exe.dev VM
Your Laravel app PHP 8.5 with PHP-FPM, as app.service; nginx serves it and public/ :8000~/app → the current release in ~/releases
git push deploys A pre-receive hook in ~/app.git builds, migrates, and switches ~/app.git
Queue worker and scheduler queue:work as app-worker.service; schedule:run every minute by app-schedule.timer
SQLite The database, uploads and the rest of storage/; settings in ~/app.env ~/data
Nightly backup app-backup.timer; database, uploads, settings and code; keeps 14 ~/backups

Deploy with Shelley#

Click Deploy and Shelley will set up Laravel on a new VM, then check that it works. No config-file wrangling required.

Have these ready; Shelley asks for them:

  • REPO: the GitHub repository to deploy, as owner/name or a URL, or new to start a fresh Laravel app
Prefer a terminal?
curl -fsSL \
  https://exe-staging.dev/deploy/laravel/prompt.txt \
  | ssh exe.dev new --prompt=/dev/stdin

When it's done, run this on your computer (only you can):

git clone -o exe my-laravel.exe.xyz:app.git    # if you started fresh
git remote add exe my-laravel.exe.xyz:app.git  # or, in your own app's checkout
What Shelley is told
REPO=<fill-this-in>

Set up Laravel on this VM by following the exe.dev recipe at https://exe-staging.dev/deploy/laravel.md from top to bottom.

The values at the top of this message are mine:
- REPO: the GitHub repository to deploy, as owner/name or a URL, or `new` to start a fresh Laravel app
Ask me for any that still say <fill-this-in> before you need them.

The recipe's example VM name is my-laravel. This VM's name is the output of `hostname`: use it wherever the recipe writes my-laravel. Skip the step that creates the VM; you are on it.

You are done when all of these are true. Check each one yourself:
- PHP 8.5, Composer and Node.js 24 installed; nginx answering on port 8000 and PHP-FPM running as app.service, both enabled to come back after a reboot.
- A ~/app.git whose pre-receive hook builds each push into a new release (Composer, the Vite build, migrations, optimize), switches to it, and checks /up; a failed deploy rejects the push and leaves the running release alone.
- Settings in ~/app.env (mode 600) with a generated APP_KEY, and the SQLite database and storage/ (uploads) in ~/data.
- The queue worker as app-worker.service and the scheduler as app-schedule.timer, both enabled.
- A nightly backup by app-backup.timer, with the first backup opened and checked in a scratch directory.
- With REPO=new, a fresh Laravel 13 app that trusts exe.dev's proxy and greets you by your exe.dev email at /hello.

Read the whole recipe, including the closed details, and follow "Install
by hand" in order, skipping VM creation. The walkthrough above it is for
the owner afterwards. REPO=new takes "Start a new app"; anything else
takes "Bring your app" (turn URLs into owner/name). If cloning fails,
the repository is private: give the owner the `ssh exe.dev integrations
add github` command from that step, with this VM's name and the
repository, and wait for them.
Never commit to the owner's code, on GitHub or in ~/app.git, so their
first push fast-forwards: settings go in ~/app.env, and the hook already
trusts exe.dev's proxy. Generate secrets; ask the owner for keys to
outside services, and never invent values. If a deploy fails, read its
output, fix what you can on the VM, and redeploy with
`~/app.git/hooks/pre-receive </dev/null`.
Don't run Restore or Update, don't reboot, and leave the VM private.
For a new app, the URLs to open include /hello.

Finish with the URLs I should open and these commands, which only I can run, from my own computer (with this VM's name in them):
  git clone -o exe my-laravel.exe.xyz:app.git    # if you started fresh
  git remote add exe my-laravel.exe.xyz:app.git  # or, in your own app's checkout

Also at /deploy/laravel/prompt.txt. The recipe it follows is /deploy/laravel.md.

Open your app#

When Shelley is done, open https://my-laravel.exe.xyz/ and sign in to exe.dev. Started fresh? You'll see Laravel's welcome page; open /hello and it greets you by your exe.dev email, from a user it just saved in SQLite. Only you, and people you share the VM with, can open it.

On the VM, everything about the app is in a few places:

What Where
The repository you push to ~/app.git
The running code: a link to the current release ~/app → ~/releases/<time>-<commit>
Settings and secrets (mode 600) ~/app.env, linked as .env in each release
The SQLite database ~/data/database.sqlite
storage/: uploads, framework files ~/data/storage, linked as storage in each release
Backups ~/backups
Logs journalctl -u app (and -u app-worker, -u app-schedule)

Deploy with git push#

Your VM holds a git repository, ~/app.git, and deploys whatever you push to its main branch, like git push heroku main used to. Set it up once on your computer.

If Shelley started a new app, clone it from the VM; the remote is set up for you:

git clone -o exe my-laravel.exe.xyz:app.git my-laravel
cd my-laravel

To run it on your computer too, with PHP and Node.js installed there: composer run setup once, then composer run dev.

If you brought a GitHub repository, add the VM as a second remote in your usual checkout. GitHub stays your source of truth; exe is where you deploy:

git remote add exe my-laravel.exe.xyz:app.git

Then change something, commit, and push:

git push exe main

Use your default branch's name if it isn't main. If ssh can't find the VM that way, use vm+my-laravel@vm.exe.xyz:app.git as the remote's address (why). The deploy runs inside git push, and you watch it:

remote: -----> Building 4cf3ba9 Add a guestbook
remote: Installing dependencies from lock file
remote: ✓ built in 250ms
remote:   2026_10_17_120000_create_messages_table ............ 12.40ms DONE
remote:   config ............................................. 4.89ms DONE
remote: -----> Switching to 4cf3ba9
remote: -----> Deployed 4cf3ba9 to https://my-laravel.exe.xyz/

Each push is built in a new directory under ~/releases, while the old release keeps serving: composer install --no-dev, npm ci and npm run build for Vite, php artisan storage:link, php artisan migrate --force, and php artisan optimize. Only then does ~/app switch to it. PHP-FPM reloads gracefully and the queue worker restarts, and the hook checks that Laravel's health route, /up, answers. The last three releases stay in ~/releases. A typical deploy takes 10 to 30 seconds.

To roll back, push an older commit: git push -f exe <good-sha>:main.

Tip

Want git push to deploy and update GitHub at once? Give origin two push addresses: git remote set-url --add --push origin git@github.com:you/app.git, then git remote set-url --add --push origin my-laravel.exe.xyz:app.git.

When a deploy fails#

If any step fails (a Composer conflict, a Vite error, a migration), git rejects the push with the error, and nothing changes:

remote: [UNRESOLVED_IMPORT] Could not resolve './nowhere' in resources/js/app.js
remote: -----> Deploy failed. Still running 4b26d80.
 ! [remote rejected] main -> main (pre-receive hook declined)

If the new release builds but /up answers with an error, the hook switches back to the previous release at once, and rejects the push:

remote: -----> Switching to b5c1d02
remote: -----> b5c1d02 answers /up with HTTP 500. Its errors: journalctl -u app
remote: -----> Rolling back to 4b26d80
remote: -----> Deploy failed. Still running 4b26d80.

main on the VM stays at the commit that's running, as on Heroku. Fix it and push again. A rollback can't undo migrations the new release already ran, so keep them backward-compatible, or restore a backup.

Settings and secrets#

Laravel's settings live in one file on the VM, ~/app.env, readable only by you. Every release links it as its .env, so it survives deploys and never goes into git. It starts with production values:

APP_ENV=production
APP_KEY=base64:...        # generated; encrypts sessions, cookies and encrypt()
APP_DEBUG=false
APP_URL=https://my-laravel.exe.xyz
LOG_CHANNEL=stderr        # logs go to the journal: journalctl -u app
DB_CONNECTION=sqlite
DB_DATABASE=/home/exedev/data/database.sqlite

To add or change a setting, edit it on the VM and redeploy. The deploy caches the configuration (php artisan optimize), so edits take effect only then:

nano ~/app.env
~/app.git/hooks/pre-receive </dev/null

Keep a copy of APP_KEY somewhere safe. Without it, encrypted data and everyone's sessions are lost; the nightly backup includes it.

Behind exe.dev's HTTPS proxy#

exe.dev's HTTPS proxy handles certificates and sends your app plain HTTP, with X-Forwarded-Proto, X-Forwarded-Host and X-Forwarded-For headers saying what the visitor asked for. Laravel believes those headers only from proxies it trusts. If it doesn't trust exe.dev's, it builds http:// links, so styles and scripts are blocked as mixed content, redirects drop to HTTP, and signed URLs (email verification, for one) fail with "Invalid signature".

New apps trust it in bootstrap/app.php:

->withMiddleware(function (Middleware $middleware): void {
    $middleware->trustProxies(
        at: 'REMOTE_ADDR',
        headers: Request::HEADER_X_FORWARDED_FOR |
            Request::HEADER_X_FORWARDED_HOST |
            Request::HEADER_X_FORWARDED_PROTO,
    );
})

REMOTE_ADDR means whoever connects, and only exe.dev's proxy can reach your VM. It's better than at: '*', which trusts every address in X-Forwarded-For, so $request->ip(), and the rate limits built on it, would believe whatever a visitor sends. For an app that doesn't say which proxies to trust, the deploy adds config/trustedproxy.php, the fallback Laravel reads, with the same REMOTE_ADDR. Commit the snippet above to make it part of your code.

Your data#

The database is SQLite, Laravel's default, in ~/data/database.sqlite, in WAL mode so pages keep reading while a job writes. Sessions, the cache and the queue are tables in it, too. storage/ lives in ~/data/storage: files on the public disk (storage/app/public) are served at /storage/... through php artisan storage:link, and the local disk keeps private files in storage/app/private. Everything in ~/data survives deploys and reboots. Look inside with php artisan tinker or sqlite3 ~/data/database.sqlite, in ~/app.

Need MySQL? Some apps' migrations only run on it. Use MySQL sets up MySQL 8 on this VM, signing the app in over its socket with no password.

For PostgreSQL, set it up on this VM with the PostgreSQL page, which also backs it up every night. It writes the app database's DATABASE_URL to ~/postgres/app.env; Laravel reads it as DB_URL. Then, on the VM:

sudo apt-get install -y php8.5-pgsql
sed -i -e 's|^DB_CONNECTION=.*|DB_CONNECTION=pgsql|' -e '/^DB_DATABASE=/d' ~/app.env
sed -n 's|^DATABASE_URL=|DB_URL=|p' ~/postgres/app.env >> ~/app.env
sudo systemctl restart app
~/app.git/hooks/pre-receive </dev/null

The deploy runs your migrations on the new database. Your SQLite data stays in ~/data/database.sqlite; ask Shelley to copy it over if you need it. Delete the sqlite3 line from ~/app-backup.sh, since the PostgreSQL backups cover the database.

Backups#

Every night, ~/app-backup.sh writes ~/backups/app-<time>.tar.gz: a consistent copy of the database (SQLite's .backup), storage/app with your uploads, ~/app.env, and a git bundle of ~/app.git. It keeps 14. A backup on a lost VM won't help much, so keep copies off the VM. On your computer:

scp -r my-laravel.exe.xyz:backups .

To take one now, run ~/app-backup.sh on the VM; it prints the file's name. To restore, see Back up and restore.

Queues and the scheduler#

The queue worker runs as app-worker.service: php artisan queue:work on the database queue, restarted on every deploy so it runs the new code. Dispatch a job and watch it run:

journalctl -u app-worker -f

The scheduler runs every minute from app-schedule.timer, which starts php artisan schedule:run in the current release. Define tasks in routes/console.php as usual, then check them:

cd ~/app && php artisan schedule:list
journalctl -u app-schedule -n 20

A timer instead of schedule:work means each run is a fresh process on the live release, and a run that crashes doesn't stop the next. Failed jobs land in the failed_jobs table: php artisan queue:failed, then php artisan queue:retry all.

Sign in with exe.dev#

exe.dev already knows who's visiting. For anyone signed in to exe.dev, its proxy adds an X-ExeDev-Email header (Login with exe). A small Laravel guard turns that header into a User, so you need no login pages, passwords or password resets. New apps have it, and /hello uses it. To add it to your app, register the guard in app/Providers/AppServiceProvider.php:

use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Str;

public function boot(): void
{
    // exe.dev's proxy sends X-ExeDev-Email for visitors signed in to
    // exe.dev. The "exedev" guard turns it into a User.
    Auth::viaRequest('exedev', function (Request $request) {
        $email = $request->header('X-ExeDev-Email');

        return $email ? User::firstOrCreate(
            ['email' => $email],
            ['name' => Str::before($email, '@'), 'password' => Str::password(32)],
        ) : null;
    });
}

Add the guard to config/auth.php:

'guards' => [
    'web' => [
        'driver' => 'session',
        'provider' => 'users',
    ],

    'exedev' => [
        'driver' => 'exedev',
    ],
],

Send visitors who aren't signed in to exe.dev's login page, in bootstrap/app.php, inside withMiddleware:

$middleware->redirectGuestsTo(
    fn (Request $request) => '/__exe.dev/login?redirect='.urlencode($request->getRequestUri())
);

Then protect routes with auth:exedev, and auth()->user() is the visitor:

Route::get('/hello', fn () => 'Hello, '.auth()->user()->email.'!')
    ->middleware('auth:exedev');

To use it for every auth route, set AUTH_GUARD=exedev in ~/app.env. A form that POSTs to /__exe.dev/logout signs the visitor out. While the VM is private, only people you've shared it with get this far. Once it's public, anyone can open the app, but the header comes only for visitors who signed in on this host, at https://my-laravel.exe.xyz/__exe.dev/login?redirect=/hello. Guests on auth:exedev routes are sent there and come back signed in.

Make it public, add a domain#

Your app is private until you say otherwise. To let anyone open it, on your computer:

ssh exe.dev share set-public my-laravel

ssh exe.dev share set-private my-laravel closes it again. To serve it at your own domain, point a CNAME at my-laravel.exe.xyz and add the domain (custom domains):

ssh exe.dev domain add my-laravel app.example.com

Then set APP_URL=https://app.example.com in ~/app.env and redeploy, so emails and queued jobs link to the right place.

Let Shelley help#

Shelley, at https://my-laravel.shelley.exe.xyz/, works on this VM with you. Try: "The last deploy failed; read the output and tell me why", "Show me today's errors from journalctl -u app", "Add a nightly task that prunes old notifications", or "Move my database to PostgreSQL on this VM".

The nuts and bolts#

Happy with what Deploy set up? You can stop here. These are the steps Shelley follows, which you can also run by hand, and the commands for later.

Install by hand

Create a VM#

On your computer (pick another name if my-laravel is taken):

ssh exe.dev new --name=my-laravel
ssh my-laravel.exe.xyz

Everything else runs on the VM, as exedev.

Install PHP, Composer and Node.js#

Ubuntu 24.04 ships PHP 8.3. Laravel 13 runs on it, but PHP 8.5 is the current release, and Ondřej Surý's PPA is where Ubuntu servers get it: the same packages Laravel Forge installs, with every extension as an apt package and security fixes as they're released. (Laravel's php.new installer is for your laptop: a single PHP binary with no PHP-FPM.)

sudo LC_ALL=C.UTF-8 add-apt-repository -y ppa:ondrej/php
sudo DEBIAN_FRONTEND=noninteractive apt-get install -y \
  php8.5-cli php8.5-fpm php8.5-sqlite3 php8.5-mbstring php8.5-xml php8.5-curl \
  php8.5-zip php8.5-intl php8.5-bcmath php8.5-gd unzip
sudo systemctl disable --now php8.5-fpm
php -v | head -1

Expect PHP 8.5.11 or later. That's every extension Laravel needs, and OPcache is built in. The last-but-one line turns off the stock PHP-FPM service: the app gets its own, below.

Composer, from getcomposer.org, checked against its published hash, and Node.js 24, the current LTS release, from NodeSource, for Vite builds. The VM has no Node.js until you install it:

curl -fsSLo /tmp/composer.phar https://getcomposer.org/download/latest-stable/composer.phar
echo "$(curl -fsSL https://getcomposer.org/download/latest-stable/composer.phar.sha256)  /tmp/composer.phar" | sha256sum -c -
sudo install -m 755 /tmp/composer.phar /usr/local/bin/composer
curl -fsSL https://deb.nodesource.com/setup_24.x | sudo -E bash -
sudo apt-get install -y nodejs
composer --version; node --version

Expect /tmp/composer.phar: OK, Composer 2.x and v24.x.

Run PHP as app.service#

nginx answers on port 8000 and serves public/ itself; PHP requests go to PHP-FPM over a socket. PHP-FPM is your app, so it runs as exedev under the name app.service, and systemctl reload app or journalctl -u app work as on any exe.dev app:

sudo tee /etc/php/8.5/fpm/app.conf >/dev/null <<'CONF'
; PHP-FPM for the Laravel app. app.service runs it as exedev;
; nginx on port 8000 hands it PHP requests over the socket.
[global]
; --force-stderr in app.service sends this log to the journal instead;
; FPM still wants a log it can open at startup.
error_log = syslog
log_limit = 8192
; On reload, give running requests up to 30 s to finish.
process_control_timeout = 30s

[app]
listen = /run/app/php-fpm.sock
listen.mode = 0660
pm = dynamic
pm.max_children = 16
pm.start_servers = 2
pm.min_spare_servers = 2
pm.max_spare_servers = 4
pm.max_requests = 1000
; Laravel logs to stderr; send it to the journal as-is.
catch_workers_output = yes
decorate_workers_output = no
php_admin_value[upload_max_filesize] = 100M
php_admin_value[post_max_size] = 100M
; PHP's own warnings and errors go to the journal too.
php_admin_value[error_log] = /dev/stderr
CONF
sudo tee /etc/systemd/system/app.service >/dev/null <<'UNIT'
[Unit]
Description=Laravel app (PHP-FPM behind nginx on port 8000)
After=network.target

[Service]
Type=notify
User=exedev
Group=exedev
RuntimeDirectory=app
ExecStart=/usr/sbin/php-fpm8.5 --nodaemonize --force-stderr --fpm-config /etc/php/8.5/fpm/app.conf
ExecReload=/bin/kill -USR2 $MAINPID
Restart=always
RestartSec=2

[Install]
WantedBy=multi-user.target
UNIT
sudo systemctl daemon-reload
sudo systemctl enable --now app
systemctl is-active app

Expect active. reload is graceful: running requests finish, and new ones wait for the new workers rather than failing. Sixteen workers suit the VM's 8 GB; raise pm.max_children for slow, I/O-bound pages.

Serve it on port 8000 with nginx#

nginx is on the VM already, switched off. This is the server block from Laravel's deployment guide, on port 8000 instead of 80, with two changes: it resolves ~/app to the live release for each request, and it drops two forwarded headers exe.dev's proxy doesn't set, so a visitor can't fake them:

sudo tee /etc/nginx/sites-available/app >/dev/null <<'CONF'
# Port 8000: exe.dev's HTTPS proxy sends your visitors here.
# Static files come straight from public/; PHP goes to app.service.
server {
    listen 8000 default_server;
    listen [::]:8000 default_server;
    server_name _;
    root /home/exedev/app/public;
    index index.php;
    charset utf-8;
    client_max_body_size 100m;
    server_tokens off;

    add_header X-Frame-Options "SAMEORIGIN";
    add_header X-Content-Type-Options "nosniff";

    location / {
        try_files $uri $uri/ /index.php?$query_string;
    }

    location = /favicon.ico { access_log off; log_not_found off; }
    location = /robots.txt  { access_log off; log_not_found off; }

    error_page 404 /index.php;

    location ~ ^/index\.php(/|$) {
        fastcgi_pass unix:/run/app/php-fpm.sock;
        include fastcgi_params;
        # ~/app links to the live release: resolve it, so a deploy
        # switches every request to the new code at once.
        fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
        fastcgi_param DOCUMENT_ROOT $realpath_root;
        # exe.dev sets X-Forwarded-For, -Host and -Proto. Drop the -Port
        # and -Prefix a visitor might send, which Laravel would trust.
        fastcgi_param HTTP_X_FORWARDED_PORT "" if_not_empty;
        fastcgi_param HTTP_X_FORWARDED_PREFIX "" if_not_empty;
        fastcgi_buffer_size 32k;
        fastcgi_buffers 8 32k;
        fastcgi_busy_buffers_size 64k;
        fastcgi_hide_header X-Powered-By;
    }

    location ~ /\.(?!well-known).* {
        deny all;
    }
}
CONF
sudo ln -sfn /etc/nginx/sites-available/app /etc/nginx/sites-enabled/app
sudo rm -f /etc/nginx/sites-enabled/default
sudo usermod -aG exedev www-data    # nginx reads public/ in /home/exedev
sudo nginx -t && sudo systemctl enable nginx && sudo systemctl restart nginx

Expect syntax is ok. The default site goes because it also listens on port 8000. Until the first deploy, curl -sI localhost:8000 answers 404.

Write the settings#

Make the directories, the settings file with a fresh APP_KEY, and the SQLite database, in WAL mode:

mkdir -p ~/data ~/releases ~/backups
if [ ! -e ~/app.env ]; then
  touch ~/app.env && chmod 600 ~/app.env
  cat >> ~/app.env <<ENV
# Laravel's settings and secrets; each release links this file as .env.
# Keep a copy somewhere safe: APP_KEY decrypts sessions and encrypted data.
APP_NAME=Laravel
APP_ENV=production
APP_KEY=base64:$(openssl rand -base64 32)
APP_DEBUG=false
APP_URL=https://$(hostname).exe.xyz

LOG_CHANNEL=stderr
LOG_LEVEL=info

DB_CONNECTION=sqlite
DB_DATABASE=$HOME/data/database.sqlite

SESSION_DRIVER=database
CACHE_STORE=database
QUEUE_CONNECTION=database
FILESYSTEM_DISK=local
MAIL_MAILER=log

VITE_APP_NAME="\${APP_NAME}"
ENV
fi
[ -e ~/data/database.sqlite ] || sqlite3 ~/data/database.sqlite 'PRAGMA journal_mode=WAL;'
stat -c '%a %n' ~/app.env

Expect wal (the first time) and 600 /home/exedev/app.env. The if never overwrites an existing ~/app.env: its APP_KEY is what decrypts your data. LOG_CHANNEL=stderr sends Laravel's log to the journal, and MAIL_MAILER=log writes emails there until you set up a mail service.

Get the code#

The VM deploys from a bare git repository, ~/app.git. Create it one of two ways.

Start a new app (REPO=new): an empty repository whose branch is main.

git init -q --bare -b main ~/app.git

Bring your app from GitHub: a copy of it. A public repository clones directly; replace OWNER/NAME:

git clone -q --bare https://github.com/OWNER/NAME.git ~/app.git
git -C ~/app.git symbolic-ref --short HEAD

The last line prints the branch that deploys: your default branch.

For a private repository, attach it to this VM with exe.dev's GitHub integration, on your computer:

ssh exe.dev integrations add github --name laravel-repo --repository OWNER/NAME --attach vm:my-laravel

Then clone through the integration on the VM; no token lands on the disk:

git clone -q --bare https://github.int.exe.xyz/OWNER/NAME.git ~/app.git

If that clone fails with "not found" or a 403, the integration isn't attached to this VM yet, or GitHub hasn't granted it the repository.

Add the deploy hook#

Git runs hooks/pre-receive on every push, before it accepts it. This one builds the pushed commit in a new directory under ~/releases, so a failed build never touches the running app. Then it switches ~/app to it, reloads PHP-FPM, restarts the queue worker, and checks /up. If anything fails, it exits with an error, and git rejects the push:

cat > ~/app.git/hooks/pre-receive <<'HOOK'
#!/usr/bin/env bash
# git push deploys: build the pushed commit in a new ~/releases directory,
# switch ~/app to it, and check that it answers. If anything fails, the
# push is rejected and the running release keeps running, so the branch
# in ~/app.git is always what's deployed.
set -euo pipefail
cd "$HOME"
branch=$(git --git-dir=app.git symbolic-ref --short HEAD)   # main, usually

# git sends one "<old> <new> <ref>" line per pushed branch. Run by hand
# with </dev/null to redeploy the branch as it is.
pushed=$(cat)
new=$(awk -v ref="refs/heads/$branch" '$3 == ref { print $2 }' <<<"$pushed")
if [ -z "$pushed" ]; then
  new=$(git --git-dir=app.git rev-parse "$branch")
elif [ -z "$new" ]; then
  echo "-----> Not deploying: only pushes to $branch deploy."
  exit 0
elif [ "$new" = 0000000000000000000000000000000000000000 ]; then
  echo "-----> Refusing to delete $branch, the deployed branch."
  exit 1
fi
exec 9>.deploy.lock && flock 9    # one deploy at a time

rev=$(git --git-dir=app.git rev-parse --short "$new")
prev=$(readlink app || true)
release=$HOME/releases/$(date -u +%Y%m%d-%H%M%S)-$rev
deployed=
trap '[ -n "$deployed" ] || failed' EXIT
failed() {
  live=$(readlink "$HOME/app" || true)
  [ "$live" = "$release" ] || rm -rf "$release"
  echo "-----> Deploy failed.${live:+ Still running ${live##*-}.}"
}

switch_to() {
  ln -sfn "$1" "$HOME/app.next" && mv -T "$HOME/app.next" "$HOME/app" &&
    sudo systemctl reload-or-restart app &&   # PHP-FPM: new workers, same socket
    sudo systemctl restart app-worker         # queue worker: picks up the new code
}

echo "-----> Building $(git --git-dir=app.git log -1 --format='%h %s' "$new")"
mkdir -p "$release"
git --git-dir=app.git archive "$new" | tar -x -C "$release"
unset $(git rev-parse --local-env-vars) GIT_QUARANTINE_PATH   # leave the push behind
cd "$release"
# Settings, the database, uploads and logs live outside the release.
ln -s "$HOME/app.env" .env
mkdir -p "$HOME"/data/storage/{app/public,framework/{cache/data,sessions,views},logs}
rm -rf storage && ln -s "$HOME/data/storage" storage
# Trust exe.dev's proxy, unless the app says which proxies to trust.
[ -e config/trustedproxy.php ] ||
  echo "<?php return ['proxies' => 'REMOTE_ADDR']; // added by the deploy hook" > config/trustedproxy.php

if [ ! -f composer.lock ]; then
  echo "-----> No composer.lock. Run composer update on your computer, commit composer.lock and push."
  exit 1
fi
composer install --no-dev --no-interaction --no-progress --prefer-dist --optimize-autoloader
if [ -f package.json ]; then
  if [ -f package-lock.json ]; then npm ci --no-audit --no-fund; else npm install --no-audit --no-fund; fi
  npm run build --if-present
fi
php artisan storage:link
LOG_CHANNEL=null php artisan migrate --force   # errors print once, without a stack trace
php artisan view:clear      # compiled views are shared; drop old releases'
php artisan optimize
url=$(php -r 'echo rtrim((require "bootstrap/cache/config.php")["app"]["url"], "/");')

echo "-----> Switching to $rev"
host=${url#*://} code=000
if switch_to "$release"; then
  sleep 1
  # Ask for /up the way exe.dev's proxy would: as APP_URL, over HTTPS.
  code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 30 -H "Host: $host" \
    -H "X-Forwarded-Host: $host" -H 'X-Forwarded-Proto: https' http://localhost:8000/up || true)
fi
if [ "$code" = 000 ] || [ "$code" -ge 500 ]; then
  echo "-----> $rev answers /up with HTTP $code. Its errors: journalctl -u app"
  if [ -z "$prev" ] || [ ! -d "$prev" ]; then
    deployed=1; echo "-----> Nothing to roll back to; $rev stays live."; exit 0
  fi
  echo "-----> Rolling back to ${prev##*-}"
  switch_to "$prev"
  echo "-----> Migrations $rev ran, if any, stay applied."
  exit 1
fi
deployed=1
cd "$HOME" && ls -1d releases/*/ | head -n -3 | xargs -r rm -rf    # keep three
echo "-----> Deployed $rev to $url/"
HOOK
chmod +x ~/app.git/hooks/pre-receive

storage in each release is a link to ~/data/storage, shared by all of them, and .env is a link to ~/app.env. If the app has no /up route, a 404 there passes the check; only errors and no answer fail it. To redeploy without a push, after editing ~/app.env for instance, run ~/app.git/hooks/pre-receive </dev/null.

Run the queue worker and the scheduler#

Both wait for the first deploy; ConditionPathExists keeps them from failing before there's an app:

sudo tee /etc/systemd/system/app-worker.service >/dev/null <<'UNIT'
[Unit]
Description=Laravel queue worker
After=network.target
ConditionPathExists=/home/exedev/app/artisan

[Service]
User=exedev
Group=exedev
WorkingDirectory=/home/exedev/app
ExecStart=/usr/bin/php artisan queue:work --sleep=3 --tries=3 --max-time=3600
Restart=always
RestartSec=5

[Install]
WantedBy=multi-user.target
UNIT
sudo tee /etc/systemd/system/app-schedule.service >/dev/null <<'UNIT'
[Unit]
Description=Laravel scheduler (php artisan schedule:run)
ConditionPathExists=/home/exedev/app/artisan

[Service]
Type=oneshot
User=exedev
Group=exedev
WorkingDirectory=/home/exedev/app
ExecStart=/usr/bin/php artisan schedule:run
UNIT
sudo tee /etc/systemd/system/app-schedule.timer >/dev/null <<'UNIT'
[Unit]
Description=Run the Laravel scheduler every minute

[Timer]
OnCalendar=*-*-* *:*:00
AccuracySec=1s

[Install]
WantedBy=timers.target
UNIT
sudo systemctl daemon-reload
sudo systemctl enable app-worker app-schedule.timer
sudo systemctl start app-schedule.timer

The deploy starts the worker. --max-time=3600 restarts it hourly, which keeps memory in check. Now get the app running: Start a new app or Bring your app, then Set up backups.

Start a new app#

Create a Laravel 13 app with Composer, the way Laravel's installer does:

composer create-project laravel/laravel:^13.0 /tmp/new-app --no-interaction
cd /tmp/new-app

Trust exe.dev's proxy, and send visitors who aren't signed in to exe.dev's login page. Both go in bootstrap/app.php, in place of the // inside withMiddleware:

cat > /tmp/middleware.php <<'PHP'
        // exe.dev's HTTPS proxy is the only client that connects to this
        // app: trust it for https URLs, the original host and visitor IPs.
        $middleware->trustProxies(
            at: 'REMOTE_ADDR',
            headers: Request::HEADER_X_FORWARDED_FOR |
                Request::HEADER_X_FORWARDED_HOST |
                Request::HEADER_X_FORWARDED_PROTO,
        );

        // Send visitors who aren't signed in to exe.dev's login page.
        $middleware->redirectGuestsTo(
            fn (Request $request) => '/__exe.dev/login?redirect='.urlencode($request->getRequestUri())
        );
PHP
sed -i -e '/->withMiddleware(/,/})/{\#^ *//$#{r /tmp/middleware.php' -e 'd}}' bootstrap/app.php
rm /tmp/middleware.php
grep -c 'trustProxies\|redirectGuestsTo' bootstrap/app.php

Expect 2. Then the exedev guard from Sign in with exe.dev, and a page that uses it:

cat > app/Providers/AppServiceProvider.php <<'PHP'
<?php

namespace App\Providers;

use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Str;

class AppServiceProvider extends ServiceProvider
{
    /**
     * Register any application services.
     */
    public function register(): void
    {
        //
    }

    /**
     * Bootstrap any application services.
     */
    public function boot(): void
    {
        // exe.dev's proxy sends X-ExeDev-Email for visitors signed in to
        // exe.dev. The "exedev" guard turns it into a User.
        Auth::viaRequest('exedev', function (Request $request) {
            $email = $request->header('X-ExeDev-Email');

            return $email ? User::firstOrCreate(
                ['email' => $email],
                ['name' => Str::before($email, '@'), 'password' => Str::password(32)],
            ) : null;
        });
    }
}
PHP
perl -0pi -e "s/(            'provider' => 'users',\n        \],\n)/\$1\n        'exedev' => [\n            'driver' => 'exedev',\n        ],\n/" config/auth.php
cat >> routes/web.php <<'PHP'

// Signed in with exe.dev: see AppServiceProvider.
Route::get('/hello', fn () => 'Hello, '.auth()->user()->email.'!')
    ->middleware('auth:exedev');
PHP
php artisan config:show auth.guards.exedev

Expect driver ... exedev. Commit it, and push it to the deploy repository, which runs the hook:

npm install --no-audit --no-fund --loglevel=error   # writes package-lock.json
git init -q -b main
git add -A
git -c user.name="exe.dev" -c user.email="exedev@$(hostname).exe.xyz" commit -q -m "New Laravel app"
git push -q ~/app.git main
cd ~ && rm -rf /tmp/new-app

With package-lock.json committed, every deploy installs the same JavaScript packages, with npm ci. The push prints the deploy, ending in -----> Deployed. Skip to Check the app.

Bring your app#

Check what your app needs before the first deploy. Change the VM, not the code: these all go in ~/app.env, apt packages or systemd. Look through a scratch checkout:

git clone -q ~/app.git /tmp/app-check && cd /tmp/app-check
jq '.require' composer.json
composer check-platform-reqs --lock --no-dev
  • PHP version: PHP 8.5 satisfies ^8.2, ^8.3 and ^8.4. If the lock file pins packages that don't support 8.5 yet, install the PHP they need instead; see Update.
  • Extensions: each ext-... that check-platform-reqs reports missing is an apt package: sudo apt-get install -y php8.5-redis (or -pgsql, -mysql, -imagick, -gmp, -soap), then sudo systemctl restart app.
  • Settings: compare .env.example with ~/app.env. Add what the app needs below the existing lines, but keep the production values for APP_*, LOG_CHANNEL and DB_*. Generate secrets with openssl rand -hex 32; keys for outside services (mail, payments, OAuth) come from the owner.
  • Lock file: the deploy installs exactly what composer.lock says, and stops if there isn't one. A repository without it (a starter-kit template, say) needs the owner to run composer update and commit composer.lock.
  • Database: try SQLite first, even if .env.example says MySQL; most apps run on it as they are. If migrations fail on SQLite, see Use MySQL below.
  • Redis (Horizon, or redis for the cache or queue): sudo apt-get install -y redis-server php8.5-redis, and REDIS_HOST=127.0.0.1 in ~/app.env. For Horizon, run php artisan horizon in app-worker.service: sudo systemctl edit app-worker, then [Service], ExecStart=, ExecStart=/usr/bin/php artisan horizon.
  • Front end: with a package-lock.json the hook runs npm ci, and npm run build if there's a build script. For pnpm-lock.yaml, yarn.lock or bun.lock, change those two lines in the hook.
  • Private Composer packages (Nova, Flux Pro, Spark): the owner's license key goes in Composer's global auth, for example composer config -g http-basic.nova.laravel.com EMAIL KEY.
  • Trusted proxies: nothing to do. If bootstrap/app.php doesn't call trustProxies with a list, the deploy adds config/trustedproxy.php. If it calls trustHosts(), the app answers only to APP_URL's host.

Then deploy it:

cd ~ && rm -rf /tmp/app-check
~/app.git/hooks/pre-receive </dev/null

The output should end with -----> Deployed. Your app's history is now in ~/app.git, so your first git push exe main is an update.

Use MySQL#

Skip this unless the app's migrations fail on SQLite, with errors like no such collation sequence: utf8mb4_bin, or its code needs MySQL. Install MySQL 8 and the PHP driver, and give the exedev user a database. auth_socket lets the app, which runs as exedev, sign in over MySQL's socket without a password:

sudo DEBIAN_FRONTEND=noninteractive apt-get install -y mysql-server php8.5-mysql
sudo mysql -e "CREATE DATABASE app; CREATE USER exedev@localhost IDENTIFIED WITH auth_socket; GRANT ALL ON app.* TO exedev@localhost;"
sed -i -e 's|^DB_CONNECTION=.*|DB_CONNECTION=mysql|' \
  -e 's|^DB_DATABASE=.*|DB_DATABASE=app\nDB_USERNAME=exedev\nDB_SOCKET=/run/mysqld/mysqld.sock|' ~/app.env
sudo systemctl restart app
mysql app -e 'SELECT current_user()'
~/app.git/hooks/pre-receive </dev/null

Expect exedev@localhost, then a deploy that ends with -----> Deployed. When you set up backups below, swap the sqlite3 line in ~/app-backup.sh for the mysqldump line above it.

Check the app#

Ask for pages the way exe.dev's proxy does, with the VM's name and the forwarded headers:

systemctl is-active app nginx app-worker
proxy=(-H "Host: $(hostname).exe.xyz" -H "X-Forwarded-Host: $(hostname).exe.xyz" -H 'X-Forwarded-Proto: https')
curl -s -o /dev/null -w '%{http_code}\n' "${proxy[@]}" localhost:8000/up
curl -s "${proxy[@]}" localhost:8000/ | grep -o 'https://[^"]*/build/[^"]*' | head -2

Expect three active, 200 (404 if the app has no /up route), and asset URLs starting with https://my-laravel.exe.xyz/build/, which shows Laravel trusts the proxy. (No Vite build, no asset lines.) For a new app, check /hello as a signed-in visitor:

curl -s "${proxy[@]}" -H 'X-ExeDev-Email: you@example.com' localhost:8000/hello

Expect Hello, you@example.com!.

Set up backups#

~/app-backup.sh copies the database with SQLite's .backup, which is consistent while the app writes (or mysqldump, if you set up MySQL). It also saves storage/app (uploads), ~/app.env and the code, as a git bundle:

cat > ~/app-backup.sh <<'SH'
#!/usr/bin/env bash
# Back up the Laravel app to ~/backups/app-<time>.tar.gz: a consistent
# copy of the database, storage/app (uploads), ~/app.env, whose APP_KEY
# decrypts what the app encrypted, and the code. Keeps 14.
set -euo pipefail
umask 077
work=$(mktemp -d ~/backups/.work-XXXXXX)
trap 'rm -rf "$work"' EXIT
# With MySQL, use this line instead of the sqlite3 one:
#   mysqldump --single-transaction --no-tablespaces app > "$work/database.sql"
sqlite3 ~/data/database.sqlite ".backup '$work/database.sqlite'"
cp ~/app.env "$work/app.env"
git --git-dir="$HOME/app.git" bundle create -q "$work/app.bundle" --all
basename "$(readlink ~/app)" > "$work/release"
out=~/backups/app-$(date -u +%Y%m%d-%H%M%S).tar.gz
tar -czf "$out.part" -C "$work" . -C ~/data storage/app
mv "$out.part" "$out"
ls -1t ~/backups/app-*.tar.gz | tail -n +15 | xargs -r rm --
echo "$out"
SH
chmod +x ~/app-backup.sh
sudo tee /etc/systemd/system/app-backup.service >/dev/null <<'UNIT'
[Unit]
Description=Back up the Laravel app to ~/backups

[Service]
Type=oneshot
User=exedev
Group=exedev
ExecStart=/home/exedev/app-backup.sh
UNIT
sudo tee /etc/systemd/system/app-backup.timer >/dev/null <<'UNIT'
[Unit]
Description=Back up the Laravel app every night

[Timer]
OnCalendar=*-*-* 03:00:00
RandomizedDelaySec=1h
Persistent=true

[Install]
WantedBy=timers.target
UNIT
sudo systemctl daemon-reload
sudo systemctl enable --now app-backup.timer

Using MySQL? Swap the sqlite3 line for the mysqldump line above it. Using PostgreSQL? Delete the sqlite3 line: the PostgreSQL page's own nightly backups cover the database.

Test a backup#

Take one now, open it in a scratch directory, and check it:

backup=$(~/app-backup.sh)
mkdir -p /tmp/restore-test && tar -xzf "$backup" -C /tmp/restore-test
sqlite3 /tmp/restore-test/database.sqlite 'PRAGMA integrity_check; SELECT count(*) FROM migrations;'
git -C ~/app.git bundle verify -q /tmp/restore-test/app.bundle && echo 'code ok'
grep -c '^APP_KEY=base64:' /tmp/restore-test/app.env
rm -rf /tmp/restore-test

Expect ok, the number of migrations that ran, code ok and 1. With MySQL, check the dump instead of the sqlite3 line: grep -c 'CREATE TABLE' /tmp/restore-test/database.sql counts its tables.

Back up and restore

Backups run nightly from app-backup.timer into ~/backups, 14 kept; see Backups. Take one by hand with ~/app-backup.sh.

Restore on the same VM#

Stop the app, put the database, uploads and settings back, and redeploy, which also re-caches the settings:

backup=~/backups/app-20261017-030000.tar.gz    # the one you want
sudo systemctl stop app app-worker app-schedule.timer
rm -rf /tmp/restore && mkdir /tmp/restore && tar -xzf "$backup" -C /tmp/restore
rm -f ~/data/database.sqlite-wal ~/data/database.sqlite-shm
cp /tmp/restore/database.sqlite ~/data/database.sqlite
rsync -a --delete /tmp/restore/storage/app/ ~/data/storage/app/
cp /tmp/restore/app.env ~/app.env
~/app.git/hooks/pre-receive </dev/null
sudo systemctl start app-schedule.timer
rm -rf /tmp/restore

The -wal and -shm files belong to the database you're replacing; leaving them would mix it into the restored one. With MySQL, replace the rm and cp lines with mysql app < /tmp/restore/database.sql. The deploy builds the code that's in ~/app.git now; to go back to the code from the backup too, its release file names the commit to push: git push -f exe <commit>:main.

Restore on a new VM#

Create a VM and follow Install by hand up to and including Add the deploy hook and the queue worker, using Start a new app's empty repository. Copy the backup over, on your computer:

scp app-20261017-030000.tar.gz my-new-laravel.exe.xyz:

Then, on the new VM, load the code from the bundle and the data, and fix APP_URL if the VM's name changed:

mkdir /tmp/restore && tar -xzf ~/app-20261017-030000.tar.gz -C /tmp/restore
git --git-dir ~/app.git fetch -q /tmp/restore/app.bundle 'refs/heads/*:refs/heads/*'
rm -f ~/data/database.sqlite ~/data/database.sqlite-wal ~/data/database.sqlite-shm
cp /tmp/restore/database.sqlite ~/data/database.sqlite
mkdir -p ~/data/storage && rsync -a /tmp/restore/storage/app/ ~/data/storage/app/
cp /tmp/restore/app.env ~/app.env && chmod 600 ~/app.env
sed -i "s|^APP_URL=.*|APP_URL=https://$(hostname).exe.xyz|" ~/app.env
~/app.git/hooks/pre-receive </dev/null
rm -rf /tmp/restore

Then set up backups, and point your exe remote at the new VM: git remote set-url exe my-new-laravel.exe.xyz:app.git.

Update Laravel, PHP and Node.js

Laravel and your packages are in composer.lock, so updates are commits. In your project, on your computer:

composer update
php artisan test
git commit -am "Update dependencies" && git push exe main

For a new major version, follow Laravel's upgrade guide first.

PHP patch releases and other system packages come from apt. exe.dev VMs don't install updates on their own, so do it now and then, on the VM:

sudo apt-get update && sudo apt-get upgrade -y
sudo systemctl restart app app-worker

A new PHP version (8.6, or an older one an app needs): install the same packages with the new number, point app.service and its config at them, and make it the php command:

v=8.4
sudo apt-get install -y php$v-cli php$v-fpm php$v-sqlite3 php$v-mbstring php$v-xml \
  php$v-curl php$v-zip php$v-intl php$v-bcmath php$v-gd
sudo systemctl disable --now php$v-fpm
sudo cp /etc/php/8.5/fpm/app.conf /etc/php/$v/fpm/app.conf
sudo sed -i "s|php-fpm8.5|php-fpm$v|; s|/etc/php/8.5/|/etc/php/$v/|" /etc/systemd/system/app.service
sudo update-alternatives --set php /usr/bin/php$v
sudo systemctl daemon-reload && ~/app.git/hooks/pre-receive </dev/null

The PPA is moving to packages.sury.org, which has the same packages; if apt-get update ever warns about ppa.launchpadcontent.net, follow the note on the PPA's page.

Composer updates itself: sudo composer self-update. Node.js 24 updates with apt; for a new major line, run NodeSource's setup script with its number (setup_26.x) and sudo apt-get install -y nodejs.

Something not working?

Start with the logs. Laravel, PHP and PHP-FPM all write to the journal:

journalctl -u app -n 50 --no-pager          # web requests
journalctl -u app-worker -n 50 --no-pager   # queued jobs
journalctl -u app-schedule -n 20 --no-pager # the scheduler
sudo tail -n 20 /var/log/nginx/error.log
  • 502 Bad Gateway: PHP-FPM isn't running. systemctl status app shows why; a bad edit to app.conf shows up there.
  • 500 Server Error: the exception is in journalctl -u app. Set APP_DEBUG=true in ~/app.env and redeploy to see it in the browser, and turn it back off afterwards.
  • No styles, http:// links, or "Invalid signature": Laravel isn't trusting exe.dev's proxy. Check bootstrap/app.php against Behind exe.dev's HTTPS proxy; a trustProxies call with other headers, or one that isn't run, wins over the deploy's fallback.
  • 400 Bad Request on every page: the app calls trustHosts(), and the address isn't APP_URL's host or a subdomain of it. Set APP_URL to the address you use (your own domain, say) and redeploy.
  • I changed ~/app.env and nothing happened: the deploy caches the configuration. Redeploy: ~/app.git/hooks/pre-receive </dev/null.
  • "Vite manifest not found": the build didn't run. Check that package.json has a build script and that public/build isn't committed half-empty.
  • Composer says the lock file needs another PHP: install that PHP version; see Update.
  • "Permission denied" under storage/: something ran as root, maybe sudo php artisan. Fix it with sudo chown -R exedev:exedev ~/data, and run php artisan as exedev, in ~/app.
  • "database is locked": a long transaction is holding the write lock. Check PRAGMA journal_mode; says wal, and keep jobs' transactions short.
  • Jobs don't run: systemctl status app-worker, and php artisan queue:failed for jobs that gave up.
  • 413 on uploads: the limit is 100 MB. Raise client_max_body_size in /etc/nginx/sites-available/app and both 100M values in /etc/php/8.5/fpm/app.conf, then reload nginx and app.
  • git push is rejected with "pre-receive hook declined": the deploy failed, and the old release is still running. The lines above it say why; see When a deploy fails.
  • git push is rejected with "fetch first": someone else, or Shelley, pushed to exe. git pull exe main, then push again.

Or ask Shelley on the VM to look; it can read the same logs.

Frequently asked questions#

Why nginx and PHP-FPM, not FrankenPHP or Octane?

It's the setup Laravel's deployment docs describe and the one most Laravel apps already run on, so packages, uploads and long requests behave as you expect. nginx is already on the VM, and PHP 8.5 with PHP-FPM comes from the same apt repository as every PHP extension you might add. Octane is faster, but only for apps written to keep no state between requests; once yours is, you can run php artisan octane:start as app.service, with nginx in front.

Is SQLite good enough for production?

For most apps on one server, yes: it's Laravel's default, the database sits on the VM's disk next to the code, and WAL mode lets pages read while a job writes. Move to PostgreSQL or MySQL when you need several servers or heavy concurrent writes; see Your data.

Is there downtime when I deploy?

No. Each push is built and migrated in a new directory while the old release serves. Then ~/app switches over, and PHP-FPM reloads gracefully: requests already running finish on the old code, and new ones wait a moment for the new workers instead of failing.

Do I still need Forge, Envoyer or Laravel Cloud?

Not for one app on one server. This page does what they do for it: zero-downtime releases, a queue worker, the scheduler, backups and HTTPS, on a VM you can SSH into. exe.dev doesn't provision servers on other clouds or scale across machines.

Can I use Redis, Horizon or Reverb?

Yes. Install Redis on the VM with apt, set REDIS_HOST=127.0.0.1 in ~/app.env, and run Horizon in app-worker.service instead of queue:work. Reverb is another long-running service; visitors reach it over HTTPS on a port from 3000 to 9999.

Can I use my own domain?

Yes. Point a CNAME at the VM, add it with ssh exe.dev domain add, and change APP_URL in ~/app.env. See Make it public, add a domain.

How big can my app be?

A new VM has 2 vCPUs, 8 GB of memory and a 25 GB disk: room for the app, its database, a queue worker and plenty of uploads. See exe.dev plans for bigger VMs.

Updated Oct 17, 2026 · Markdown · manifest.json · Something wrong? Tell us