Under the hood
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 this post
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-Controlitself; - the page did not ask who is signed in, such as
@authorauth()->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:
environments:
production:
edge:
cache: 1d
The edge cache documentation 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:
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 setCache-Control: privateorno-storeitself.
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 ascrc32(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:
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, and on the first request after a sleep in Laravel cold starts on serverless.
In short
- A serverless container sleeps only after 15 minutes without a request that reaches it.
- Laravel's session cookie keeps every page out of the edge cache, so every crawler wakes the container.
- Let guests without a cookie go without a session, and send forms on public pages with
fetch(). - Let the edge keep public pages for as long as a release lasts, and version files by their content.
- 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.