Skip to content

Storage

Included free on every install, S3, MinIO, Google Cloud Storage and Azure Blob included.

Object storage decides where your files live. Media uploads, data exports and every other file the instance keeps go through it. Out of the box files go to local disk. Add a provider to send them to Amazon S3, an S3-compatible store, Google Cloud Storage or Azure Blob, and clients can then upload large files straight to the bucket.

PieceWhat it does
Local diskThe default, under STORAGE_LOCAL_PATH. Used while no provider is configured.
ProvidersBuckets or containers you add. A tenant with providers of its own uses only those. Other tenants use the instance's providers, the ones configured in the default tenant.
Choice per writeA write goes to the healthy provider with the lowest cost_weight above 0. When no provider sets one, the lowest priority wins. Health is checked every 30 seconds.
ReadsA read looks in every provider the file may be on.
Tenant prefixEach tenant's files sit under tenant_<slug>/, so tenants never overwrite each other in a shared bucket.

You need an admin token for a super admin in TOKEN. The quickstart shows how to get one.

  1. See where files go now:

    Terminal window
    curl http://localhost:3001/api/admin/storage/health \
    -H "Authorization: Bearer $TOKEN"
    {"providers": [{"provider_id": "20d35ec0-bd5c-53a3-8111-b122a0d7ba4e", "provider_type": "local", "provider_name": "local-default", "status": "healthy", "latency_ms": 0.07, "last_check": "2026-10-01T09:17:20Z", "failures": 0}]}

    With no provider configured, files go to local disk.

  2. Ask for a presigned upload. Local disk cannot presign, so this answers 501 until a bucket is added:

    Terminal window
    curl -X POST http://localhost:3001/api/admin/storage/presign-upload \
    -H "Authorization: Bearer $TOKEN" \
    -H "Content-Type: application/json" \
    -d '{"key": "videos/intro.mp4", "content_type": "video/mp4", "content_length": 73400320}'
  3. Add your S3 bucket:

    Terminal window
    curl -X POST http://localhost:3001/api/admin/storage/providers \
    -H "Authorization: Bearer $TOKEN" \
    -H "Content-Type: application/json" \
    -d '{
    "name": "primary",
    "provider_type": "s3",
    "bucket": "acme-media",
    "region": "eu-west-1",
    "access_key": "<access key id>",
    "secret_key": "<secret access key>",
    "use_ssl": true,
    "enabled": true
    }'

    The answer is 201 with the provider, without its credentials. Copy its id into PROVIDER_ID.

  4. Check that the instance can reach it:

    Terminal window
    curl -X POST http://localhost:3001/api/admin/storage/providers/$PROVIDER_ID/test \
    -H "Authorization: Bearer $TOKEN"
    {"healthy": true, "latency_ms": 41.2, "provider_id": "...", "provider_name": "primary", "provider_type": "s3"}

    When the bucket refuses, healthy is false and error says why, such as s3: status 403 Forbidden.

  5. Repeat step 2. The answer now carries an upload_url on your bucket.

  6. Open Settings > Storage in the admin console to see and edit the providers.

FieldMeaningDefault
nameLabel.required
provider_types3, minio, gcs, azure_blob or local.required
bucketBucket or container.
regionRegion.
endpointEndpoint URL, for MinIO and other S3-compatible stores.
access_key, secret_keyCredentials. Stored encrypted and never returned. On update, leave them out to keep the stored ones.
use_sslUse TLS.false
path_stylePath-style bucket addressing, which MinIO needs.false
cdn_base_urlPrefix for public file URLs.
cost_weightRelative cost from 0 to 1. The lowest value above 0 is chosen first, and 0 means not set.0
priorityLower is preferred when no provider sets a cost_weight.0
enabledWhether the provider is used.false
local_public_unsignedLocal providers only. See below.false

Only a super admin can add, change or remove a provider. Set ENCRYPTION_KEY to encrypt stored credentials with a key per tenant. An endpoint that resolves to a private, loopback, link-local or cloud metadata address is refused, so a store on your private network cannot be used.

