# Why your Laravel container never scales to zero

A serverless Laravel container sleeps only after 15 quiet minutes. Why crawlers and session cookies keep it awake, and how the edge cache lets it rest.

## In short

- A Scaleway serverless container scales to zero only after 15 minutes without a request, and every request that reaches it starts those 15 minutes again.
- On a public site, crawlers and visitors arrive often enough that a container which answers every request hardly ever sleeps.
- Laravel's web middleware sets a session cookie on every page, and a page with a cookie is never kept at the edge, so every visit reaches the container.
- With brewless/laravel on Laravel 13, a guest without a cookie gets no session on a page that puts nothing in it, so bunny.net can keep that page.
- After every release Brewless warms the edge from your sitemaps and checks whether the edge can keep your homepage, so the container is only woken by what really needs it.

Scale to zero is the reason to run Laravel serverless: when nobody visits, nothing runs, and you pay for nothing. Then you open the usage of your production environment, and the container ran 23 hours a day. Nothing is broken. Something keeps waking it.

This post explains what keeps a serverless Laravel container awake, why Laravel's defaults make it worse, and what [Brewless](/) does at the bunny.net edge so the container sleeps when nobody needs it.

## Why does my serverless Laravel container never sleep?

Because requests keep reaching it. A Scaleway serverless container scales to zero only after 15 minutes without a request, and every request that reaches it starts those 15 minutes again. On a public site, crawlers, uptime monitors, link previews and the odd visitor easily arrive more often than that.

The fix is not fewer visitors. It is fewer of them reaching the container: let the edge answer the pages that are the same for everybody.

## The 15-minute rule

The arithmetic is unforgiving. A container sleeps only in a gap of more than 15 minutes between two requests that reach it, so:

| Requests reaching the container | Longest possible sleep |
| --- | --- |
| One every 5 minutes | None |
| One every 14 minutes | None |
| One every 30 minutes | About 15 minutes out of every 30 |
| A burst at 9:00, nothing until 17:00 | Nearly 8 hours |

Who sends those requests? Search engine crawlers walk your site page by page. Image crawlers fetch your share previews and logos on their own schedule. Browser tabs left open on an old page ask for files. Chat apps fetch a page to draw a link preview. None of them buys anything, and every one of them starts the 15 minutes again.

A CDN in front should absorb most of that. In practice it often absorbs almost nothing, for a reason that sits in Laravel itself.

## Laravel sets a cookie on every page

Every route in the `web` middleware group starts a session. The session sends a cookie with the answer, and so does the `XSRF-TOKEN` cookie for the CSRF check. A shared cache must not keep an answer that sets a cookie: it would hand the session of one visitor to the next. So bunny.net passes every such page on to your container, the homepage and the pricing page included.

Laravel's own default header says the same. A response without a `Cache-Control` of its own goes out as `no-cache, private`. That is the right default for an application with signed-in users, and it means a plain Laravel site behind a CDN is cached for its images and stylesheets only.

## What decides whether the edge keeps a page

A pull zone Brewless manages keeps nothing it was not allowed to keep: your application decides, with `Cache-Control`. The `brewless/laravel` package (from version 0.17) gives an answer `Cache-Control: public, max-age=0, s-maxage=3600` when it cannot depend on who asked. That is when:

- the request is a GET or HEAD without cookies and without `Authorization`;
- the answer is a 200, 301, 308, 404 or 410, sets no cookie and started no session;
- the application did not set `Cache-Control` itself;
- the page did not ask who is signed in, such as `@auth` or `auth()->user()`.

`s-maxage` is for the edge, `max-age=0` keeps the page out of the browser. Every release empties the edge, so nobody gets a page of the previous release after a deploy.

The cache time is one line in `brewless.yml`. Pages that only change with a release can stay a day:

```yaml brewless.yml
environments:
  production:
    edge:
      cache: 1d
```

