DEV Community

Cover image for Setting Up and Securing CloudFront for S3 Static Sites with Custom Subdomains Using AWS Cloud Development Kit(CDK)
Kevin Lactio Kemta
Kevin Lactio Kemta

Posted on

Setting Up and Securing CloudFront for S3 Static Sites with Custom Subdomains Using AWS Cloud Development Kit(CDK)

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:

  1. 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.

  2. 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.

  3. Update the existing stack
    Incorporate the new CloudFront distribution and any associated changes into the existing infrastructure stack.

  4. Deploy the new infrastructure
    Deploy the updated infrastructure, including the new CloudFront distribution and any changes to the existing stack.

Diagram of infrastructure

Day 011 Diagram

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
}
Enter fullscreen mode Exit fullscreen mode
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!)
Enter fullscreen mode Exit fullscreen mode
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)
})
Enter fullscreen mode Exit fullscreen mode
  • 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 like 12345abcdef.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 the cachePolicy.
    • 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!
})
Enter fullscreen mode Exit fullscreen mode

⚠️⚠️ 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}`
})
Enter fullscreen mode Exit fullscreen mode
  • 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
  }
}
Enter fullscreen mode Exit fullscreen mode

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: ['*']
})
Enter fullscreen mode Exit fullscreen mode

website-stack.ts[β†—]

  • 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']
        })
    ]
})
Enter fullscreen mode Exit fullscreen mode

website-stack.ts[β†—]

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'
    })
  }
}

Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

After a few minutes, CloudFront will complete the deployment and distribution of the content. Here is the result:

Image resulat

__

πŸ₯³βœ¨
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)