# Laravel uploads on Scaleway Object Storage

How a serverless Laravel app keeps uploads on Scaleway Object Storage with the s3 disk, a private bucket, temporary URLs and a bucket for backups.

## In short

- A serverless container keeps no files between requests, so every upload that has to last belongs in a bucket.
- Scaleway Object Storage speaks the S3 API, so Laravel's own s3 disk works with it without a custom driver.
- Brewless makes the bucket private and gives the app a key that can read, write and remove objects but cannot make, change or remove a bucket.
- A private bucket hands out files through temporary URLs or through your own route, never through a public link.
- Backups go in a second bucket in another region, with a key that may only put and a lifecycle rule that removes old backups.

On a server you own, `storage/app` is a folder that stays. On a serverless container it is not: the container starts when a request comes in, scales back to zero when it is quiet, and two copies of your app never share a disk. An upload written to local disk is gone with the container.

This post shows where uploads go instead when a Laravel app runs on Scaleway with [Brewless](/): a private bucket on Scaleway Object Storage, through Laravel's own `s3` disk, plus a second bucket for backups that the app can write to but never read.

## Where do Laravel uploads go on a serverless container?

They go to a bucket. Scaleway Object Storage speaks the S3 API, so Laravel's `s3` disk stores and reads files there as it would on Amazon S3. With `storage:` in `brewless.yml`, Brewless makes a private bucket and sets the variables the `s3` disk reads.

The local disk is still there for what lives within one request: a file you resize before you store it, an export you build and then upload. Anything that has to outlive the request goes to the bucket.

## Ask for a bucket

One key in `brewless.yml` asks for it:

```yaml brewless.yml
environments:
  production:
    region: fr-par
    storage: shop-production-files
```

The next `brewless deploy` makes the bucket in the Scaleway project of the environment, in the region of the environment. You can also ask for one when you set an environment up with `brewless init` or `brewless provision`, or with **Set up** in the console.

Laravel needs the Flysystem adapter for S3:

```bash
composer require league/flysystem-aws-s3-v3 "^3.0"
```

Your `config/filesystems.php` needs no change. The `s3` disk that ships with Laravel reads these variables, and Brewless puts them in a new revision of the environment's variables in your own Secret Manager:

| Variable | Value |
| --- | --- |
| `FILESYSTEM_DISK` | `s3` |
| `AWS_BUCKET` | The name of the bucket |
| `AWS_ENDPOINT` | The Object Storage endpoint of the region |
| `AWS_DEFAULT_REGION` | The region, such as `fr-par` |
| `AWS_USE_PATH_STYLE_ENDPOINT` | `false` |
| `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY` | The key of the identity below |

With `FILESYSTEM_DISK=s3`, everything you store through `Storage::` without naming a disk lands in the bucket:

```php app/Http/Controllers/InvoiceController.php
$path = $request->file('invoice')->store('invoices');

// later
$contents = Storage::get($path);
```

> [!NOTE]
> A variable change reaches the containers with the next deploy, not before. Adding `storage:` to an environment that runs adds its variables in the same deploy.

## The identity behind the key

Brewless does not hand your app its own Scaleway key. It makes an identity for the bucket that may read the buckets of the project and read, write and remove the objects in them. It may not make, change or remove a bucket. Scaleway grants these rights per project, not per bucket, so the identity reaches the objects of every bucket in that project. That is one reason to give each application a Scaleway project of its own.

