Refactoring of a static site deployment, from Terraform to AWS CDK
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:
- Fast to load, optimized and first-paint times are low
- Optimized for mobile
- Operate as cheaply as possible
- Content can be added dynamically (CMS) as we grow
- Codebase can grow and accepts new contributors that write a common development language (Python)
- State is managed remotely (Amazon S3, for example)
- 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
-
Copy above code and change the values to fit your domain name and respective attributes.
-
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 theprovider
block above to match. -
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" } }
-
Run:
terraform init
-
Run:
terraform plan
and ensure it’s as expected -
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 theenvironment
andStaticSiteStack
which calls and synthesizes thepython3
code into an AWS CloudFormation stackcdk.json
defines our dictionary (as shown asprops
inapp.py
) that represents the parameters we’re providing to theStaticSiteStack
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
- Download and install AWS-CDK v2 CLI based on your operating system
- Establish your Python virtual environment accordingly
- Ensure your default AWS credentials resemble the account you’re deploying to (stored in
~/.aws/credentials
) - Ensure your account is bootstrapped if not done previously
- Run:
cdk synth
to ensure your pre-flight checks are operating as expected - Run:
cdk ls
to view your AppStack names - Run:
cdk deploy example-stack
or similar (based on your findings from step #5) to deploy the application - 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 - 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:
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! :)