# Deploy Laravel to a VPS with a queue and scheduler Deploy Laravel to your own Ubuntu VPS: PHP from apt, Composer, a Vite build, PostgreSQL, a queue worker and the scheduler every minute, from one ox.toml. To deploy Laravel to a VPS with ox, add the ox.toml below to your repo, set `APP_KEY` and the other variables, and deploy. ox installs PHP and Composer from Ubuntu's packages, builds your assets with Vite, runs `php artisan migrate --force` after a database snapshot, and runs the queue worker and the scheduler beside the app. ox's real-server tests deploy a plain PHP app this way, but no Laravel app yet. ox reads the ox.toml on this page, makes the plan on the card, and starts `php -S 127.0.0.1:$PORT ../vendor/laravel/framework/src/Illuminate/Foundation/resources/server.php` on your server. The cylinder is the service the file asks for: `postgres`. ## What ox detects in a Laravel repo ox sees a Laravel app from `composer.json` (with `laravel/framework`) and `artisan`. Laravel writes to `storage` while it runs, so a Laravel repo always needs an ox.toml with `[storage] keep`: without one, `ox check` stops with `Laravel writes storage/ at runtime, which is read-only in a release`. Inside your ox.toml, ox fills what you leave out: PHP, Composer and the extensions your `composer.json` and `composer.lock` require, from Ubuntu's packages; the install `composer install --no-dev --no-interaction --prefer-dist --optimize-autoloader` (followed by your lockfile's `npm ci --include=dev`); your `build` script; the migrate step `php artisan migrate --force`; and the health page your `bootstrap/app.php` names with `health:` (`/up` in Laravel 11 and later). With no `[app] start`, ox proposes one that serves `public` with FrankenPHP, which no real-server test covers yet. If you set a start and `packages` has no PHP, `ox check` says so. The ox.toml below lists PHP's packages and runs your app with PHP's built-in web server, the same one `php artisan serve` uses. ox's real-server tests deploy a plain PHP app this way, but no Laravel app yet. ## The ox.toml for Laravel Put this file at the root of your repo, next to `artisan`. Change the domain to yours. ox.toml for a Laravel app: ```toml domains = ["example.com"] packages = ["php-cli", "php-mbstring", "php-xml", "php-curl", "php-zip", "php-intl", "php-bcmath", "php-pgsql", "unzip", "composer"] [app] start = "cd public && exec php -S 127.0.0.1:$PORT ../vendor/laravel/framework/src/Illuminate/Foundation/resources/server.php" health = "/up" [build] install = "composer install --no-dev --no-interaction --prefer-dist --optimize-autoloader && npm ci --include=dev" commands = ["npm run build", "php artisan config:cache", "php artisan route:cache"] migrate = "php artisan migrate --force" [workers] queue = "php artisan queue:work --tries=3" [cron] scheduler = { schedule = "* * * * *", run = "php artisan schedule:run" } [services] postgres = {} [storage] keep = ["storage"] [tools] node = "24" ``` What each key does: - `domains` is the name Caddy serves your app on, with HTTPS. - `packages` installs PHP, the extensions Laravel needs, and Composer from Ubuntu. - `[app] start` makes the folders Laravel writes to, then starts PHP's web server on the port ox gives it. - `[app] health` is Laravel's built-in `/up` page, which must answer before your app gets traffic. - `[build] install` installs your PHP and JavaScript packages. - `[build] commands` builds your CSS and JavaScript, then caches the config and routes. - `[build] migrate` runs your migrations after ox takes a snapshot of the database. - `[workers] queue` runs the queue worker as its own process next to the app ([[workers]](https://console.t68.sorv.dev/docs/config#workers)). - `[cron] scheduler` runs Laravel's scheduler every minute ([[cron]](https://console.t68.sorv.dev/docs/config#cron)). - `[services] postgres` gives your app its own PostgreSQL database. - `[storage] keep` makes `storage` writable and keeps it across deploys, because a PHP release's own files are read-only while it runs. - When the repo has `storage/app/public`, ox's build links it to `public/storage` (what `php artisan storage:link` does), so files on Laravel's public disk are served at `/storage`. A `[build] commands` list in your ox.toml replaces the detected build steps, this link included: add `ln -sfn ../storage/app/public public/storage` to it if your app uses the public disk. - `[tools] node` installs Node.js for the Vite build. PHP's built-in server answers one request at a time. Set the variable `PHP_CLI_SERVER_WORKERS` (for example to `4`) to run more at once. ## PostgreSQL, MySQL and Redis for Laravel `postgres = {}` gives your app `DATABASE_URL`. Laravel reads its database from `DB_CONNECTION` and `DB_URL` instead, so ox sets both from the service for you, once your `config/database.php` shows the app reads them. Your own value for either one wins. - For MySQL, use `mysql = {}` instead. ox gives you `MYSQL_URL`, served by MariaDB, and sets `DB_CONNECTION=mysql` and the URL key your config reads (`DB_URL`, or `DATABASE_URL` in a Laravel 10 config). ox adds `php-mysql` to `packages` for you, as it adds `php-pgsql` for `postgres = {}`. - For Redis, add `redis = {}`. ox gives you `REDIS_URL`, which Laravel reads as is. Add `php-redis` to `packages`. ## APP_KEY, the .env values and trusted proxies ox never reads your `.env` file. You set each value on the dashboard's Variables tab, or with `ox vars set`, which asks for the value so it never lands in your shell history. Variables for this ox.toml: ```text APP_KEY=base64:... # from php artisan key:generate --show APP_ENV=production APP_DEBUG=false APP_URL=${PUBLIC_URL} LOG_CHANNEL=stderr ``` Make the `APP_KEY` on your own computer with `php artisan key:generate --show`, then run `ox vars set APP_KEY` and paste it. `${PUBLIC_URL}` points at a value ox provides. `LOG_CHANNEL=stderr` sends Laravel's log to `ox logs`. Every key in your `.env.example` with an empty value, a secret name or a placeholder must have a value before the first deploy, even an empty one. A key with a real value (`APP_DEBUG=false`) keeps Laravel's own default. With `postgres` or `mysql` in `[services]`, ox sets `DB_CONNECTION` and the key your `config/database.php` reads the URL from, because Laravel 11 never reads `DATABASE_URL`: `DB_URL`, or `DATABASE_URL` in a Laravel 10 config. ox reads that file and sets nothing it already covers: `DB_CONNECTION` when it defaults to `pgsql` (or `mysql`), and the url key when the connection's `url` falls back to the URL ox provides (`env('DATABASE_URL')` for postgres, `env('MYSQL_URL')` for mysql). If your `.env.example` lists `DB_HOST`, `DB_PORT`, `DB_DATABASE`, `DB_USERNAME` and `DB_PASSWORD`, ox sets them from the service too, and `ox check` lists them under "From postgres". A value that names your own machine (`localhost`) in another key is asked. The config cache is made during the build, so a changed variable needs a new deploy. **Save & deploy** on the Variables tab does that. Caddy sits in front of your app. Tell Laravel to trust it, so links and redirects use HTTPS: bootstrap/app.php: ```php ->withMiddleware(function (Middleware $middleware) { $middleware->trustProxies(at: '127.0.0.1'); }) ``` ## Check and deploy the Laravel app Run `ox check` in the repo. It works offline and lists the plan, then the variables to set, then `Ready to deploy.` Here are its last lines for ox's own Laravel test app with the ox.toml above and a stock Laravel 11 `config/database.php`. Your `.env.example` decides which keys it asks for, and your `config/database.php` decides which keys come from postgres. ox check, last lines: ```text Provided by ox: PORT, HOST, OX_ENV, OX_PROJECT, OX_RELEASE, OX_DATA_DIR, PUBLIC_URL, PUBLIC_HOST, PUBLIC_SCHEME, PUBLIC_PORT, DATABASE_URL From postgres (a value of yours replaces one): DB_CONNECTION, DB_URL Set on the dashboard before the first deploy: APP_KEY (App key: base64: and 32 random bytes, Generate makes it) hint: [build] commands replaces the detected build step "ln -sfn ../storage/app/public public/storage" (from artisan); add it to the list if the release still needs it hint: the example file's 1 key with a real value (APP_NAME) is not asked and not copied, so the app sees it unset: set on the Variables screen any the app reads with no default of its own Ready to deploy. ``` Then ship it and watch the app's log: Ship from a terminal: ```sh ox check ox deploy --wait ox logs --follow ``` ## If the Laravel deploy fails - `set these before deploying: APP_KEY`: a key from `.env.example` has no value. Set it with `ox vars set APP_KEY` and deploy again. - `Read-only file system` in the app's log for a path under `storage`: `storage` is missing from `[storage] keep`. Add it as in the file above. - `GET http://127.0.0.1:/up did not answer within 120s`: the app did not answer on its health page. Laravel 10 and older have no `/up` page, so add a route that returns a 200, or point `health` at a page you have. Your visitors never see a failed deploy: the previous release keeps serving. [Troubleshooting](https://console.t68.sorv.dev/docs/troubleshooting) explains each message, and the [config reference](https://console.t68.sorv.dev/docs/config) lists every key. ## Next steps - [Set APP_KEY and the other variables](https://console.t68.sorv.dev/docs/operate/variables#set) from the dashboard or the CLI. - [Add your own domain with HTTPS](https://console.t68.sorv.dev/docs/operate/domains): one A record, and Caddy gets the certificate. - [Read and search the app's logs](https://console.t68.sorv.dev/docs/operate/logs), live or for a time range. - [Roll back a bad deploy](https://console.t68.sorv.dev/docs/operate/rollback) to a kept release without a rebuild. - [See the daily PostgreSQL backups](https://console.t68.sorv.dev/docs/operate/backups) and restore one. Last updated 2026-10-17. The page as HTML: https://console.t68.sorv.dev/docs/guides/laravel