Making identities needs the permission set `IAMManager` on the key you gave Brewless. Without it, the bucket is still made, and the result names `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` for you to add yourself. See [A bucket for files](https://docs.brewless.eu/applications/applications-and-environments#a-bucket-for-files).

## Private files and temporary URLs

The bucket is private. Nothing in it has a public address, which is what you want for invoices, contracts, ID scans and exports. To let a visitor download one, make a temporary URL:

```php app/Http/Controllers/InvoiceDownloadController.php
use Illuminate\Support\Facades\Storage;

public function __invoke(Invoice $invoice): RedirectResponse
{
    Gate::authorize('view', $invoice);

    return redirect()->away(
        Storage::temporaryUrl($invoice->path, now()->addMinutes(5)),
    );
}
```

The URL is signed with the app's key and stops working when it expires. The visitor downloads the file from Scaleway Object Storage directly, so a large file does not keep your container busy, and your authorisation check runs before the URL is made.

The other way is to stream the file through a route of your own with `Storage::download()` or `Storage::response()`. That keeps the file behind your app on every request, at the cost of a container serving the bytes.

## Public assets and public media

Two kinds of files look public, and they go to different places.

**Your built frontend.** With bunny.net connected, a deploy puts the hashed files of your frontend build in a storage zone in your own bunny.net account, and the pull zone serves those paths from there. A page of an earlier release still finds its files, and a request for one never wakes a container. This is only for what `npm run build` produces. See [Built files behind bunny.net](https://docs.brewless.eu/deployments/deployments#built-files-behind-bunnynet).

**Uploads that everyone may see**, such as avatars or product photos. Brewless does not put those on bunny.net Storage, and the bucket it makes is private. Serve them through a route of your own that reads from the bucket, or with temporary URLs that last longer. Behind bunny.net, a route that sends cache headers can be answered from the edge.

| File | Where it lives | How a visitor gets it |
| --- | --- | --- |
| CSS, JavaScript, fonts from the build | bunny.net storage zone | The pull zone, without waking a container |
| Private uploads | Scaleway bucket | A temporary URL after an authorisation check |
| Public uploads | Scaleway bucket | Your own route, or a temporary URL |

> [!TIP]
> Large uploads pass through the web container on their way to the bucket. Its local disk (`web.tmp-storage`, 1000 MB by default) and its request timeout (`web.timeout`, 60 seconds by default) are set in `brewless.yml`. Raise them when users upload large files.

## A bucket for backups

Backups need the opposite of an uploads bucket: the app should be able to add one, and nothing else. If the app's key could remove backups, so could whoever got hold of that key. The `backup:` key asks for a second, private bucket, in a region of its own:

```yaml brewless.yml
environments:
  production:
    region: fr-par
    storage: shop-production-files
    backup:
      name: shop-production-backups
      region: nl-ams
      expire_days: 35
```

`region` and `expire_days` are required. Without `name`, the bucket is named after the application, the environment and your project, with `-backups`. Brewless then:

- gives the bucket two lifecycle rules: Object Storage removes each object `expire_days` after it was put, and aborts an upload in parts that did not finish within a day;
- makes a write identity whose key the app gets. It may put a backup, also in parts, and nothing more: it cannot read, list or remove;
- makes a read identity for a restore. Its key is kept in your Secret Manager as `<application>-<environment>-backup-read-key` and never goes into the variables;
- puts a bucket policy on the bucket, so every other key of the project, the one of the files bucket included, can neither read nor remove a backup.

The app gets `BACKUP_BUCKET`, `BACKUP_REGION`, `BACKUP_ENDPOINT`, `BACKUP_ACCESS_KEY_ID` and `BACKUP_SECRET_ACCESS_KEY`. Your files disk is left as it is. A disk for them:

```php config/filesystems.php
'backups' => [
    'driver' => 's3',
    'key' => env('BACKUP_ACCESS_KEY_ID'),
    'secret' => env('BACKUP_SECRET_ACCESS_KEY'),
    'region' => env('BACKUP_REGION'),
    'bucket' => env('BACKUP_BUCKET'),
    'endpoint' => env('BACKUP_ENDPOINT'),
    'use_path_style_endpoint' => false,
    'throw' => true,
],
```

> [!WARNING]
> The key may only put. A backup tool that lists the bucket, checks the newest backup or cleans up old ones is refused. Let the lifecycle rule do the cleaning and switch those steps off.

The bucket policy needs identities. When Brewless's key may not manage them, no policy is set, nothing is given to the app, and setting the environment up stops with what to do. To restore, read the read key from Secret Manager in the Scaleway console and use it on your own machine only. See [A bucket for backups](https://docs.brewless.eu/applications/applications-and-environments#a-bucket-for-backups).

## What stays when you change your mind

A deploy never switches a bucket for another one. Another name under `storage` or `backup`, or another backup region, is refused before anything is made. `storage: false` or `backup: false` lets the bucket go from the environment, but the bucket and its files keep running at Scaleway, and your variables keep pointing at them until you change them. Removing data is yours to do, in your own Scaleway console.

## Summary

1. Add `storage:` to the environment and `league/flysystem-aws-s3-v3` to the app.
2. Store through `Storage::`; the `s3` disk and its variables are set for you.
3. Keep the bucket private and hand out files with temporary URLs or your own route.
4. Let bunny.net serve the built frontend, not uploads.
5. Add `backup:` for a write-only bucket in another region, and let `expire_days` remove old backups.

For the rest of a serverless Laravel setup, read [How to host a Laravel app EU-sovereign](/blog/eu-sovereign-laravel-hosting).

## Frequently asked questions

### Does Laravel's s3 disk work with Scaleway Object Storage?

Yes. Scaleway Object Storage is S3-compatible, so the s3 disk only needs a different endpoint. Install league/flysystem-aws-s3-v3 and set the AWS_* variables, which Brewless does for you when it makes the bucket.

### Is the bucket Brewless makes public?

No. Brewless makes a private bucket for the files of an environment. To show a file to a visitor, create a temporary URL with Storage::temporaryUrl or serve it through a route of your own.

### Can the application key reach every bucket in my Scaleway project?

It can read the buckets of the project and read, write and remove the objects in them, because Scaleway grants this per project, not per bucket. It cannot make, change or remove a bucket. The bucket for backups is closed to it by a bucket policy.

### Can my app read or delete its own backups?

No. The key the app gets for the backup bucket may only put objects, also in parts. Reading needs a separate key that Brewless keeps in your Secret Manager and never puts in the app's variables.

### Does Brewless put uploads on bunny.net Storage?

No. With bunny.net in front, a deploy puts the hashed files of your frontend build in a bunny.net storage zone. Uploads from your users go to the Scaleway bucket.

Source: https://test.brewless.eu/blog/laravel-uploads-scaleway-object-storage
