Skip to main content

More Professional

·2215 words·11 mins

My personal site started as a quick static page to prove I exist. It worked, but it was missing basic professional touches: HTTPS, proper caching, and infrastructure-as-code. Here’s how I fixed it with Terraform, CloudFront, and a bit of CloudFront Functions."

Problems Identified
#

There’s no TLS/SSL
#

As this website used to be very simplistic webpage for me to ‘show I exists’ and a place where I can place my posts - [001-how-i-built-this-website] but since day one some - (Gonzalo) - were pointing to the obvious - there’s no TLS/SSL - not that I don’t want to but the underlying technology (static website published off the S3 bucket) doesn’t support HTTPS natively.

Denial-of-wallet
#

I was aware of this thing in general - basically somebody will request the S3 object and I will be charged for that - and this somebody can request the object million of times and I will be 40p (!!) but I was not aware how much it can cost me (and how quickly) and CloudFront can be used to mitigate this risk significantly.

My inner terraform tyrant
#

My biggest concern was about the fact that I had to maintain this website and updated it and added new posts and I did’t want to do it manually - I wanted to do it with code and as terraform is my tool of choice: I created the domain manually, the S3 bucket manually, I didn’t remember if I’ve alloweded versioning or not … the regular problems of the clickops - something has been completed, and just two eye-blinks later, you don’t remember how you did it and how to do it again. This is not how we do those things in this household! :)

Let’s terraform it, shall we?
#

S3 bucket
#

Ok, let’s start with the bucket - I already have a bucket (there the website lives) and get it under terraform - so I am going to use a combination of ‘statement’ for the S3 bucket but at the same time, I am going to import existing resource into it!

/* JUST ONCE 
import {
  to = aws_s3_bucket.website
  id = local.aws_config_env.name
}
*/

resource "aws_s3_bucket" "website" {
  provider = aws.eu-west-1
  bucket = local.aws_config_env.name
  tags = merge(local.tags, {
    Name        = local.aws_config_env.name
  })    
}

When I apply this, terraform will see that the bucket already exists and it will import it into the state file - so now I can manage it with terraform and I can be sure that if I need to create another bucket for some reason, I will do it with terraform and not clickops. Also I can now comment out the ‘import’ statement as I only need to do it once.

Certificate
#

Next step is to get the TLS/SSL certificate - I am going to use AWS Certificate Manager (ACM) for this and I am going to request a certificate for my domain - and I will do it in the us-east-1 region as CloudFront only accepts certificates from that region. (I think I can use certificate from other region but I don’t want to risk it and I don’t want to do it twice - once for the certificate and once for the CloudFront distribution - all these certificates and IAMs things are just safer to be created in us-east-1 ;

Also I am going to use multiple names for the certificate - I want to have www and ipv6 subdomains as well - so I will request a certificate for my domain and also for www and ipv6 subdomains - this way I can use the same certificate for all of them and I don’t have to worry about it in the future. Also if I’d like to utilise anything else, I can - (easily?) add new names … so far it’s a manual list but… we will see in the future as this is potentially another candidate to improve

resource "aws_acm_certificate" "website" {
  provider = aws.us-east-1   

  domain_name               = local.aws_config_env.name
  subject_alternative_names = ["www.${local.aws_config_env.name}","ipv6.${local.aws_config_env.name}"]
  validation_method         = "DNS"
  
  tags = merge(local.tags, {
    Name        = local.aws_config_env.name
  })    

  lifecycle {
    create_before_destroy = true
  }
}

Certificate validation
#

This is one of those things why I fall in love with terraform & AWS combo… the general challange is that I can request whatever CN within my certificate - e.g. google.com - and AWS - as CA - needs to reach out to me to prove that I am the owner / in control of the domain - and the way to do it is to create a DNS record with specific name and value - historically this was BIG problem - but now we live in 2nd quater of 21st century - and all I need to do is to tell AWS I want to verify by DNS and AWS will request me to create certain ‘random’ DNS records to prove I am the owner of the domain and AWS will check if that record exists and if it has the correct value - if it does, it will issue the certificate - if it doesn’t, it will not. This literally screams for automation :)

resource "aws_route53_record" "cert_validation" {
  for_each = {
    for dvo in aws_acm_certificate.website.domain_validation_options : dvo.domain_name => {
      name   = dvo.resource_record_name
      record = dvo.resource_record_value
      type   = dvo.resource_record_type
    }
  }
  provider = aws.eu-west-1
  allow_overwrite = true
  name            = each.value.name
  records         = [each.value.record]
  ttl             = 60
  type            = each.value.type
  zone_id         = data.aws_route53_zone.dnszone.id   # We'll define this
}

