Deploying a site to AWS with the CDK

In the following we will create a CloudFront, s3 with lambda@edge based website.
AWS is not free and you may be charged for services used.

So, I got through a renewal email for some hosting I had with GoDaddy and to be honest the project has not really moved on that much from the first couple of evenings of work (nearly a year ago). However, I have now decided to give the project a kick with the use of some free AWS credits I have on my account that won’t last forever.

One of the big problems I was facing when using GoDaddy for hosting was that pushing website changes to the sever for testing was a huge pain and also the lack of ability to be able to develop separate backend API’s alongside the application within the same platform also didn’t get me particularly motivated about using it.

So, on a Sunday night while watching a bit of TV, sat on the sofa, I decided that I would move the site over to AWS with the use of AWS CDK. CDK is used to define all the infrastructure needed, bringing the whole project into a simple to use single repository that could also hold the code needed for the API layer later.

The website code itself is just an Angular with Ionic web app that’s half done. The first step to getting the CDK imported into the project was to create a cdk directory, I do this at the top level of the project. Next move into the newly created cdk directory.

If you have not already got it installed, install CDK onto your machine. You will also need to have the AWS cli installed and a valid account setup to continue.

npm install -g aws-cdk

Once you have the cdk installed you need to bootstrap the region that you will be deploying your account too.

cdk bootstrap aws://<ACCOUNT NUMBER>/<REGION>
#To get the details you can run
aws sts get-caller-identity
aws configure get region

For Lambda@edge we will also need to bootstrap the us-east 1 region

cdk bootstrap aws://<ACCOUNT NUMBER>/us-east-1

Next, initiate the cdk cli from inside the cdk directory we created earlier, this will generate a template project. Since the angular webapp code is written in typescript I like to use the typescript cdk but other languages are available.

cdk init app --language typescript

For this project we need to install a bunch of cdk libs from AWS the following is the list added.

npm i @aws-cdk/core
npm i @aws-cdk/aws-cloudfront
npm i @aws-cdk/aws-lambda
npm i @aws-cdk/aws-s3-deployment
npm i @aws-cdk/aws-route53-patterns
npm i @aws-cdk/aws-route53-targets
npm i @aws-cdk/aws-lambda-nodejs

Now we can add the code to get the stack built.

To start with, in ./bin/cdk.ts we will add the deployment stack we are going to use in the simple deployment. You can remove any of the automatically generated code or modify it to match.

#!/usr/bin/env node
import cdk = require('@aws-cdk/core');
import { DeploySiteStack } from '../lib/deploy-site-stack';

const app = new cdk.App();

new DeploySiteStack(app, 'SimpleSiteDeploySiteStack', {
  env: {
    account: process.env.CDK_DEFAULT_ACCOUNT,
    region: process.env.CDK_DEFAULT_REGION
  }
});

Now we need to create the stacks in the project. The stacks live in ./lib and you can delete the example one and create one called deploy-site-stack.ts

The following stack is developed from the library cdk-spa-deploy for a simple stack follow the instructions on the link. The following is a slightly modified version for my needs and resolves a deployment issue when using BucketDeployment.

The following stack also uses Lambda at edge to add security headers and TLS 1.2 security policy.

import cdk = require('@aws-cdk/core');
import * as cf from '@aws-cdk/aws-cloudfront';
import * as lambda from '@aws-cdk/aws-lambda';
import * as aws_route53 from '@aws-cdk/aws-route53';
import * as aws_certificate_manager from '@aws-cdk/aws-certificatemanager';
import * as s3deploy from '@aws-cdk/aws-s3-deployment';
import * as s3 from '@aws-cdk/aws-s3';
import * as iam from '@aws-cdk/aws-iam';
import * as aws_route53_patterns from '@aws-cdk/aws-route53-patterns';
import * as aws_route53_targets from '@aws-cdk/aws-route53-targets';
import {LambdaEdgeEventType} from '@aws-cdk/aws-cloudfront';

export interface HostedZoneConfig {
  readonly indexDoc: string;
  readonly errorDoc?: string;
  readonly cfBehaviors?: cf.Behavior[];
  readonly websiteFolder: string;
  readonly zoneName: string;
  readonly subdomain?: string;
  readonly securityPolicy?: cf.SecurityPolicyProtocol;
  readonly role?: iam.Role;
}

export interface SPAGlobalConfig {
  readonly encryptBucket?: boolean;
  readonly ipFilter?: boolean;
  readonly ipList?: string[];
}

