Recently I’ve had to deploy a couple of client-side web applications to the web, and my cloud provider of choice is AWS. If you are familiar with the various tools provided by AWS, setting up a web stack through the console is straightforward. It may be tempting to depend on the UI, especially for something that is usually pretty static, but I highly recommend adopting Infrastructure-as-Code (IaC) principles and using a management tool. You’ll find that the simplicity in deploying new sites and regions is worth the upfront time in setting up your deployment, and it’ll be much easier to manage.

If you are a web developer or full-stack developer with little or no devops experience, you’ll find that this is a great way to get started. In this post, I’ll walkthrough managing your infrastructure with an open-source IaC tool called Terraform but these principles will apply with any other cloud agnostic tool, or AWS’s IaC tool, CloudFormation.

  1. Terraform Introduction
  2. AWS Resources
  3. Prerequisites
  4. Writing the Plan
  5. Deployments
  6. Final Notes

Terraform Introduction

Before I jump into how we’re going to deploy a static site, a brief introduction to Terraform is required to make sense of the code we’ll write. Terraform allows for engineers to write declarative code to create, modify and destroy cloud assets on various cloud platforms such as GCP, AWS and others. Instead of having to navigate a platform’s CLI or UI, we can write terraform files that can be version controlled and added to the CI/CD platform of your choice.

This makes for more maintainable cloud infrastructure - doing it without the IaC approach is the software developer’s equivalent of manually copying files with FTP or rsync to the production server. We are aiming for reliable and repeatable deployments, and continuously shipping infrastructure is a part of the modern stack.

Below is some sample code from their homepage. The syntax is straightforward and describes a running AWS instance with some attributes defined outside in another block. Different types of AWS (and other platform) resources and their definitions and syntax can be found in their documentation.

resource "aws_instance" "iac_in_action" {
  ami               = var.ami_id
  instance_type     = var.instance_type
  availability_zone = var.availability_zone

  // dynamically retrieve SSH Key Name
  key_name = aws_key_pair.iac_in_action.key_name

  // dynamically set Security Group ID (firewall)
  vpc_security_group_ids = [aws_security_group.iac_in_action.id]

  tags = {
    Name = "Terraform-managed EC2 Instance for IaC in Action"
  }
}

Once you have your infrastructure defined, Terraform gives you two CLI tools to get your plan deployed. The plan operation compares your defined infrastructure versus what’s currently there. In the same way that configuration drift occurs in physical servers, it also happens in your cloud infrastructure. Maybe an engineer makes a change without anyone knowing, or a resource has new features launched. Either way, terraform plan shares an execution plan where you can confirm the upcoming changes are exactly what you want.

apply does exactly what you would expect, it will roll out that listed execution plan across the resources as defined. I won’t be diving into integrating these into continuous deployment workflows in this post, but basic knowledge of the above will let you version control your static site in preparation for automated deployments in the future. Now, onto the AWS resources required to host a static site.

AWS Resources

Hosting a static website is a common and standard need for any business or developer, and AWS provides production-grade resources to standup a new site in minutes, so that developers don’t need to worry about reliability. I’ll highlight each of the components and explain how each is used in the toolchain.

Diagram
of AWS resources required for hosting a static site.
Diagram of AWS resources required for hosting a static site.

1. S3 Buckets

S3 buckets are the most critical resource, as they are responsible for storing your collection of images, Javascript, and HTML. S3 is AWS’s object storage offering, and it is essentially a giant key-value store that allows for users to reliably store objects of any size with a key, namespaced with buckets. Deploying a new release to your site will involve overwriting existing assets in this bucket.

2. CloudFront Distributions

CloudFront distributions are globally available content delivery networks (CDNs) that allow for the contents of a single S3 bucket to be distributed with low latency all over the globe, depending on the configuration. Do you want your content optimized for access in Asia? Managing that is a simple configuration change with CloudFront.

3. Route53 routes

Route53 is a DNS web service, that allows for you to programatically direct network traffic to internal and external assets with your domain name. We’ll use Route53 to direct traffic to our CloudFront distribution so that our static site uses our memorable domain name.

4. IAM Policies

IAM stands for Identity Access Management, and is AWS’s tool for managing secure platform access within their ecosystem. We will use this to prevent unauthorized access to our S3 bucket, so that the only way users access our content is through our CloudFront distribution. This prevents unauthorized access, and enforces the client requirements we will set in our CDN. We’ll write an IAM policy in our terraform code below.

5. AWS Certificate Manager

ACM helps us manage our SSL/TLS certificates for secured HTTPS access to our static site. While not always necessary for certain kinds of content, I’ll assume your site requires HTTPS, although deploying a site with HTTP only access is just as easy.

Prerequisites

There are two prerequisites assumed below, so you will have to modify the configuration plan accordingly, or manually configure these assets. The reason these are not contained below is that they are really static, and are much more “set-it and forget-it” than anything below. They can be automated as well, but I deemed it out of scope for this example.

First, this tutorial assumes you have an existing Hosted Zone created in Route53. For each unique domain you have, you’ll need a hosted zone. You don’t necessarily need to purchase a domain through AWS, but if you manage a domain through another domain provider like Namecheap, you’ll have to configure their portal to point to the AWS name servers provided after you have a hosted zone created. You will also need the hosted zone id once you have it setup.

