Refactoring of a static site deployment, from Terraform to AWS CDK

Share on:

Refactoring of a static site deployment, from TF to AWS CDK

Intro

Personal projects are fun, especially when you have the flexibility to choose your provisioner. In my case, I initially deployed the static site via Terraform. I realized shortly after, that.. even though it did what I wanted it to.. it may not scale in the way that I’d like it to. This is where I shifted to rewrite it in AWS CDK (Cloud Development Kit) v2.x to allow for:

  • Flexibility in deployment, including adapting it to an AWS CI\CD Pipeline (CodeBuild orchestrated via CodePipeline)
  • Continue to sharpen my Python 3.x skills along with how AWS CDK operates, including hands on ability

Table of Contents

Requirements

We always should start with the “business” requirements, or in case of a personal project.. what we need to accomplish. I’ve broken it down into the following:

  1. Fast to load, optimized and first-paint times are low
  2. Optimized for mobile
  3. Operate as cheaply as possible
  4. Content can be added dynamically (CMS) as we grow
  5. Codebase can grow and accepts new contributors that write a common development language (Python)
  6. State is managed remotely (Amazon S3, for example)
  7. Secure, ensuring it passes as a B or higher on SecurityHeaders

Deployment Options

Our provisioning options leads us with both HashiCorp Terraform and AWS CDK (Cloud Development Kit) to provision the AWS resources required to sustain our requirements. I’ll provide the journey I went through below to explore each option and its respective characteristics.

HashiCorp Terraform

This was our initial deployment, and genuinely.. my go-to when trying to get AWS resources up and running quickly. It’s (subjectively) an easy language to learn and work with as a declarative language. I decided to use Terraform modules to get up and running quickly, without compromising my requirements. In my initial run through, I developed a pretty flat Terraform file which is depicted in the next block:

HashiCorp Terraform - Code: (main.tf)

# For cloudfront, the acm has to be created in us-east-1 or it will not work  
provider "aws" {  
  region = "us-east-1"  
}  
  
variable "domain_name" {  
  description = "The Domain Name used"  
  default = "example.group"  
}  
  
data "aws_cloudfront_cache_policy" "Managed-CachingOptimized" {  
  name = "Managed-CachingOptimized"  
}  
  
data "aws_route53_zone" "selected" {  
  name = var.domain_name  
  private_zone = false  
}  
  
resource "aws_cloudfront_response_headers_policy" "security_headers_policy" {  
  name = "sec-policy"  
  security_headers_config {  
    content_type_options {  
      override = true  
  }  
    frame_options {  
      frame_option = "DENY"  
  override = true  
  }  
    referrer_policy {  
      referrer_policy = "same-origin"  
  override = true  
  }  
    xss_protection {  
      mode_block = true  
  protection = true  
  override = true  
  }  
    strict_transport_security {  
      access_control_max_age_sec = "63072000"  
  include_subdomains = true  
  preload = true  
  override = true  
  }  
    content_security_policy {  
      content_security_policy = "frame-ancestors 'none'; default-src 'none'; img-src 'self'; script-src 'self'; style-src 'self'; object-src 'none'"  
  override = true  
  }  
  }  
}  
  
# create acm and explicitly set it to us-east-1 provider  
module "acm_request_certificate" {  
  source = "cloudposse/acm-request-certificate/aws"  
  
  domain_name                       = var.domain_name  
  subject_alternative_names         = ["www.example.group", "*.example.group"]  
  process_domain_validation_options = false  
  ttl                               = "300"  
}  
  
module "cdn" {  
  source            = "cloudposse/cloudfront-s3-cdn/aws"  
  namespace         = "michiganawsug"  
  stage             = "prod"  
  name              = var.domain_name  
  aliases           = ["www.example.group", "example.group"]  
  dns_alias_enabled = true  
  parent_zone_id = data.aws_route53_zone.selected.id  
  cache_policy_id   = data.aws_cloudfront_cache_policy.Managed-CachingOptimized.id  
  response_headers_policy_id = aws_cloudfront_response_headers_policy.security_headers_policy.id  
  
  acm_certificate_arn = module.acm_request_certificate.arn  
  depends_on          = [module.acm_request_certificate]  
  
}  
  
output "s3_bucket_name" {  
  value       = module.cdn.s3_bucket  
  description = "Amazon S3 Bucket Origin Name"  
}  
  
