Creating This Website

The Groundwork

When I started creating this website, I had an idea of how I wanted it to work but I wasn’t too sure of the detail. I’ll show you how I got it to where it is now.

The first decision towards that I didn’t want to go down the WordPress route and that a static website would be fine. A static website meant I could serve it from an AWS S3 bucket and not have to worry about the overheads (financial or otherwise) of running a standalone web server.

Having looked around at the various options, I decided to use Hugo, which bills itself as one of the most popular open-source static site generators, to build the website. I shan’t go into detail about building with Hugo, except for the configuration file changes required to complete my solution.

Following the AWS Well-Architected Framework, I wanted my data encrypted both in transit and at rest. Both of these are very simple to implement:

  • Encryption at rest is handled by server-side encryption, using Amazon S3-managed encryption keys (SSE-S3).
  • In transit encryption uses CloudFront to deliver the content stored in the S3 website bucket encrypted using HTTPS. This requires an SSL certificate for your domain, which is available for free from Amazon in Certificate Manager, and redirecting HTTP requests to HTTPS.

I had been introduced to CloudFormation in my AWS Solutions Architect course and wanted to automate the build/maintenance of any website(s) I built. Again, a bit of research helped me here and I decided to go with Terraform as my IaC tool.

A Diversion

As I mentioned before, serving the content over HTTPS requires a certificate. I originally wrote this as part of my project but decided to rewrite it as a Terraform module to keep everything DRY. The module handles the domain ownership validation using Route 53 and creates the required validation records:

module "get_certificate" {
  source = "git@github.com:robinvenables/get_aws_dns_validated_certificate"

  dns_domain_name         = var.root_domain_name
  certificate_domain_name = var.root_domain_name
  certificate_san         = ["*.${var.root_domain_name}"]
  certificate_tags = {
    Name = var.root_domain_name
  }
  is_cloudfront_certificate = true
}

locals {
  certificate_arn = module.get_certificate.certificate_arn
}

The important point to remember here is that if the certificate is going to be used with a CloudFront distribution, you must set is_cloudfront_certificate to true. This ensures that the certificate is created in the North Virginia (us-east-1) region for CloudFront to use. It is this certificate ARN that is returned by the module.

The Building Blocks

Having established what I wanted and roughly how I was going to do it, let’s look at the implementation. I’ll start with the variables that will be used throughout the script:

variable "root_domain_name" {
  description = "The root domain name for this website. The www subdomain will redirect to this"
  type        = string
}

variable "region" {
  description = "The AWS region this website will be created in (e.g. eu-west-2)"
  type        = string
}

variable "pages" {
  description = "Names of the Index and Error pages to use"
  type        = map(any)
  default = {
    index = "index.html"
    error = "404.html"
  }
}

variable "access_logging" {
  description = "Enable logging for the website"
  type        = bool
  default     = false
}

variable "logging_bucket" {
  description = "Name and prefix for website logging bucket"
  type        = map(any)
  default = {
    name   = "none"
    prefix = "none"
  }
}

I wanted the website to be served from the top-level domain with requests to the www subdomain being redirected here. First, an S3 bucket was created with the name of the root domain (e.g. example.com) and configured for Static Website Hosting and SSE-S3 encryption. Then, a second bucket was created with the name of the www subdomain (e.g. www.example.com) redirecting to the first bucket.

This example demonstrates dynamic configuration for the logging stanza; only if access_logging is set to true at ‘terraform apply’ are the target_bucket and target_prefix parameters included in the configuration:

resource "aws_s3_bucket" "root_site" {
  bucket = var.root_domain_name
  acl    = "public-read"
  policy = <<POLICY
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "PublicReadGetObject",
            "Effect": "Allow",
            "Principal": "*",
            "Action": [
                "s3:GetObject"
            ],
            "Resource": [
                "arn:aws:s3:::${var.root_domain_name}/*"
            ]
        }
    ]
}
POLICY

  server_side_encryption_configuration {
    rule {
      apply_server_side_encryption_by_default {
        sse_algorithm = "AES256"
      }
    }
  }

  website {
    index_document = var.pages.index
    error_document = var.pages.error
  }

  dynamic "logging" {
    for_each = var.access_logging ? [1] : []
    content {
      target_bucket = var.logging_bucket.name
      target_prefix = var.logging_bucket.prefix
    }
  }
}

resource "aws_s3_bucket" "www_site" {
  bucket = "www.${var.root_domain_name}"
  acl    = "private"
  website {
    redirect_all_requests_to = "https://${var.root_domain_name}"
  }
}

Next, I created a CloudFront distribution to sit in front of each of the buckets but I’m just showing the one that points to the root_domain_name bucket:

resource "aws_cloudfront_distribution" "root_site_cdn" {
  origin {
    custom_origin_config {
      http_port              = "80"
      https_port             = "443"
      origin_protocol_policy = "http-only"
      origin_ssl_protocols   = ["TLSv1.2"]
    }

    domain_name = aws_s3_bucket.root_site.website_endpoint
    origin_id   = var.root_domain_name
  }

  enabled             = true
  default_root_object = var.pages.index

  default_cache_behavior {
    viewer_protocol_policy = "redirect-to-https"
    compress               = true
    allowed_methods        = ["GET", "HEAD"]
    cached_methods         = ["GET", "HEAD"]
    target_origin_id       = var.root_domain_name
    min_ttl                = 0
    default_ttl            = 86400
    max_ttl                = 31536000

    forwarded_values {
      query_string = false
      cookies {
        forward = "none"
      }
    }
  }

  aliases = [var.root_domain_name]

  restrictions {
    geo_restriction {
      restriction_type = "none"
    }
  }

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

  depends_on = [local.certificate_arn]
}

Finally I created two Alias record sets in my hosted domain. The first is for the top level domain, the second for the www subdomain, each pointing to their associated CloudFront distribution:

data "aws_route53_zone" "my_dns_zone" {
  name = var.root_domain_name
}

resource "aws_route53_record" "root_record" {
  zone_id = data.aws_route53_zone.my_dns_zone.zone_id
  name    = ""
  type    = "A"
  alias {
    name                   = aws_cloudfront_distribution.root_site_cdn.domain_name
    zone_id                = aws_cloudfront_distribution.root_site_cdn.hosted_zone_id
    evaluate_target_health = false
  }
}

resource "aws_route53_record" "www_record" {
  zone_id = data.aws_route53_zone.my_dns_zone.zone_id
  name    = "www.${var.root_domain_name}"
  type    = "A"
  alias {
    name                   = aws_cloudfront_distribution.www_site_cdn.domain_name
    zone_id                = aws_cloudfront_distribution.www_site_cdn.hosted_zone_id
    evaluate_target_health = false
  }
}

Finishing Off

Now we have the infrastructure in place, we need to get our data into S3. The script outputs the CloudFront distribution ID that is generated:

output "cloudfront_id" {
  value       = aws_cloudfront_distribution.root_site_cdn.id
  description = "The CloudFront ID to deploy the website to."
}

This ID needs to be added to config.toml to enable automatic deployment of the website:

[deployment]

[[deployment.targets]]
  name = "AWS S3"
  URL = "s3://robinvenables.com?region=eu-west-2"
  cloudFrontDistributionID = "XXXXXXXXXXXXXX"

You just need to run the following to build and deploy your website:

$ hugo
$ hugo deploy --invalidateCDN