Deploy a Laravel app
Your Laravel app on a server of its own. git push to deploy.
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/stdinLaravel 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
https://my-laravel.exe.xyz/
Your app, until you make it public
ssh my-laravel.exe.xyz
git push to deploy; shell and logs
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, ornewto start a fresh Laravel app
Prefer a terminal?
curl -fsSL \
https://exe-staging.dev/deploy/laravel/prompt.txt \
| ssh exe.dev new --prompt=/dev/stdinWhen 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 checkoutWhat 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 pushto deploy and update GitHub at once? Giveorigintwo push addresses:git remote set-url --add --push origin git@github.com:you/app.git, thengit 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.3and^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-...thatcheck-platform-reqsreports missing is an apt package:sudo apt-get install -y php8.5-redis(or-pgsql,-mysql,-imagick,-gmp,-soap), thensudo systemctl restart app. - Settings: compare
.env.examplewith~/app.env. Add what the app needs below the existing lines, but keep the production values forAPP_*,LOG_CHANNELandDB_*. Generate secrets withopenssl rand -hex 32; keys for outside services (mail, payments, OAuth) come from the owner. - Lock file: the deploy installs exactly what
composer.locksays, and stops if there isn't one. A repository without it (a starter-kit template, say) needs the owner to runcomposer updateand commitcomposer.lock. - Database: try SQLite first, even if
.env.examplesays MySQL; most apps run on it as they are. If migrations fail on SQLite, see Use MySQL below. - Redis (Horizon, or
redisfor the cache or queue):sudo apt-get install -y redis-server php8.5-redis, andREDIS_HOST=127.0.0.1in~/app.env. For Horizon, runphp artisan horizoninapp-worker.service:sudo systemctl edit app-worker, then[Service],ExecStart=,ExecStart=/usr/bin/php artisan horizon. - Front end: with a
package-lock.jsonthe hook runsnpm ci, andnpm run buildif there's a build script. Forpnpm-lock.yaml,yarn.lockorbun.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.phpdoesn't calltrustProxieswith a list, the deploy addsconfig/trustedproxy.php. If it callstrustHosts(), the app answers only toAPP_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 appshows why; a bad edit toapp.confshows up there. - 500 Server Error: the exception is in
journalctl -u app. SetAPP_DEBUG=truein~/app.envand 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. Checkbootstrap/app.phpagainst Behind exe.dev's HTTPS proxy; atrustProxiescall 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'tAPP_URL's host or a subdomain of it. SetAPP_URLto the address you use (your own domain, say) and redeploy. - I changed
~/app.envand 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.jsonhas abuildscript and thatpublic/buildisn'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, maybesudo php artisan. Fix it withsudo chown -R exedev:exedev ~/data, and runphp artisanasexedev, in~/app. - "database is locked": a long transaction is holding the write lock.
Check
PRAGMA journal_mode;sayswal, and keep jobs' transactions short. - Jobs don't run:
systemctl status app-worker, andphp artisan queue:failedfor jobs that gave up. - 413 on uploads: the limit is 100 MB. Raise
client_max_body_sizein/etc/nginx/sites-available/appand both100Mvalues in/etc/php/8.5/fpm/app.conf, then reload nginx and app. git pushis 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 pushis rejected with "fetch first": someone else, or Shelley, pushed toexe.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