output "s3_bucket_arn" {  
  value       = module.cdn.s3_bucket_arn  
  description = "Amazon S3 Bucket ARN"  
}  
  
output "cloudfront_dist_id" {  
  value = module.cdn.cf_id  
  description = "Amazon Cloudfront ID"  
}

Code Walkthrough

As you can see above, the Terraform HCL (HashiCorp Language) code is pretty straight forward and utilizes multiple modules to satisfy the requirements. This was deployed using Terraform v1.2.3 and managed its remote state via a file labeled remote_state.tf which dictated that any remote state be stored in Amazon S3 bucket that I previously created.

Code Deployment
  1. Download and install Terraform if you haven’t already.

  2. Copy above code and change the values to fit your domain name and respective attributes.

  3. Ensure you have AWS credentials loaded into your ~/.aws/credentials file either for default (as depicted below) or name a profile. If you name a profile, you’ll need to change the provider block above to match.

  4. Create a remote state file, such as remote_state.tf and add:

     terraform {  
       backend "s3" {  
         bucket               = "example.group-tfstate"  
         key                  = "example-iac.tfstate"  
         workspace_key_prefix = "example-tfstate"  
         region               = "us-east-1"  
       }  
     }
    
  5. Run: terraform init

  6. Run: terraform plan and ensure it’s as expected

  7. Run: terraform deploy and deploy

AWS CDK

AWS CDK – The AWS Cloud Development Kit (AWS CDK) is an open-source software development framework for defining cloud infrastructure as code with modern programming languages and deploying it through AWS CloudFormation. I’ve worked with it extensively using Python, although I have plenty of examples of me deploying in CDK via Typescript on my GitHub ;). Let’s take a look at the folder structure first, as CDK requires a bit more structuring than a flat file in Terraform.

Folder Structure

Withholding cdk.out and related:

.
├── README.md
├── app.py
├── cdk.json
├── cf-functions
│   └── redirect.js
├── requirements.txt
└── site_stack.py

Now, let’s see what the code looks like..

AWS CDK - Code: (app.py)

#!/usr/bin/env python3  
import os  
from aws_cdk import App, Environment  
from site_stack import StaticSiteStack  
  
app = App()  
props = {  
    "namespace": app.node.try_get_context("namespace"),  
  "default_root_object": app.node.try_get_context("default_root_object"),  
  "domain_name": app.node.try_get_context("domain_name"),  
  "domain_name_root": app.node.try_get_context("domain_name_root"),  
  "sub_domain_name": app.node.try_get_context("sub_domain_name"),  
  "hosted_zone_name": app.node.try_get_context("hosted_zone_name"),  
  "domain_email_txt_record": app.node.try_get_context("domain_email_txt_record"),  
  "domain_email_mx1": app.node.try_get_context("domain_email_mx1"),  
  "domain_email_mx2": app.node.try_get_context("domain_email_mx2")  
}  
  
env = Environment(  
    account=os.environ.get(  
        "CDK_DEPLOY_ACCOUNT", os.environ.get("CDK_DEFAULT_ACCOUNT")  
    ),  
  region=os.environ.get(  
        "CDK_DEPLOY_REGION", os.environ.get("CDK_DEFAULT_REGION")  
    ),  
)  
  
StaticSite = StaticSiteStack(  
    scope=app,  
  construct_id=f"{props['namespace']}-stack",  
  props=props,  
  env=env,  
  description="Example.Group - Static Site using S3, CloudFront and Route53",  
)  
  
app.synth()

AWS CDK - Code: (cdk.json)

{  
  "app": "python3 app.py",  
  "context": {  
    "namespace": "example",  
  "domain_name": "www.example.group",  
  "domain_name_root": "example.group",  
  "enable_s3_website_endpoint": false,  
  "default_root_object": "index.html",  
  "domain_email_txt_record": "forward-email=example@example.com",  
  "domain_email_mx1": "mx1.forwardemail.net",  
  "domain_email_mx2": "mx2.forwardemail.net"  
  }  
}

AWS CDK - Code: (requirements.txt)

aws-cdk-lib==2.57.0  
constructs==10.1.202

AWS CDK - Code: (cf-functions/redirect.js)

