»

Terraform AWS Static Site with CloudFront

UPDATE: This pull request has been merged into Terraform

A recent patch on the Terraform GitHub repository adds support for CloudFront distributions to the Terraform AWS Provider. The patch has not been merged into Terraform mainline yet, but I wanted to share my experience setting up an S3 static site, fronted with CloudFront and DNS routed with Route53. Until the CloudFront PR gets merged, you’ll have to build the branch from source in order to use the aws_cloudfront_distribution resource. The words you’re reading right now were served up from this very Terraform configuration via CloudFront, migrated from a simple nginx setup. If you haven’t used Terraform before, please review the Introduction and Getting Started Guide before proceeding.

Step 1: Setup your S3 Static Site Bucket

The first thing you need to do is setup an S3 bucket to act as your ‘origin’. This is where all your static HTML files and assets will live. Here’s what the code looks like:

provider "aws" {
  alias = "prod"

  region = "us-east-1"
  access_key = "${var.aws_access_key}"
  secret_key = "${var.aws_secret_key}"
}

resource "aws_s3_bucket" "origin_blakesmith_me" {
  provider = "aws.prod"

  bucket = "origin.blakesmith.me"
  acl = "public-read"
  policy = <<POLICY
{
  "Version":"2012-10-17",
  "Statement":[{
    "Sid":"PublicReadForGetBucketObjects",
        "Effect":"Allow",
      "Principal": "*",
      "Action":"s3:GetObject",
      "Resource":["arn:aws:s3:::origin.blakesmith.me/*"
      ]
    }
  ]
}
POLICY
  
  website {
    index_document = "index.html"
  }
}

After running terraform apply, you will have an S3 bucket that’s setup to serve HTTP traffic from the root of the bucket. Let’s examine some of the important parameters:

  • policy: Bucket policy that makes the bucket publicly readable.
  • website: Configure the S3 bucket to serve up a static website, in this case setting the default index_document to index.html.

You can find other configurations on the aws_s3_bucket resource page.

After uploading your static site to the S3 bucket, you should already be able to view the website at http://${bucketname}.s3-website-${aws_region}.amazonaws.com. As an example, my blog can be served up at http://origin.blakesmith.me.s3-website-us-east-1.amazonaws.com/.

Step 2: Add a Route53 Record for your Origin

We need a DNS entry for this origin. In this example, we’ll create one at http://origin.blakesmith.me. This will give us a helpful DNS record that will not route through CloudFront and can be used to access the S3 bucket static site directly with no caching or other CloudFront routing rules applied.

resource "aws_route53_zone" "blakesmith_me" {
  provider = "aws.prod"
  name = "blakesmith.me"
}

resource "aws_route53_record" "origin" {
  provider = "aws.prod"
  zone_id = "${aws_route53_zone.blakesmith_me.zone_id}"
  name = "origin.blakesmith.me"
  type = "A"

  alias {
    name = "${aws_s3_bucket.origin_blakesmith_me.website_domain}"
    zone_id = "${aws_s3_bucket.origin_blakesmith_me.hosted_zone_id}"
    evaluate_target_health = false
  }
}

First we setup our top level zone, and create a record that’s associated with that zone. We setup an alias record configuration that targets our S3 bucket we created before. Here are the important parameters:

  • zone_id: A reference to our top level DNS zone id
  • alias#zone_id: A reference to the S3 bucket’s existing zone identifier

Once you terraform apply this, you should be able to access your origin at the above DNS record and the behavior should be the same as when you set up the S3 bucket static website hosting directly. Notice that http://origin.blakesmith.me has no behavior change from the simple S3 static bucket site we setup before.

Step 3: Setup your CloudFront Distribution

We have all the basic pieces in place, now comes to the meat: Let’s setup a CloudFront distribution that will use the origin we just configured to serve up the website at the edge. If you’re not too familiar with CDNs, think of them as a “big distributed cache across the globe”. After we configure this distribution, our content will be served from edge servers closest to visitor’s location.

resource "aws_cloudfront_distribution" "blakesmith_distribution" {
  provider = "aws.prod"
  origin {
    domain_name = "origin.blakesmith.me.s3.amazonaws.com"
    origin_id = "blakesmith_origin"
    s3_origin_config {}
  }
  enabled = true
  default_root_object = "index.html"
  aliases = ["blakesmith.me", "www.blakesmith.me"]
  price_class = "PriceClass_200"
  retain_on_delete = true
  default_cache_behavior {
    allowed_methods = [ "DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT" ]
    cached_methods = [ "GET", "HEAD" ]
    target_origin_id = "blakesmith_origin"
    forwarded_values {
      query_string = true
      cookies {
        forward = "none"
      }
    }
    viewer_protocol_policy = "allow-all"
    min_ttl = 0
    default_ttl = 3600
    max_ttl = 86400
  }
  viewer_certificate {
    cloudfront_default_certificate = true
  }
  restrictions {
    geo_restriction {
      restriction_type = "none"
    }
  }
}

There’s a lot going on here, so let’s break it down a bit. The most important part is our origin declaration:

  origin {
    domain_name = "origin.blakesmith.me.s3.amazonaws.com"
    origin_id = "blakesmith_origin"
    s3_origin_config {
    }
  }
  • domain_name: points to the origin Route53 record we created in the last step
  • origin_id: A unique identifier for this origin configuration. Since you can setup multiple origins, used to link caching behavior to origin configurations.
  • s3_origin_config: Extra S3 origin options, we leave this blank

