DevOpsLesson
DevOpsLesson

Free, comprehensive DevOps tutorials and learning roadmaps. Master Docker, Kubernetes, CI/CD, and more.

Stay Updated

Get notified about new tutorials and features.

Tutorials

  • What is DevOps?
  • Docker Tutorial
  • Terraform Tutorial
  • CI/CD Pipeline
  • All Tutorials

Roadmaps

  • DevOps Engineer
  • Cloud Engineer
  • SRE Path
  • All Roadmaps

Company

  • About Us
  • Blog
  • Contact
  • Privacy Policy
  • Terms of Service

© 2026 DevOpsLesson. All rights reserved.

DOCKERKUBERNETESTERRAFORMAWSCI/CDLINUXGITDEVOPS ROADMAPCLOUD ROADMAPSRE ROADMAPGIT CHEATSHEETDOCKER CHEATSHEETK8S CHEATSHEETTF CHEATSHEETLINUX CHEATSHEETDOCKERFILE LINTERYAML VALIDATORCRON PARSERREGEX TESTER

Terraform Tutorial

Introduction to Terraform
Installing Terraform
The Terraform Core Workflow
Terraform Variables and Outputs
Terraform State and Backends
Terraform Modules
Terraform Data Sources
Terraform Count and for_each
Terraform Expressions and Functions
Terraform Dynamic Blocks
Terraform Workspaces
Terraform with AWS
Terraform Provisioners
Terraform CI/CD Pipeline
Terraform Security

Terraform AWS S3 Static Assets

PreviousPrev
Next

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_bucket resource
  • 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 = 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.

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:

  1. Direct object URLs
  2. 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

Exercise

Question 1: Unique Bucket Names

Why does this tutorial use a `random_id` resource when naming the S3 bucket?

Exercise

Question 2: Public Access Block

Why does the tutorial set `block_public_policy = false` on the S3 public access block resource?

Exercise

Question 3: Lifecycle Rules

What is the main purpose of an S3 lifecycle configuration?

PreviousPrev
Next

Continue Learning

Terraform with AWS

Build a complete AWS project with Terraform by combining a custom VPC, a public EC2 web server, and an S3 bucket for static assets.

5 min read·Medium

Terraform AWS VPC Setup

Build a custom AWS VPC with Terraform, including public and private subnets, an internet gateway, route tables, and a NAT gateway.

14 min read·Medium

Terraform AWS EC2 Web Server

Continue the AWS Terraform project by launching an EC2 web server with a security group, key pair, user data, and Elastic IP.

12 min read·Medium

Explore Related Topics

AW

AWS Tutorials

Provision AWS infrastructure with Terraform

CI

CI/CD Tutorials

Run terraform plan/apply in CI pipelines

Try the Tool

YAML Validator

Validate Terraform and Kubernetes YAML configs before applying.

On This Page

Continuing the AWS ProjectWhy S3 Belongs in This ProjectThe Bucket Naming ProblemCreating the S3 BucketBucket Ownership ControlsWhat does `BucketOwnerPreferred` mean?Public Access Block ConfigurationWhy these settings?Enabling Bucket VersioningWhy enable versioning?Adding Lifecycle RulesWhat this rule doesCreating the Website ConfigurationImportant distinctionWriting the Bucket PolicyWhat this policy allowsUploading Files with `aws_s3_object`Why use `content` here?Outputting the Bucket Name and Website EndpointRunning Plan and ApplyComplete Working Code`main.tf``variables.tf``outputs.tf`A Practical Production NoteFinal ThoughtsPractice Questions