Managing AWS S3 Buckets with Pulumi
Learn how to provision S3 buckets with versioning, encryption, and public access blocking using Pulumi and TypeScript
This guide covers provisioning a secure S3 bucket with Pulumi - versioning, default encryption, and blocked public access, split across separate resources the way the current AWS provider (and Pulumi’s bridged version of it) requires.
The Bucket
import * as aws from "@pulumi/aws";
const bucket = new aws.s3.BucketV2("app-data", {
bucket: "my-unique-app-data-bucket",
tags: { Environment: "production" },
});
aws.s3.BucketV2 (not the older aws.s3.Bucket) is the current resource - the underlying AWS provider split S3’s inline versioning, serverSideEncryptionConfiguration, and website arguments out into their own resources several major versions ago, and Pulumi’s bridged provider carries the same split. Configuring those features as arguments directly on BucketV2 either does nothing or raises a schema error depending on the provider version - they need their own resources below.
Versioning
const versioning = new aws.s3.BucketVersioningV2("app-data", {
bucket: bucket.id,
versioningConfiguration: {
status: "Enabled",
},
});
Default Encryption
const encryption = new aws.s3.BucketServerSideEncryptionConfigurationV2("app-data", {
bucket: bucket.id,
rules: [{
applyServerSideEncryptionByDefault: {
sseAlgorithm: "aws:kms",
},
bucketKeyEnabled: true,
}],
});
bucketKeyEnabled: true turns on S3 Bucket Keys, which cuts KMS request costs substantially on buckets with heavy PUT traffic by reducing how often S3 calls KMS directly.
Blocking Public Access
const publicAccessBlock = new aws.s3.BucketPublicAccessBlock("app-data", {
bucket: bucket.id,
blockPublicAcls: true,
blockPublicPolicy: true,
ignorePublicAcls: true,
restrictPublicBuckets: true,
});
All four flags should be true unless the bucket is intentionally serving public content (a static website, public downloads) - and even then, prefer fronting it with CloudFront and an Origin Access Control over making the bucket itself public.
Lifecycle Rules
const lifecycle = new aws.s3.BucketLifecycleConfigurationV2("app-data", {
bucket: bucket.id,
rules: [{
id: "archive-old-versions",
status: "Enabled",
noncurrentVersionTransitions: [{
noncurrentDays: 30,
storageClass: "STANDARD_IA",
}],
noncurrentVersionExpiration: {
noncurrentDays: 365,
},
}],
});
This transitions noncurrent (superseded) object versions to Infrequent Access after 30 days and expires them entirely after a year - a common pattern once versioning is enabled, since old versions otherwise accumulate storage cost indefinitely.
Stack Outputs
export const bucketName = bucket.bucket;
export const bucketArn = bucket.arn;
Best Practices
- Enable the public access block on every bucket by default - opt out per-bucket only when you have a specific, reviewed reason to serve public content directly from S3.
- Enable versioning before anything writes to the bucket, not after - objects written while versioning is off have no prior version to recover if later overwritten or deleted.
- Pair lifecycle rules with versioning so old versions don’t accumulate storage cost forever; without a lifecycle rule, every version of every object is retained indefinitely.
Conclusion
The BucketV2 + BucketVersioningV2 + BucketServerSideEncryptionConfigurationV2 + BucketPublicAccessBlock split mirrors the current Terraform AWS provider’s S3 resources one-for-one - if you’ve read the S3 Terraform guide on this site, every resource here has a direct HCL counterpart with the same arguments.
For more Pulumi topics, check out: