Premium
S3 temporary file cleanup
Chunked uploads land under Config::S3.tmp_prefix first; what removes them afterwards is S3's own
lifecycle policy, not the application.
How a file leaves the temp prefix
| Situation | What removes the object |
|---|---|
| The record is saved | Providers::S3#finalize server-side copies the object to its final key and deletes the temp original |
| The user cancels | The abort endpoint deletes the multipart upload; the object was never completed |
| The form is abandoned | Nothing — the object stays until the lifecycle rule expires it |
Required lifecycle rules
Two rules on the bucket:
- Expire objects under
Config::S3.tmp_prefixafter a short period — one day is enough, since a file that has not been saved by then never will be. - Abort incomplete multipart uploads after the same period, otherwise their parts are stored and billed without ever appearing as an object.
The expiry period should be at least as long as Config::S3.presign_expiry (default 1 hour); 1–7
days is a reasonable window to cover slow or interrupted uploads.
aws s3api put-bucket-lifecycle-configuration \
--bucket YOUR_BUCKET \
--lifecycle-configuration '{
"Rules": [{
"ID": "expire-tmp-uploads",
"Status": "Enabled",
"Filter": { "Prefix": "tmp/uploads/" },
"Expiration": { "Days": 1 },
"AbortIncompleteMultipartUpload": { "DaysAfterInitiation": 1 }
}]
}'
For DigitalOcean Spaces, add --endpoint-url https://<region>.digitaloceanspaces.com.
resource "aws_s3_bucket_lifecycle_configuration" "uploads" {
bucket = aws_s3_bucket.uploads.id
rule {
id = "expire-tmp-uploads"
status = "Enabled"
filter {
prefix = "tmp/uploads/"
}
expiration {
days = 1
}
abort_incomplete_multipart_upload {
days_after_initiation = 1
}
}
}
caution
Without these rules abandoned uploads accumulate silently: they are not visible as ActiveStorage
records, and incomplete multipart parts are not visible in the bucket listing at all.
Bucket CORS
The browser uploads parts directly to S3, so the bucket must allow it:
aws s3api put-bucket-cors \
--bucket YOUR_BUCKET \
--cors-configuration '{
"CORSRules": [{
"AllowedHeaders": ["*"],
"AllowedMethods": ["GET", "PUT", "POST", "DELETE", "HEAD"],
"AllowedOrigins": ["https://your-frontend-origin.example.com"],
"ExposeHeaders": ["ETag"],
"MaxAgeSeconds": 3600
}]
}'
See also
- Configuration — where
Config::S3.tmp_prefixis set - Provider selection — the tus alternative and its own tmp cleanup