Agora
Storage

S3 Disk

The bundled disks.s3() driver — an S3 (or S3-compatible) disk with extended operations (copy/move/deleteMany/list/size/stat), native multipart, and presigned URLs. The AWS SDK is an optional, lazily-imported peer.

Media delegates storage to @adonisjs/drive, so local / s3 / gcs Drive disks work out of the box. On top of that, the package bundles its own S3 driver — disks.s3() — a first-class Disk that also implements the richer add-on surfaces the direct-upload, resumable, and dashboard features lean on: native multipart, prefix listing, cross-bucket copy/move, batched deletes, and cheap metadata. The AWS SDK stays an optional peer, imported lazily only when an S3 disk is actually selected.

You don't need this driver to use S3 through Drive. Reach for disks.s3() when you want the extended operations (copy / move / deleteMany / list / size / stat) and direct-to-S3 multipart uploads that a plain Drive disk doesn't expose. See Upload modes and the Dashboard.


Install the peer

The driver needs one optional peer. Install it only if you use an S3 disk:

npm i @aws-sdk/client-s3

It is declared optional in peerDependenciesMeta, so nothing is pulled in unless you configure disks.s3() and the provider builds it at boot. Presigned URLs need no extra package: SigV4 query signing is implemented in the library itself (pure node:crypto), which is also what makes the split internal/public endpoint below possible.


Configure

disks is a top-level key on defineConfig, parallel to stores and processors: a name → factory map. Select one with the top-level disk (or a per-collection / per-attach disk). A name in disks takes precedence over a Drive disk of the same name; any name not found here falls through to Drive.

config/media.ts
import { defineConfig, disks, stores } from '@adonis-agora/media'

export default defineConfig({
  disk: 's3',
  disks: {
    s3: disks.s3({
      bucket: 'my-bucket',
      region: 'us-east-1',
      publicBaseUrl: 'https://cdn.example.com', // stable public URLs via getUrl()
    }),
  },
  store: 'lucid',
  stores: { lucid: stores.lucid() },
})

disks.s3(config) options

KeyTypeDefaultWhat it controls
bucketstring— (required)Target bucket.
regionstringSDK defaultAWS region, e.g. us-east-1.
credentials{ accessKeyId, secretAccessKey, sessionToken? }SDK provider chainStatic credentials. Omit to use env vars / shared config / IAM role.
endpointstringCustom endpoint for S3-compatible services (MinIO, Cloudflare R2, DigitalOcean Spaces, …).
publicEndpointstringEndpoint presigned URLs are signed against, when it differs from endpoint. Presigned URLs are consumed by the browser and SigV4 bakes the host into the signature — so when endpoint is an internal FQDN the internet can't resolve, set the public one here. Server-side operations keep using endpoint.
forcePathStylebooleanfalsePath-style addressing (endpoint/bucket/key) — required by most S3-compatible services.
keyPrefixstringPrefix prepended to every key (e.g. uploads).
publicBaseUrlstringBase URL for stable public URLs (a CDN or the bucket website), used by getUrl.
visibility'public' | 'private'privateWhether objects are readable without credentials. Declarative — never applied as an ACL, never probed. Read by delivery.mode: 'auto'.

Each disks.s3(...) returns a lazy thunk. Calling it in the config file costs nothing; @aws-sdk/client-s3 is imported only when the provider builds the selected disk at boot — so you can list an S3 disk in the map even where the AWS SDK isn't installed, as long as you don't select it.

S3-compatible services

For MinIO / R2 / Spaces, set endpoint and (usually) forcePathStyle: true. When the app reaches MinIO through a private network but the browser must consume presigned URLs (direct uploads, signed reads), add publicEndpoint:

disks.s3({
  bucket: 'media',
  endpoint: 'http://minio.internal:9000',        // server-side operations
  publicEndpoint: 'https://files.example.com',   // what presigned URLs are signed for
  forcePathStyle: true,
  credentials: { accessKeyId: '…', secretAccessKey: '…' },
})

The base Disk contract

Like any Drive disk, S3Disk satisfies the structural Disk contract, so it works everywhere the library already writes:

  • put / putStream / getBytes / getStream / exists / delete / getMetaData
  • getUrl(key) — a stable public URL from publicBaseUrl (or a synthesised virtual-hosted / path-style URL).
  • getVisibility(key) — the configured visibility, verbatim. S3 can't be asked cheaply (a GetObjectAcl per read isn't free, and object ACLs say nothing about a bucket policy), so it reports what the disk was told. Read only by delivery.mode: 'auto'.
  • getSignedUrl(key, { expiresIn }) — a time-limited signed read URL (hand-rolled SigV4 query signing, no SDK presigner; signed against publicEndpoint when set). expiresIn accepts Drive's human duration strings ('30m', '1h', '45s', '2d') or a number of seconds; the default is 30 minutes.

