---
title: "Deploy a Laravel app"
description: "Deploy a Laravel app to an exe.dev VM: git push to deploy, PHP 8.5 with nginx, SQLite on a persistent disk, queues, the scheduler, nightly backups."
url: https://exe.dev/deploy/laravel
category: "Frameworks"
updated: 2026-10-17
---

# Deploy a Laravel app

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

- **Software:** Laravel (https://laravel.com)
- **Source:** https://github.com/laravel/laravel
- **License:** MIT
- **Time:** 15 minutes
- **Example VM name:** `my-laravel` (yours will differ; on the VM, `hostname` prints it)
- **Machine-readable:** https://exe.dev/deploy/laravel/manifest.json

## 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.

Running on the VM:

- **Your Laravel app**: PHP 8.5 with PHP-FPM, as app.service; nginx serves it and public/, port 8000, data in `~/app → the current release in ~/releases`
- **git push deploys**: A pre-receive hook in ~/app.git builds, migrates, and switches, data in `~/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 in `~/data`
- **Nightly backup**: app-backup.timer; database, uploads, settings and code; keeps 14, data in `~/backups`

How it is reached:

- https://my-laravel.exe.xyz/ (private): Your app, until you make it public
- ssh my-laravel.exe.xyz (private): git push to deploy; shell and logs

## Deploy with Shelley

Open https://exe.dev/new?prompt=REPO%3D%3Cfill-this-in%3E%0A%0ASet%20up%20Laravel%20on%20this%20VM%20by%20following%20the%20exe.dev%20recipe%20at%20https%3A%2F%2Fexe.dev%2Fdeploy%2Flaravel.md%20from%20top%20to%20bottom.%0A%0AThe%20values%20at%20the%20top%20of%20this%20message%20are%20mine%3A%0A-%20REPO%3A%20the%20GitHub%20repository%20to%20deploy%2C%20as%20owner%2Fname%20or%20a%20URL%2C%20or%20%60new%60%20to%20start%20a%20fresh%20Laravel%20app%0AAsk%20me%20for%20any%20that%20still%20say%20%3Cfill-this-in%3E%20before%20you%20need%20them.%0A%0AThe%20recipe%27s%20example%20VM%20name%20is%20my-laravel.%20This%20VM%27s%20name%20is%20the%20output%20of%20%60hostname%60%3A%20use%20it%20wherever%20the%20recipe%20writes%20my-laravel.%20Skip%20the%20step%20that%20creates%20the%20VM%3B%20you%20are%20on%20it.%0A%0AYou%20are%20done%20when%20all%20of%20these%20are%20true.%20Check%20each%20one%20yourself%3A%0A-%20PHP%208.5%2C%20Composer%20and%20Node.js%2024%20installed%3B%20nginx%20answering%20on%20port%208000%20and%20PHP-FPM%20running%20as%20app.service%2C%20both%20enabled%20to%20come%20back%20after%20a%20reboot.%0A-%20A%20~%2Fapp.git%20whose%20pre-receive%20hook%20builds%20each%20push%20into%20a%20new%20release%20%28Composer%2C%20the%20Vite%20build%2C%20migrations%2C%20optimize%29%2C%20switches%20to%20it%2C%20and%20checks%20%2Fup%3B%20a%20failed%20deploy%20rejects%20the%20push%20and%20leaves%20the%20running%20release%20alone.%0A-%20Settings%20in%20~%2Fapp.env%20%28mode%20600%29%20with%20a%20generated%20APP_KEY%2C%20and%20the%20SQLite%20database%20and%20storage%2F%20%28uploads%29%20in%20~%2Fdata.%0A-%20The%20queue%20worker%20as%20app-worker.service%20and%20the%20scheduler%20as%20app-schedule.timer%2C%20both%20enabled.%0A-%20A%20nightly%20backup%20by%20app-backup.timer%2C%20with%20the%20first%20backup%20opened%20and%20checked%20in%20a%20scratch%20directory.%0A-%20With%20REPO%3Dnew%2C%20a%20fresh%20Laravel%2013%20app%20that%20trusts%20exe.dev%27s%20proxy%20and%20greets%20you%20by%20your%20exe.dev%20email%20at%20%2Fhello.%0A%0ARead%20the%20whole%20recipe%2C%20including%20the%20closed%20details%2C%20and%20follow%20%22Install%0Aby%20hand%22%20in%20order%2C%20skipping%20VM%20creation.%20The%20walkthrough%20above%20it%20is%20for%0Athe%20owner%20afterwards.%20REPO%3Dnew%20takes%20%22Start%20a%20new%20app%22%3B%20anything%20else%0Atakes%20%22Bring%20your%20app%22%20%28turn%20URLs%20into%20owner%2Fname%29.%20If%20cloning%20fails%2C%0Athe%20repository%20is%20private%3A%20give%20the%20owner%20the%20%60ssh%20exe.dev%20integrations%0Aadd%20github%60%20command%20from%20that%20step%2C%20with%20this%20VM%27s%20name%20and%20the%0Arepository%2C%20and%20wait%20for%20them.%0ANever%20commit%20to%20the%20owner%27s%20code%2C%20on%20GitHub%20or%20in%20~%2Fapp.git%2C%20so%20their%0Afirst%20push%20fast-forwards%3A%20settings%20go%20in%20~%2Fapp.env%2C%20and%20the%20hook%20already%0Atrusts%20exe.dev%27s%20proxy.%20Generate%20secrets%3B%20ask%20the%20owner%20for%20keys%20to%0Aoutside%20services%2C%20and%20never%20invent%20values.%20If%20a%20deploy%20fails%2C%20read%20its%0Aoutput%2C%20fix%20what%20you%20can%20on%20the%20VM%2C%20and%20redeploy%20with%0A%60~%2Fapp.git%2Fhooks%2Fpre-receive%20%3C%2Fdev%2Fnull%60.%0ADon%27t%20run%20Restore%20or%20Update%2C%20don%27t%20reboot%2C%20and%20leave%20the%20VM%20private.%0AFor%20a%20new%20app%2C%20the%20URLs%20to%20open%20include%20%2Fhello.%0A%0AFinish%20with%20the%20URLs%20I%20should%20open%20and%20these%20commands%2C%20which%20only%20I%20can%20run%2C%20from%20my%20own%20computer%20%28with%20this%20VM%27s%20name%20in%20them%29%3A%0A%20%20git%20clone%20-o%20exe%20my-laravel.exe.xyz%3Aapp.git%20%20%20%20%23%20if%20you%20started%20fresh%0A%20%20git%20remote%20add%20exe%20my-laravel.exe.xyz%3Aapp.git%20%20%23%20or%2C%20in%20your%20own%20app%27s%20checkout%0A to create a VM whose agent, Shelley, follows this recipe. From a terminal:

```sh
curl -fsSL https://exe.dev/deploy/laravel/prompt.txt | ssh exe.dev new --prompt=/dev/stdin
```

Then, on your own computer:

```sh
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
```

Or follow the steps below by hand.

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`.

## 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](/docs/sharing), 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:

```bash
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:

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

Then change something, commit, and push:

```bash
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](/docs/faq/ssh-destination)). The deploy runs inside
`git push`, and you watch it:

