Putting bunny.net in front of a Laravel app

How to run Laravel behind bunny.net, with the pull zone, Bunny DNS, TLS, Shield, what to cache and what not, and the real visitor IP.

In this post
  1. What does bunny.net do in front of a Laravel app?
  2. The pull zone
  3. Bunny DNS and TLS
  4. Shield
  5. What to cache, and what not
  6. Getting the real visitor IP back
  7. Keeping the edge in Europe
  8. Summary

A Laravel app on serverless containers is fast to deploy and cheap when it is quiet. What it does not have on its own is a CDN, a firewall, or a DNS provider that sits close to visitors. bunny.net, from Ljubljana in Slovenia, covers those in one account: DNS, a CDN, TLS certificates and Shield.

This post walks through putting bunny.net in front of a Laravel app: the pull zone, the DNS record, TLS, Shield, what the edge should cache, and how Laravel gets the visitor's real address back. The examples use a setup deployed with Brewless, but the Laravel side applies to any app behind bunny.net.

What does bunny.net do in front of a Laravel app?

bunny.net answers your visitors first: it resolves the domain, ends TLS, filters requests through Shield and serves what it has cached. Everything else it passes on to your Laravel containers, which it calls its origin. Your app keeps deciding what may be cached and who the visitor is.

Part What it does for a Laravel app
Pull zone The CDN: caches responses and forwards the rest to your containers
Bunny DNS Holds your zone and points your domain at the pull zone
TLS Issues and renews the certificate for your domain
Shield Firewall, rate limits and DDoS protection in front of the pull zone
Storage zone Serves your hashed frontend build files without waking a container

The pull zone

The pull zone is the part that does the work. It needs an origin (the address of your web container) and a few settings that matter for Laravel.

When bunny.net is connected on the Providers page, Brewless makes the pull zone when it sets an environment up. Per the deployments documentation, that pull zone:

  • accepts TLS 1.2 and newer only; a deploy turns 1.0 and 1.1 off again if someone switched them on;
  • asks the container once more, a second later, when the connection failed or the answer was a 5xx, but never after a request ran out of time, so a slow request is not run twice;
  • serves the hashed files of your frontend build from a storage zone, so a page of an earlier release still finds its files;
  • is emptied once a new release answers healthy, so no visitor gets a page from the release before.

Note

Use a separate bunny.net account for what Brewless manages. bunny.net has one API key per account, with full access to every pull zone and DNS record in it. See Connecting providers.

Bunny DNS and TLS

With your zone at Bunny DNS in the connected account, adding a domain makes the record for you:

brewless domain:add production shop.example.eu

The record leads straight to the pull zone. Brewless only creates records that do not exist yet; when one is in the way, the error names it. A wildcard such as *.shop.example.eu needs its zone at Bunny DNS, because bunny.net only proves a wildcard through its own DNS. Is your DNS elsewhere? Point the name at the target the console shows with a CNAME. The details are on Domains.

bunny.net issues the certificate once the record is live and renews it by itself, with or without Brewless.

Shield

Shield is bunny.net's firewall: managed rules, rate limits and DDoS protection, set per pull zone in the bunny.net dashboard. For a Laravel app, the useful first steps are:

  • a rate limit on /login, /register, /forgot-password and your API routes, next to Laravel's own RateLimiter;
  • the managed rules in a logging mode first, so you see what they would block before they block it;
  • blocking paths your app never serves but scanners always try, such as /wp-admin or /.env.

Your release commands, queue and scheduler do not pass through the pull zone: Brewless calls those containers on their own Scaleway address. A Shield rule cannot stop a migration or a queued job.

What to cache, and what not

A pull zone Brewless makes leaves the decision to your app: it follows the Cache-Control header of each response. That puts the rules in your Laravel code, where you can test them.

Laravel's defaults are safe. A response without its own header goes out as Cache-Control: no-cache, private, and the web middleware group starts a session on every request, which sets the session and XSRF-TOKEN cookies. Nothing of that is cached.