So media.library.url(id) and media.library.signedUrl(id, '1h') resolve through the S3 disk with no extra wiring.


Extended operations

S3Disk also implements the optional ExtendedDisk surface — richer object operations that the base contract deliberately leaves out (the in-memory / Drive disks don't provide them). The library detects it structurally at runtime with isExtendedDisk(disk); you can reach the raw disk with media.disk('s3').

const s3 = media.disk('s3')

if (isExtendedDisk(s3)) {
  await s3.copy('a/photo.jpg', 'b/photo.jpg')                 // server-side copy
  await s3.move('b/photo.jpg', 'c/photo.jpg')                 // copy then delete source
  await s3.copy('a/x.jpg', 'x.jpg', { toBucket: 'archive' })  // cross-bucket
  await s3.deleteMany(['a/1.jpg', 'a/2.jpg', 'a/3.jpg'])      // batched delete
  await s3.size('a/photo.jpg')                                // bytes, no body download
  await s3.stat('a/photo.jpg')                                // { size, contentType, lastModified }

  const page = await s3.list('uploads/', { delimiter: '/', limit: 100 })
  page.folders  // sub-prefixes (each ends in the delimiter), from CommonPrefixes
  page.files    // [{ key, name, sizeBytes, lastModified }]
  page.cursor   // present when truncated — pass back as options.cursor
}
MethodReturnsNotes
copy(from, to, { toBucket? })voidServer-side CopyObject; optional cross-bucket.
move(from, to, { toBucket? })voidCopy then delete the source.
deleteMany(keys)voidDeleteObjects in as few round-trips as possible; empty array is a no-op.
list(prefix, options?)ListResultCursor-paginated ListObjectsV2; rolls deeper keys into folders via delimiter (default '/').
size(key)numberObject size from HeadObject.
stat(key)DiskStat{ size, contentType?, lastModified? } from HeadObject.
capabilitiesDiskCapabilities{ presign, multipart, publicUrls, list } — a coarse feature descriptor.

list tolerates S3-compatible services that return non-standard XML: when the SDK's entity deserialization trips, the driver falls back to parsing the ListObjectsV2 XML directly, so listing keeps working on MinIO / R2 and friends.


Native multipart

S3Disk implements the optional MultipartUploadDisk surface (createMultipartUpload / uploadPart / presignUploadPart / completeMultipartUpload / abortMultipartUpload), detected with isMultipartCapable(disk). This is what powers the direct upload mode and native-multipart resumable uploads — see Upload modes and Resumable / TUS. You rarely call these directly; the UploadManager and ResumableUploadManager drive them for you.


The presigner, on its own

Every signed URL this driver hands out — read URLs, multipart part URLs — comes from presignS3Url, a hand-rolled SigV4 query presigner written against node:crypto. No AWS presigner package is involved, which is what keeps presigning available even though @aws-sdk/client-s3 is an optional peer, and what makes the endpoint / publicEndpoint split possible at all.

It is exported, for signing a URL outside the disk — a bucket the media config doesn't know about, an operation with no method here, a URL minted by a service that isn't this app:

import { presignS3Url } from '@adonis-agora/media'
import type { PresignS3UrlInput, SigV4Credentials } from '@adonis-agora/media'

const url = presignS3Url({
  method: 'GET',
  protocol: 'https:',
  host: 'files.example.com',
  path: '/media/reports/q3 summary.pdf', // UNENCODED — encoded here, exactly once
  query: { 'response-content-disposition': 'attachment; filename="q3.pdf"' },
  credentials: { accessKeyId: '…', secretAccessKey: '…' },
  region: 'us-east-1',
  expiresInSeconds: 300,
})
FieldNotes
methodThe HTTP method the URL authorizes (GET, PUT, …).
protocol / host / pathGiven separately, never as a pre-built URL. The signature covers host and path, and both must be byte-identical between what was signed and what the client requests.
queryExtra parameters signed into the URL (uploadId, partNumber, response-content-type, …).
credentialsSigV4CredentialsaccessKeyId, secretAccessKey, and an optional STS sessionToken.
region / serviceSigning region (must match what the endpoint expects — us-east-1 for MinIO defaults) and service (default s3).
expiresInSecondsWhole seconds. SigV4 caps this at 7 days.
nowInjectable signing time, so a test can pin the exact signature.

The separate host / path is the part worth understanding. The presigner encodes the raw path exactly once and reuses that single encoding for both the canonical request and the returned URL, so the two can never drift. It also implements SigV4's own percent-encoding rather than reaching for encodeURIComponent, which leaves !'()* bare — any of those in an object key would make the canonical request disagree with what S3 reconstructs server-side, producing a signature mismatch that bites on exactly one file and no others.


Import the class directly

Select the disk lazily with disks.s3() in config, or import the class from the subpath if you want to construct one by hand (e.g. in a test with a mocked S3Client):

import { S3Disk } from '@adonis-agora/media/disks/s3'

Next steps

On this page