Skip to content

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.

Published Updated 7 min read

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.

Diagram loads as it approaches the viewport.

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.

txt
User
-> CloudFront distribution
-> Origin Access Control
-> Private S3 bucket
-> Static files

When 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.

txt
infra/
  providers.tf
  variables.tf
  main.tf
  outputs.tf
  versions.tf

Provider and version are explicitly fixed.

hcl
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.

hcl
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.

hcl
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.

hcl
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.

hcl
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.

hcl
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.

hcl
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.

bash
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.tfplan

S3 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:

  1. Create infrastructure with Terraform.
  2. 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.

yaml
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.

bash
curl -I https://dxxxxx.cloudfront.net/
curl -I https://www.example.com/
aws cloudfront get-distribution --id E1234567890ABC
aws s3 ls s3://example-site-bucket

You can check the cache status in the response header.

txt
x-cache: Miss from cloudfront
x-cache: Hit from cloudfront

Also check if direct access to S3 is blocked.

bash
curl -I https://example-site-bucket.s3.ap-northeast-2.amazonaws.com/index.html

In 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.

txt
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 automation

Taking 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 Operations

Related writing