diff --git a/examples/aws-bucket-lifecycle-rules/sst-env.d.ts b/examples/aws-bucket-lifecycle-rules/sst-env.d.ts
new file mode 100644
index 0000000000..bfe7264453
--- /dev/null
+++ b/examples/aws-bucket-lifecycle-rules/sst-env.d.ts
@@ -0,0 +1,12 @@
+/* tslint:disable */
+/* eslint-disable */
+import "sst"
+declare module "sst" {
+ export interface Resource {
+ MyBucket: {
+ name: string
+ type: "sst.aws.Bucket"
+ }
+ }
+}
+export {}
diff --git a/examples/aws-bucket-lifecycle-rules/sst.config.ts b/examples/aws-bucket-lifecycle-rules/sst.config.ts
new file mode 100644
index 0000000000..012b5fd95c
--- /dev/null
+++ b/examples/aws-bucket-lifecycle-rules/sst.config.ts
@@ -0,0 +1,38 @@
+///
+
+/**
+ * ## Bucket lifecycle policies
+ *
+ * Configure S3 bucket lifecycle policies to expire objects automatically.
+ */
+export default $config({
+ app(input) {
+ return {
+ name: "aws-bucket-lifecycle-rules",
+ home: "aws",
+ removal: input?.stage === "production" ? "retain" : "remove",
+ };
+ },
+ async run() {
+ const bucket = new sst.aws.Bucket("MyBucket", {
+ lifecycle: [
+ {
+ expiresIn: "60 days",
+ },
+ {
+ id: "expire-tmp-files",
+ prefix: "tmp/",
+ expiresIn: "30 days",
+ },
+ {
+ prefix: "data/",
+ expiresAt: "2028-12-31",
+ },
+ ],
+ });
+
+ return {
+ bucket: bucket.name,
+ };
+ },
+});
diff --git a/platform/src/components/aws/bucket.ts b/platform/src/components/aws/bucket.ts
index 62c9230e0d..d0c6c4aab2 100644
--- a/platform/src/components/aws/bucket.ts
+++ b/platform/src/components/aws/bucket.ts
@@ -100,6 +100,25 @@ interface BucketCorsArgs {
}
interface BucketLifecycleArgs {
+ /**
+ * The unique identifier for the lifecycle rule.
+ *
+ * This ID must be unique across all lifecycle rules in the bucket and cannot exceed 255 characters.
+ * Whitespace-only values are not allowed.
+ *
+ * If not provided, SST will generate a unique ID based on the bucket component name and rule index.
+ *
+ * @example
+ * Use stable IDs to ensure rule identity is preserved when reordering rules.
+ * ```js
+ * {
+ * id: "expire-tmp-files",
+ * prefix: "/tmp",
+ * expiresIn: "7 days"
+ * }
+ * ```
+ */
+ id?: Input;
/**
* An S3 object key prefix that the lifecycle rule applies to.
* @example
@@ -443,6 +462,24 @@ export interface BucketArgs {
* ]
* }
* ```
+ *
+ * Use stable IDs to preserve rule identity when reordering.
+ * ```js
+ * {
+ * lifecycle: [
+ * {
+ * id: "expire-tmp-files",
+ * prefix: "/tmp",
+ * expiresIn: "7 days"
+ * },
+ * {
+ * id: "archive-old-logs",
+ * prefix: "/logs",
+ * expiresIn: "90 days"
+ * }
+ * ]
+ * }
+ * ```
*/
lifecycle?: Input>[]>;
/**
@@ -924,6 +961,35 @@ export class Bucket extends Component implements Link.Linkable {
return output(args.lifecycle).apply((lifecycleRules) => {
if (!lifecycleRules || lifecycleRules.length === 0) return;
+ const seenIds = new Map();
+
+ const resolvedIds = lifecycleRules.map((rule, index) => {
+ const rawId = rule.id ?? `${name}LifecycleRule${index}`;
+ const resolvedId = rawId.trim();
+
+ if (resolvedId.length === 0) {
+ throw new VisibleError(
+ `Lifecycle rule at index ${index} has an empty or whitespace-only "id". Please provide a valid id or omit it to use the auto-generated id.`,
+ );
+ }
+
+ if (resolvedId.length > 255) {
+ throw new VisibleError(
+ `Lifecycle rule at index ${index} has an "id" that is ${resolvedId.length} characters long. AWS S3 lifecycle rule IDs cannot exceed 255 characters.`,
+ );
+ }
+
+ const existingIndex = seenIds.get(resolvedId);
+ if (existingIndex !== undefined) {
+ throw new VisibleError(
+ `Lifecycle rule "id" values must be unique. The id "${resolvedId}" is used by rules at indexes ${existingIndex} and ${index}.`,
+ );
+ }
+ seenIds.set(resolvedId, index);
+
+ return resolvedId;
+ });
+
return new s3.BucketLifecycleConfigurationV2(
...transform(
args.transform?.lifecycle,
@@ -931,7 +997,7 @@ export class Bucket extends Component implements Link.Linkable {
{
bucket: bucket.bucket,
rules: lifecycleRules.map((rule, index) => ({
- id: `${name}LifecycleRule${index}`,
+ id: resolvedIds[index],
status: rule.enabled !== false ? "Enabled" : "Disabled",
expiration:
rule.expiresIn || rule.expiresAt