function handler(event) {  
    var request = event.request;  
    var uri = request.uri;  
  
    if (uri.endsWith('/')) {  
        request.uri += 'index.html';  
    } else if (!uri.includes('.')) {  
        request.uri += '/index.html';  
    }  
  
    return request;  
}

AWS CDK - Code: (site_stack.py)

from aws_cdk import Stack, RemovalPolicy, CfnOutput, Tags, Duration  
from aws_cdk.aws_certificatemanager import Certificate, CertificateValidation  
from aws_cdk.aws_cloudfront import Distribution, BehaviorOptions, PriceClass, ViewerProtocolPolicy, CachePolicy, \  
    FunctionCode, FunctionAssociation, FunctionEventType, Function, ResponseHeadersPolicy, \  
    ResponseSecurityHeadersBehavior, ResponseHeadersFrameOptions, \  
    HeadersFrameOption, ResponseHeadersReferrerPolicy, ResponseHeadersContentTypeOptions, HeadersReferrerPolicy, \  
    ResponseHeadersStrictTransportSecurity  
from aws_cdk.aws_cloudfront_origins import S3Origin  
from aws_cdk.aws_route53 import HostedZone, ARecord, RecordTarget, TxtRecord, MxRecord, MxRecordValue  
from aws_cdk.aws_route53_targets import CloudFrontTarget  
from aws_cdk.aws_s3 import Bucket, BlockPublicAccess, BucketEncryption  
from aws_cdk.aws_s3_deployment import BucketDeployment, Source  
  
  
class StaticSiteStack(Stack):  
    def __init__(self, scope, construct_id, props, **kwargs):  
        super().__init__(scope, construct_id, **kwargs)  
  
        Tags.of(self).add("project", props["namespace"])  
        site_domain_name = props["domain_name"]  
        wildcard_domain_name = "*." + props["domain_name_root"]  
  
        route53_zone = self.r53_zone(props)  
        cert = self.cert_creation(props, route53_zone, site_domain_name, wildcard_domain_name)  
  
        site_bucket = self.bucket_creation(site_domain_name, enforce_ssl=True, versioned=True)  
  
        redirect_func = Function(self, "CFFunction", code=FunctionCode.from_file(file_path="cf-functions/redirect.js"),  
  comment="Deployed via AWS CDK")  
  
        sec_policy = self.sec_method  
  
        site_distribution = self.static_site(cert, props, redirect_func, sec_policy, site_bucket, site_domain_name)  
  
        self.r53_records(props, route53_zone, site_distribution)  
  
        # Add stack outputs  
  CfnOutput(  
            self,  
  "SiteBucketName",  
  value=site_bucket.bucket_name,  
  )  
        CfnOutput(  
            self,  
  "DistributionId",  
  value=site_distribution.distribution_id,  
  )  
        CfnOutput(  
            self,  
  "DistributionDomainName",  
  value=site_distribution.distribution_domain_name,  
  )  
        CfnOutput(  
            self,  
  "CertificateArn",  
  value=cert.certificate_arn,  
  )  
  
    def bucket_creation(self, site_domain_name, enforce_ssl, versioned):  
        site_bucket = Bucket(self, "Bucket",  
  block_public_access=BlockPublicAccess.BLOCK_ALL,  
  bucket_name=site_domain_name,  
  encryption=BucketEncryption.S3_MANAGED,  
  enforce_ssl=enforce_ssl,  
  versioned=versioned,  
  removal_policy=RemovalPolicy.RETAIN  
                             )  
        BucketDeployment(self, "DeployStaticSiteContents", sources=[Source.asset("../static")],  
  destination_bucket=site_bucket)  
        return site_bucket  
  
    def r53_records(self, props, route53_zone, site_distribution):  
        ARecord(self, "wwwRecord", zone=route53_zone,  
  target=RecordTarget.from_alias(CloudFrontTarget(site_distribution)), record_name="www")  
        ARecord(self, "ApexRecord", zone=route53_zone,  
  target=RecordTarget.from_alias(CloudFrontTarget(site_distribution)))  
        TxtRecord(self, "EmailRedirect", zone=route53_zone, values=[props["domain_email_txt_record"]])  
        MxRecord(self, "MX", zone=route53_zone, values=[MxRecordValue(  
            host_name=props["domain_email_mx1"],  
  priority=10  
  ), MxRecordValue(  
            host_name=props["domain_email_mx2"],  
  priority=10  
  )])  
  
    def static_site(self, cert, props, redirect_func, sec_policy, site_bucket, site_domain_name):  
        site_distribution = Distribution(self, "SiteDistribution",  
  comment=props["domain_name_root"],  
  price_class=PriceClass.PRICE_CLASS_100,  
  default_root_object=props["default_root_object"],  
  domain_names=[props["domain_name_root"], site_domain_name],  
  certificate=cert,  
  default_behavior=BehaviorOptions(origin=S3Origin(site_bucket),  
  viewer_protocol_policy=ViewerProtocolPolicy.REDIRECT_TO_HTTPS,  
  cache_policy=CachePolicy.CACHING_OPTIMIZED,  
  function_associations=[FunctionAssociation(  
                                                                              function=redirect_func,  
  event_type=FunctionEventType.VIEWER_REQUEST)],  
  response_headers_policy=sec_policy))  
        return site_distribution  
  
    @property  
  def sec_method(self):  
        sec_policy = ResponseHeadersPolicy(self, "SecPolicy",  
  security_headers_behavior=ResponseSecurityHeadersBehavior(  
                                               content_type_options=ResponseHeadersContentTypeOptions(  
                                                   override=True),  
  frame_options=ResponseHeadersFrameOptions(  
                                                   frame_option=HeadersFrameOption.DENY, override=True),  
  referrer_policy=ResponseHeadersReferrerPolicy(  
                                                   referrer_policy=HeadersReferrerPolicy.NO_REFERRER,  
  override=True),  
  strict_transport_security=ResponseHeadersStrictTransportSecurity(  
                                                   access_control_max_age=Duration.days(30),  
  include_subdomains=True, override=True),  
  ))  
        return sec_policy  
  
    def cert_creation(self, props, route53_zone, site_domain_name, wildcard_domain_name):  
        cert = Certificate(self, "SiteCertificate",  
  domain_name=wildcard_domain_name,  
  subject_alternative_names=[site_domain_name, props["domain_name_root"]],  
  validation=CertificateValidation.from_dns(route53_zone)  
                           )  
        return cert  
  
    def r53_zone(self, props):  
        route53_zone = HostedZone(self, "MainZone",  
  zone_name=props["domain_name_root"],  
  comment="Provisioned by AWS CDK",  
  )  
        route53_zone.apply_removal_policy(RemovalPolicy.RETAIN)  
        return route53_zone