Response Cache at the edge? How
Hashed build files (/build/*) Yes, for a year Served from the storage zone; the default Caddyfile sends immutable
Images and fonts in public/ Yes A Cache-Control line in docker/Caddyfile
Public pages, the same for everyone Yes, briefly cache.headers middleware, without the session
Pages for signed-in users No Laravel's default private
Forms, anything with @csrf No Needs the session
API responses with user data No private or no-store

For a public page, such as a pricing or a docs page, take the session out of that route and let Laravel's cache.headers middleware set the header:

routes/web.php
use Illuminate\Foundation\Http\Middleware\ValidateCsrfToken;
use Illuminate\Session\Middleware\StartSession;
use Illuminate\View\Middleware\ShareErrorsFromSession;

Route::get('/pricing', PricingController::class)
    ->withoutMiddleware([StartSession::class, ShareErrorsFromSession::class, ValidateCsrfToken::class])
    ->middleware('cache.headers:public;max_age=300;etag');

That page can no longer use auth(), session(), old() or @csrf. If its layout shows a "Signed in as" menu, it is not a page to cache.

Warning

Never send public on a response that sets a cookie or shows one visitor's data. A cached response is handed to the next visitor as it is, cookie included.

Keep query strings in the cache key. Laravel paginates with ?page=2, and a zone that ignores the query string serves page one for every page.

After a deploy you do not have to purge by hand: Brewless empties the pull zone's cache once the new release answers healthy.

Getting the real visitor IP back

Behind a CDN, every request comes from the edge. Laravel's $request->ip() returns the edge's address unless you tell Laravel to trust X-Forwarded-For, and rate limits, logs and audit trails then all see the same few addresses.

Trusting that header is only safe when something has checked that the request came through the edge. Otherwise anyone who calls the container directly can claim any address.

The brewless/laravel package does that check. The pull zone adds a secret header to every request (BREWLESS_EDGE_SECRET in your variables), and the package's middleware, which runs before all others:

  • keeps only the entry the edge appended to X-Forwarded-For, and drops the header when the secret is missing;
  • puts the visitor's hostname back, from a header of the edge, only together with the secret and only for your own domains (BREWLESS_DOMAINS, by default the host of APP_URL).

Then let Laravel trust the forwarding headers, except the host:

bootstrap/app.php
use Illuminate\Http\Request;

->withMiddleware(function (Middleware $middleware): void {
    $middleware->trustProxies(
        at: '*',
        headers: Request::HEADER_X_FORWARDED_FOR | Request::HEADER_X_FORWARDED_PROTO | Request::HEADER_X_FORWARDED_PORT,
    );
})

X-Forwarded-Host stays out on purpose: the package already restored the host, and only for domains you own.

Warning

at: '*' is only safe with the edge secret set. Without BREWLESS_EDGE_SECRET, the package leaves X-Forwarded-For as it arrived, and Laravel would believe whatever a caller puts in it.

If another proxy stands between bunny.net and your container, set BREWLESS_EDGE_HOPS to the number of proxies that append to the header. The default is 1.

Keeping the edge in Europe

bunny.net has locations all over the world, and a pull zone can be limited to routing through some regions only. A pull zone Brewless makes is routed through the EU only, so your visitors' requests are handled at European locations. Visitors in other parts of the world are then served from Europe too, which is slower for them; weigh that against where your users are.

Two things this does not change: Bunny DNS answers lookups from bunny.net's worldwide network, and the routing setting only covers the pull zone. The EU-sovereign hosting guide goes through the rest of the stack.

Summary

  1. Connect a separate bunny.net account; Brewless makes the pull zone, routed through the EU only, TLS 1.2 and newer.
  2. Keep your zone at Bunny DNS, and brewless domain:add makes the record; bunny.net handles the certificate.
  3. Set up Shield rate limits and rules in the bunny.net dashboard.
  4. Cache build files and public assets; cache HTML only without a session; keep signed-in pages private.
  5. Install brewless/laravel and trust X-Forwarded-For, -Proto and -Port, never -Host.

Frequently asked questions

Do I need bunny.net to run Laravel on Scaleway?

No. Without it, an environment is reached on its Scaleway address or through DNS you manage yourself. bunny.net adds a CDN, the Shield firewall and Bunny DNS in front of the containers, and Brewless sets the pull zone up when you connect it.

Should bunny.net cache my Laravel HTML pages?

Only pages that look the same for every visitor and set no cookie. Laravel's web middleware starts a session on every request, so a cacheable page has to leave the session out. Pages for signed-in users stay private and uncached.

Why does request()->ip() return the CDN's address?

Laravel only reads X-Forwarded-For from proxies it trusts. Behind bunny.net you configure trustProxies in bootstrap/app.php, and with brewless/laravel installed the header is first cut down to the address the edge saw, so a visitor cannot choose their own.

Can I keep bunny.net's edge locations in Europe?

Yes, bunny.net lets a pull zone route through selected regions only. A pull zone Brewless makes is routed through the EU only. Visitors outside Europe are then served from a European location, which is slower for them.

Who renews the TLS certificate for my domain?

bunny.net issues the certificate once the DNS record of your domain is live and renews it by itself. That keeps working when Brewless is not around.

All posts Read this post as markdown

Try it on your own app

Brewless deploys your Laravel app to Scaleway and bunny.net, in your own accounts. We open access to Laravel teams in small batches.