what the heck? :) Well it’s not that complicated - when I created the certificate in previous section, I requested the certificate for three names - “mplexia.com” - (domain.name) and another two - “www.mplexia.com” and “ipv6.mplexia.com” - (subject_alternative_names) - so AWS will ask me to create three DNS records to prove that I am the owner of the domain - and these three records will be different for each name - so I need to create three records with different names and values - but I don’t want to do it manually - so I am using ‘for_each’ to loop through all the domain validation options that AWS provides me and I am creating a record for each of them - and I am using ‘allow_overwrite’ because if I need to re-validate the certificate, I can just run terraform apply again and it will update the existing records with new values - this way I don’t have to worry about deleting old records or creating new ones - terraform will take care of it for me. Isn’t this awesome? :)

Two notes here: 1) I am creating the records in the same Route53 zone where my domain is - so I need to define the data source for that zone - this zone is something - similarly like the s3 bucket - I created manually some time ago - this is wrong and I need to fix this - later. 2) Creating the records for validation is not enough , it has to be requested with AWS to validate. Now this seems to be obvious but when I started with terraform years ago, I didn’t know and I remember I spent several hours on “WHY THE VALIDATION IS NOT WORKING?!??!” as I didn’t do:

resource "aws_acm_certificate_validation" "website" {
  provider                = aws.us-east-1
  certificate_arn         = aws_acm_certificate.website.arn
  validation_record_fqdns = [for record in aws_route53_record.cert_validation : record.fqdn]
}

(now I know and will remember forever: if DNS validation is required, creating of those DNS in not enough, you need to ASK AWS to valida)

CloudFront distribution
#

I will be honest, this could be difficult - in theory I know very well, how CDN/Cloudfront works but in practice, not really and this really looks intimidating: https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudfront_distribution After checking an alternative source of inspiration - https://github.com/terraform-aws-modules/terraform-aws-cloudfront ( this collection on github is an absolutely brilliant source of inspiration - highly recommened! ) and a brief consultation with grok, I come up with this:

resource "aws_cloudfront_origin_access_control" "website" {
  name                              = "mplexia-com-oac"
  description                       = "Origin Access Control for mplexia.com S3 bucket"
  origin_access_control_origin_type = "s3"
  signing_behavior                  = "always"
  signing_protocol                  = "sigv4"
  provider = aws.eu-west-1
}

