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, 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.
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.
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] startmakes the folders Laravel writes to, then starts PHP's web server 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.- When the repo has
storage/app/public, ox's build links it topublic/storage(whatphp artisan storage:linkdoes), so files on Laravel's public disk are served at/storage. A[build] commandslist in your ox.toml replaces the detected build steps, this link included: addln -sfn ../storage/app/public public/storageto it if your app uses the public disk. [tools] nodeinstalls 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 youMYSQL_URL, served by MariaDB, and 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
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.
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 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:
->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.
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, so add a route that returns a 200, or pointhealthat a page you have.
Your visitors never see a failed deploy: the previous release keeps serving. 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 self-hosts your apps gives the overview, and the blog's deploy guides walk through a setup by hand.