Guides
Deploy Laravel on cmdz: Octane, migrations and queues
A real Laravel deployment: the release command, queue workers as their own workload, the scheduler, Octane, storage on object storage, and where the horizontal-scaling surprises are.
Laravel is the second most common stack here and the one with the most moving parts, because a real Laravel app is rarely one process.
The shape of a Laravel deployment
Most Laravel apps are three workloads, not one:
[apps.web]
start = "php artisan octane:start --host=0.0.0.0 --port=8000"
release = "php artisan migrate --force"
[apps.worker]
start = "php artisan queue:work --tries=3 --max-time=3600"
type = "worker"
[apps.scheduler]
start = "php artisan schedule:work"
type = "worker"
They share an image and an environment and scale independently. The web app scales with traffic; the worker scales with queue depth; the scheduler stays at exactly one replica, which matters more than it looks.
Migrations run once, in release
release runs once per deploy, after the build and before traffic moves. Not in the build — a build can run many times, including for previews.
If php artisan migrate --force fails, the deploy fails and the current version keeps serving. A failed migration followed by new code against an old schema is the worst outcome available, and this ordering makes it impossible.
For zero-downtime deploys, write migrations that are backwards compatible for one release: add a column before you write to it, stop reading a column before you drop it. That is ordinary practice anywhere with rolling releases, and it is worth saying because the alternative is discovering it during a rollout.
The scheduler is exactly one replica
schedule:work on three replicas runs your scheduled tasks three times. Keep it at one, and use withoutOverlapping() on anything long.
If you would rather not run a scheduler process at all, the platform has native cron workloads that invoke an artisan command on a schedule. Fewer processes, same result, and the cost is per invocation rather than per idle minute.
Queue workers
cmdz scale worker --replicas 3
Two settings people forget:
--max-time=3600 recycles the worker every hour, which keeps a long-running PHP process from accumulating memory. --tries=3 bounds retries, so a poison job fails permanently instead of forever.
Point the queue at Valkey rather than the database. It is what it is for, and the database connection you save under load is real.
cmdz services create valkey --size small --attach web,worker
Octane
Octane keeps the framework in memory between requests, and on a small instance the difference is large — bootstrapping Laravel is a meaningful fraction of a short request.
It also changes the rules: static properties, singletons and anything you set on a service container survive between requests. Most apps are fine. Apps that hold request state in a singleton are not, and the bug looks like data from one visitor appearing for another, which is the kind of bug you want to find in staging.
Deploy without Octane first, confirm the app is healthy, then switch the start command. Rolling back is one key if it goes badly.
Storage goes to object storage
storage/app/public on a local disk does not survive a redeploy and is not shared between replicas. Use the S3 driver:
cmdz services create bucket --name uploads --attach web,worker
FILESYSTEM_DISK=s3
The credentials and endpoint are injected. Do not commit them.
Sessions and cache
With more than one replica, the file session driver means a visitor is logged in on one replica and not on another. Use redis for sessions and cache, pointed at the same Valkey.
This is the single most common "it works locally" failure with Laravel on any platform that runs more than one instance.
Logs
Set LOG_CHANNEL=stderr. The platform collects stdout and stderr, indexes them and keeps seven days of runtime logs (build logs are kept for ninety days), searchable, and readable by your agent through cmdz_get_runtime_logs.
Writing to storage/logs/laravel.log means the logs live on a disk that disappears with the container and are invisible to everything that could help you.
Health checks
Octane answers quickly, but the first request after a release still has work to do. Give the app a health route that touches nothing expensive:
Route::get('/up', fn () => response('ok'));
Laravel 11 and later ship this. The startup probe polls it every two seconds and traffic only moves once it answers. A health check that queries the database will make a slow database look like a broken deploy.
What it costs
Web at 2 × (1 vCPU, 1 GB), one worker at 0.5 vCPU, a scheduler at 0.25, a small Postgres, a small Valkey and 20 GB of uploads:
compute € 7.66
memory € 5.20
database € 1.46
valkey € 1.46
storage + bucket € 3.05
backups € 0.25
────────────────────────────
€ 19.08 / month
Outbound traffic is € 0.02 per GB, which for this app is a rounding error — and 7.5× less than the same gigabytes cost at Vercel or Render, which is where the surprise usually is.
Set your limit and start.
One click with a passkey, then you verify a payment method once to start your 14-day free trial (€ 10 of credit). After that it is prepaid pay-as-you-go — you only ever spend credit you have already bought, and no invoice ever arrives above the amount you set.