Skip to main content
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​

SituationWhat removes the object
The record is savedProviders::S3#finalize server-side copies the object to its final key and deletes the temp original
The user cancelsThe abort endpoint deletes the multipart upload; the object was never completed
The form is abandonedNothing — the object stays until the lifecycle rule expires it

Required lifecycle rules​

Two rules on the bucket:

  1. Expire objects under Config::S3.tmp_prefix after a short period — one day is enough, since a file that has not been saved by then never will be.
  2. 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​