Setting Up S3 Lifecycle Rules for Temporary Uploads
Put every browser upload into a staging/ prefix, attach one Expiration.Days rule scoped to that prefix, and make promotion a CopyObject out of it β the rule then deletes exactly the objects nobody claimed, and nothing else.
This is the concrete recipe for a staging bucket. The mechanics of the evaluation engine β midnight rounding, rule union semantics, transition economics β live on the parent guide to cloud storage lifecycle rules inside backend validation and cloud storage architecture. What follows assumes you have read that and now need a policy you can deploy on Monday.
When to use this approach
- You issue presigned URLs and cannot see the outcome. With presigned URL workflows the browser talks to S3 directly, so your server never learns that a user closed the tab at 80%. A lifecycle rule is the only cleanup that does not depend on a client callback arriving.
- Promotion is already a copy, not a database flag. If a validated object physically moves from
staging/tooriginals/, the prefix boundary carries the whole retention decision. If promotion only flips a column, stop here and fix that first β a lifecycle rule cannot tell live data from debris by reading your database. - Sub-hour precision is not required.
Daysis the finest granularity S3 offers, and the real window is wider than the number suggests. If you need βgone within 30 minutesβ, you need a scheduled sweeper, not this.
Against the alternatives: an EventBridge-scheduled Lambda that walks ListObjectsV2 gives you minute-level precision but costs a LIST request per 1,000 keys per run and has to be monitored like any other job. Deleting from the application after processing is free but never fires for the transfers that failed β which is the entire population you are trying to clean up.
Prerequisites
- Node 20+ with
@aws-sdk/client-s33.600 or later. The examples are ESM with top-levelawait; add"type": "module"topackage.json. - AWS CLI v2.15+ for the verification commands.
- A bucket whose keys are already split by state β
staging/,originals/,derivatives/. If yours are flat, changing the key scheme is a prerequisite, not an optimisation. - An IAM principal with the bucket-level lifecycle permissions:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "ManageLifecycle",
"Effect": "Allow",
"Action": ["s3:GetLifecycleConfiguration", "s3:PutLifecycleConfiguration"],
"Resource": "arn:aws:s3:::media-uploads"
},
{
"Sid": "PromoteAndProbe",
"Effect": "Allow",
"Action": ["s3:GetObject", "s3:PutObject", "s3:DeleteObject", "s3:PutObjectTagging"],
"Resource": "arn:aws:s3:::media-uploads/*"
}
]
}
The three kinds of debris a staging prefix accumulates
A staging prefix does not fill up with one kind of junk, and a single rule will not clear all of it. Completed objects that nobody promoted are visible in a listing and billed normally. Abandoned multipart parts are billed but invisible to ListObjectsV2. Rejected uploads β the ones that failed server-side file validation or came back positive from a scanner β are visible, known-bad, and should not wait out the full window.
The abort action is the half most teams miss, and it has enough depth of its own to warrant a separate treatment in expiring incomplete multipart uploads automatically. Include it in the policy below, then read that page to size DaysAfterInitiation against your slowest genuine session.
Sizing the expiry window
The number you put in Days is not the lifetime of the object. S3 rounds the creation timestamp up to the next UTC midnight, adds Days, and only then queues the delete β with up to 48 hours of execution lag after eligibility. Two consequences fall out, and you need both:
- Guaranteed minimum life is
Days Γ 24hours. The worst case for your pipeline is an object created a second before midnight, where the rounding adds nothing. Your processing budget must fit inside that. - Worst-case maximum life is
(Days + 1) Γ 24 + 48hours. The worst case for your storage bill is an object created a second after midnight that then waits out the full execution lag. Your cost model must assume that.
Measure the budget rather than guessing it. The inputs are the p99 wall-clock time from PutObject to promotion, plus whatever manual requeue window your on-call rotation needs. For a pipeline that runs a virus scan and one transcode pass through SQS and Lambda, 8 hours of p99 plus 22 hours of retry budget is realistic; that lands on Days: 2, a guaranteed 48 hours of survival and a worst case of 120.
| Input | Where the number comes from | Typical | Effect on the rule |
|---|---|---|---|
| Pipeline p99 | Time between the s3:ObjectCreated:* event and the promotion copy, from your job metrics |
2β8 h | Sets the lower bound on Days Γ 24 |
| Retry budget | How long a poisoned queue message can sit before someone replays it | 12β24 h | Added to the pipeline p99 before rounding |
| Midnight ceiling | Fixed by S3 | 0β24 h | Extends life, never shortens it β free safety |
| Execution lag | Fixed by S3, documented worst case | 0β48 h | Pure cost, no safety value |
| Presigned URL TTL | expiresIn on the signer |
5β60 min | Must be far below Days Γ 24 |
Implementation
Build the policy from a typed function so the window and the rule stay in sync β hand-edited JSON is where the mismatch between βwe expire after two daysβ and βwe actually expire after oneβ comes from.
// staging-lifecycle.ts
import type { LifecycleRule } from "@aws-sdk/client-s3";
export interface StagingWindow {
/** Prefix the browser uploads land in. Must end with a slash. */
prefix: string;
/** p99 wall-clock hours from PutObject to promotion. Measured, not guessed. */
pipelineHours: number;
/** Hours of retry and manual-requeue budget on top of the pipeline. */
retryHours: number;
}
const HOURS_PER_DAY = 24;
const EXECUTION_LAG_HOURS = 48;
/** Guaranteed minimum survival is Days * 24h, so round the budget up to whole days. */
export function expirationDays(spec: StagingWindow): number {
const budget = spec.pipelineHours + spec.retryHours;
if (budget <= 0) throw new RangeError("processing budget must be positive");
return Math.max(1, Math.ceil(budget / HOURS_PER_DAY));
}
/** Worst-case billed lifetime, for the cost model. */
export function worstCaseHours(days: number): number {
return (days + 1) * HOURS_PER_DAY + EXECUTION_LAG_HOURS;
}
export function stagingPolicy(spec: StagingWindow): LifecycleRule[] {
if (!spec.prefix.endsWith("/")) {
throw new Error(`prefix ${JSON.stringify(spec.prefix)} must end with "/" β S3 prefix matching is literal`);
}
const days = expirationDays(spec);
return [
{
ID: "expire-staging-objects",
Status: "Enabled",
Filter: { Prefix: spec.prefix },
Expiration: { Days: days },
},
{
ID: "abort-staging-parts",
Status: "Enabled",
Filter: { Prefix: spec.prefix },
AbortIncompleteMultipartUpload: { DaysAfterInitiation: days },
},
{
// Known-bad uploads do not deserve the full window. Tags need an And block.
ID: "expire-rejected-uploads",
Status: "Enabled",
Filter: { And: { Prefix: spec.prefix, Tags: [{ Key: "state", Value: "rejected" }] } },
Expiration: { Days: 1 },
},
];
}
Four parameters do the real work here:
Filter.Prefix: "staging/"β the trailing slash is load-bearing. S3 matches prefixes as literal byte strings with no notion of directories, sostagingwithout the slash also matchesstaging-archive/2024/report.csv. TheendsWithguard exists because that mistake deletes a different prefix entirely and you find out 48 hours later.Expiration.Daysβ a positive integer only. There is no hours field, noExpiration.Hours, and no way to express βexpire at 04:00β.AbortIncompleteMultipartUpload.DaysAfterInitiationβ kept in a rule of its own rather than merged into the first. S3 accepts both actions in one rule, but separate IDs mean thes3:LifecycleExpiration:*audit events and any CloudWatch alarm you build can tell an abandoned part from an unclaimed object.Filter.And.Tagsβ a rule may carryPrefixorTagat the top level, never both. Combining them requires theAndwrapper, and the tag must be an object tag, not user metadata;x-amz-meta-*is invisible to the lifecycle engine.
Deploying is a read-modify-write, because PutBucketLifecycleConfiguration replaces the whole document with no append and no conditional header. The merge-by-ID guard is explained in full on the parent guide; the staging-specific part is ExpectedBucketOwner, which turns a mistyped bucket name in a shared account from a silent policy overwrite into a 403.
// deploy-staging-lifecycle.ts
import {
S3Client,
GetBucketLifecycleConfigurationCommand,
PutBucketLifecycleConfigurationCommand,
type LifecycleRule,
} from "@aws-sdk/client-s3";
import { stagingPolicy, expirationDays, worstCaseHours } from "./staging-lifecycle.js";
const BUCKET = process.env.BUCKET ?? "media-uploads";
const OWNER = process.env.AWS_ACCOUNT_ID ?? "111122223333";
const client = new S3Client({ region: process.env.AWS_REGION ?? "eu-west-1", maxAttempts: 5 });
const spec = { prefix: "staging/", pipelineHours: 8, retryHours: 22 };
const desired = stagingPolicy(spec);
let live: LifecycleRule[] = [];
try {
const res = await client.send(
new GetBucketLifecycleConfigurationCommand({ Bucket: BUCKET, ExpectedBucketOwner: OWNER }),
);
live = res.Rules ?? [];
} catch (error) {
if ((error as { name?: string }).name !== "NoSuchLifecycleConfiguration") throw error;
}
const byId = new Map(live.map((rule) => [rule.ID ?? "", rule]));
const ourIds = new Set(desired.map((rule) => rule.ID ?? ""));
for (const rule of desired) byId.set(rule.ID ?? "", rule);
const merged = [...byId.values()];
await client.send(
new PutBucketLifecycleConfigurationCommand({
Bucket: BUCKET,
ExpectedBucketOwner: OWNER,
LifecycleConfiguration: { Rules: merged },
}),
);
const days = expirationDays(spec);
console.log(
`${BUCKET}: ${merged.length} rule(s) live, ${ourIds.size} owned here, ` +
`${merged.length - ourIds.size} preserved. Guaranteed ${days * 24}h, worst case ${worstCaseHours(days)}h.`,
);
A first run against a bucket that already carries an unrelated archival rule prints:
media-uploads: 4 rule(s) live, 3 owned here, 1 preserved. Guaranteed 48h, worst case 120h.
Promoting an object out of the window
The rule only stays safe if promotion physically leaves the prefix. CopyObject is a server-side operation β no bytes traverse your process β and it is the moment to replace the staging tags, because TaggingDirective: "COPY" would carry state=rejected straight into your permanent prefix.
// promote.ts
import {
S3Client,
CopyObjectCommand,
DeleteObjectCommand,
HeadObjectCommand,
} from "@aws-sdk/client-s3";
const BUCKET = process.env.BUCKET ?? "media-uploads";
const client = new S3Client({ region: process.env.AWS_REGION ?? "eu-west-1" });
/** Encode each path segment: keys legitimately contain spaces, '+' and '#'. */
function copySource(bucket: string, key: string): string {
return `/${bucket}/${key.split("/").map(encodeURIComponent).join("/")}`;
}
export async function promote(stagingKey: string, finalKey: string): Promise<string> {
const copied = await client.send(
new CopyObjectCommand({
Bucket: BUCKET,
Key: finalKey,
CopySource: copySource(BUCKET, stagingKey),
MetadataDirective: "COPY",
TaggingDirective: "REPLACE",
Tagging: "state=live",
ChecksumAlgorithm: "SHA256",
}),
);
// The destination must not match any expiration rule. If it does, the prefix is wrong.
const head = await client.send(new HeadObjectCommand({ Bucket: BUCKET, Key: finalKey }));
if (head.Expiration) {
await client.send(new DeleteObjectCommand({ Bucket: BUCKET, Key: finalKey }));
throw new Error(`refusing to promote into an expiring prefix: ${head.Expiration}`);
}
await client.send(new DeleteObjectCommand({ Bucket: BUCKET, Key: stagingKey }));
return copied.CopyObjectResult?.ETag ?? "";
}
Two limits to plan around. CopyObject refuses anything over 5 GB with InvalidRequest: The specified copy source is larger than the maximum allowable size for a copy source: 5368709120 β above that you need a multipart copy using UploadPartCopy, which is a different code path and worth testing before a customer finds it. And the explicit DeleteObject after the copy is optional: leaving the staging object in place lets the lifecycle rule collect it, which costs a day of duplicate storage but keeps a free rollback while the promotion is still fresh. Record the new key in the same transaction that updates your index, following the schema in how to index file metadata in PostgreSQL.
Configuration gotchas
MalformedXML from a prefix and a tag in the same filter
Writing Filter: { Prefix: "staging/", Tag: { Key: "state", Value: "rejected" } } fails with An error occurred (MalformedXML) when calling the PutBucketLifecycleConfiguration operation: The XML you provided was not well-formed or did not validate against our published schema. The schema allows exactly one condition at the top level of Filter. Wrap two or more in And, and note that And requires at least two conditions β an And containing only a prefix is rejected by the same error.
InvalidRequest on a zero or fractional window
Expiration: { Days: 0 } returns InvalidRequest: 'Days' for Expiration action must be a positive integer. The same applies to DaysAfterInitiation. There is no sub-day expiration in S3 lifecycle at all, so if a compliance rule says βdelete within 6 hoursβ the honest answer is a scheduled job, and the lifecycle rule becomes the backstop behind it rather than the mechanism.
The presigned URL that outlives its own object
An upload URL signed with a 12-hour expiresIn against a prefix that expires in one day leaves almost no margin: a client that starts the transfer at hour 11, retries through a bad connection, and finishes at hour 20 has written an object that may already be eligible. Keep expiresIn at minutes, not hours, and re-sign on resume β the pattern described in resuming uploads after network loss. A stale URL fails loudly with 403 AccessDenied: Request has expired, which is far easier to debug than an object that quietly disappeared.
Versioning turns the delete into a delete marker
On a versioned bucket, Expiration.Days does not free any bytes: it writes a delete marker and moves the old version to noncurrent, where it is still billed. A staging prefix on a versioned bucket therefore needs NoncurrentVersionExpiration: { NoncurrentDays: 1 } alongside the expiration, and a separate rule with Expiration: { ExpiredObjectDeleteMarker: true } to sweep the markers left behind. ExpiredObjectDeleteMarker cannot share a rule with Days or Date β that combination is another MalformedXML.
Verification
There is no dry-run endpoint, but there is something better for this specific case: after the rule is live, S3 returns an x-amz-expiration header on every PutObject and HeadObject response for a matching key, telling you the exact instant and the exact rule ID that owns it.
Three checks, in order. First, confirm a staging key is covered:
aws s3api head-object --bucket media-uploads \
--key staging/9f2c41/clip.mov --query 'Expiration' --output text
expiry-date="Sat, 28 Mar 2026 00:00:00 GMT", rule-id="expire-staging-objects"
Second, confirm the filter does not over-match. Write a probe just outside the prefix and check that the header is absent β this is the trailing-slash bug catching itself in one command:
aws s3api put-object --bucket media-uploads --key staging-archive/probe.bin \
--body /dev/null --query 'Expiration' --output text
None
Third, assert it continuously from the application. HeadObjectCommand surfaces the same header as Expiration, so a test that runs after each deploy is four lines:
// assert-coverage.ts
import { S3Client, HeadObjectCommand } from "@aws-sdk/client-s3";
import assert from "node:assert/strict";
const client = new S3Client({ region: process.env.AWS_REGION ?? "eu-west-1" });
const BUCKET = process.env.BUCKET ?? "media-uploads";
const staged = await client.send(new HeadObjectCommand({ Bucket: BUCKET, Key: "staging/probe.bin" }));
assert.match(staged.Expiration ?? "", /rule-id="expire-staging-objects"/, "staging key is not covered");
const live = await client.send(new HeadObjectCommand({ Bucket: BUCKET, Key: "originals/probe.bin" }));
assert.equal(live.Expiration, undefined, `promoted key is still expiring: ${live.Expiration}`);
console.log("lifecycle coverage verified for staging/ and originals/");
Beyond the header, enable the s3:LifecycleExpiration:Delete event notification and alarm on the daily count going to zero. A rule that stops matching β because someone changed the key scheme β is silent, and the first symptom is a storage bill three months later.
Frequently Asked Questions
Can I expire a staging object after a few hours instead of a day?
Not with a lifecycle rule: Days is an integer and the engine only guarantees a daily evaluation pass. If you genuinely need hours, run a scheduled sweeper over the state=pending tag and keep the one-day lifecycle rule underneath it as the backstop for whatever the sweeper misses. Two mechanisms with different failure modes beat one aggressive rule.
Should the staging prefix be a separate bucket instead?
A separate bucket gives you a smaller blast radius β a wrong filter can only destroy temporary data β and a lifecycle document with a single rule and no prefix filter at all. The cost is that promotion becomes a cross-bucket copy, which is still server-side and free of egress within a region but bills a GET and a PUT, and you now maintain two CORS configurations. For most teams one bucket with disciplined prefixes is easier to keep correct.
What happens if a rule deletes an object while a worker holds a presigned GET for it?
The signature stays valid, but the object is gone, so the request returns 404 NoSuchKey. Presigned URLs authorise a request; they do not pin the object. Any worker that fetches from a staging prefix must treat NoSuchKey as an expected outcome and fail the job cleanly rather than retrying forever.
Do lifecycle rules stop an attacker filling my bucket overnight?
No. The rule cleans up hours later, and you pay for the storage in between plus every PUT request. Cap the damage at the door with S3 POST policy size limits and rate limiting on URL issuance; treat expiration as the cleanup, never the defence.
Is ExpiredObjectDeleteMarker: true safe to add next to Expiration.Days?
No β S3 rejects a rule containing both with MalformedXML, and plenty of configurations copied from blog posts carry that exact combination without ever having been applied. Put the delete-marker sweep in its own rule with its own ID, and only on a versioned bucket, where it has anything to do.