Code Walkthrough

You’ll find a few notable components in the above code:

  • app.py is the entry-point into the environment and StaticSiteStack which calls and synthesizes the python3 code into an AWS CloudFormation stack
  • cdk.json defines our dictionary (as shown as props in app.py) that represents the parameters we’re providing to the StaticSiteStack application stack
  • The cf-functions\redirect.js file creates an Amazon CloudFront function that assists with single page application redirects
  • site_stack.py includes the meat & potatoes of the application stack, self explanatory
  • Create a static directory to include your page content, look for that above to adjust accordingly
Code Deployment
  1. Download and install AWS-CDK v2 CLI based on your operating system
  2. Establish your Python virtual environment accordingly
  3. Ensure your default AWS credentials resemble the account you’re deploying to (stored in ~/.aws/credentials)
  4. Ensure your account is bootstrapped if not done previously
  5. Run: cdk synth to ensure your pre-flight checks are operating as expected
  6. Run: cdk ls to view your AppStack names
  7. Run: cdk deploy example-stack or similar (based on your findings from step #5) to deploy the application
  8. This will create an Amazon Route53 Public Forward Zone that you will need to retrieve the contents of the nameserver entries and input it into your domain registrar. For example, ns1234.amazonaws.org, ns123.amazonaws.co.uk. If you’re confused, look here
  9. This will allow for the Amazon Certificate Validation to proceed, and unblock the rest of the deployment

Output - AWS CDK

This leads us into our final form, an AWS CDK deployment of a static site that is (at the time) adhering to AWS Well-Architected Patterns.

Solution Architecture Diagram

Our final deployment resembles the below:

Solution Architecture Diagram

Final Thoughts

This was a great exercise in experimenting, along with a lot of learning baked in. Next steps are to move it into proper a proper CI\CD pipeline, either substantiated through AWS or GitHub Actions. I’ll keep everyone posted on the journey!

Thanks for reading! :)