export class DeploySiteStack extends cdk.Stack {
  globalConfig: SPAGlobalConfig;
  constructor(scope: cdk.Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    const zoneNameStr = "websiteaddress.com";
    if (zoneNameStr === undefined) {
      throw new Error(( 'zoneNameStr undefined'));
    }


    const originRespLambda = new cf.experimental.EdgeFunction(this, 'lambdaAtEdge', {
      runtime: lambda.Runtime.NODEJS_14_X,
      handler: 'headers.handler',
      code: lambda.Code.fromAsset('lambda')
    });


    this.globalConfig = { encryptBucket: true };
    const spaDeploy = this.createSiteFromHostedZone(
      {
          zoneName: zoneNameStr,
          indexDoc: 'index.html',
          errorDoc: 'index.html',
          websiteFolder: '../www',
          securityPolicy: cf.SecurityPolicyProtocol.TLS_V1_2_2021,
          cfBehaviors: [{
            isDefaultBehavior: true,
            pathPattern: '*',
            lambdaFunctionAssociations: [
              {
                eventType: LambdaEdgeEventType.ORIGIN_RESPONSE,
                lambdaFunction: originRespLambda
              }
            ]
            }
          ]
        }
    );



  }

  createSiteFromHostedZone(config: HostedZoneConfig) {
    const websiteBucket = this.getS3Bucket(config, true);
    const zone = aws_route53.HostedZone.fromLookup(this, 'HostedZone', { domainName: config.zoneName });
    const domainName = config.subdomain ? `${config.subdomain}.${config.zoneName}` : config.zoneName;
    const cert = new aws_certificate_manager.DnsValidatedCertificate(this, 'Certificate', {
        hostedZone: zone,
        domainName,
        region: 'us-east-1',
    });
    const accessIdentity = new cf.OriginAccessIdentity(this, 'OriginAccessIdentity', { comment: `${websiteBucket.bucketName}-access-identity` });
    const distribution = new cf.CloudFrontWebDistribution(this, 'cloudfrontDistribution', this.getCFConfig(websiteBucket, config, accessIdentity, cert));
    const bd = new s3deploy.BucketDeployment(this, 'BucketDeployment', {
        sources: [s3deploy.Source.asset(config.websiteFolder)],
        destinationBucket: websiteBucket,
        // Invalidate the cache for / and index.html when we deploy so that cloudfront serves latest site
        distribution,
        role: config.role,
        distributionPaths: ['/', `/${config.indexDoc}`],
        memoryLimit: 512
    });
    const alias = new aws_route53.ARecord(this, 'Alias', {
        zone,
        recordName: domainName,
        target: aws_route53.RecordTarget.fromAlias(new aws_route53_targets.CloudFrontTarget(distribution)),
    });
    if (!config.subdomain) {
        const redirect = new aws_route53_patterns.HttpsRedirect(this, 'Redirect', {
            zone,
            recordNames: [`www.${config.zoneName}`],
            targetDomain: config.zoneName,
        });
    }
    return { websiteBucket, distribution };
}

  /**
   * Helper method to provide a configured s3 bucket
   */
  getS3Bucket(config: HostedZoneConfig, isForCloudFront: boolean) {
    const bucketConfig: any = {
        websiteIndexDocument: config.indexDoc,
        websiteErrorDocument: config.errorDoc,
        publicReadAccess: true,
    };
    if (this.globalConfig.encryptBucket === true) {
        bucketConfig.encryption = s3.BucketEncryption.S3_MANAGED;
    }
    if (this.globalConfig.ipFilter === true || isForCloudFront === true) {
        bucketConfig.publicReadAccess = false;
        // if (typeof config.blockPublicAccess !== 'undefined') {
        //     bucketConfig.blockPublicAccess = config.blockPublicAccess;
        // }
    }
    const bucket = new s3.Bucket(this, 'WebsiteBucket', bucketConfig);
    if (this.globalConfig.ipFilter === true && isForCloudFront === false) {
        if (typeof this.globalConfig.ipList === 'undefined') {
            this.node.addError('When IP Filter is true then the IP List is required');
        }
        const bucketPolicy = new iam.PolicyStatement();
        bucketPolicy.addAnyPrincipal();
        bucketPolicy.addActions('s3:GetObject');
        bucketPolicy.addResources(`${bucket.bucketArn}/*`);
        bucketPolicy.addCondition('IpAddress', {
            'aws:SourceIp': this.globalConfig.ipList,
        });
        bucket.addToResourcePolicy(bucketPolicy);
    }
    // The below "reinforces" the IAM Role's attached policy, it's not required but it allows for customers using permission boundaries to write into the bucket.
    if (config.role) {
        bucket.addToResourcePolicy(new iam.PolicyStatement({
            actions: [
                's3:GetObject*',
                's3:GetBucket*',
                's3:List*',
                's3:DeleteObject*',
                's3:PutObject*',
                's3:Abort*'
            ],
            effect: iam.Effect.ALLOW,
            resources: [bucket.arnForObjects('*'), bucket.bucketArn],
            conditions: {
                StringEquals: {
                    'aws:PrincipalArn': config.role.roleArn,
                },
            },
            principals: [new iam.AnyPrincipal()]
        }));
    }
    return bucket;
  }

