Cloud & Platform Engineering
Static Hosting with S3, CloudFront, and Terraform
A small infrastructure project that provisions static hosting with S3, CloudFront, Route 53, and Terraform.
There are many ways to deploy a static site. You can use a platform optimized for application distribution, such as Vercel or Netlify, or configure it yourself by combining S3 and CloudFront on AWS. The purpose of this article is not to necessarily move blog hosting to AWS, but to create the basic components of a static web service in a form that can be explained as Infrastructure as Code.
If you manually create an S3 bucket in the Console and attach CloudFront to it, you can see results quickly. But if you want to recreate the same configuration, review the change history, or clone to another environment, a Console click isn't enough. If you use Terraform, you can leave buckets, access control, CDN, cache policy, DNS, and distribution permissions as code and check before and after changes with plan.
Organize the structure first
The simplest structure for static hosting is to open an S3 bucket to the public and download index.html. It's good for practice, but in actual service, it's safer and easier to operate if you don't expose S3 directly and put CloudFront in front.
User
-> CloudFront distribution
-> Origin Access Control
-> Private S3 bucket
-> Static filesWhen CloudFront becomes the user entry point, you can handle TLS, caching, compression, domain connectivity, error pages, and invalidation all in one place. S3 focuses on serving as an origin storage, and external users access CloudFront domains or custom domains rather than S3 website endpoints.
The important decisions are as follows.
- Do not open S3 bucket to public
- Enable S3 objects to be read only through CloudFront Origin Access Control (OAC)
- Organize 404/403 processing of SPA or static sites with CloudFront custom error response.
- Decide cache policy and invalidation strategy along with distribution method
- Use OIDC Role instead of long-term AWS Access Key in GitHub Actions
Terraform project structure
Terraform code does not need to be created as a huge module from scratch. For small projects, it is better to start with provider, variables, main, and outputs, and separate modules when repetition occurs.
The same architecture is available as an executable Terraform project. It includes variable checks, outputs, the OIDC deployment role, and GitHub Actions validation and deployment instead of isolated snippets only.
infra/
providers.tf
variables.tf
main.tf
outputs.tf
versions.tfProvider and version are explicitly fixed.
terraform {
required_version = ">= 1.10.0, < 2.0.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.0"
}
}
}
provider "aws" {
region = var.aws_region
}Variables separate values that vary from environment to environment and values that can be reused.
variable "aws_region" {
type = string
default = "ap-northeast-2"
}
variable "project_name" {
type = string
}
variable "domain_name" {
type = string
default = null
}An important file in Terraform is .tfstate. This file stores the connection state between the actual infrastructure and the code. Local state is enough for an isolated lab, but team and CI workflows should use a versioned S3 backend with native S3 lockfiles. The older DynamoDB-based locking mechanism is now deprecated.
terraform {
backend "s3" {
bucket = "example-terraform-state"
key = "static-site/terraform.tfstate"
region = "ap-northeast-2"
encrypt = true
use_lockfile = true
}
}The state contains a resource identifier and some properties. Because sensitive values may remain in the state, state storage access permissions must be managed more strictly than code storage permissions.
S3 buckets are set to private by default.
The S3 bucket is the original storage for static files. This configuration blocks public access and allows reading only from CloudFront OAC.
resource "aws_s3_bucket" "site" {
bucket = "${var.project_name}-site"
}
resource "aws_s3_bucket_public_access_block" "site" {
bucket = aws_s3_bucket.site.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
resource "aws_s3_bucket_ownership_controls" "site" {
bucket = aws_s3_bucket.site.id
rule {
object_ownership = "BucketOwnerEnforced"
}
}There is also a way to turn on the static website hosting option, but when using CloudFront, it is cleaner to set the S3 REST endpoint as the origin and attach OAC. This is because S3 website endpoint cannot be combined with OAC.
CloudFront OAC and Bucket Policies
CloudFront Origin Access Control forces CloudFront to use signed requests when accessing S3. Previously, OAI (Origin Access Identity) was used a lot, but in the new configuration, OAC is more natural.
resource "aws_cloudfront_origin_access_control" "site" {
name = "${var.project_name}-oac"
description = "OAC for private S3 origin"
origin_access_control_origin_type = "s3"
signing_behavior = "always"
signing_protocol = "sigv4"
}CloudFront distribution defines S3 origin, default cache behavior, viewer protocol policy, and custom error response.
data "aws_cloudfront_cache_policy" "optimized" {
name = "Managed-CachingOptimized"
}
resource "aws_cloudfront_distribution" "site" {
enabled = true
default_root_object = "index.html"
origin {
domain_name = aws_s3_bucket.site.bucket_regional_domain_name
origin_id = "s3-origin"
origin_access_control_id = aws_cloudfront_origin_access_control.site.id
}
default_cache_behavior {
target_origin_id = "s3-origin"
viewer_protocol_policy = "redirect-to-https"
allowed_methods = ["GET", "HEAD"]
cached_methods = ["GET", "HEAD"]
compress = true
cache_policy_id = data.aws_cloudfront_cache_policy.optimized.id
}
custom_error_response {
error_code = 403
response_code = 200
response_page_path = "/index.html"
}
restrictions {
geo_restriction {
restriction_type = "none"
}
}
viewer_certificate {
cloudfront_default_certificate = true
}
}Grant S3 read permission conditional on the distribution ARN created by CloudFront.
data "aws_iam_policy_document" "site_bucket" {
statement {
actions = ["s3:GetObject"]
resources = ["${aws_s3_bucket.site.arn}/*"]
principals {
type = "Service"
identifiers = ["cloudfront.amazonaws.com"]
}
condition {
test = "StringEquals"
variable = "AWS:SourceArn"
values = [aws_cloudfront_distribution.site.arn]
}
}
}
resource "aws_s3_bucket_policy" "site" {
bucket = aws_s3_bucket.site.id
policy = data.aws_iam_policy_document.site_bucket.json
}With this configuration, users will be rejected when they access the S3 object URL directly, and files should only be downloaded when accessed through CloudFront.
Terraform execution flow
Terraform proceeds through init, plan, and apply flows.
terraform init -upgrade
terraform fmt -recursive
terraform validate
terraform plan -out main.tfplan
terraform apply main.tfplan
terraform output
`
```plan` is not just a preview. This is a review point to check what is being created, modified, or deleted before making actual infrastructure changes. In particular, it is important to have the habit of reading plans for resources with a large impact of failure, such as CloudFront, Route 53, IAM, and S3 policy.
For deletion, look at the plan first in the same way.
```bash
terraform plan -destroy -out destroy.tfplan
terraform apply destroy.tfplanS3 buckets may not be deleted if objects remain in them. In a lab environment, you can use force_destroy = true, but in a production environment, you must be careful because there is a risk of accidentally deleting the original data.
Deploy from GitHub Actions
Static site deployment is divided into two steps:
- Create infrastructure with Terraform.
- Upload the built static files to S3 and invalidate the CloudFront cache.
In GitHub Actions, inserting a long-term AWS Access Key into a Secret is simple, but key leakage and rotation issues remain. By connecting GitHub OIDC and AWS IAM Role, temporary credentials can be issued at the time of workflow execution.
name: deploy-static-site
on:
push:
branches:
- main
permissions:
id-token: write
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v4
with:
node-version: 22
- run: npm ci
- run: npm run build
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ vars.AWS_ROLE_ARN }}
aws-region: ap-northeast-2
- run: aws s3 sync ./out s3://example-site-bucket --delete
- run: |
aws cloudfront create-invalidation \
--distribution-id E1234567890ABC \
--paths "/*"If you deploy Next.js as a static export, you can upload ./out. If you use a function that requires a server runtime among the App Router functions, a completely static export may not be suitable, so you must check the deployment target and framework functions together.
Cache invalidation costs money and time. Invalidating /* for every deployment is simple, but a strategy of long-term caching of static assets with hashes attached to file names and short-caching of only entry files such as index.html may be more efficient.
Verified deployment result
I applied this project to a sandbox account after reviewing the Terraform plan. The resulting pipeline uses no long-lived access key: GitHub Actions exchanges its OIDC token for a short-lived AWS role session restricted to the repository's main branch. The workflow then synchronizes the sample site to the private bucket and creates a CloudFront invalidation.
The deployment verification covered the following signals without publishing account identifiers or resource names:
- Terraform remote state stored in a separate encrypted and versioned S3 bucket with native lockfiles
- direct public access to the origin blocked
- CloudFront response returned HTTP 200 through the private OAC path
- HSTS, frame denial, and MIME sniffing protection headers present
- a second Terraform plan reported no infrastructure drift
Points to check during operation
Just because it’s a static site doesn’t mean operational verification disappears. Rather, in a static site, CDN cache, origin authority, DNS, and TLS are intertwined, so if a problem occurs, you must distinguish at which point the blockage occurred.
curl -I https://dxxxxx.cloudfront.net/
curl -I https://www.example.com/
aws cloudfront get-distribution --id E1234567890ABC
aws s3 ls s3://example-site-bucketYou can check the cache status in the response header.
x-cache: Miss from cloudfront
x-cache: Hit from cloudfrontAlso check if direct access to S3 is blocked.
curl -I https://example-site-bucket.s3.ap-northeast-2.amazonaws.com/index.htmlIn a normal configuration, direct access to S3 should be denied, and only CloudFront access should succeed. If direct access to S3 is successful, you should reexamine the use of bucket policy, public access block, ACL, and website endpoint.
Differentiating roles between Terraform and Ansible
Terraform and Ansible are often mentioned together in infrastructure automation. Both are automation tools, but their roles are different.
Terraform is strong at declaring the desired state of resources and creating, modifying, and deleting infrastructure through cloud APIs. Define resources such as VPC, Subnet, S3, CloudFront, AKS, and IAM Role.
Ansible is strong at configuring the settings of already existing servers or systems. It connects to the management node through SSH in an agentless manner and performs tasks such as installing packages, distributing configuration files, and restarting services using YAML Playbook.
Terraform
- cloud resource provisioning
- network, IAM, storage, cluster
- state file based lifecycle
Ansible
- server configuration
- package install, config file, service restart
- inventory and playbook based automationTaking a Kubernetes cluster as an example, you can create an Azure VM or AKS cluster with Terraform and configure a VM-based Kubernetes cluster with Ansible/Kubespray. If you use managed AKS/EKS, the proportion of Terraform and Helm/GitOps increases compared to Ansible, and if you create a cluster directly on a VM, Ansible's role increases.
Cleanup
Constructing a static site with S3 and CloudFront may seem simple, but moving to Terraform presents many operational questions. Should the S3 bucket be public, what policy is needed to allow only CloudFront to access the source, where will the state be stored, what permissions will the distribution pipeline have to change S3 and CloudFront, and when will the cache be invalidated?
Even for small static sites, asking this question as code makes infrastructure changes accountable. Resources created once in the Console rely on memory, but resources declared in Terraform are subject to review and reproduction. This difference is the biggest reason for using Infrastructure as Code.
Official references
Series
Cloud Delivery and OperationsRelated writing
Cloud & Platform Engineering
AWS Web Service Infrastructure and Operations Model
A connected view of Route 53, IAM, EC2, S3, CloudFront, ELB, Auto Scaling, and CloudWatch as a web service request flow.
Cloud & Platform Engineering
Safely Migrating a Next.js Development Environment from Windows to macOS
A practical recovery workflow for moving a Next.js project to Apple Silicon macOS while preserving Git history, private local files, and build reproducibility.
Cloud & Platform Engineering
Cloud Network Addressing and Routing Foundations
A network fundamentals note covering CIDR, subnetting, gateways, routing, DNS, TLS, and cloud network boundaries.