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-s3It 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.
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
| Key | Type | Default | What it controls |
|---|---|---|---|
bucket | string | — (required) | Target bucket. |
region | string | SDK default | AWS region, e.g. us-east-1. |
credentials | { accessKeyId, secretAccessKey, sessionToken? } | SDK provider chain | Static credentials. Omit to use env vars / shared config / IAM role. |
endpoint | string | — | Custom endpoint for S3-compatible services (MinIO, Cloudflare R2, DigitalOcean Spaces, …). |
publicEndpoint | string | — | Endpoint 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. |
forcePathStyle | boolean | false | Path-style addressing (endpoint/bucket/key) — required by most S3-compatible services. |
keyPrefix | string | — | Prefix prepended to every key (e.g. uploads). |
publicBaseUrl | string | — | Base URL for stable public URLs (a CDN or the bucket website), used by getUrl. |
visibility | 'public' | 'private' | private | Whether 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/getMetaDatagetUrl(key)— a stable public URL frompublicBaseUrl(or a synthesised virtual-hosted / path-style URL).getVisibility(key)— the configuredvisibility, verbatim. S3 can't be asked cheaply (aGetObjectAclper read isn't free, and object ACLs say nothing about a bucket policy), so it reports what the disk was told. Read only bydelivery.mode: 'auto'.getSignedUrl(key, { expiresIn })— a time-limited signed read URL (hand-rolled SigV4 query signing, no SDK presigner; signed againstpublicEndpointwhen set).expiresInaccepts 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
}| Method | Returns | Notes |
|---|---|---|
copy(from, to, { toBucket? }) | void | Server-side CopyObject; optional cross-bucket. |
move(from, to, { toBucket? }) | void | Copy then delete the source. |
deleteMany(keys) | void | DeleteObjects in as few round-trips as possible; empty array is a no-op. |
list(prefix, options?) | ListResult | Cursor-paginated ListObjectsV2; rolls deeper keys into folders via delimiter (default '/'). |
size(key) | number | Object size from HeadObject. |
stat(key) | DiskStat | { size, contentType?, lastModified? } from HeadObject. |
capabilities | DiskCapabilities | { 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,
})| Field | Notes |
|---|---|
method | The HTTP method the URL authorizes (GET, PUT, …). |
protocol / host / path | Given 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. |
query | Extra parameters signed into the URL (uploadId, partNumber, response-content-type, …). |
credentials | SigV4Credentials — accessKeyId, secretAccessKey, and an optional STS sessionToken. |
region / service | Signing region (must match what the endpoint expects — us-east-1 for MinIO defaults) and service (default s3). |
expiresInSeconds | Whole seconds. SigV4 caps this at 7 days. |
now | Injectable 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
- Upload modes — proxy vs. direct-S3 multipart
- Resumable / TUS — chunked, resumable uploads
- Dashboard — a console over
list/stat/copy/move/deleteMany
Resumable / TUS
Resumable, chunked uploads over the tus 1.0.0 protocol — media.resumable and the opt-in TUS routes under /media/uploads/tus, backed by a pluggable UploadSessionStore (in-memory + Lucid) that persists offset/length/metadata/expiry so a dropped connection resumes.
Delivery
A configurable read strategy — public URL, signed URL, or streaming the bytes through your app — plus the framework-agnostic MediaDeliveryHandler you mount behind your own auth.