A tenant with a region writes only to providers in that region, on every install. The region comes from the tenant's data residency assignment when that feature is installed, so the assignment you make there is the pin. STORAGE_TENANT_REGIONS is the fallback, for an install without data residency or a tenant its assignment names no region for:

Terminal window
STORAGE_TENANT_REGIONS="acme=eu-west-1, globex=us-east-1"
  • Every write the tenant makes, an upload, a presigned or multipart upload or a listing, goes only to a provider whose region equals the pinned one. Among those, the usual choice by cost and priority applies.
  • When none of the tenant's providers is in the region, the write answers 409 with no storage provider is configured in this tenant's data residency region. The local fallback has no region, so it never takes a pinned tenant's files.
  • When the tenant's assignment cannot be read, the upload answers 503 with cannot determine this tenant's data residency region rather than land anywhere. STORAGE_TENANT_REGIONS is not used in its place, because it may disagree with the assignment.
  • Reads still try every provider the tenant may use, so a file written before the pin stays readable.
  • The assignment and the setting are read on every write, so a change applies to the next one. A tenant neither one names chooses providers as before.

The pin holds whatever the license says, so a lapse never lets a pinned tenant's next file leave its region.

Any store that speaks the S3 API works through provider_type: "s3" or "minio":

StoreendpointNotes
Amazon S3Leave it out.Regional virtual-host addressing.
MinIOYour MinIO host, at a public address.Set path_style: true.
Cloudflare R2The R2 account endpoint.
Another S3-compatible storeThe store's endpoint.Any store with an S3 API and keys.

Upload and download a test file before you rely on a new store.

If STORAGE_S3_BUCKET is set when the instance starts and finds no providers at all, it creates a provider named engine-config from the STORAGE_S3_* settings, so a bucket you were already using keeps receiving files. Without STORAGE_S3_KEY and STORAGE_S3_SECRET, the machine's own credentials, such as an instance role, are used. The variables are read on that one start only. After that, edit the provider.

A file on a local provider is served at /api/v1/storage/local/{provider}?key=<key>&expires=<time>&sig=<signature>. The link is signed and expires, after 15 minutes unless the caller asks for longer, so it works without a sign-in and cannot be edited to reach another file. An edited or expired link answers 403, and a missing file 404. Range requests work.

A local provider with local_public_unsigned: true hands out plain cdn_base_url plus the key instead, with no signature and no expiry. Anyone with the link can read the file for as long as it exists. Use it only when a CDN serves the directory anyway. Only a super admin can set it, and only on a local provider.

A client uploads straight to S3, MinIO, Google Cloud Storage or Azure Blob with a presigned URL, without sending the file through the instance:

{
"upload_url": "https://acme-media.s3.eu-west-1.amazonaws.com/tenant_acme/videos/intro.mp4?X-Amz-Signature=<signature>",
"key": "tenant_acme/videos/intro.mp4",
"expires_in": 900
}

PUT the file to upload_url before it expires.

  • content_length locks the URL to that size. It is required unless the operator sets MAX_PRESIGN_UPLOAD_BYTES, which then also caps it.
  • ttl_seconds defaults to 3600 and is capped by MAX_PRESIGN_UPLOAD_TTL_SECONDS, 15 minutes by default.
  • Content types a browser would run, such as text/html, JavaScript, XML and image/svg+xml, are refused.
  • With no provider available the route answers 503. A local provider answers 501.

For a very large file, use a multipart upload through the instance:

  1. multipart/init with key and content_type returns an upload_id and the prefixed key.
  2. Each multipart/part call sends one part's bytes as the request body, with key, upload_id and part_number (1 to 10000) in the query string. The answer carries the part_number, the etag and the size read.
  3. multipart/complete takes key, upload_id and the list of parts, each with its part_number and etag. multipart/abort cancels.
Terminal window
curl -X POST "http://localhost:3001/api/admin/storage/multipart/part?key=tenant_acme/videos/intro.mp4&upload_id=$UPLOAD_ID&part_number=1" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/octet-stream" \
--data-binary @intro.mp4.part1
{"part_number": 1, "etag": "<etag>", "size": 5242880}

