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/ to originals/, 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. Days is 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

  1. Node 20+ with @aws-sdk/client-s3 3.600 or later. The examples are ESM with top-level await; add "type": "module" to package.json.
  2. AWS CLI v2.15+ for the verification commands.
  3. 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.
  4. 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 three exits from a staging prefix An object or set of parts written into the staging prefix leaves by one of three routes: promotion by CopyObject, expiration of the completed object, or an abort of incomplete multipart parts. Every byte in staging/ leaves by one of three doors staging/ objects and open parts Promoted CopyObject to originals/ no rule matches it Expired Expiration.Days DELETE, billed at $0 Parts aborted DaysAfterInitiation invisible bytes freed
Promotion is the only exit you control from application code; the other two must be configuration, because the client that abandoned the upload will never call you back.

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 Γ— 24 hours. 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 + 48 hours. 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.
Timeline of a staging object under a two-day expiration rule A horizontal timeline over 120 hours showing the processing budget, the retry budget, the expiry clock reaching eligibility at hour 52, and the 48-hour band in which the delete actually executes. How long a staging object can actually live (Days = 2) Validate, scan, transcode Retry and requeue budget Expiry clock to eligibility eligible at hour 52 S3 execution lag DELETE lands somewhere here 0h 24h 48h 72h 96h 120h Object created at 20:00 UTC: the midnight ceiling adds 4 hours before the two days even start counting. Budget the pipeline against the left edge of the band; budget the storage bill against the right edge.
A two-day rule can hold a byte for four days. The gap between hour 52 and hour 100 is not a bug β€” it is the contract.

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, so staging without the slash also matches staging-archive/2024/report.csv. The endsWith guard 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, no Expiration.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 the s3:LifecycleExpiration:* audit events and any CloudWatch alarm you build can tell an abandoned part from an unclaimed object.
  • Filter.And.Tags β€” a rule may carry Prefix or Tag at the top level, never both. Combining them requires the And wrapper, 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.

Anatomy of the x-amz-expiration response header The header string is split into its expiry-date and rule-id components, each annotated with what it proves about the lifecycle configuration. What x-amz-expiration proves x-amz-expiration: expiry-date="Sat, 28 Mar 2026 00:00:00 GMT", rule-id="expire-staging-objects" The UTC midnight the object becomes eligible β€” not when it is deleted. Add up to 48h for execution. Which rule matched this key. No header at all means no rule matched β€” the promotion test. One HEAD request answers both "is it covered?" and "by which rule?" without waiting a day for the sweep.
The absent header is as informative as the present one: it is the assertion that a promoted object escaped the expiring prefix.

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.