Amazon S3 and Compatible Services
The s3 driver stores files in an Amazon S3 bucket, or in any service that speaks the S3 API, such as Cloudflare R2 or MinIO. Once the disk is configured, you use it with the same Storage methods as a local disk.
Quick example
import { defineStorageConfig } from '@holo-js/storage'
import { env } from '@holo-js/config'
export default defineStorageConfig({
disks: {
s3: {
driver: 's3',
bucket: env('STORAGE_DISKS_S3_BUCKET'),
region: env('STORAGE_DISKS_S3_REGION', 'us-east-1'),
accessKeyId: env('STORAGE_DISKS_S3_ACCESS_KEY_ID'),
secretAccessKey: env('STORAGE_DISKS_S3_SECRET_ACCESS_KEY'),
},
},
})import { Storage } from '@holo-js/storage/runtime'
export async function uploadBackup(contents: Uint8Array) {
await Storage.disk('s3').put('backups/latest.sql', contents)
}Install
The S3 driver lives in its own package, @holo-js/storage-s3. Projects created with the storage package already include it, along with an s3 disk in config/storage.ts.
In other projects, install it with your package manager:
npm install @holo-js/storage-s3When config/storage.ts has an s3 disk, npx holo dev and npx holo prepare add @holo-js/storage-s3 to your dependencies if it is missing. If the package isn't installed when the app starts, Holo stops with an error telling you to install it.
Set your credentials
Put credentials in .env, never in config/storage.ts:
STORAGE_DISKS_S3_BUCKET=my-app-uploads
STORAGE_DISKS_S3_REGION=eu-west-1
STORAGE_DISKS_S3_ACCESS_KEY_ID=AKIA...
STORAGE_DISKS_S3_SECRET_ACCESS_KEY=...STORAGE_DISKS_S3_* variables also override the disk's config directly, even if config/storage.ts doesn't read them with env(). See environment overrides.
Holo signs requests with the access key you configure. It doesn't read credentials from ~/.aws or an instance role. For temporary credentials, also set sessionToken (STORAGE_DISKS_S3_SESSION_TOKEN).
An s3 disk with missing settings doesn't stop the app from starting. The first read or write on that disk throws an error naming the missing option, so the disk in a new project's config is harmless until you use it.
Options
| Option | Default | Description |
|---|---|---|
driver | Must be 's3' | |
bucket | Bucket name. Required | |
region | 'us-east-1' | Bucket region |
endpoint | https://s3.<region>.amazonaws.com | API endpoint. Set this for non-AWS services |
accessKeyId | Access key. Required | |
secretAccessKey | Secret key. Required | |
sessionToken | Session token for temporary credentials | |
forcePathStyleEndpoint | false | Use endpoint/bucket/key URLs instead of bucket.endpoint/key |
visibility | 'private' | Set to 'public' to allow url() |
url | Base URL for url(), such as a CDN domain |
Cloudflare R2
R2 uses your account endpoint and the region auto:
r2: {
driver: 's3',
bucket: env('R2_BUCKET'),
region: 'auto',
endpoint: env('R2_ENDPOINT'),
accessKeyId: env('R2_ACCESS_KEY_ID'),
secretAccessKey: env('R2_SECRET_ACCESS_KEY'),
forcePathStyleEndpoint: true,
},Set R2_ENDPOINT to https://<account-id>.r2.cloudflarestorage.com.
MinIO and other self-hosted services
Self-hosted services usually need path-style URLs, because the bucket isn't a subdomain:
minio: {
driver: 's3',
bucket: 'uploads',
region: 'us-east-1',
endpoint: env('MINIO_ENDPOINT', 'http://localhost:9000'),
accessKeyId: env('MINIO_ACCESS_KEY'),
secretAccessKey: env('MINIO_SECRET_KEY'),
forcePathStyleEndpoint: true,
},Public files
url() only works when the disk has visibility: 'public'. Holo builds the URL from the endpoint and bucket:
assets: {
driver: 's3',
visibility: 'public',
bucket: 'my-app-assets',
region: 'us-east-1',
accessKeyId: env('STORAGE_DISKS_ASSETS_ACCESS_KEY_ID'),
secretAccessKey: env('STORAGE_DISKS_ASSETS_SECRET_ACCESS_KEY'),
},Storage.disk('assets').url('logos/acme.png')
// https://my-app-assets.s3.us-east-1.amazonaws.com/logos/acme.pngWith forcePathStyleEndpoint: true, the same call returns https://s3.us-east-1.amazonaws.com/my-app-assets/logos/acme.png. If you serve the bucket through a CDN or a custom domain (for example an R2 public bucket), set url and Holo uses it as the base instead.
WARNING
visibility: 'public' only tells Holo it may build public URLs. It doesn't change the bucket's permissions. Allow public reads in your provider's bucket policy, or the URLs return access denied.
Temporary URLs
For private buckets, create a pre-signed URL:
const url = Storage.disk('s3').temporaryUrl('invoices/1001.pdf', {
expiresIn: 900,
})The link lasts 5 minutes by default and at most 7 days, which is the S3 limit. It needs accessKeyId, secretAccessKey, and region. Holo signs it locally, so creating one doesn't make a network request.
temporaryUrl() calls createS3TemporaryUrl(disk, path, options) under the hood. That function is exported from @holo-js/storage/runtime if you already hold a resolved disk config, but app code should call Storage.disk(name).temporaryUrl() instead.
What works on S3
Every Storage method works on S3 disks, including:
stat(), which makes one request that reads the object's size and its first 64 KiB, and detects the type the same way as on local disks. The object's stored content type is ignored.listFiles(), which returns one page of S3 keys per request.readStream(), which downloads the object in chunks as you read them.writeStream(), which uses a multipart upload, so large files don't need to fit in memory. It holds at most one part in memory: parts start at 5 MiB and grow to at most 64 MiB for very long streams, so one stream can upload up to about 620 GiB. Withoverwrite: false, it returnsfalseif the object already exists.clear(), which lists 1,000 keys at a time and runs at most 8 deletes at once.
path() returns an s3://bucket/key string.
File names
Holo stores each file under its exact name, so Storage.disk('s3').put('reports/Q1 café.pdf', pdf) creates the object reports/Q1 café.pdf. url() and temporaryUrl() encode the name once, so links work for names with spaces, accents, %, or other special characters.
Content types
Holo sets Content-Type on each object it writes, whether it comes from put(), putJson(), copy(), move(), or writeStream(). The type comes from the file's first 64 KiB, then from its extension, the same detection stat() uses. A file with no recognized type is stored as application/octet-stream, and so is a file whose extension names a type Holo can detect, such as .png, when its bytes don't match that type.
Document types a browser renders, such as HTML, SVG, and XML, are also stored as application/octet-stream. A public bucket or CDN then serves them as downloads, as local public disks do. Objects written before this change keep the content type they already have.
Timeouts
Every S3 request fails if it gets no response within 30 seconds. Uploads get 1 extra second for every 128 KiB they send. While you read a download, Holo stops it if S3 sends no data for 30 seconds. Time your code spends between reads doesn't count, so a slow consumer of readStream() never trips the timeout.
Next steps
- File Storage: every storage method
- Media Library: store model uploads and image conversions on S3
- Production checklist