The [edge cache documentation](https://docs.brewless.eu/running/edge-cache) has the full list of rules, including the behaviour of `edge.cache: 0`.

## Guests without a session

The cookie is still the problem. From `brewless/laravel` 0.18, on Laravel 13, a guest without any cookie gets no session cookie and no `XSRF-TOKEN` when the page put nothing in the session and does not show the CSRF token. Nothing is stored for that visit, and the page can be kept at the edge. A page with a form, a flash message or anything else in the session keeps its session, and so does every visitor who sent a cookie.

On Laravel 12, or to say it yourself, take the session off the public routes:

```php routes/web.php
Route::withoutSession(function () {
    Route::view('/', 'home');
    Route::get('/blog/{post}', [BlogController::class, 'show']);
});
```

The trap is a form on every page: a newsletter field in the footer, or a contact modal in the layout. Its `@csrf` puts the token in the page, the page needs the session, and the whole site reaches the container. Send such a form with `fetch()` instead and leave the token out: Laravel 13's CSRF check lets a request with `Sec-Fetch-Site: same-origin` through without one.

> [!WARNING]
> The edge keeps one answer per hostname and address and does not look at `Vary`. A page that answers differently per visitor IP address, per header or per cookie it reads must set `Cache-Control: private` or `no-store` itself.

## Emptying the edge wakes it again

A cache that is emptied on every release has a cost of its own. bunny.net has a cache in every point of presence, and Origin Shield puts one cache in front of your container so a page reaches it once per cache time rather than once per location. Still, after a deploy every page has to reach the container once more, and a crawler that walks the site page by page can then keep it awake for hours.

So after a deploy and a rollback, once the release is live, Brewless asks every page once through the edge. The container is awake anyway at that moment. The pages come from the sitemaps of the environment's own domains, the `Sitemap:` lines of `robots.txt` or `/sitemap.xml`, at most 500 of them. The files those pages name are asked too: stylesheets, scripts, fonts, images, the share image crawlers fetch by themselves, the icons Safari and iOS ask of every domain, and `/llms.txt` for AI agents. The deploy ends with a line such as `Edge warmed  216 pages and 31 files`.

Nothing is asked on a schedule. That is a product rule: Brewless never sends requests to your application to measure it, because a health check would keep the container awake. `edge.warm: false` in `brewless.yml` turns the warm-up off.

## Old file versions

Files in `public/` usually carry a version in their address: `/logo.png?v=2903919055`. The web server serves the same file for every version, but the edge keeps a copy per address. Image crawlers and old tabs keep asking versions of earlier releases, which the edge never had.

A pull zone Brewless manages therefore keeps one copy of a static file across its versions, when the query string is nothing but `v`, `ver` or `id`. With bunny.net in front, the build also puts those versioned files and the hashed frontend build in a storage zone in your bunny.net account, so they are served without the container at all.

> [!TIP]
> Version a file by its content, not by `filemtime()`. Every file in the image carries the time of the commit, so a version made from the modification time changes with every deploy and every browser fetches every file again. A hash such as `crc32(file_get_contents($file))` keeps the address until the file changes.

## Checking that it works

After a deploy, Brewless asks your homepage through the edge the way a first visitor does, without a cookie. When the edge cannot keep it, the deploy says why: the page set a cookie, its `Cache-Control` forbids a shared cache, or bunny.net still did not answer it from its cache. It also points at the first file the page loads past the edge, such as a stylesheet from the container's own `scw.cloud` address. When nothing stands in the way, it says so: `bunny.net caches the homepage of example.com`. `brewless plan` shows the same check as a line `edge.homepage`.

You can ask yourself as well. The second answer should come from the edge:

```bash
curl -sI https://example.com/pricing | grep -i -E 'cache-control|cdn-cache'
curl -sI https://example.com/pricing | grep -i -E 'cache-control|cdn-cache'
```

`cdn-cache: HIT` on the second request means the page is kept. `MISS` with `cache-control: no-cache, private` means a cookie or a session is still in the way.

Then look at the result where it counts. The environment's page shows serverless usage: how long the web container ran and how long it was scaled to zero, counted from Scaleway's own figures. Next to it, **At the edge** shows how many requests bunny.net answered itself and how many it passed on. The gap between those two is what the container did not have to wake for. More on the cost side in [estimating what a Laravel app costs on serverless](/blog/estimate-laravel-serverless-costs), and on the first request after a sleep in [Laravel cold starts on serverless](/blog/laravel-scale-to-zero-cold-starts).

## In short

1. A serverless container sleeps only after 15 minutes without a request that reaches it.
2. Laravel's session cookie keeps every page out of the edge cache, so every crawler wakes the container.
3. Let guests without a cookie go without a session, and send forms on public pages with `fetch()`.
4. Let the edge keep public pages for as long as a release lasts, and version files by their content.
5. Check the deploy output and the serverless usage, not just whether the site is up.

## Frequently asked questions

### How long does a Scaleway serverless container stay awake?

It scales to zero after 15 minutes without a request. Every request that reaches the container starts those 15 minutes again, so one request every ten minutes keeps it running all day.

### Why does bunny.net not cache my Laravel pages?

Usually because the page sets a cookie. The web middleware group starts a session on every request and sends the session and XSRF-TOKEN cookies, and a shared cache must not hand one visitor's cookie to the next. Pages without a session, and without a Cache-Control header of their own that forbids it, can be kept.

### Can a page with a form be cached at the edge?

Not if the form carries a CSRF token, because the token lives in the session. A form a script sends with fetch() passes Laravel 13's CSRF check on Sec-Fetch-Site same-origin without a token, so the page around it can stay without a session.

### Does Brewless send requests to my app to keep it warm?

Not on a schedule. Brewless asks your pages through the edge only right after a deploy or a rollback, when the container is awake anyway, so the edge has them before visitors and crawlers arrive. It never runs health checks against your app.

### How do I see whether my container actually sleeps?

The environment's page in the Brewless console shows serverless usage, how much of a period the web container ran and how much it was scaled to zero. It is counted from Scaleway's own figures, not by asking your app.

Source: https://brewless.eu/blog/laravel-container-never-sleeps