resource "aws_cloudfront_distribution" "website" {
  provider = aws.eu-west-1
  enabled             = true
  is_ipv6_enabled     = true
  comment             = "mplexia.com - Hugo Static Website"
  default_root_object = "index.html"
  price_class         = "PriceClass_100"        # Use PriceClass_100 to save money or PriceClass_All

  aliases = ["mplexia.com", "www.mplexia.com", "ipv6.mplexia.com"]

  origin {
    domain_name              = aws_s3_bucket.website.bucket_regional_domain_name
    origin_id                = "s3-origin"
    origin_access_control_id = aws_cloudfront_origin_access_control.website.id
  }


  default_cache_behavior {
    allowed_methods  = ["GET", "HEAD", "OPTIONS"]
    cached_methods   = ["GET", "HEAD"]
    target_origin_id = "s3-origin"

    viewer_protocol_policy = "redirect-to-https"
    compress               = true

    min_ttl     = 0
    default_ttl = 3600      # 1 hour
    max_ttl     = 86400     # 24 hours

    
    forwarded_values {
      query_string = false
      cookies {
        forward = "none"
      }
    }
    function_association {
      event_type   = "viewer-request"
      function_arn = aws_cloudfront_function.www_redirect.arn
    }    
  }

  ordered_cache_behavior {
    path_pattern     = "/assets/*"
    allowed_methods  = ["GET", "HEAD", "OPTIONS"]
    cached_methods   = ["GET", "HEAD"]
    target_origin_id = "s3-origin"

    viewer_protocol_policy = "redirect-to-https"
    compress               = true

    min_ttl     = 0
    default_ttl = 31536000   # 1 year
    max_ttl     = 31536000

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

  restrictions {
    geo_restriction {
      restriction_type = "none"
    }
  }

  viewer_certificate {
    acm_certificate_arn      = aws_acm_certificate.website.arn
    ssl_support_method       = "sni-only"
    minimum_protocol_version = "TLSv1.2_2021"
  }

  tags = merge(local.tags, {
    Name        = local.aws_config_env.name
  }) 
}

I had few problems but I quickly figured out there could be a function_association which would control (or alternate) the generic behavious: some basic redirecton from www to the main non-www site. Second issue I had was that e.g. request for /post was sent directly to S3 bucket and S3 bucket doesn’t know how to handle it - it doesn’t know that it should serve index.html file - so I had to add default_root_object = “index.html” to the distribution configuration - this way, if somebody goes to mplexia.com/post, CloudFront will serve mplexia.com/post/index.html and it will work as expected. and I guess more will come:

resource "aws_cloudfront_function" "www_redirect" {
  name    = "www-to-apex-redirect"
  runtime = "cloudfront-js-2.0"
  comment = "Redirect www.mplexia.com to mplexia.com"
  publish = true

code = <<-EOF
function handler(event) {
    var request = event.request;
    var uri = request.uri;

    // 1. www  non-www redirect
    var host = request.headers.host.value;
    if (host === "www.mplexia.com" || host === "www.mplexia.com:443") {
        var newUrl = "https://mplexia.com" + uri;
        if (request.querystring && Object.keys(request.querystring).length > 0) {
            newUrl += "?" + new URLSearchParams(
                Object.keys(request.querystring).map(k => [k, request.querystring[k].value])
            ).toString();
        }
        return {
            statusCode: 301,
            statusDescription: "Moved Permanently",
            headers: { "location": { "value": newUrl } }
        };
    }

    // 2. Trailing slash  add index.html
    if (uri.endsWith("/")) {
        request.uri = uri + "index.html";
    }
    // 3. If no extension and doesn't end with /, add /index.html (optional but nice)
    else if (!uri.includes(".")) {
        request.uri = uri + "/index.html";
    }

    return request;
}
EOF
}

And later, I can add more logic/magic to this function - this is the beauty of CloudFront Functions - they are very lightweight and they can be easily modified and deployed without any downtime - so I can experiment with them and see what works best for my website.

DNS Cutover
#

As mentioned earlier, I created the route53 zone of mplexia.com manually (maybe it was created on my behalf when I registered it?) and it’s outside of terraform (hence I was using terraform data to look it up). Generally, that’s suboptimal as I want it to be ‘under’ terraform - so very similarly how I did the already-existing S3 bucket:

## I import the already existing zone here
 /* JUST ONCE 
data "aws_route53_zone" "dnszone" {
   provider = aws.eu-west-1
   name = local.aws_config_env.name
   private_zone = false
}
  

import {
  to = aws_route53_zone.mplexia_com
  id = data.aws_route53_zone.dnszone.id
}
*/


resource "aws_route53_zone" "mplexia_com" {
  name = local.aws_config_env.name

  tags = merge(local.tags, {
    Name        = local.aws_config_env.name
  })
}

Lookup the existing zone and import it into my resource - once done / run one-time, I can comment it out (it’s already imported)

and just add the DNS names …


resource "aws_route53_record" "apex" {
  provider = aws.eu-west-1
  #zone_id = data.aws_route53_zone.dnszone.id
  zone_id = aws_route53_zone.mplexia_com.id
  name    = local.aws_config_env.name
  type    = "A"
  alias {
    name                   = aws_cloudfront_distribution.website.domain_name
    zone_id                = aws_cloudfront_distribution.website.hosted_zone_id
    evaluate_target_health = false
  }
}

## TWO MORE - ommitted 

resource "aws_route53_record" "ipv6_ipv6" {
  provider = aws.eu-west-1
  #zone_id = data.aws_route53_zone.dnszone.id
  zone_id = aws_route53_zone.mplexia_com.id
  name    = "ipv6.${local.aws_config_env.name}"
  type    = "AAAA"
  alias {
    name                   = aws_cloudfront_distribution.website.domain_name
    zone_id                = aws_cloudfront_distribution.website.hosted_zone_id
    evaluate_target_health = false
  }
}

oh this is getting pretty long - this should be done slightly differently too (likely a list and check ‘for_each’) but about that some time next…

Conclusion & Next Steps
#

This was great. I really enjoyed it - it’s something which has to completed for some time and now as I was working on it I really liked how it turned out - I have a nice and secure website with TLS/SSL, yesterday, I didn’t really know CloudFront but now I have CloudFront distribution which will cache my content and serve it faster to my users and I have everything under terraform which means that I can easily manage it and update it in the future - I can add new posts, I can change the configuration, I can experiment with CloudFront Functions and see what works best for my website - this is really great and I’m looking forward to see how it evolves in the future as I also managed to get myself more stuff which I can improve!

As usually, the source code is here: https://github.com/lrozehnal/mplexia.com/tree/main/terraform

Ludek Rozehnal
Author
Ludek Rozehnal
AWS Cloud Network Engineer & Terraform Expert with 20+ years’ experience. For the last 8+ years I’ve been the primary cloud network architect and IaC authority at Flextrade Systems (UK remote), where I designed and delivered fully automated global multi-region/multi-account AWS networking using Terraform and GitOps. I combine deep traditional networking knowledge with DevOps practices to eliminate manual processes, reduce risk, and accelerate cloud migrations — especially for low-latency, business-critical workloads. I’m passionate about sharing my expertise through blogging, open-source contributions, and speaking engagements. If you’re looking for guidance on AWS cloud networking or Terraform best practices, let’s connect!