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-08
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.
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:
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 release's own files are read-only while it runs.[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_URL, so you point one at the other in the next step.
- For MySQL, use
mysql = {}instead. ox gives youMYSQL_URL, served by MariaDB. SetDB_CONNECTION=mysqlandDB_URL=${MYSQL_URL}, and swapphp-pgsqlforphp-mysqlinpackages. - 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=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 <project> 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:
->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.
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:
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.the app wrote to storage/logs/laravel.log, and a release's files are read-only while it runs: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.