A part is at most 5 GiB, or MAX_PRESIGN_UPLOAD_BYTES when the operator sets it. A larger part answers 413, including a chunked request that runs past the cap, and an empty body answers 400.

VariableWhat it doesDefault
STORAGE_LOCAL_PATHDirectory for local storage. Mount a volume here../uploads
STORAGE_S3_BUCKET, STORAGE_S3_REGION, STORAGE_S3_ENDPOINT, STORAGE_S3_KEY, STORAGE_S3_SECRET, STORAGE_S3_USE_SSL, STORAGE_S3_FORCE_PATH_STYLE, STORAGE_S3_CDN_BASE_URLCreate the first provider on a start that finds none. The region defaults to us-east-1 and TLS to on.unset
ENCRYPTION_KEYEncrypts stored provider credentials per tenant.unset
STORAGE_TENANT_REGIONSComma-separated tenant=region pairs that pin each tenant's writes to providers in that region, for a tenant no data residency assignment names a region for. Read on every write.unset
MAX_PRESIGN_UPLOAD_BYTESLargest presigned upload, and largest multipart part when below 5 GiB. 0 makes content_length required and leaves parts at 5 GiB.0
MAX_PRESIGN_UPLOAD_TTL_SECONDSLongest presigned upload URL. Longer requests are shortened.900
MAX_PRESIGN_DOWNLOAD_TTL_SECONDSLongest signed download link.86400
STORAGE_LOCAL_SIGNING_SECRETKey that signs local download links. When unset it is derived from the instance's JWT secret. Set it to rotate local links without ending sessions.derived
LYEVE_BASE_URLPrefix of signed local links. Unset gives a link relative to the site root.unset
STORAGE_BASE_URLPrefix for unsigned local links when a provider has no cdn_base_url.unset

While this feature runs, which is the default, STORAGE_DRIVER changes nothing: the providers decide where files go. If you set LYEVE_PLUGINS to choose which features start, include storage. See Licensing and tiers.

Every storage route
MethodPathRolePurpose
GET/api/admin/storage/providersadminList providers, paginated.
POST/api/admin/storage/providerssuper_adminAdd a provider.
GET/api/admin/storage/providers/{id}adminFetch one provider.
PUT/api/admin/storage/providers/{id}super_adminChange a provider.
DELETE/api/admin/storage/providers/{id}super_adminRemove a provider.
POST/api/admin/storage/providers/{id}/testadminCheck a provider and report its latency.
GET/api/admin/storage/healthadminHealth of every provider.
POST/api/admin/storage/presign-uploadadminGet a presigned upload URL.
POST/api/admin/storage/multipart/initadminStart a multipart upload.
POST/api/admin/storage/multipart/partadminUpload one part.
POST/api/admin/storage/multipart/completeadminFinish a multipart upload.
POST/api/admin/storage/multipart/abortadminCancel a multipart upload.
GET, HEAD/api/v1/storage/local/{provider}none, the signature is the credentialDownload a file from a local provider.
StatusMessageCause
400content_length is required (no server-side max configured)Send content_length, or set MAX_PRESIGN_UPLOAD_BYTES.
400storage: endpoint SSRF blocked: ...The endpoint resolves to a private or internal address.
501failed to generate presigned upload URLThe chosen provider is local, which cannot presign.
Every other error
StatusMessageCause
400name is required or unknown provider type: <type>A provider field is missing or wrong.
400content type "<type>" is not allowed: executable in browser contextThe upload type could run in a browser.
400key and upload_id are requiredA part upload left out key or upload_id in the query string.
400part_number must be an integer from 1 to 10000The part number is missing or out of range.
400part body is emptyA part upload sent no bytes.
403local_public_unsigned requires super_adminOnly a super admin can make local links public.
403tenant context required for storage operationsThe request named no tenant.
409no storage provider is configured in this tenant's data residency regionThe tenant is pinned to a region none of its providers is in.
503cannot determine this tenant's data residency regionThe tenant's residency assignment could not be read, so the upload is refused.
413part exceeds server-side max <n> bytesThe part is larger than 5 GiB or MAX_PRESIGN_UPLOAD_BYTES.
501failed to upload multipart partThe chosen provider is local, which has no multipart upload.