# Public Laravel files on cdn.yourdomain, not on a bucket URL

Brewless now puts every bucket for files behind bunny.net on cdn.<your domain>, and sets AWS_URL once it answers, so Storage::url() stops naming the bucket.

## In short

- Without AWS_URL, Laravel's s3 disk links a public file to the bucket's own address, such as my-bucket.s3.nl-ams.scw.cloud, which shows your storage provider and skips the edge.
- Brewless now gives every environment with a bucket for files and bunny.net in front a files address, cdn. plus the host of APP_URL, served by a pull zone of its own in front of the bucket.
- The pull zone asks the bucket without a key, so only files your app stores as public are served there, and the bucket itself stays private.
- Brewless points the name in Bunny DNS or Scaleway DNS itself, and sets AWS_URL only once the name answers with its certificate, so a link never points at an address that does not work yet.
- An environment without a domain of its own gets cdn-<label> under brewless.dev, and storage.cdn in brewless.yml picks another hostname or switches the files address off.

Store a file as public on Laravel's `s3` disk and ask for its URL, and you get the address of the bucket: `https://shop-production-files.s3.nl-ams.scw.cloud/avatars/42.jpg`. It works, but it names your storage provider and your bucket in every link, it skips the edge, and moving the files later means changing every link that was ever shared.

From today [Brewless](/) gives every bucket for files an address on your own domain: `cdn.shop.example.eu`, served by bunny.net. This post explains what that address is, what it serves and what it leaves alone.

## Why does Laravel link public files to the bucket?

Because the `s3` disk has no other address to use. `Storage::disk('s3')->url($path)` uses the disk's `url` when there is one, which `config/filesystems.php` reads from `AWS_URL`, and otherwise builds one from the bucket and the endpoint. Until now Brewless set `AWS_BUCKET` and `AWS_ENDPOINT` but no `AWS_URL`, so every public file got a bucket URL.

We noticed it on our own sites: preview images made by spatie/laravel-og-image are stored on the `s3` disk, and every share preview pointed at a `….s3.nl-ams.scw.cloud` address.

## What the files address is

With bunny.net connected, every deploy now gives the bucket for files an address of its own at the edge:

| `APP_URL` | Files address |
| --- | --- |
| `https://shop.example.eu` | `cdn.shop.example.eu` |
| `https://example.com` | `cdn.example.com` |
| The test address, or no domain of your own | `cdn-<label>.brewless.dev` |

The `<label>` is the same one your environment's test address has: organisation, application and environment, shortened with a hash when it would not fit a DNS label.

Behind the name is a pull zone of its own, next to the one in front of your app, named after it with `-files`. Its origin is the public endpoint of your bucket. Like the zone in front of your app, it routes through the EU only, takes TLS 1.2 and newer, has Origin Shield in front of the bucket and keeps its logs anonymised.

> [!NOTE]
> A file is kept at the edge as long as the `Cache-Control` the bucket sends with it says, and for a day when it sends none. Give a file that changes a new path rather than overwriting it, as hashed file names do.

## Only public files, and the bucket stays private

The pull zone asks the bucket without a key. That decides what it can serve:

| Stored as | At the bucket URL | At the files address |
| --- | --- | --- |
| `public` visibility | Served | Served, from the edge |
| Private (the default) | `403` | `403` |

So the files address adds no new way in: what answers there is what already had a public link. Invoices, contracts and other private uploads stay behind temporary URLs or your own route, as described in [Laravel uploads on Scaleway Object Storage](/blog/laravel-uploads-scaleway-object-storage). The bucket itself stays private, without a bucket policy that opens it.

## DNS and the certificate

Brewless points the name where your DNS is:

- **Bunny DNS**: a pull zone record for `cdn`.
- **Scaleway DNS**: a CNAME to the pull zone.
- **No domain of your own**: a CNAME in Brewless's own zone under `brewless.dev`.
- **DNS kept elsewhere**: nothing is touched. The deploy names the record to make, such as `cdn.shop.example.eu CNAME <pull zone>.b-cdn.net`.

A record that already stands on the name is never changed. bunny.net issues the certificate once the name points at the pull zone. Waiting for DNS or a certificate never fails a deploy; the next deploy simply looks again.

## AWS_URL only once it answers

A link to an address that does not answer yet would break every image on your site, so Brewless waits. Once the files address answers with its certificate, a deploy gives the containers:

```dotenv
AWS_URL=https://cdn.shop.example.eu
```

From then on `Storage::url()`, and every package that builds links through the disk, names the files address. The first deploy points the name, which takes a moment to spread, so it still links to the bucket; the next deploy asks for the certificate before it hands out the variables and usually switches. Until then the bucket URL keeps working, and existing links to it keep working afterwards too.

> [!TIP]
> `AWS_URL` is a platform variable, given at deploy. An `AWS_URL` of your own always wins, and Brewless never writes it into your Secret Manager. See [Variables](https://docs.brewless.eu/running/variables).

## Another name, or none

`cdn` under `storage` in `brewless.yml` chooses:

```yaml brewless.yml
environments:
  production:
    storage:
      name: shop-production-files
      cdn: files.example.eu   # or false for no files address
```

The short form `storage: shop-production-files` keeps the default. A name under `brewless.dev` is refused. With `cdn: false` the containers no longer get `AWS_URL`; a pull zone and a record made earlier stay as they are until you remove them, because Brewless removes nothing of yours by itself. See [The files address](https://docs.brewless.eu/applications/applications-and-environments#the-files-address).

## Summary

1. Every environment with a bucket for files and bunny.net in front gets `cdn.<your domain>` on its next deploy.
2. Only files stored as public are served there; the bucket stays private.
3. Brewless writes the DNS record at Bunny DNS or Scaleway DNS, and names it when your DNS is elsewhere.
4. `AWS_URL` follows once the address answers, and yours always wins.
5. `storage.cdn` picks another hostname, or `false` for none.

## Frequently asked questions

### Why does Storage::url() return a bucket URL?

Laravel's s3 disk builds the URL from the bucket and the endpoint unless the disk has a url, which config/filesystems.php reads from AWS_URL. Without AWS_URL a public file gets the address of the bucket at your storage provider.

### What is the files address in Brewless?

It is cdn. plus the host of your APP_URL, such as cdn.shop.example.eu, served by a bunny.net pull zone of its own in front of the environment's bucket for files. Every deploy sets it up, and once it answers with a certificate the containers get AWS_URL pointing at it.

### Does the files address make private uploads public?

No. The pull zone asks the bucket without a key, so a file stored as private still answers 403 there. Only files your app stores with public visibility are served, which are the files that already had a public bucket URL.

### Do I have to change my DNS?

Not when your domain is at Bunny DNS or Scaleway DNS, because Brewless writes the record there. When your DNS is kept elsewhere, the deploy names the CNAME to make and changes nothing itself.

### Can I keep my own AWS_URL or switch the files address off?

Yes. An AWS_URL of your own always wins and Brewless never writes AWS_URL into your Secret Manager. storage.cdn set to false makes no files address, and storage.cdn set to a hostname uses that name instead of cdn. plus your domain.

Source: https://brewless.eu/blog/laravel-files-on-a-cdn-subdomain