```text
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:

```text
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:

```text
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:

```bash
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:

```bash
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](/docs/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`:

```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](#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](/deploy/postgres#run-it-next-to-your-app), 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:

```bash
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:

```bash
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](#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:

```bash
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:

```bash
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](/docs/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`:

```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`:

```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`:

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

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

```php
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:

```bash
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](/docs/cnames)):

```bash
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.

<details id="create-the-vm" data-manual-setup class="dp-details">
<summary>Install by hand</summary>

### Create a VM

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

```bash
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](https://php.new) installer is for your laptop: a single PHP
binary with no PHP-FPM.)

```bash
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:

```bash
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:

```bash
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](https://laravel.com/docs/13.x/deployment#nginx),
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:

```bash
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:

```bash
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`.

```bash
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`:

```bash
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](/docs/integrations-github), on your computer:

```bash
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:

```bash
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:

```bash
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:

```bash
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:

```bash
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`:

```bash
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](#sign-in-with-exedev), and a page that uses it:

```bash
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:

```bash
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](#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:

```bash
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](#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](#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:

```bash
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:

```bash
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:

```bash
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:

```bash
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:

```bash
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:

```bash
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.

</details>

<details id="back-up-and-restore" class="dp-details">
<summary>Back up and restore</summary>

Backups run nightly from `app-backup.timer` into `~/backups`, 14 kept;
see [Backups](#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:

```bash
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](#create-the-vm) 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:

```bash
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:

```bash
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`.

</details>

<details id="update" class="dp-details">
<summary>Update Laravel, PHP and Node.js</summary>

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

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

For a new major version, follow Laravel's
[upgrade guide](https://laravel.com/docs/13.x/upgrade) 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:

```bash
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:

```bash
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](https://packages.sury.org/php/),
which has the same packages; if `apt-get update` ever warns about
`ppa.launchpadcontent.net`, follow the note on the
[PPA's page](https://launchpad.net/~ondrej/+archive/ubuntu/php).

**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`.

</details>

<details id="troubleshooting" class="dp-details">
<summary>Something not working?</summary>

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

```bash
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](#behind-exedevs-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](#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](#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.

</details>

## 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](#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](#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](https://exe.dev/pricing) for bigger VMs.


## Related

- [Deploy a Rails app](https://exe.dev/deploy/rails.md): Your Rails app on a server of its own. git push, and it's live.
- [Deploy a Django app](https://exe.dev/deploy/django.md): Your Django app on a computer of its own. git push to deploy.
- [Self-host PostgreSQL](https://exe.dev/deploy/postgres.md): Your own Postgres on a real disk, with backups you've actually restored.
- [Migrate a Heroku app to exe.dev](https://exe.dev/deploy/migrate-heroku.md): Your Procfile, config vars, and Postgres on a VM of your own.
- [Deploy a Docker Compose app from GitHub](https://exe.dev/deploy/docker-compose.md): Your compose.yaml on a VM of its own. git push to deploy.
- [Deploy a FastAPI app](https://exe.dev/deploy/fastapi.md): Your API on a VM of its own. Push to deploy; the data stays put.