  /**
   * Helper method to provide configuration for cloudfront
   */
    getCFConfig(websiteBucket: any, config: any , accessIdentity: any, cert: any) {
      const cfConfig: any = {
          originConfigs: [
              {
                  s3OriginSource: {
                      s3BucketSource: websiteBucket,
                      originAccessIdentity: accessIdentity,
                  },
                  behaviors: config.cfBehaviors ? config.cfBehaviors : [{ isDefaultBehavior: true }],
              },
          ],
          // We need to redirect all unknown routes back to index.html for angular routing to work
          errorConfigurations: [{
                  errorCode: 403,
                  responsePagePath: (config.errorDoc ? `/${config.errorDoc}` : `/${config.indexDoc}`),
                  responseCode: 200,
              },
              {
                  errorCode: 404,
                  responsePagePath: (config.errorDoc ? `/${config.errorDoc}` : `/${config.indexDoc}`),
                  responseCode: 200,
              }],
      };
      if (typeof config.certificateARN !== 'undefined' && typeof config.cfAliases !== 'undefined') {
          cfConfig.aliasConfiguration = {
              acmCertRef: config.certificateARN,
              names: config.cfAliases,
          };
          if (typeof config.sslMethod !== 'undefined') {
              cfConfig.aliasConfiguration.sslMethod = config.sslMethod;
          }
          if (typeof config.securityPolicy !== 'undefined') {
              cfConfig.aliasConfiguration.securityPolicy = config.securityPolicy;
          }
      }
      if (typeof config.zoneName !== 'undefined' && typeof cert !== 'undefined') {
          cfConfig.viewerCertificate = cf.ViewerCertificate.fromAcmCertificate(cert, {
              aliases: [config.subdomain ? `${config.subdomain}.${config.zoneName}` : config.zoneName],
              securityPolicy: config.securityPolicy
          });
      }
      return cfConfig;
  }

}

Next, create the lambda function defined in the stack function originRespLambda. Create a new directory in the cdk directory called lambda, in this lambda directory create a file called headers.js in this you can add the headers for the origin response, some examples for this are below.

exports.handler = (event, context, callback) => {
    
  //Get contents of response
  const response = event.Records[0].cf.response;
  const headers = response.headers;

//Set new headers 
headers['strict-transport-security'] = [{key: 'Strict-Transport-Security', value: 'max-age=63072000; includeSubdomains; preload'}]; 
headers['x-content-type-options'] = [{key: 'X-Content-Type-Options', value: 'nosniff'}]; 
headers['x-frame-options'] = [{key: 'X-Frame-Options', value: 'DENY'}]; 
headers['x-permitted-cross-domain-policies'] = [{key: 'X-Permitted-Cross-Domain-Policies', value: 'none'}]; 
headers['cache-control'] = [{key: 'Cache-Control', value: 'no-cache'}]; 
headers['x-xss-protection'] = [{key: 'X-XSS-Protection', value: '1; mode=block'}]; 
headers['referrer-policy'] = [{key: 'Referrer-Policy', value: 'same-origin'}]; 
  
  //Return modified response
  callback(null, response);
};

 

Before we can build the stack, we first need to build the webapp to the ../www directory from the ./cdk directory in angular I just run npm run build from the project root directory and then move back to the cdk directory. We can now run the command to build the stack

npm run build

The stack will now be built and checked for any issues. Once built you can now deploy the stack, see https://docs.aws.amazon.com/cdk/v2/guide/cli.html for detailed information on deployment.



Categories: AWS

Tags: , , , , , , , , , , , ,

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s

%d bloggers like this: