---
title: "Files"
description: "Uploads and file storage with ablage, in the app's own bucket."
canonical_url: "https://wervt.app/guides/files"
---
# Files

> Uploads and file storage with ablage, in the app's own bucket.

Apps store files with [ablage](https://github.com/Niki2k1/ablage). On wervt every app gets its own
bucket in the stack's Garage (S3-compatible, self-hosted), and only that app can read or write it.
`@wervt/nuxt` connects ablage to it, so there's nothing to configure.

```bash
pnpm add ablage
```

```ts [nuxt.config.ts]
export default defineNuxtConfig({
  modules: ['@wervt/nuxt', 'ablage'],
})
```

```ts [server/api/avatars.post.ts]
import { defineEventHandler } from 'nuxt/server'
import { readUploadedFile, useFileStorage } from '#imports'

export default defineEventHandler(async (event) => {
  const upload = await readUploadedFile(event, { types: ['image'], maxSize: '5MB' })
  return useFileStorage().put('avatars', upload.data, {
    contentType: upload.type,
    name: upload.name,
  })
})
```

```ts [server/api/avatars/[id].ts]
import { defineEventHandler } from 'nuxt/server'
import { sendStoredFile } from '#imports'

// Without a method suffix, so HEAD and Range requests reach it too.
export default defineEventHandler((event) =>
  sendStoredFile(event, { group: 'avatars', id: event.context.params!.id }),
)
```

`put`, `get`, `head`, `list`, `remove` and the rest of ablage's API work as documented there.
`nuxt dev` creates the app's bucket in the local stack's Garage. Needs ablage 0.1.1 or newer.

## Large files: straight to the bucket

Routes like the ones above pass the bytes through the app's isolate, which has 150 MB of memory
and 5 minutes per request, so keep them to a few tens of MB. For anything bigger, such as video,
let browsers talk to the bucket directly at `media.<domain>`:

- **Downloads:** `signedUrl()` returns a presigned link to the bucket, valid for `expiresIn`
seconds (up to 7 days). The bucket serves the bytes and `Range` requests.
- **Uploads:** three small routes start, finish and abort an upload, and `useDirectUpload()` sends
the file: in one `PUT`, or as a multipart upload in parallel parts for large files.

```ts [server/api/videos.get.ts]
import { defineEventHandler } from 'nuxt/server'
import { useFileStorage } from '#imports'

export default defineEventHandler(async () => {
  const storage = useFileStorage()
  const { objects } = await storage.list('videos', { limit: 50 })
  return Promise.all(
    objects.map(async (file) => ({
      name: file.name,
      url: await storage.signedUrl(file, { expiresIn: 3600 }),
    })),
  )
})
```

```ts [server/api/uploads/start.post.ts]
import { defineEventHandler, readBody } from 'nuxt/server'
import { useFileStorage } from '#imports'

export default defineEventHandler(async (event) => {
  const { name, type, size } = await readBody(event)
  // Check who may upload here; the size and type are checked before anything is signed.
  return useFileStorage().createUpload('videos', {
    name,
    contentType: type,
    size,
    maxSize: '2GB',
    types: ['video'],
  })
})
```

```ts [server/api/uploads/complete.post.ts]
import { defineEventHandler, readBody } from 'nuxt/server'
import { useFileStorage } from '#imports'

export default defineEventHandler(async (event) => {
  const { token, parts } = await readBody(event)
  return useFileStorage().completeUpload(token, { parts })
})
```

```ts [server/api/uploads/abort.post.ts]
import { defineEventHandler, readBody } from 'nuxt/server'
import { useFileStorage } from '#imports'

export default defineEventHandler(async (event) => {
  await useFileStorage().abortUpload((await readBody(event)).token)
  return null
})
```

```vue [app/pages/upload.vue]
<script setup lang="ts">
const uploads = useDirectUpload({
  start: '/api/uploads/start',
  complete: '/api/uploads/complete',
  abort: '/api/uploads/abort',
})
</script>

<template>
  <input type="file" @change="uploads.add([...($event.target as HTMLInputElement).files!])" />
  <p v-for="item in Object.values(uploads.items)" :key="item.file.name">
    {{ item.file.name }}: {{ item.progress.toFixed(0) }}% {{ item.error }}
  </p>
</template>
```

A file only shows up in `list()` once `completeUpload()` has checked its size. The bucket accepts
uploads from the app's own origin (CORS), and unfinished multipart uploads are removed after a day.

## Not in isolates

ablage's tus uploads and its local image processing (sharp) don't work in isolates: tus stages
files on disk, and sharp is a native module. Use direct uploads instead of tus.

## Under the hood

- The bucket is `app-<name>`, with an S3 key that only works on it. Both are created on deploy.
The key is derived from `WERVT_DB_SECRET` like the database password, and passed to the app as
`WERVT_S3_*`.
- `wervt delete <app> --drop-database` also deletes the bucket, its files and unfinished uploads;
without the flag they're kept, like the database. The key stays, without access to anything, so
an app created again under the same name gets storage again.
- Signed links and direct uploads go to `media.<domain>` (`WERVT_S3_PUBLIC_URL`), which serves the
bucket straight from the server, not through Cloudflare.
- Files are backed up nightly; see [Backups](https://wervt.app/self-hosting/backups#files-garage).


## Sitemap

See the full [sitemap](https://wervt.app/sitemap.md) for all pages.
