Overview
Tornado writes every completed file into the bucket you configure, and hands your customers a presigned GET URL for it (thes3_url / download_url field in
job status and in the job.completed webhook, valid 24 hours).
Both halves of that sentence matter for the IAM policy. The worker needs to
write, and the presigned link needs the same identity to be allowed to
read. For a long time the connection test only checked the write half, so a
policy with one and not the other looked healthy until a customer clicked a
link. That gap is closed.
s3:GetObject or
s3:AbortMultipartUpload fails the test by name instead of surfacing weeks
later. See what it proves.Why reading matters: a presigned URL carries no permissions of its own. AWS
states it plainly, “the credentials used by the presigned URL are those of the
AWS user who generated the URL”, so S3 evaluates every download as the identity
whose key you gave Tornado. Grant s3:GetObject or every delivered link
returns 403 Forbidden while jobs keep reporting Completed.Before you start
- An AWS account with permission to create an S3 bucket and an IAM policy plus user.
- An S3 bucket dedicated to Tornado deliveries (mixing it with unrelated data makes the scoped policy pointless).
- An IAM identity with long-term access keys. Tornado stores a key pair and signs its own requests, so an assumed role or an IAM Identity Center session is not usable here: there is no field to hand Tornado a role ARN to assume. AWS’s standing recommendation is to prefer temporary credentials, so treat this key as a workload credential, scope it to this one bucket, and rotate it.
- Admin or Owner role in your Tornado organisation.
POST /settings/storageis gated on that; ordinary members can read the configuration but not change it.
1. Create the bucket
Pick the Region first
Create a general purpose bucket
Leave the defaults alone
Which Region
The Region you choose sets the round trip between Tornado’s worker and your bucket for every byte of every delivery, and it is fixed for the life of the bucket.- Pick the Region closest to whoever consumes the files (your backend, your users, your CDN origin). That is the leg you control and the leg your customers feel.
- Once configured, read the real number instead of guessing. “Test connection” reports the measured round trip of the whole probe (write, read back, multipart create and abort, delete), and job status exposes the upload duration separately from the download and mux phases. If the upload phase looks disproportionate, try a bucket in another Region and compare.
2. The IAM policy
Minimum
Exactly the four actions Tornado is known to call against your bucket, and nothing else. Copy this, replacemy-tornado-downloads with your bucket name,
and save it as a customer managed policy.
Recommended
The minimum plus one bucket-level action:CreateMultipartUpload,
UploadPart and CompleteMultipartUpload all to s3:PutObject, so they need
nothing added. Do not add s3:CreateMultipartUpload or
s3:CompleteMultipartUpload: those are not IAM actions.Stopping an upload is the exception. AWS puts it under its own action,
s3:AbortMultipartUpload, and that is the one to grant. Without it, an upload
that fails part-way leaves its uploaded parts behind. AWS is explicit that you
keep paying for them: “you are billed for all storage, bandwidth, and requests
for this multipart upload and its associated parts” until you complete or stop
the upload. Those parts are not objects, so they never appear in the console’s
object list. A bucket lifecycle rule using AbortIncompleteMultipartUpload is
the belt-and-braces backstop and needs no permission on Tornado’s side.s3:ListBucket is not required. Tornado never enumerates your bucket: it
writes to a key it computed itself, signs a URL for that key, and deletes by
key. Leave it out. So are s3:ListMultipartUploadParts (nothing calls
ListParts), s3:ListAllMyBuckets, s3:GetBucketLocation, s3:PutObjectAcl
and s3:PutObjectTagging.Omitting ListBucket has one side effect worth knowing when you debug. Without
it, a request for a key that does not exist comes back as 403 AccessDenied
rather than 404 NoSuchKey, because S3 will not confirm the absence of an
object you cannot list. So a 403 on a presigned URL means either “no
GetObject” or “wrong key”. Check the object exists in the console before you
go rewriting the policy.3. Create the user and get the keys
Create an IAM user for Tornado
tornado-delivery) with no
console access. It is a machine identity; it never needs to sign in.“Programmatic access” is no longer a checkbox during user creation. Access
keys are created afterwards, from the user’s own security credentials.Attach the policy
Create the access key
AKIA…) and a secret access key.4. Fill in the four fields
The fields Tornado accepts for S3 are exactlybucket, region,
access_key_id, secret_access_key, plus two optional ones covered in
Edge cases.
Onboarding wizard
Or Settings, after onboarding
POST /user/s3, and it names two of these
fields differently: access_key and secret_key instead of access_key_id
and secret_access_key, with endpoint required rather than optional. See
Custom Storage for that surface, including its
per-key gotcha.What “Test connection” proves
Pressing Test connection callsPOST /settings/storage/test, which runs a
five-step probe against your real bucket and reports the measured latency.
Nothing is persisted until it succeeds.
AccessDenied. The delete runs last, and a failure at
step 2, 3 or 4 still triggers a best-effort cleanup of the probe object.
It proves:
- The access key ID and secret are valid and active.
- The bucket exists, is reachable from Tornado, and the Region you typed matches it.
- The identity may write (
s3:PutObject), read (s3:GetObject), stop a multipart upload (s3:AbortMultipartUpload) and delete (s3:DeleteObject). - That the read works as the identity your download links are signed with, which is the whole reason the read leg exists.
- The whole round trip completes inside the 20 second budget (5 s connect, 10 s read, one attempt, no retries).
- That a real delivery will fit. The probe moves 23 bytes; a 2 GB video is 30-odd real
UploadPartcalls, all authorised by the sames3:PutObject. - That your policy allows the delivery prefix. The probe writes to
tornado-connection-test/, so a Deny scoped tovideos/*passes the test and fails the job. - That
s3:ListBucketMultipartUploadsis granted. Nothing in the probe lists uploads. - That a recipient can fetch the link. The probe reads with a signed request from Tornado’s own network; a bucket policy conditioned on
aws:SourceIp,aws:SecureTransportors3:signatureAgecan still block your customers. - Anything about lifecycle rules, Object Lock, or KMS grants that only bite on larger or longer-lived objects.
DeleteObject
without a version ID writes a delete marker and leaves a noncurrent version
behind, so tornado-connection-test/ accumulates one tiny noncurrent object per
test. A lifecycle rule expiring noncurrent versions clears them.Checking a real delivered link
The probe coverss3:GetObject, so this is no longer the gap it was. It is still
the only way to check the delivery prefix and the path your customers actually
take. Run one real job, take the s3_url from the job status or the
job.completed webhook, and fetch a single byte of it:
Troubleshooting
The dashboard shows the backend’s own error string. Match on it exactly.Jobs complete, but downloads 403
The bucket contains the files, the job saysCompleted, and every s3_url
returns 403 Forbidden / AccessDenied. The probe’s read leg makes a plain
missing s3:GetObject much less likely than it used to be, so work down this
list in order.
Confirm the object is really there
Re-run Test connection
AccessDenied: s3:GetObject, the policy really is missing the action and you
are done diagnosing.If the test passes, suspect the prefix or the key
tornado-connection-test/ but not on the delivery path. Look for a policy or
bucket-policy Resource narrowed to something other than bucket/*, or an
access key that was rotated after the link was signed. Presigned URLs die
with the key that signed them, expiry or no expiry.Confirm from outside
s3_url as shown in
Checking a real delivered link. Tornado
reads from its own network; a bucket policy conditioned on source IP or TLS
behaves differently for your customers.s3:GetObject was missing starts working the moment you grant it, as long as
it has not passed its 24 hour expiry.Edge cases
Large files and multipart uploads
Large files and multipart uploads
CreateMultipartUpload, UploadPart and CompleteMultipartUpload all
to s3:PutObject. There is no s3:CreateMultipartUpload IAM action to add,
and asking for one will just confuse whoever reviews the policy.Stopping a partial upload is the one separate action,
s3:AbortMultipartUpload, and it is in the policy above. Without it an
interrupted upload leaves its parts behind as an incomplete multipart upload:
not an object, so absent from the console’s object list, and billed as
storage until something removes it. A bucket lifecycle rule using
AbortIncompleteMultipartUpload is a good backstop for the cases Tornado
cannot clean up itself, such as a worker that died mid-upload, and it needs
no permission on Tornado’s side.Tornado’s own abort is best-effort in the delivery path: if it 403s, the job
still succeeds and nothing is logged to you. That is why the connection test
now exercises the abort explicitly, so the gap surfaces at configuration time
instead of on a storage bill.Endpoint and path style (fields the S3 tile does not show)
Endpoint and path style (fields the S3 tile does not show)
endpoint and path_style.Leave endpoint empty for AWS. With no endpoint set, the SDK derives the
correct regional host from Region. The field exists for S3-compatible
providers (R2, MinIO, Wasabi, Backblaze B2), which is why the onboarding
wizard shows it only on the “Any S3-compatible” tile, and why Settings labels
it “Endpoint (optional)”.If you do fill it, the backend normalises it before use: whitespace trimmed,
an accidental doubled scheme (https://https://…) collapsed, https://
prepended when absent, trailing slash removed. That defence exists because a
double-wrapped endpoint used to fail every connection test with an
unresolvable host.path_style forces path-style addressing (host/bucket/key). AWS does not
need it and neither UI exposes it; it is reachable through the API only.Requests are always signed with AWS Signature Version 4. This is forced,
not defaulted, because SigV2 presigned URLs are rejected outright by
Cloudflare R2 and were never supported by Regions launched after 2014.SSE-KMS encrypted buckets
SSE-KMS encrypted buckets
kms:GenerateDataKey and kms:Decrypt on the key, and the
KMS key policy must allow that principal.We have not verified this path end to end against a Tornado delivery, so
treat it as the standard AWS requirement rather than a tested Tornado
configuration: test with a real job before relying on it. SSE-S3 needs no KMS
permissions and is the simpler choice.Rotating the access key
Rotating the access key
Create the second key
Update Tornado
Watch, then delete
Where files land inside the bucket
Where files land inside the bucket
videos/ top-level folder, with any
folder_prefix nested inside it and the per-job folder below that. Both are
configurable. See Custom Storage for the full path
layout, including base_folder.Related
Custom Storage
Webhooks
job.completed payload, including the presigned s3_url this policy has to allow