Terraform AWS S3 Static Assets
Continue the AWS Terraform project by creating an S3 bucket for static assets with versioning, lifecycle rules, public-read policy, and object uploads.
Continuing the AWS Project
So far, the project has a network layer and a public EC2 web server. The next useful component is object storage. In AWS, that means S3.
In this lesson, you will create an S3 bucket that stores static assets such as HTML, CSS, JavaScript, images, and downloads. The tutorial goes a bit further than “create a bucket” because real buckets need policy and lifecycle decisions too.
By the end, your S3 configuration will include:
- an
aws_s3_bucketresource - a unique bucket name built with a random suffix
- ownership controls
- public access block settings that support a simple public static site
- bucket versioning
- lifecycle rules for storage cost control
- a bucket policy that allows public reads
- a website configuration and uploaded objects
- outputs for the bucket name and website endpoint
This lesson intentionally uses a public static-asset pattern because it is easy to understand and test. In production, many teams prefer CloudFront in front of S3 instead of a directly public bucket, but learning the underlying S3 pieces is still valuable.
Why S3 Belongs in This Project
An EC2 web server is good for dynamic pages or server-hosted apps, but it is inefficient to store every image, CSS file, or downloadable document on the instance itself. S3 is a better fit because it gives you:
- highly durable object storage
- easy HTTP-based access for static files
- versioning for safer changes
- lifecycle rules for cost management
- a clean way to separate app compute from asset storage
Even if the EC2 server disappears, the bucket and its objects remain. That decoupling is one of the big operational wins of cloud architecture.
The Bucket Naming Problem
S3 bucket names must be globally unique across AWS. That means a name like devopslesson-assets may already be taken by another account anywhere in the world.
Terraform solves this neatly with the random_id resource from the Random provider.
First, add the provider requirement:
terraform {
required_version = ">= 1.6.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
random = {
source = "hashicorp/random"
version = "~> 3.6"
}
}
}
Then create a suffix:
resource "random_id" "bucket_suffix" {
byte_length = 4
}
That generates a short hex value you can append to the bucket name.
Creating the S3 Bucket
Now create the bucket itself.
resource "aws_s3_bucket" "assets" {
bucket = "${var.project_name}-assets-${random_id.bucket_suffix.hex}"
tags = {
Name = "${var.project_name}-assets"
ManagedBy = "Terraform"
}
}
This is intentionally simple. The bucket resource creates the container, but most of the real behavior comes from additional resources.
Bucket Ownership Controls
Modern AWS S3 setups often use explicit ownership controls.
resource "aws_s3_bucket_ownership_controls" "assets" {
bucket = aws_s3_bucket.assets.id
rule {
object_ownership = "BucketOwnerPreferred"
}
}
What does BucketOwnerPreferred mean?
It helps ensure the bucket owner remains the preferred owner of uploaded objects, which avoids confusing access patterns later. In multi-account or multi-uploader environments, ownership rules matter a lot.
For a simple tutorial, you may not feel the impact immediately, but it is worth learning because modern S3 configuration is more explicit than older examples you may see online.
Public Access Block Configuration
S3 public access settings confuse many beginners because they interact with both ACLs and bucket policies.
For this tutorial, the bucket should allow public read access through a bucket policy so static files can be served openly. That means the public access block cannot completely forbid public policies.
resource "aws_s3_bucket_public_access_block" "assets" {
bucket = aws_s3_bucket.assets.id
block_public_acls = true
ignore_public_acls = true
block_public_policy = false
restrict_public_buckets = false
}
Why these settings?
block_public_acls = trueandignore_public_acls = truekeep you away from old ACL-based public access patterns.block_public_policy = falseallows the tutorial's public-read bucket policy to work.restrict_public_buckets = falseprevents AWS from blocking that intended public policy.
This is one of those cases where context matters. If you were building a private application bucket, you would likely lock these values down much harder.
Enabling Bucket Versioning
Versioning is one of the most practical features in S3.
resource "aws_s3_bucket_versioning" "assets" {
bucket = aws_s3_bucket.assets.id
versioning_configuration {
status = "Enabled"
}
}
Why enable versioning?
Because static assets change over time, and mistakes happen. Versioning helps when:
- someone uploads the wrong file
- a deployment replaces the current object with a broken version
- you want a recovery path without immediately restoring from backup
In production, versioning is often non-negotiable for important buckets.
Adding Lifecycle Rules
Buckets can quietly grow forever if you never manage object age. Lifecycle rules help move old data to cheaper storage classes or expire it automatically.
resource "aws_s3_bucket_lifecycle_configuration" "assets" {
bucket = aws_s3_bucket.assets.id
rule {
id = "static-assets-lifecycle"
status = "Enabled"
filter {
prefix = ""
}
transition {
days = 30
storage_class = "STANDARD_IA"
}
expiration {
days = 365
}
}
}
What this rule does
- after 30 days, objects transition to Standard-IA
- after 365 days, objects expire
This exact policy may or may not match a production use case, but it teaches an important idea: storage should be managed intentionally.
If your bucket stores immutable assets with long-term value, you might keep them much longer. If it stores build artifacts or temporary content, expiration could happen much sooner.
Creating the Website Configuration
Because the lesson asks for a website endpoint, the bucket needs static website hosting configured.
resource "aws_s3_bucket_website_configuration" "assets" {
bucket = aws_s3_bucket.assets.id
index_document {
suffix = "index.html"
}
error_document {
key = "error.html"
}
}
This tells S3 how to behave when someone browses the bucket as a static website.
Important distinction
There are two common ways to serve files from S3:
- Direct object URLs
- Static website hosting
The website configuration is what gives you the website-style endpoint.
Writing the Bucket Policy
To allow public reads, create a bucket policy. Using aws_iam_policy_document makes the policy easier to read than hand-writing JSON strings.
data "aws_iam_policy_document" "assets_public_read" {
statement {
sid = "PublicReadGetObject"
effect = "Allow"
principals {
type = "*"
identifiers = ["*"]
}
actions = ["s3:GetObject"]
resources = [
"${aws_s3_bucket.assets.arn}/*"
]
}
}
resource "aws_s3_bucket_policy" "assets" {
bucket = aws_s3_bucket.assets.id
policy = data.aws_iam_policy_document.assets_public_read.json
}
What this policy allows
Anyone on the internet can read objects in the bucket with s3:GetObject. They cannot upload, delete, or list all bucket settings. It is intentionally narrow.
Still, public buckets require care. For a production public website, CloudFront plus a private bucket is often a stronger pattern. Here, the tutorial keeps the moving parts small so you can clearly see how S3 policies work.
Uploading Files with aws_s3_object
A bucket with no objects is not very exciting. Terraform can upload files directly.
resource "aws_s3_object" "index" {
bucket = aws_s3_bucket.assets.id
key = "index.html"
content_type = "text/html"
content = <<-HTML
<html>
<head>
<title>DevOpsLesson Assets</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<h1>Static assets deployed with Terraform</h1>
<p>This file lives in S3.</p>
</body>
</html>
HTML
}
resource "aws_s3_object" "styles" {
bucket = aws_s3_bucket.assets.id
key = "styles.css"
content_type = "text/css"
content = <<-CSS
body {
font-family: Arial, sans-serif;
margin: 2rem;
background: #f7fafc;
color: #1a202c;
}
CSS
}
resource "aws_s3_object" "error" {
bucket = aws_s3_bucket.assets.id
key = "error.html"
content_type = "text/html"
content = "<h1>Page not found</h1>"
}
Why use content here?
For a tutorial, embedding content directly keeps the example self-contained. In larger projects, you will often prefer source = "path/to/file" so your real HTML, CSS, and image assets live as normal files in the repository.
Outputting the Bucket Name and Website Endpoint
Outputs make post-apply testing simple.
output "assets_bucket_name" {
description = "Name of the S3 bucket"
value = aws_s3_bucket.assets.bucket
}
output "assets_website_endpoint" {
description = "Static website endpoint for the bucket"
value = aws_s3_bucket_website_configuration.assets.website_endpoint
}
After apply, run:
terraform output assets_bucket_name
terraform output assets_website_endpoint
Then open the endpoint in your browser.
Running Plan and Apply
As always, initialize first if you added a new provider:
terraform init
Then plan:
terraform plan
Review the plan carefully for:
- the generated bucket name
- public access block values
- versioning enabled
- lifecycle rules present
- the public-read bucket policy
- object uploads
- website configuration
Then apply:
terraform apply
Complete Working Code
Below is a complete working example for this S3 layer.
main.tf
terraform {
required_version = ">= 1.6.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
random = {
source = "hashicorp/random"
version = "~> 3.6"
}
}
}
provider "aws" {
region = var.aws_region
}
resource "random_id" "bucket_suffix" {
byte_length = 4
}
resource "aws_s3_bucket" "assets" {
bucket = "${var.project_name}-assets-${random_id.bucket_suffix.hex}"
tags = {
Name = "${var.project_name}-assets"
ManagedBy = "Terraform"
}
}
resource "aws_s3_bucket_ownership_controls" "assets" {
bucket = aws_s3_bucket.assets.id
rule {
object_ownership = "BucketOwnerPreferred"
}
}
resource "aws_s3_bucket_public_access_block" "assets" {
bucket = aws_s3_bucket.assets.id
block_public_acls = true
ignore_public_acls = true
block_public_policy = false
restrict_public_buckets = false
}
resource "aws_s3_bucket_versioning" "assets" {
bucket = aws_s3_bucket.assets.id
versioning_configuration {
status = "Enabled"
}
}
resource "aws_s3_bucket_lifecycle_configuration" "assets" {
bucket = aws_s3_bucket.assets.id
rule {
id = "static-assets-lifecycle"
status = "Enabled"
filter {
prefix = ""
}
transition {
days = 30
storage_class = "STANDARD_IA"
}
expiration {
days = 365
}
}
}
resource "aws_s3_bucket_website_configuration" "assets" {
bucket = aws_s3_bucket.assets.id
index_document {
suffix = "index.html"
}
error_document {
key = "error.html"
}
}
data "aws_iam_policy_document" "assets_public_read" {
statement {
sid = "PublicReadGetObject"
effect = "Allow"
principals {
type = "*"
identifiers = ["*"]
}
actions = ["s3:GetObject"]
resources = [
"${aws_s3_bucket.assets.arn}/*"
]
}
}
resource "aws_s3_bucket_policy" "assets" {
bucket = aws_s3_bucket.assets.id
policy = data.aws_iam_policy_document.assets_public_read.json
depends_on = [aws_s3_bucket_public_access_block.assets]
}
resource "aws_s3_object" "index" {
bucket = aws_s3_bucket.assets.id
key = "index.html"
content_type = "text/html"
content = <<-HTML
<html>
<head>
<title>DevOpsLesson Assets</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<h1>Static assets deployed with Terraform</h1>
<p>This page is served from S3 static website hosting.</p>
</body>
</html>
HTML
}
resource "aws_s3_object" "styles" {
bucket = aws_s3_bucket.assets.id
key = "styles.css"
content_type = "text/css"
content = <<-CSS
body {
font-family: Arial, sans-serif;
margin: 2rem;
background: #f7fafc;
color: #1a202c;
}
CSS
}
resource "aws_s3_object" "error" {
bucket = aws_s3_bucket.assets.id
key = "error.html"
content_type = "text/html"
content = "<h1>Page not found</h1>"
}
variables.tf
variable "aws_region" {
description = "AWS region for the infrastructure"
type = string
default = "us-east-1"
}
variable "project_name" {
description = "Name used in resource names and tags"
type = string
default = "devopslesson"
}
outputs.tf
output "assets_bucket_name" {
description = "Name of the S3 bucket"
value = aws_s3_bucket.assets.bucket
}
output "assets_website_endpoint" {
description = "Static website endpoint for the bucket"
value = aws_s3_bucket_website_configuration.assets.website_endpoint
}
A Practical Production Note
This tutorial makes the bucket public because the goal is to teach bucket policy, public access controls, and website hosting in one place. For production internet-facing sites, a stronger pattern is often:
- private S3 bucket
- CloudFront distribution in front
- TLS certificates via ACM
- origin access control instead of direct public read
Still, if you understand the simpler public bucket design first, the more secure production pattern becomes easier to reason about.
Final Thoughts
The S3 part of the project teaches an important Terraform lesson: cloud resources are rarely “done” after the first resource block. Buckets need naming strategy, access design, versioning decisions, lifecycle management, and often object uploads or policies.
That is why Terraform is so useful. Instead of manually remembering every checkbox in the AWS console, you define the bucket once in code and recreate it consistently.
Practice Questions
Question 1: Unique Bucket Names
Why does this tutorial use a `random_id` resource when naming the S3 bucket?
Question 2: Public Access Block
Why does the tutorial set `block_public_policy = false` on the S3 public access block resource?
Question 3: Lifecycle Rules
What is the main purpose of an S3 lifecycle configuration?