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:
aws_s3_bucket resourceThis 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.
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:
Even if the EC2 server disappears, the bucket and its objects remain. That decoupling is one of the big operational wins of cloud architecture.
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.
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.
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"
}
}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.
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
}block_public_acls = true and ignore_public_acls = true keep you away from old ACL-based public access patterns.block_public_policy = false allows the tutorial's public-read bucket policy to work.restrict_public_buckets = false prevents 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.
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"
}
}Because static assets change over time, and mistakes happen. Versioning helps when:
In production, versioning is often non-negotiable for important buckets.
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
}
}
}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.
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.
There are two common ways to serve files from S3:
The website configuration is what gives you the website-style endpoint.
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
}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.
aws_s3_objectA 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>"
}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.
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_endpointThen open the endpoint in your browser.
As always, initialize first if you added a new provider:
terraform initThen plan:
terraform planReview the plan carefully for:
Then apply:
terraform applyBelow is a complete working example for this S3 layer.
main.tfterraform {
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.tfvariable "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.tfoutput "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
}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:
Still, if you understand the simpler public bucket design first, the more secure production pattern becomes easier to reason about.
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.
<MCQExercise
exercise={{
id: 'terraform-aws-s3-q1',
title: 'Question 1: Unique Bucket Names',
description: 'Why does this tutorial use a random_id resource when naming the S3 bucket?',
options: [
{ id: 'a', text: 'Because S3 bucket names only need to be unique inside a single AWS account' },
{ id: 'b', text: 'Because Terraform requires all bucket names to contain hexadecimal characters' },
{ id: 'c', text: 'Because S3 bucket names must be globally unique, and a random suffix reduces naming collisions' },
{ id: 'd', text: 'Because versioning cannot be enabled unless the bucket name was generated randomly' },
],
correctId: 'c',
explanation: 'S3 bucket names are globally unique across AWS. Appending a random suffix is a simple way to avoid collisions when a common project name might already be taken.',
wrongExplanations: {
a: 'S3 bucket names must be unique globally, not just within one account.',
b: 'Terraform does not require hexadecimal names; the random suffix is only a convenience for uniqueness.',
d: 'Bucket versioning is unrelated to whether the bucket name includes a random suffix.',
},
}}
/>
<MCQExercise
exercise={{
id: 'terraform-aws-s3-q2',
title: 'Question 2: Public Access Block',
description: 'Why does the tutorial set block_public_policy = false on the S3 public access block resource?',
options: [
{ id: 'a', text: 'Because the bucket policy needs to allow public reads for the static website example' },
{ id: 'b', text: 'Because versioning does not work unless public bucket policies are enabled' },
{ id: 'c', text: 'Because Terraform cannot manage lifecycle rules on private buckets' },
{ id: 'd', text: 'Because aws_s3_object uploads fail when public policy blocking is enabled' },
],
correctId: 'a',
explanation: 'The tutorial intentionally uses a public-read bucket policy for a simple static website. If block_public_policy stayed true, AWS would block that policy from taking effect.',
wrongExplanations: {
b: 'Bucket versioning and public policy settings are separate features.',
c: 'Terraform can manage lifecycle rules on both public and private buckets.',
d: 'aws_s3_object can upload to private buckets too. The setting is about policy enforcement, not object upload capability.',
},
}}
/>
<MCQExercise exercise={{ id: 'terraform-aws-s3-q3', title: 'Question 3: Lifecycle Rules', description: 'What is the main purpose of an S3 lifecycle configuration?', options: [ { id: 'a', text: 'To automatically rotate IAM access keys used by Terraform' }, { id: 'b', text: 'To transition or expire objects over time for storage management and cost control' }, { id: 'c', text: 'To prevent bucket names from changing across Terraform applies' }, { id: 'd', text: 'To encrypt website traffic with HTTPS' }, ], correctId: 'b', explanation: 'Lifecycle rules help you manage stored objects over time, such as moving them to a cheaper storage class after a set number of days or deleting them when they are no longer needed.', wrongExplanations: { a: 'Lifecycle rules manage S3 objects, not IAM credentials.', c: 'Bucket names are controlled by the bucket resource definition, not lifecycle rules.', d: 'HTTPS for web delivery is typically handled by CloudFront or another front-end service, not S3 lifecycle configuration.', }, }} />
Create an aws_s3_bucket_public_access_block with all four settings true, which is a hard override that no bucket policy can bypass. Combine it with aws_s3_bucket_ownership_controls set to BucketOwnerEnforced, which disables ACLs entirely. Object ACLs were behind most historic S3 exposure incidents. For content that genuinely must be public, front the private bucket with CloudFront using an Origin Access Control rather than opening the bucket.
By default AWS refuses to delete a non-empty bucket and the destroy fails, which is a useful accident. force_destroy = true makes Terraform empty it first, which is convenient for ephemeral test buckets and dangerous anywhere else. Set it deliberately and never on anything holding real data. For production, add lifecycle { prevent_destroy = true } so Terraform refuses any plan that would remove it.
With aws_s3_bucket_lifecycle_configuration, containing rule blocks with transition and expiration actions. Add an abort_incomplete_multipart_upload rule to every bucket. Failed multipart uploads leave parts that are billed as storage but do not appear in a normal listing, and teams discover them years later as an unexplained cost. If versioning is enabled, include noncurrent_version_expiration or old versions accumulate indefinitely.
Within one account, IAM policies on the principals are sufficient and simpler. Use a bucket policy for cross-account access, for conditions that must apply to every caller regardless of their IAM, such as denying any request not over HTTPS, and for granting access to AWS services like CloudFront. A common mistake is writing both when only IAM is needed, which doubles the places a permission can be wrong.
Not in the configuration that uses it, since the bucket must exist before terraform init can store state there. Create it in a small separate configuration with local state, or once via the CLI. Enable versioning so a bad state write can be recovered, enable encryption, block all public access, and restrict who can read it. State contains secrets in plain text. Keep that bootstrap configuration in version control even though its own state is local; it changes rarely.
Learn how Terraform provisioners work: local-exec, remote-exec, file and connection blocks, null_resource, and why provisioners should be a last resort.
Build a Terraform pipeline in GitLab CI/CD with validate, plan, manual apply and destroy stages, GitLab-managed state and secure credential handling.
Secure Terraform code by scanning with tfsec and Checkov, protecting secrets in state, fixing common misconfigurations and adding checks to CI/CD.