# 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. ## What ox detects in a Laravel repo ox sees a Laravel app from `composer.json` (with `laravel/framework`) and `artisan`. It installs PHP only from an ox.toml's `packages`, so a Laravel repo always needs one: without it, `ox check` stops with `Laravel found (composer.json, artisan): ox installs PHP only from an ox.toml`. Inside your ox.toml, ox fills what you leave out: the install `composer install --no-dev --no-interaction --prefer-dist --optimize-autoloader` (followed by your lockfile's `npm ci`), your `build` script, the migrate step `php artisan migrate --force`, and the health page `/up` when `composer.lock` has Laravel 11 or later. It proposes no start command, so you set `[app] start` as below. If `packages` has no PHP, `ox check` says so. PHP comes from Ubuntu's own packages, which you list in `packages`. ox runs your app with PHP's built-in web server, the same one `php artisan serve` uses. ox has no PHP-FPM setup. 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 = "mkdir -p storage/framework/cache/data storage/framework/sessions storage/framework/views storage/logs && 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" 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://deploywithox.com/docs/config#workers)). - `[cron] scheduler` runs Laravel's scheduler every minute ([[cron]](https://deploywithox.com/docs/config#cron)). - `[services] postgres` gives your app its own PostgreSQL database. - `[storage] keep` makes `storage` writable and keeps it across deploys, because a release's own files are read-only while it runs. - `[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_URL`, so you point one at the other in the next step. - For MySQL, use `mysql = {}` instead. ox gives you `MYSQL_URL`, served by MariaDB. Set `DB_CONNECTION=mysql` and `DB_URL=${MYSQL_URL}`, and swap `php-pgsql` for `php-mysql` in `packages`. - 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 DB_CONNECTION=pgsql DB_URL=${DATABASE_URL} ``` 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}` and `${DATABASE_URL}` point at values ox provides. `LOG_CHANNEL=stderr` sends Laravel's log to `ox logs`. Every key in your `.env.example` must have a value before the first deploy, even an empty one. Laravel's own `.env.example` lists many keys, so trim it to the ones your app uses. 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.` ox check, last lines: ```text Provided by ox: PORT, HOST, OX_ENV, OX_PROJECT, OX_RELEASE, OX_DATA_DIR, PUBLIC_URL, PUBLIC_HOST, DATABASE_URL Set on the dashboard before the first deploy: APP_ENV, APP_KEY, APP_DEBUG, APP_URL, LOG_CHANNEL, DB_CONNECTION, DB_URL 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. - `the app wrote to storage/logs/laravel.log, and a release's files are read-only while it runs`: `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://deploywithox.com/docs/troubleshooting) explains each message, and the [config reference](https://deploywithox.com/docs/config) lists every key. ## Next steps - [Set APP_KEY and the other variables](https://deploywithox.com/docs/operate/variables#set) from the dashboard or the CLI. - [Add your own domain with HTTPS](https://deploywithox.com/docs/operate/domains): one A record, and Caddy gets the certificate. - [Read and search the app's logs](https://deploywithox.com/docs/operate/logs), live or for a time range. - [Roll back a bad deploy](https://deploywithox.com/docs/operate/rollback) to a kept release without a rebuild. - [See the daily PostgreSQL backups](https://deploywithox.com/docs/operate/backups) and restore one. Last updated 2026-10-08. The page as HTML: https://deploywithox.com/docs/guides/laravel