Docs menuDeploy Laravel

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.

ox.toml for a Laravel app
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]).
  • [cron] scheduler runs Laravel's scheduler every minute ([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
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:

bootstrap/app.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
  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
ox check
ox deploy <project> --wait
ox logs <project> --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 <project> 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:<port>/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 explains each message, and the config reference lists every key.

Next steps