Upload files to Amazon S3
Amazon S3 is the reference implementation of the API every other provider on
this site imitates, and it is the only one upup needs no endpoint for. Point
storage.type at aws, give it a bucket and a region, and the AWS SDK resolves
the host itself.
This page covers server mode, where @useupup/server
holds the credentials. In client mode your own endpoint signs the URLs and the
browser PUTs straight to the bucket — see
Client Mode vs Server Mode for the split, and
S3 Presign Responses for the
response shape a client-mode endpoint must return.
Upload Files to S3 from React with Presigned URLs
builds that client-mode endpoint step by step with the AWS SDK v3.
The config
Native AWS needs no endpoint. This is the complete handler; every other
provider guide changes only the storage block.
// app/api/upup/[...route]/route.ts
import { createUpupHandler } from '@useupup/server'
const handler = createUpupHandler({
storage: {
type: 'aws',
bucket: process.env.S3_BUCKET!,
region: process.env.S3_REGION!, // e.g. 'us-east-1'
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
// Omit both keys to use the host's IAM role instead.
},
// Required, server-only: a stable, high-entropy secret (min 16 chars),
// shared across every server instance. createUpupHandler throws without it.
uploadTokenSecret: process.env.UPUP_UPLOAD_TOKEN_SECRET!,
// getUserId, providers, tokenStore — see the Server Mode setup guide.
})forcePathStyle is ignored here: it only applies when endpoint is set, and
native AWS S3 uses virtual-hosted-style addressing.
Console setup
- Create the bucket. S3 → Create bucket. Pick the region you will put in
regionand leave Block Public Access on — upup uploads through signed requests, so the bucket never needs to be public. - Create credentials. Either attach an IAM role to the host that runs your server (preferred on EC2, ECS, and Lambda) or create an IAM user with programmatic access and copy its access key ID and secret.
- Attach the policy below to that role or user.
- Add CORS. The browser uploads straight to the bucket by default in both client and server mode — server mode hands it a presigned URL rather than proxying the bytes — so the bucket needs a CORS policy allowing your app's origin. See CORS below for the two exceptions.
IAM policy
@useupup/server issues exactly these S3 operations: PutObject, GetObject,
CreateMultipartUpload, UploadPart, CompleteMultipartUpload,
AbortMultipartUpload, ListParts, and HeadBucket for the health route. That
maps to five IAM actions:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"s3:PutObject",
"s3:GetObject",
"s3:AbortMultipartUpload",
"s3:ListMultipartUploadParts"
],
"Resource": "arn:aws:s3:::YOUR_BUCKET/*"
},
{
"Effect": "Allow",
"Action": "s3:ListBucket",
"Resource": "arn:aws:s3:::YOUR_BUCKET"
}
]
}The object statement targets YOUR_BUCKET/*; the ListBucket statement targets
the bucket ARN itself with no /*. Mixing those two up is the most common reason
a policy that "looks right" still returns AccessDenied. Drop s3:GetObject if
your server never reads objects back, and drop the ListBucket statement if you
do not enable the health route's storage check.
Omitting the keys
Leave both accessKeyId and secretAccessKey out and the AWS SDK falls back to
its normal credential chain — the instance profile on EC2, the task role on ECS,
the execution role on Lambda. That is the safer production setup: nothing to
rotate, nothing to leak. upup validates the pair as both-or-neither, so a
half-set pair fails at construct time rather than at the first upload.
CORS
[
{
"AllowedOrigins": ["https://your-app.com"],
"AllowedMethods": ["PUT", "POST", "GET", "HEAD"],
"AllowedHeaders": ["*"],
"ExposeHeaders": ["ETag"],
"MaxAgeSeconds": 3000
}
]ExposeHeaders: ["ETag"] is not optional for multipart uploads: the browser
must read each part's ETag to complete the upload, and without it the final
CompleteMultipartUpload fails with parts it cannot identify. This applies in
both client and server mode: server mode's /presign, /multipart/init, and
/multipart/sign-part routes hand the browser a presigned S3 URL, so by
default it uploads directly to the bucket there too. The two exceptions are
cloud-drive transfers (Google Drive/OneDrive/Dropbox/Box → S3), which go
server-to-S3 and never touch the browser, and a deployment that rewrites
uploadUrl in onPresignResponse
to a same-origin proxy route.
Region mismatch is the usual 403
A SignatureDoesNotMatch or PermanentRedirect on the first upload almost
always means region does not match the bucket's actual region. S3
signatures are region-scoped and time-scoped, so a wrong region and a clock
more than a few minutes off produce the same 403. Check the bucket's region
in the console rather than trusting a copied .env.
Costs and lifecycle
Abandoned multipart uploads keep their uploaded parts, and those parts are
billed as storage even though the object never appears in the bucket. Add an
AbortIncompleteMultipartUpload lifecycle rule that expires them after a day
or two — one rule, and abandoned uploads stop accumulating charges.
With cross-reload resume on — which it is by default for server-mode multipart — that rule stops being optional hygiene. Interrupted uploads deliberately keep their parts so a later attempt can continue from them, so nothing else will ever clean up the ones that are never resumed.
Next steps
- Upload Files to S3 from React with Presigned URLs — the client-mode flow written by hand: signing, a React upload with progress, CORS, and 403 errors.
- Storage Providers — the provider matrix and
the shared
storageconfig reference. - Server Mode — Setup —
getUserId,providers,tokenStore, and tuning. - Client Mode vs Server Mode — which one you want and what changes on the wire.
- Other S3-compatible services — the same config against a non-AWS backend.