# 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 short

- bunny.net puts DNS, a CDN, TLS certificates and the Shield firewall in front of a Laravel app, from a company based in Slovenia.
- Let your Laravel app decide what the edge caches with Cache-Control, and never mark a response public when it carries a cookie or a signed-in user's data.
- Behind a CDN, Laravel sees the edge's address unless it trusts X-Forwarded-For, and it should only trust that header once something has checked the request really came through the edge.
- With Brewless, the pull zone in front of your containers is routed through the EU only and accepts TLS 1.2 and newer only.

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](https://docs.brewless.eu/deployments/deployments), 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](https://docs.brewless.eu/applications/providers).

## Bunny DNS and TLS

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

```bash
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](https://docs.brewless.eu/running/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:

```php 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:

```php 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](/blog/eu-sovereign-laravel-hosting) 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.

Source: https://test.brewless.eu/blog/bunny-net-in-front-of-laravel
