Laravel scale-to-zero: running a Laravel API on Cloud Run with min-instances 0, and what it really costs
How to host a low-traffic Laravel API that costs close to nothing when idle, what breaks under scale-to-zero (queues, cron, local disk, the database) and how to replace each piece.
A new Laravel API behind a mobile app usually has very little traffic for its first months. Paying for an always-on server and an always-on database during that time is money spent on idle CPUs. "Scale-to-zero" hosting runs your app only while requests are being handled. This guide shows how to do it on Google Cloud Run, and which parts of a normal Laravel setup quietly prevent it.
What scale-to-zero actually means on Cloud Run
Cloud Run runs your container and starts instances on demand. With --min-instances 0 and request-based billing, CPU is allocated only while a request is being processed, and instances that sit idle are not charged.
The monthly free allowance for request-based billing in Tier 1 regions is:
- 180,000 vCPU-seconds (that's 50 hours of CPU time)
- 360,000 GiB-seconds of memory
- 2 million requests
Beyond that, list prices are about $0.000024 per vCPU-second, $0.0000025 per GiB-second and $0.40 per million requests.
A quick sanity check: an API handling 150,000 requests a month at 200 ms each on 1 vCPU uses about 30,000 vCPU-seconds. That's well inside the free tier. Concurrency helps further, because one instance can serve several requests at once.
The four things that stop Laravel from scaling to zero
A typical Laravel deployment assumes a long-running server. Each of these keeps something awake or breaks on a stateless container.
1. Queue workers
php artisan queue:work is a process that never exits. On scale-to-zero there's no process to run it. Replace it with a push queue: Google Cloud Tasks calls an HTTP endpoint for each job. Run the same container image as a second, private Cloud Run service (--no-allow-unauthenticated, OIDC-authenticated) that receives those calls. A package such as stackkit/laravel-google-cloud-tasks-queue provides the Laravel queue driver. Cloud Tasks includes its first million operations a month free.
2. The scheduler
* * * * * php artisan schedule:run needs cron. Replace it with Cloud Scheduler triggering a Cloud Run Job that runs schedule:run. Running it every minute keeps waking your database, so run it every 10 minutes (and set your schedule frequencies to match), or create one Scheduler job per important command.
3. Local disk
The container filesystem is ephemeral, and on Cloud Run writes go to an in-memory filesystem that counts against the instance's memory. So:
- Uploads go to object storage through Laravel's
s3driver (Cloudflare R2 has zero egress fees). Let mobile clients upload directly with presigned URLs. LOG_CHANNEL=stderr, so Cloud Logging collects the logs.- Sessions: a token-authenticated API (Sanctum) can use
SESSION_DRIVER=cookieorarray. - Cache: start with
CACHE_STORE=database, move to a serverless Redis later if needed.
4. The database (the expensive surprise)
Compute scales to zero easily. Most managed SQL databases don't. They bill per provisioned hour, so the database becomes your largest launch cost. Options:
| Database | Scales to zero? | Notes |
|---|---|---|
| Neon serverless Postgres | Yes, after 5 minutes idle | Wakes "within a few hundred milliseconds" per Neon's docs. Use the pooled endpoint. |
| Laravel Cloud Postgres | Hibernates | Practical only if your compute is also on Laravel Cloud. |
| Supabase | No on paid plans | Free plan pauses after a week idle. Pro has a fixed monthly floor. |
| Cloud SQL | No | Billed while provisioned. |
Pick a database region close to your Cloud Run region, otherwise every query pays cross-region latency.
A container built for Cloud Run: FrankenPHP
The classic php-fpm + nginx setup needs two processes and a supervisor inside one container. Cloud Run expects one process listening on $PORT. FrankenPHP (Caddy with PHP embedded) does exactly that, and Laravel's deployment docs list it as a supported server.
A minimal shape of the image:
FROM dunglas/frankenphp:1-php8.4-bookworm
RUN install-php-extensions opcache intl zip bcmath pcntl pdo_pgsql redis
ENV APP_ENV=production APP_DEBUG=false LOG_CHANNEL=stderr PORT=8080
WORKDIR /app
COPY . .
RUN composer install --no-dev --optimize-autoloader \
&& php artisan route:cache && php artisan view:cache && php artisan event:cacheTwo details that matter:
- Cache config at boot, not at build. Environment variables only exist at runtime on Cloud Run, so run
php artisan config:cachein the entrypoint before starting the server. - Octane is optional. FrankenPHP's classic mode behaves like php-fpm (each request is isolated). Worker mode via Laravel Octane removes the per-request framework boot, but only enable it after checking your code for state that leaks between requests (static properties, singletons holding request data).
Deploying
gcloud run deploy api \
--image=REGION-docker.pkg.dev/PROJECT/apps/api:TAG \
--region=europe-west1 \
--min-instances=0 --max-instances=10 \
--concurrency=10 --cpu=1 --memory=512Mi \
--cpu-boostRun migrations as a Cloud Run Job using the same image, before shifting traffic, rather than at container start. Several instances starting at once would otherwise race to migrate.
For CI, GitHub Actions can deploy with Workload Identity Federation, so you never store a long-lived JSON service-account key in your repository secrets.
Cold starts: the trade-off you accept
The first request after an idle period has to start a container, boot PHP and open a database connection, and possibly wake the database too. Expect a noticeable delay on that request. Ways to soften it:
--cpu-boostgives extra CPU during startup.- Keep the image small and do the caching work at build time.
- Mobile clients should use generous timeouts and retry idempotent GETs.
- When traffic justifies it,
--min-instances=1removes the cold start for a small always-on cost. That's a deliberate decision you make later, not on day one.
What a launch month looks like
Using Cloud Run with Neon, R2 for files, and Cloud Tasks and Scheduler for background work, a launch-stage API (roughly 1,000 monthly users and 150,000 requests a month at the assumptions above) stays inside or near the free tiers: on the order of $0 to $6 a month, mostly the database. The cost grows smoothly with traffic instead of starting from a fixed floor.
Alternatives worth knowing
- Laravel Cloud: the least DevOps work. Its Flex compute and Postgres hibernate when idle, queues and the scheduler are built in, and there's a low-cost starter plan. Autoscaling beyond one replica needs a higher plan.
- Fly.io: machines can autostop and autostart, but the proxy only starts machines you've already created; it doesn't create new ones for you.
- A small VPS with Coolify: the cheapest flat bill, but no scale-to-zero and no autoscaling, and you own patching, backups and monitoring.
Checklist
--min-instances=0, request-based billing.- No
queue:work: Cloud Tasks push queue to a private worker service. - No cron: Cloud Scheduler to a Cloud Run Job, at a sane interval.
- Files to S3/R2, logs to stderr, sessions and cache off local disk.
- A database that sleeps, in a nearby region, via its pooled endpoint.
- FrankenPHP image,
config:cacheat boot, migrations as a Job. - Keyless deploys from CI.
Sources
- Cloud Run pricing (free tier, request-based billing, idle instances)
- Cloud Run billing settings
- Cloud Run container runtime contract (in-memory filesystem,
$PORT) - Cloud Tasks pricing and Cloud Scheduler pricing
- Neon: Scale to zero
- Laravel Cloud pricing and Laravel blog: scale-to-zero compute
- Laravel deployment docs
- FrankenPHP: Laravel and Docker images
- stackkit/laravel-google-cloud-tasks-queue
- Fly.io: autostop/autostart
- Cloudflare R2 pricing
Want this done for you?
Kit
Laravel scale-to-zero on Cloud Run: study + deploy kit
A 10-platform comparison with sources and costs at three tiers, a FrankenPHP Dockerfile, keyless GitHub Actions deploys with min-instances 0, and a migrations job.