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.
Last updated 2026-10-17
On this page
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 and builds your assets with Vite. Each deploy takes a database snapshot, then runs php artisan migrate --force. The queue worker and the scheduler run beside the app. ox's real-server tests deploy a plain PHP app this way, but no Laravel app yet.
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
A Laravel repo always needs an ox.toml with [storage] keep, because Laravel writes to storage while it runs. Without one, ox check stops with Laravel writes storage/ at runtime, which is read-only in a release. ox knows a repo is Laravel when it has artisan and a composer.json that requires laravel/framework.
Inside your ox.toml, ox fills in what you leave out:
- PHP, Composer and the extensions your
composer.jsonandcomposer.lockrequire, from Ubuntu's packages. - The install
composer install --no-dev --no-interaction --prefer-dist --optimize-autoloader, followed by your lockfile'snpm ci --include=dev. - Your
buildscript. - The migrate step
php artisan migrate --force. - The health page your
bootstrap/app.phpnames withhealth:. That is/upin Laravel 11 and later.
With no [app] start, ox proposes one that serves public with FrankenPHP. No real-server test covers that yet. 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. If you set a start and packages has no PHP, ox check says so.
The ox.toml for Laravel
Put this file at the root of your repo, next to artisan. Change the domain to yours.
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:
domainsis the name Caddy serves your app on, with HTTPS.packagesinstalls PHP, the extensions Laravel needs, and Composer from Ubuntu.[app] startruns PHP's built-in web server frompublic, on the port ox gives it.[app] healthis Laravel's built-in/uppage, which must answer before your app gets traffic.[build] installinstalls your PHP and JavaScript packages.[build] commandsbuilds your CSS and JavaScript, then caches the config and routes.[build] migrateruns your migrations after ox takes a snapshot of the database.[workers] queueruns the queue worker as its own process next to the app ([workers]).[cron] schedulerruns Laravel's scheduler every minute ([cron]).[services] postgresgives your app its own PostgreSQL database.[storage] keepmakesstoragewritable and keeps it across deploys, because a PHP release's own files are read-only while it runs. Each deploy copies in any folder or file your repo ships understoragethat the kept folder does not have yet, such asstorage/framework/views. It never overwrites what your app wrote.[tools] nodeinstalls Node.js for the Vite build.
When the repo has storage/app/public, ox's detected build links it to public/storage, as php artisan storage:link does. Files on Laravel's public disk are then served at /storage. A [build] commands list in your ox.toml replaces the detected build steps, this link included. If your app uses the public disk, add ln -sfn ../storage/app/public public/storage to the list.
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. When your config/database.php shows the app reads them, ox sets both from the service. Your own value for either one wins.
- For MySQL, use
mysql = {}instead. ox gives youMYSQL_URL, served by MariaDB. It setsDB_CONNECTION=mysqland the URL key your config reads:DB_URL, orDATABASE_URLin a Laravel 10 config. ox addsphp-mysqltopackagesfor you, as it addsphp-pgsqlforpostgres = {}. - For Redis, add
redis = {}. ox gives youREDIS_URL, which Laravel reads as is. Addphp-redistopackages.
APP_KEY, the .env values and trusted proxies
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. ox never reads your .env file.
APP_KEY=base64:... # from php artisan key:generate --show
APP_ENV=production
APP_DEBUG=false
APP_URL=${PUBLIC_URL}
LOG_CHANNEL=stderrMake the APP_KEY on your own computer with php artisan key:generate --show, then run ox vars set <project> 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 needs a value before the first deploy, even an empty one. A key with a real value (APP_DEBUG=false) keeps Laravel's own default.
The config cache is made during the build, so a changed variable needs a new deploy. Save & deploy on the Variables tab does that.
How ox sets the database keys
Laravel 11 never reads DATABASE_URL. So with postgres or mysql in [services], ox sets DB_CONNECTION and the key your config/database.php reads the URL from: DB_URL, or DATABASE_URL in a Laravel 10 config. ox reads that file and skips what it already covers. It skips DB_CONNECTION when the file defaults it to pgsql (or mysql). It skips 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". ox asks you for any other key whose value names your own machine, such as localhost.
Trusted proxies
Caddy sits in front of your app. Tell Laravel to trust it, so links and redirects use HTTPS:
->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. Below 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. Your config/database.php decides which keys come from postgres.
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:
ox check
ox deploy <project> --wait
ox logs <project> --followIf the Laravel deploy fails
set these before deploying: APP_KEY. A key from.env.examplehas no value. Set it withox vars set <project> APP_KEYand deploy again.Read-only file systemin the app's log for a path understorage.storageis missing from[storage] keep. Add it as in the file above.GET http://127.0.0.1:<port>/up did not answer within 120s. The app did not answer on its health page. Laravel 10 and older have no/uppage. Add a route that returns a 200, or pointhealthat a page you have.
If an older release is live, it keeps serving while you fix any of these. Troubleshooting explains each message, and the config reference lists every key.
Next steps
- Set APP_KEY and the other variables from the dashboard or the CLI.
- Add your own domain with HTTPS: one A record, and Caddy gets the certificate.
- Read and search the app's logs, live or for a time range.
- Roll back a bad deploy to a kept release without a rebuild.
- See the daily PostgreSQL backups and restore one.
New to running your own server? How ox runs your apps gives the overview, and the blog's deploy guides walk through a setup by hand.