S3 Website with Terraform

  1. Overview
  2. Getting started
  3. Set up your domain
  4. Using AWS Certificate Manager to enable HTTPS traffic
  5. Distributing with CloudFront
  6. Correcting resource URLs with Lambda
  7. Static files in S3
  8. Applying the Terraform
  9. Deploy your website

Overview

In this tutorial, you will step through setting up secure static web hosting on AWS with Terraform.

The image above shows the flow between AWS services that you need for your website.

A request to view the website will be routed to your domain, which will be configured in Route53. AWS Certificate Manager is set up with Route53 and CloudFront to allow HTTPS traffic. When the request reaches the CloudFront distribution an edge lambda function is invoked to rewrite the URL so that it points to the correct resources stored in the S3 bucket.

At this point, you might be asking yourself, "Hey, I thought that S3 had static website hosting built-in?". Unfortunately, The static web hosting provided by the S3 bucket does not allow you to set up SSL/TLS correctly, which is what you need CloudFront for.

Getting started

This tutorial assumes that you have the following:

The code in this tutorial is available on github. You can add it to your project and modify it as necessary.

We will be going through the diagram above and explaining some of the resources created in the terraform files. You will see that each terraform file is named for the primary resources it creates to keep things organized.

We will start on the left side of the diagram and talk about how a request flows through the resources to return the website.

Set up your domain

The first thing you need to do is set up your domain so that network traffic is routed to AWS resources. You will need to do some manual setup to create a hosted zone. In the AWS console navigate to Route 53 and create a new hosted zone for your domain.

After your hosted zone is created you can enter the namespace info into your domain registrar.

Once that is complete you can fill in the domain name in the settings.auto.tfvars file.

prefix      = "dev"
domain_name = "<yourdomain.here>"

These variables are defined in the vars.tf file and allow for any of your terraform files in that directory to use them. This is exactly how we can retrieve our hosted zone info in route53.tf.

data "aws_route53_zone" "domain" {
  name = var.domain_name
}

Using AWS Certificate Manager to enable HTTPS traffic

Next up, We need to use AWS Certificate Manager to create an SSL/TLS certificate so that you can secure your website and allow HTTPS traffic. We do this by creating an aws_acm_certificate resource in acm.tf.

resource "aws_acm_certificate" "domain" {
  domain_name       = var.domain_name
  validation_method = "DNS"
}

Your certificate needs to be validated before it can be used. This is done by creating an aws_acm_certificate_validation in acm.tf with a reference to an aws_route53_record in route53.tf.

It can take a few minutes to validate the domain, but once that is done you can use this certificate to secure your requests to CloudFront.

Distributing with CloudFront

AWS CloudFront is a content delivery network that makes your website loads quickly by having servers close to the end users and by allowing for content caching.

We use CloudFront to provide secure access to the contents of your S3 bucket that we created in s3.tf.

resource "aws_s3_bucket" "domain" {
  bucket = "${var.prefix}-${var.domain_name}"
}

And we connect the bucket to the CloudFront distribution as the origin in cloudfront.tf.

resource "aws_cloudfront_distribution" "s3_distribution" {
  origin {
    domain_name = aws_s3_bucket.domain.bucket_regional_domain_name
    origin_id   = aws_s3_bucket.domain.id
  }
  ...
}

Your ACM certificate is also connected to the distribution to allow HTTPS traffic to CloudFront.

resource "aws_cloudfront_distribution" "s3_distribution" {
  ...
  viewer_certificate {
    acm_certificate_arn      = aws_acm_certificate.domain.arn
    ssl_support_method       = "sni-only"
    minimum_protocol_version = "TLSv1.2_2021"
  }
}

Note: Caching is disabled in the example which makes it a little easier to develop against. This should be adjusted to something more sensible if you aren't changing your website rapidly. See the documentation on aws_cloudfront_cache_policy for more details.

Correcting resource URLs with Lambda

CloudFront has an issue routing to the correct files in the S3 bucket. Navigating to /articles/index needs to route to /articles/index.html in the bucket. The solution is a lambda function that is connected to the CloudFront distribution and is invoked on the origin-request event.


resource "aws_cloudfront_distribution" "s3_distribution" {
  ...
  default_cache_behavior {
    ...
    lambda_function_association {
      event_type = "origin-request"
      lambda_arn = aws_lambda_function.origin_request.qualified_arn
    }
  }
  ...
}

This lambda function rewrites the URL to correctly append .html and index.html to the resource path requested from the S3 bucket.

def lambda_handler(event, context):
    request = event['Records'][0]['cf']['request']
    
    if request['uri'][-1] == '/':
        request['uri'] += 'index.html'
    elif not '.' in request['uri'] and '/' in request['uri']:
        request['uri'] += '.html'
    elif not '.' in request['uri']:
        request['uri'] += '/index.html'

    return request

In lambda.tf you zip up the lambda code and configure the function.

data "archive_file" "zip" {
  type        = "zip"
  output_path = "lambda_zip_file_int_1.zip"
  source {
    content  = file("../lambdas/origin-request.py")
    filename = "origin-request.py"
  }
}

resource "aws_lambda_function" "origin_request" {
  filename         = data.archive_file.zip.output_path
  source_code_hash = data.archive_file.zip.output_base64sha256
  handler          = "origin-request.lambda_handler"

  function_name = "${var.prefix}-origin_request"

  runtime = "python3.9"

  role    = aws_iam_role.lambda_edge_exec.arn
  publish = true
}

The lambda also needs an execution role with permissions to run as an edge lambda.

Static files in S3

Your S3 bucket will be storing the output of your website build. Since you are providing access to your content through CloudFront you can configure your bucket to block public access.

resource "aws_s3_bucket_public_access_block" "domain" {
  bucket = aws_s3_bucket.domain.id

  block_public_acls       = true
  block_public_policy     = true
  ignore_public_acls      = true
  restrict_public_buckets = true
}

There are some configurations in s3.tf to allow specified accounts or roles to push to your bucket. You shouldn't need this right away, but it will be helpful if you set up a CodeBuild for publishing your website changes off of a GitHub push or similar build system.

CloudFront also needs permission to read files from your bucket which you can grant with the following policy.


data "aws_iam_policy_document" "allowed_accounts" {
  statement {
    actions   = ["s3:GetObject"]
    resources = ["${aws_s3_bucket.domain.arn}/*"]

    principals {
      type        = "AWS"
      identifiers = [aws_cloudfront_origin_access_identity.domain.iam_arn]
    }
  }
  ...
}

Applying the Terraform

Now that you understand what your Terraform files will create, let's run Terraform and get your infrastructure ready to host your website.

Terraform has several commands you will be using:

  • terraform init - Initializes the Terraform environment and downloads any package dependencies. (like the AWS provider)
  • terraform plan - Tells you what terraform will create based on changes you have made in your terraform files and what is deployed in your environment.
  • terraform apply - Creates or updates your infrastructure to what is shown in the output of your terraform plan.
  • terraform destroy - Removes all resources created by Terraform from your environment.

By default, Terraform will create a state file in the directory in which you called the apply command. It is important to not lose this file. Terraform allows you to configure backend storage for the state file. For example, you could store it in S3.

Deploy your website

With your infrastructure in place, it's now time to get your website content into the S3 bucket.

For this, you can use the S3 sync command in the AWS CLI to fully replace the contents in your bucket with the content of the current directory.

aws s3 sync . s3://<your_bucket> --delete --acl bucket-owner-full-control

Once the sync is done you should be able to navigate to your website!

If you don't have a website ready yet check out my comparison of some of the UI frameworks or look into using a static website generator like HUGO.