Second, I’m assuming you have a valid SSL/TLS certificate created through ACM. You can create one with a wildcard to your domain, such as *.customdomain.com, and this will allow you to use the same certificate in all future subdomain static sites. Keep track of the ARN that comes with that certificate.

Writing the Plan

With all of that out of the way, we can get into the details and look at what such a plan will look like. I’ve pasted the entire plan below as well as in this this gist.

The parts that should be overriden by your own config are in the <> brackets, and the brackets should also be replaced by whatever text or variable is specified. The region can also be changed, I just defaulted to us-east-1.

The locals block allows for you to parameterize variables that get referenced in multiple places later on. These could also be converted to input variables so that they can be dynamically set as well.

The amount of CloudFront distribution parameters would take a full blog post to cover all of the details. I selected some sane defaults for this distribution: it requires HTTPS, uses PriceClass_100, which caches your content in NA and EU (cheapest option), and uses some standard caching values.

terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "3.19.0"
    }
  }
}

provider "aws" {
  # region can be overriden, parameterized if desired
  region = "us-east-2"
}

# PARAMETERS, certificate and hosted zone id required
locals {
  s3_origin_id = "myS3Origin"
  certficate_arn = "<certificate_arn_here>"
  dns_zone_id = "<hosted_zone_id>"
}

# s3 bucket configuration
resource "aws_s3_bucket" "bucket" {
  bucket = "<your_bucket_name_here>"
  acl    = "private"

  website {
    # change this if you have something like root.html or home.html configured instead
    index_document = "index.html"
  }

  # feel free to modify tags for your own use, used for cost analytics
  tags = {
    Service = "<service_name>"
    Operation = "app-hosting"
    Environment = "prod"
  }
}

# cloudfront principal identity for s3 access
resource "aws_cloudfront_origin_access_identity" "s3_access_identity" {
  comment = "Cloudfront user for S3 bucket access."
}

# cloudfront distribution configuration
resource "aws_cloudfront_distribution" "s3_distribution" {
  origin {
    domain_name = aws_s3_bucket.bucket.bucket_regional_domain_name
    origin_id = local.s3_origin_id

    s3_origin_config {
      origin_access_identity = aws_cloudfront_origin_access_identity.s3_access_identity.cloudfront_access_identity_path
    }
  }

  enabled = true
  is_ipv6_enabled = true
  comment = "Host for Blog"
  default_root_object = "index.html"

  # logging_config {
  #   include_cookies = false
  #   bucket          = "mylogs.s3.amazonaws.com"
  #   prefix          = "myprefix"
  # }

  aliases = ["<domain desired here, ex: blog.customdomain.com>"]

  default_cache_behavior {
    allowed_methods  = ["DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT"]
    cached_methods   = ["GET", "HEAD"]
    target_origin_id = local.s3_origin_id

    forwarded_values {
      query_string = false

      cookies {
        forward = "none"
      }
    }

    viewer_protocol_policy = "allow-all"
    min_ttl                = 0
    default_ttl            = 3600
    max_ttl                = 86400
  }

  restrictions {
    geo_restriction {
      restriction_type = "none"
    }
  }

  price_class = "PriceClass_100"

  viewer_certificate {
    acm_certificate_arn = local.certficate_arn
    ssl_support_method = "sni-only"
  }

  tags = {
    Service = "<your_service_name>"
    Operation = "cdn"
    Environment = "prod"
  }
}

# json policy for cloudfront -> s3 access
data "aws_iam_policy_document" "s3_policy" {
  statement {
    actions = ["s3:GetObject"]
    resources = [
      "${aws_s3_bucket.bucket.arn}/*"
    ]

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

# iam policy
resource "aws_s3_bucket_policy" "s3_read_access" {
  bucket = aws_s3_bucket.bucket.id
  policy = data.aws_iam_policy_document.s3_policy.json
}

# dns route to cloudfront
resource "aws_route53_record" "app_route" {
  zone_id = local.dns_zone_id
  name = "blog.customdomain.com"
  type = "A"

  alias {
    name = aws_cloudfront_distribution.s3_distribution.domain_name
    zone_id = aws_cloudfront_distribution.s3_distribution.hosted_zone_id
    evaluate_target_health = false
  }
}

Once you have a plan that you are happy with, you can test it out using terraform plan to get a full list of what will happen, and deploy it using terraform apply if nothing errors out.

Deployments

All that is required to deploy updates to your static site is to sync your desired build directory to the S3 bucket, and then create an Invalidation in your CF distribution to let it know that the cached content needs to be refreshed from the S3 bucket.

For a quick and easy example of how I do this with this blog, see the following deployment script in this very blog that uses the AWS CLI to sync my build folder, and create the invalidation.

That script runs automatically with TravisCI so that each merge to my production branch is deployed without any effort.

Final Notes

On the topic of pricing: you might be intimidated by all the resources we’ve created in this post and wondering what kind of AWS bill you will incur at the end of the month. I can assure you that for the average site, AWS’s usage based pricing of S3, CloudFront and Route53 will be competitive to almost any alternative out there for hosting websites with a CDN. If you don’t believe me, make sure your tagging schema is set correctly, and use the Cost Explorer next month to see how little it costs. For reference, this site costs less than a cup of coffee a month to host.

With the rise of cloud platforms in the past decade, running infrastructure in the cloud has never been more accessible. I hope this gives you a peek into the power of IaC for your side project or business. If you have any questions, feel free to email or ask below, I’m always happy to help if I can.