This page sets up where Seed keeps files: the bundled Garage server, or another S3 store such as Hetzner Object Storage.
Seed keeps files in an S3 store. By default, Compose runs Garage, a small S3 server, beside the apps, so a self-hosted product needs no outside service. Moving to another S3 store is a change of settings.
Browsers upload and download with signed URLs. The API signs, the store checks the signature, and no file passes through the API. Note attachments, profile pictures and data exports use it.
The other parts of this guide:
- Add file uploads to a feature: the signed upload, keys and processed images.
- Note attachments and profile pictures: the two features built on it.
This is the default.
Keep
filesinCOMPOSE_PROFILESin the env file.Leave
S3_ENDPOINT,S3_PUBLIC_ENDPOINTandS3_BUCKETempty.On a new clone, write the key and the secrets:
deno task initStart the stack with
deno task dev, or deploy:deno task deploy
Every start creates the key and the bucket from the env file when they are missing. It changes nothing when they exist. A deploy refuses the example's values.
An older env file written before the file store existed lacks the settings, and the API refuses
to start, naming each missing one. Copy the file store block of infra/envs/.env.example into your
env file, then run deno task init again.
- Create a bucket and an access key in the store.
- Take
filesout ofCOMPOSE_PROFILES. - Set the six
S3_*settings below to the store's values. - In the bucket's CORS rules, allow
PUTandGETfromhttps://<DOMAIN>, with thecontent-typeheader. The browser now uploads to another origin. - Have the store answer with
X-Content-Type-Options: nosniffwhere it can. - If the store's origin differs from the app's, allow it in the SPA's
img-srctoo. Otherwise every avatar shows initials.
The store's own backups replace Seed's (backups.md).
| Setting | Bundled Garage (default) | Another S3 store |
|---|---|---|
COMPOSE_PROFILES |
includes files |
without files |
S3_ENDPOINT |
empty: http://<PROJECT>-garage:3900 |
the store's address |
S3_PUBLIC_ENDPOINT |
empty: https://<DOMAIN> (http:// in dev) |
the same address |
S3_REGION |
garage |
the store's region |
S3_BUCKET |
empty: <PROJECT>-files |
the bucket's name |
S3_ACCESS_KEY_ID |
GK and 24 hex characters |
the store's key |
S3_SECRET_ACCESS_KEY |
64 hex characters | the store's secret |
GARAGE_RPC_SECRET |
64 hex characters | any value but the example's |
The API reaches the store at S3_ENDPOINT. It signs browser URLs for S3_PUBLIC_ENDPOINT, since a
signature covers the host. Both use path-style addresses: <endpoint>/<bucket>/<key>.
Whatever the plan, one person signs at most 50 uploads and 50 MiB an hour. Note attachments and
profile pictures count together, confirmed or not. More is refused with UPLOAD_BUDGET, a 429 over
HTTP. This stops one account, a guest of the public demo included, from filling the server's disk.
| Variable | What it does | Default |
|---|---|---|
ATTACHMENT_UPLOAD_FILES_PER_HOUR |
Uploads one person signs an hour | 50 |
ATTACHMENT_UPLOAD_BYTES_PER_HOUR |
Bytes one person signs an hour | 52428800 (50 MiB) |
Both take whole numbers; leave them empty for the defaults. Each account has its own budget. One client address can start 5 guest accounts an hour, so a stranger can sign 250 uploads and 250 MiB in the first hour from one address, and more as the accounts pile up.
The plan's caps only limit what a group keeps (attachments).
- One node. Its data is in
.volumes/garage, andinfra/configs/garage.tomlis its configuration. - Browsers reach it on the app's own host. Traefik sends
https://<DOMAIN>/<bucket>/…to Garage and everything else to the SPA. So uploads are same-origin, and the bucket needs no CORS rules. - Every answer on that path carries
Content-Security-Policy: sandbox; default-src 'none'andX-Content-Type-Options: nosniff. A browser then never runs a file as a page of the app. - The bucket is private: every request needs a signature, and only the API holds the key. Garage's admin port is not published.
- Backups: backups.md.
A new S3_ACCESS_KEY_ID is added next to the old key, which keeps its access. To retire the old
one:
deno task compose exec garage /garage key delete --yes <old key id>
/api/health and the admin app's System page report whether the API reaches the store. A store
that does not answer leaves the API up, and health says degraded. Only uploads and downloads
fail.
To test the store end to end on your machine:
deno task test:files
It starts a Garage container of its own and uploads, confirms, downloads and deletes a file through
signed URLs. It also checks that the store refuses a larger body, another type, and an HTML page
sent as a PNG. CI does not run it, since CI's steps have no container tool.
libs/server/files/files.test.ts checks the rules against a fake store, in the usual tests.