Day 011 - 100DaysAWSIaCDevopsChallenge
Recently on Day 010 for my 100 Days of Code Challenge, I demonstrated how to create a custom domain name for API Gateway to make API endpoints more human-readable. Today, I will address setting up a secure subdomain for an Angular app deployed in an S3 bucket to avoid using the insecure default S3 URL http://<BUCKET_NAME>.website-static.s3.amazonaws.com
. To achieve this, I will use CloudFront Distribution
, a Content Delivery Network (CDN) service provided and managed by AWS. It is designed to deliver a content such as Images, Video Streams and static webside (like ours) quickly and securely to users around the world. CloudFront enhances the performance and security of websites and applications by delivering content closer to end users.
To reach out the goal, I will follow these steps:
Set Up a New CDK Construct for CloudFront Distribution with Subdomain Configuration
Create a new Construct that defines the CloudFront distribution. This will include setting up the distribution to serve content via a subdomain, specifying the origin, and configuring any necessary settings like request and caching policies and SSL certificates.Update the CORS Configuration of the Website Bucket: Modify the CORS configuration of the S3 bucket hosting the website. This involves adding the newly created CloudFront distribution as an allowed origin, ensuring that the content can be accessed correctly through the subdomain.
Update the existing stack
Incorporate the new CloudFront distribution and any associated changes into the existing infrastructure stack.Deploy the new infrastructure
Deploy the updated infrastructure, including the new CloudFront distribution and any changes to the existing stack.
Diagram of infrastructure
Set Up a New CDK Construct for CloudFront Distribution with Subdomain Configuration
I chose to create a new CDK construct rather than simply updating the stack with the necessary resources. Since this setup requires multiple resources that need to work together cohesively, creating a custom construct provides a more organized and modular approach. This allows for better management, easier reuse, and a cleaner integration of the CloudFront distribution with the subdomain configuration without forgeting the SSL configuration.
// The construct properties
interface WebsiteDistributeionProps extends CustomStackProps {
websiteBucketName: string;
domain: string; // the base domain
acm: {
certificateArn: string
};
distribution: {
domainName: string
}
}
// the custom construct
export class WebsiteDistribution extends Construct {
constructor(scope: Construct, id: string, props: WebsiteDistributeionProps) {
super(scope, id);
// retrieve or add resources here
}
Lookup the necessaries resources
To create a secure distribution that points to the S3 static website, we need to retrieve the certificate and the bucket resource.
const bucket = s3.Bucket.fromBucketName(this, `BucketName_${id}`, props.websiteBucketName!)
const certificate = acm.Certificate.fromCertificateArn(this, `SSLCertificate_${id}`, props.acm?.certificateArn!)
Create CloudFront Distribution
const cachePolicy = new cf.CachePolicy(this, `Cache-${id}-Policy`, {
cachePolicyName: `Cache-${id}-Policy`,
enableAcceptEncodingGzip: true,
enableAcceptEncodingBrotli: true,
queryStringBehavior: cf.CacheQueryStringBehavior.all(),
cookieBehavior: cf.CacheCookieBehavior.none(),
defaultTtl: Duration.seconds(30),
headerBehavior: cf.CacheHeaderBehavior.allowList(
'Origin',
'Accept',
'Access-Control-Request-Method',
'Access-Control-Request-Headers'
)
})
const originRequestPolicy = new cf.OriginRequestPolicy(this, `Origin-Request-${id}-Policy`, {
originRequestPolicyName: `Origin-Request-${id}-Policy`,
queryStringBehavior: cf.CacheQueryStringBehavior.all(),
cookieBehavior: cf.CacheCookieBehavior.none(),
headerBehavior: cf.CacheHeaderBehavior.allowList(
'Origin',
'Accept',
'Access-Control-Request-Method',
'Access-Control-Request-Headers'
)
})
const distribution = new cf.Distribution(this, `SSLCertificate_${id}`, {
enabled: true,
defaultBehavior: {
origin: new origins.HttpOrigin(bucket.bucketWebsiteDomainName, {
protocolPolicy: cf.OriginProtocolPolicy.HTTP_ONLY,
httpPort: 80,
httpsPort: 443,
connectionTimeout: Duration.seconds(10),
originId: generateResourceID()
}),
allowedMethods: cf.AllowedMethods.ALLOW_ALL,
cachedMethods: cf.CachedMethods.CACHE_GET_HEAD_OPTIONS,
cachePolicy,
originRequestPolicy
},
certificate,
httpVersion: cf.HttpVersion.HTTP2,
// @ts-ignore
domainNames: [props.distribution?.domainName].filter(value => !!value)
})
-
certificate
- We need this to secure the traffic in and out of our system. -
httpVersion
- Refer to the documentation: For viewers and CloudFront to use HTTP/ 2, viewers must support TLS 1.2 or later, and must support server name identification (SNI). -
domainNames
- Since we don't want to use the default URL provided by AWS (somthing like12345abcdef.cloudfront.net
), we want inbound traffic to come from the specified subdomain. -
defaultBehavior
-
origin
- Where the cloudFront will route the traffic to. S3 static website in our case. -
allowedMethods
- HTTP methods to allow for this behavior. In this case GET, POST, PUT, HEAD, DELETE and OPTIONS -
cachedMethods
- HTTP methods to cache for this behavior. In this case CloudFront will cache only the responses of GET and OPTIONS request according to thecachePolicy
. -
cachePolicy
- Determines what values are included in the cache key, and the time-to-live (TTL) values for the cache. -
originRequestPolicy
- Determines which values (e. g., headers, cookies) are included in requests that CloudFront sends to the origin.
-
This is the minimal configuration that we need to create a new cloudFront distribution.
Create a subdomain - RecordSet of type CNAME
Now that the CloudFront distribution is properly configured, let can proceed to create the new subdomain and attach it to the distribution. Let's begin by retrieving the HostedZone
:
const hostedZone = route53.HostedZone.fromLookup(this, `HostedZone_${id}`, {
domainName: props.domain!
})
⚠️⚠️ Note that domainName
refers to the main domain, not the subdomain that we are going to create.
const cnameRecord = new route53.CnameRecord(this, `DomainCNAME_${id}`, {
recordName: props.distribution?.domainName + '.',
domainName: distribution.distributionDomainName,
zone: hostedZone,
deleteExisting: true,
ttl: Duration.minutes(10),
comment: `RecordSet to send traffic from ${props.distribution?.domainName} to ${distribution.distributionDomainName}`
})
-
recordName
- The subdomain nomination. -
domainName
- The target resource (URL
) to which the traffic will be routed. -
zone
- The HostedZone to which the subdomain will be attached.
The complete source code of the custom construct:
import { Construct } from 'constructs'
import {
aws_certificatemanager as acm,
aws_cloudfront as cf,
aws_cloudfront_origins as origins,
aws_route53 as route53,
aws_s3 as s3,
Duration
} from 'aws-cdk-lib'
import { CustomStackProps } from '../custom-stack.props'
import { generateResourceID } from './utils'
interface WebsiteDistributeionProps extends CustomStackProps {
websiteBucketName: string;
acm: {
certificateArn: string
},
distribution: {
domainName: string
}
}
export class WebsiteDistribution extends Construct {
private readonly _distributionUrl: string
private readonly _viewersUrl: string
constructor(scope: Construct, id: string, props: WebsiteDistributeionProps) {
super(scope, id)
const bucket = s3.Bucket.fromBucketName(this, `BucketName_${id}`, props.websiteBucketName!)
const certificate = acm.Certificate.fromCertificateArn(this, `Certificate_${id}`, props.acm?.certificateArn!)
const cachePolicy = new cf.CachePolicy(this, `Cache-${id}-Policy`, {
cachePolicyName: `Cache-${id}-Policy`,
enableAcceptEncodingGzip: true,
enableAcceptEncodingBrotli: true,
queryStringBehavior: cf.CacheQueryStringBehavior.all(),
cookieBehavior: cf.CacheCookieBehavior.none(),
defaultTtl: Duration.seconds(30),
headerBehavior: cf.CacheHeaderBehavior.allowList(
'Origin',
'Accept',
'Access-Control-Request-Method',
'Access-Control-Request-Headers'
)
})
const originRequestPolicy = new cf.OriginRequestPolicy(this, `Origin-Request-${id}-Policy`, {
originRequestPolicyName: `Origin-Request-${id}-Policy`,
queryStringBehavior: cf.CacheQueryStringBehavior.all(),
cookieBehavior: cf.CacheCookieBehavior.none(),
headerBehavior: cf.CacheHeaderBehavior.allowList(
'Origin',
'Accept',
'Access-Control-Request-Method',
'Access-Control-Request-Headers'
)
})
const distribution = new cf.Distribution(this, `CfDistribution_${id}`, {
enabled: true,
defaultBehavior: {
origin: new origins.HttpOrigin(bucket.bucketWebsiteDomainName, {
protocolPolicy: cf.OriginProtocolPolicy.HTTP_ONLY,
httpPort: 80,
httpsPort: 443,
connectionTimeout: Duration.seconds(10),
originId: generateResourceID()
}),
allowedMethods: cf.AllowedMethods.ALLOW_ALL,
cachedMethods: cf.CachedMethods.CACHE_GET_HEAD_OPTIONS,
cachePolicy,
originRequestPolicy
},
certificate,
httpVersion: cf.HttpVersion.HTTP2,
// @ts-ignore
domainNames: [props.distribution?.domainName].filter(value => !!value)
})
distribution.metric5xxErrorRate({
label: `website-distribution-${id}-5xxError`,
color: '#e93d1a'
})
const hostedZone = route53.HostedZone.fromLookup(this, `HostedZone_${id}`, {
domainName: props.domain!
})
const cnameRecord = new route53.CnameRecord(this, `DomainCNAME_${id}`, {
recordName: props.distribution?.domainName + '.',
domainName: distribution.distributionDomainName,
zone: hostedZone,
deleteExisting: true,
ttl: Duration.minutes(10),
comment: `RecordSet to send traffic from ${props.distribution?.domainName} to ${distribution.distributionDomainName}`
})
this._distributionUrl = distribution.distributionDomainName
this._viewersUrl = cnameRecord.domainName
}
get distributionUrl(): string {
return this._distributionUrl
}
get viewsUrl(): string {
return this._viewersUrl
}
}
website-distribution.construct.ts[↗]
Update the CORS Configuration of the Website Bucket
export interface WebsiteStackProps extends StackProps {
...
origins?: string[] // the list of allowed origins
}
const wsBucket = new s3.Bucket(...)
wsBucket.addCorsRule({
allowedMethods: [
s3.HttpMethods.GET, s3.HttpMethods.HEAD,
s3.HttpMethods.DELETE, s3.HttpMethods.PUT,
s3.HttpMethods.POST
],
allowedOrigins: (props.origins ?? ['*']).filter(value => !!value && value.trim().length > 0),
maxAge: Duration.minutes(10).toSeconds(),
allowedHeaders: ['*']
})
-
allowedOrigins
- Includes the subdomain that was previously created. -
allowedHeaders
- Allows all headers coming from CloudFront.
Upload the website files to the S3 Bucket
new s3deploy.BucketDeployment(this, 'DeployTodoApp', {
destinationBucket: wsBucket,
sources: [
s3deploy.Source.asset('../apps/todo-app/dist/todo-app/browser', {
exclude: ['node_modules']
})
]
})
Update the existing stack
Find the full stack class 👉🏽👉🏽 cdk-stack.ts[↗]
export class CdkStack extends BaseStack {
constructor(scope: Construct, id: string, private props: CustomStackProps) {
...
const distribution = new WebsiteDistribution(this, 'TodoCloudfront', <WebsiteDistributeionProps>{ ...props })
new CfnOutput(this, 'TodoCloudfrontUrl', {
value: distribution.distributionUrl,
key: 'CfURL'
})
new CfnOutput(this, 'TodoCloudfrontViewUrl', {
value: distribution.viewsUrl,
key: 'ViewUrl'
})
new CfnOutput(this, 'ApiGatewayUrlOutput', {
value: restApi.url,
key: 'ApiGatewayUrl'
})
}
}
Deploy the new infrastructure
git clone https://github.com/nivekalara237/100DaysTerraformAWSDevops.git
cd 100DaysTerraformAWSDevops/apps && ng build --configuration production && cd ../..
cd 100DaysTerraformAWSDevops/day_011
export MAIN_DOMAIN="yourdomain.com"
export STAGE_NAME="dev" # needed by api gateway staging
export CERTIFICATE_ARN="arn:aws:acm:us-east-1:xxxx:certificate/xxxx-xxxx-xxxx-xxxxxxxx"
cdk deploy --profile cdk-user --all
After a few minutes, CloudFront will complete the deployment and distribution of the content. Here is the result:
__
🥳✨
We have reached the end of the article.
Thank you so much 🙂
Your can find the full source code on GitHub Repo↗
Top comments (0)