Next we have some other important top level declarations:

  enabled = true
  default_root_object = "index.html"
  aliases = ["blakesmith.me", "www.blakesmith.me"]
  price_class = "PriceClass_200"
  retain_on_delete = true
  • enabled: Enable our CloudFront distribution
  • default_root_object: Use index.html as our root object
  • aliases: HTTP hostnames you will be serving your site from. These must match your DNS records, or you will get 403 Forbidden Errors.
  • price_class: How CloudFront will prioritize where traffic gets served from based on price. See: CloudFront Pricing.
  • retain_on_delete: Causes CloudFront deletions to simply disable your distribution. Useful since CloudFront distributions can take upwards of 15 minutes to propagate.

Then we setup our basic caching behavior:

  default_cache_behavior {
    allowed_methods = [ "DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT" ]
    cached_methods = [ "GET", "HEAD" ]
    target_origin_id = "blakesmith_origin"
    forwarded_values {
      query_string = true
      cookies {
        forward = "none"
      }
    }
    viewer_protocol_policy = "allow-all"
    min_ttl = 0
    default_ttl = 3600
    max_ttl = 86400
  }

The most important part is that we reference our target_origin_id to link these two stanza configurations together.

  • allowed_methods: Which HTTP verbs we permit our distribution to serve
  • cached_methods: Which HTTP verbs we let this behavior apply to
  • target_origin_id: The name of the previous origin_id in our origin stanza
  • forwarded_values: Entities that will be passed from the edge to our origin.
  • viewer_protocol_policy: Which HTTP protocol policy to enforce. One of: allow-all, https-only, or redirect-to-https.
  • min_ttl: Minimum time (seconds) to live for objects in the distribution cache
  • max_ttl: Maximum time (seconds) objects can live in the distribution cache
  • default_ttl: The default time (seconds) objects will live in the distribution cache

Finally, allow CloudFront to use its default SSL cert and serve anywhere:

  viewer_certificate {
    cloudfront_default_certificate = true
  }
  restrictions {
    geo_restriction {
      restriction_type = "none"
    }
  }

Once you run terraform apply with your patched version of Terraform and wait the 10-15 minutes for AWS to asynchronously setup your distribution, you will have a CloudFront provided domain name that you can validate your setup with. For example, this website is viewable via CloudFront domain name at: d1u25xzl6dnmgy.cloudfront.net

Until the aws_cloudfront_resource gets released, you’ll have to consult the documentation provided in the pull request if you need to deviate from my simple setup here. There are also other helpful examples in the integration tests if you want to see other settings in action.

Step 4: Add Root Route53 Records

The last step adds Route53 records to reference the CloudFront distribution we just setup.

resource "aws_route53_record" "root" {
  provider = "aws.prod"
  zone_id = "${aws_route53_zone.blakesmith_me.zone_id}"
  name = "blakesmith.me"
  type = "A"

  alias {
    name = "${aws_cloudfront_distribution.blakesmith_distribution.domain_name}"
    zone_id = "Z2FDTNDATAQYW2"
    evaluate_target_health = false
  }
}

resource "aws_route53_record" "www" {
  provider = "aws.prod"
  zone_id = "${aws_route53_zone.blakesmith_me.zone_id}"
  name = "www.blakesmith.me"
  type = "A"

  alias {
    name = "${aws_cloudfront_distribution.blakesmith_distribution.domain_name}"
    zone_id = "Z2FDTNDATAQYW2"
    evaluate_target_health = false
  }
}

Here we setup our apex zone and www record to point to our CloudFront distribution. The two critical new pieces you should observe are:

  • alias#name: This ALIAS name references our CloudFront distribution created in the previous step
  • zone_id: This is a fixed hardcoded constant zone_id that is used for all CloudFront distributions

One final terraform apply and voilà! You have your final product: A static site being served from an S3 bucket and fronted by the AWS CloudFront distribution with Route53 knitting everything together.

You can verify everything is working by examining the HTTP response headers and looking for the CloudFront headers:

> GET / HTTP/1.1
> User-Agent: curl/7.37.1
> Host: blakesmith.me
> Accept: */*
>
< HTTP/1.1 200 OK
< Content-Type: text/html
< Content-Length: 36938
< Connection: keep-alive
< Date: Sat, 02 Apr 2016 17:48:17 GMT
< Last-Modified: Fri, 01 Apr 2016 11:44:21 GMT
< ETag: "1ae13b1b0471e67bad10eb95347f99da"
< Accept-Ranges: bytes
* Server AmazonS3 is not blacklisted
< Server: AmazonS3
< Age: 29
< X-Cache: Hit from cloudfront
< Via: 1.1 62e12fdf0f65bd8388f763f504606830.cloudfront.net (CloudFront)
< X-Amz-Cf-Id: 4F85Bkl9_nSPQvqjDWAEMYkssuPA04gl8V5qLLIU3cPlS5E1Gtam7A==
<

Helpful headers:

  • Server: The origin server identifier, in our case AmazonS3
  • Age: The age (in seconds) of the object you just retrieved from the distribution cache
  • X-Cache: Whether the HTTP request was a cache hit or miss.

Here is the full code example if you’d like to see the full setup that powers this site.

Happy Terraforming!



about the author

Blake Smith is a Principal Software Engineer and leads the Infrastructure group at at Sprout Social.

Blake Smith

create. code. learn.