Skip to content

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 ​

ts
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'),
    },
  },
})
ts
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:

bash
npm install @holo-js/storage-s3

When 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:

bash
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 ​

OptionDefaultDescription
driverMust be 's3'
bucketBucket name. Required
region'us-east-1'Bucket region
endpointhttps://s3.<region>.amazonaws.comAPI endpoint. Set this for non-AWS services
accessKeyIdAccess key. Required
secretAccessKeySecret key. Required
sessionTokenSession token for temporary credentials
forcePathStyleEndpointfalseUse endpoint/bucket/key URLs instead of bucket.endpoint/key
visibility'private'Set to 'public' to allow url()
urlBase URL for url(), such as a CDN domain

Cloudflare R2 ​

R2 uses your account endpoint and the region auto:

ts
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:

ts
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:

ts
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'),
},
ts
Storage.disk('assets').url('logos/acme.png')
// https://my-app-assets.s3.us-east-1.amazonaws.com/logos/acme.png

With 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:

ts
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. With overwrite: false, it returns false if 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 ​

Released under the MIT License.