CI/CD for static websites with AWS CDK

A german version of this post can be found on superluminar.io

CDK Pipelines is a CDK construct developed by AWS which creates a self-managed AWS CodePipeline. This means the Pipeline will update itself when its definition is changed in the Infrastructure as Code (IaC) setup. This pipeline can be extended with further actions, e.g. to provision infrastructure for different application stages (think dev/qa/prod) or to do more classic CI/CD stuff, like compiling assets and deploying artificats to their environment.

Setup

We are using Typescript, both as the language to write our CDK code and also for the static website which is built with React. Of course you can change to any CDK supported Language for the Infrastructure code as well as any language for the static website if you want to. you have to have an AWS Account where the project should be deployed to and you have to setup a Route53 hosted zone for the Domain that your website is hosted on. You have to configure the zones nameservers as the NS records for that domain at your domain registrar where you bought the domain, else the deployment will fail since AWS can’t create a HTTPS certificate for you.

The Project is split into infrastrucure code and frontendcode. First we’ll create our project: mkdir static-site && cd static-site.

A static website

First we’ll create an example static site using create-react-app, which we’ll deploy with our pipeline.

yarn create-react-app frontend --template typescript

Since this is only an example, we’re already done. Once you have setup the rest of the CI/CD setup you would normally start building your content in here.

Pipeline setup with cdk-pipelines

Now we will setup the AWS CDK and CDK pipelines. Lets create a new subdirectory for the code in static-site and initialize the CDk project:

mkdir infrastructure && cd infrastructure

npx cdk init --language=typescript
yarn

To use CDK pipelines, we have to change some settings in infrastrucure/cdk.json: under context we have to add "@aws-cdk/core:newStyleStackSynthesis": true. Now the AWS account can be bootstrapped so that we can use CDK to manage resources in it:yarn cdk bootstrap --cloudformation-execution-policies arn:aws:iam::aws:policy/AdministratorAccess.

Defifining the pipeline and further infrastructure

Lets go on and define our infrastructure, including the pipeline itself and the infrastructure that will be deployed by the pipeline. Important: All steps we’re going to do here are performed in the infrastructure directory. First we’ll add all CDK packets we’re going to use later:

yarn add @aws-cdk/pipelines @aws-cdk/aws-codepipeline @aws-cdk/aws-codepipeline-actions @aws-cdk/aws-codebuild \
  @aws-cdk/aws-s3 @aws-cdk/aws-cloudfront @aws-cdk/aws-cloudfront-origins \
  @aws-cdk/aws-certificatemanager @aws-cdk/aws-route53 @aws-cdk/aws-route53-targets

Then we’ll create infrastructure/lib/stacks/pipeline.ts with a minimal pipeline setup:

import * as Codepipeline from "@aws-cdk/aws-codepipeline";
import * as CodepipelineActions from "@aws-cdk/aws-codepipeline-actions";
import { Construct, SecretValue, Stack, StackProps } from "@aws-cdk/core";
import { CdkPipeline, SimpleSynthAction } from "@aws-cdk/pipelines";

/**
 * The stack that defines the pipeline
 */
export class Pipeline extends Stack {
  // The Domain where we want to host our site.
  readonly domainName = "my.static.site";
  readonly hostedZoneId = "Route53HostedZoneId";

  constructor(scope: Construct, id: string, props?: StackProps) {
    super(scope, id, props);

    const sourceArtifact = new Codepipeline.Artifact();
    const buildArtifact = new Codepipeline.Artifact();
    const cloudAssemblyArtifact = new Codepipeline.Artifact();

    const pipeline = this.buildCDKPipeline(
      cloudAssemblyArtifact,
      sourceArtifact
    );

    // here we'll add further stuff to the pipeline next.
  }

  private buildCDKPipeline(
    cloudAssemblyArtifact: Codepipeline.Artifact,
    sourceArtifact: Codepipeline.Artifact
  ) {
    return new CdkPipeline(this, "Pipeline", {
      // The pipeline name
      pipelineName: "StaticWebsitePipeline",
      cloudAssemblyArtifact,

      // Where the source can be found
      sourceAction: new CodepipelineActions.GitHubSourceAction({
        actionName: "GitHub",
        output: sourceArtifact,
        oauthToken: SecretValue.secretsManager("github-token"),
        owner: "superluminar-io",
        repo: "static-site",
        branch: "main",
      }),

      synthAction: SimpleSynthAction.standardYarnSynth({
        sourceArtifact,
        cloudAssemblyArtifact,
      }),
    });
  }
}

This is everything necessary to create a self updating AWS CodePipeline, which will automatically run on changes in the source code in the referenced GitHub Repo. To connect to the GitHub repository we have to create a GitHub access token and store it as github-token in AWS SecretsManager. Howto do this is described in the final part of this post. The instance variables domainName and hostedZoneId have to be adapted to your needs.

Next we want to extend the pipeline to build and deploy our static website. To do so we first add a new Stage to the constructor where the comment states so. This stage consists of two Actions which we also create next.

// previos imports
import * as CodeBuild from "@aws-cdk/aws-codebuild";
import { Bucket } from "@aws-cdk/aws-s3";

export class Pipeline extends Stack {
  // previous code

  constructor(scope: Construct, id: string, props?: StackProps) {
    // previous code

    const websiteBuildAndDeployStage = pipeline.addStage(
      "WebsiteBuildAndDeployStage"
    );

    websiteBuildAndDeployStage.addActions(
      this.buildAction(
        sourceArtifact,
        buildArtifact,
        websiteBuildAndDeployStage.nextSequentialRunOrder()
      ),
      this.deployAction(
        buildArtifact,
        this.domainName,
        websiteBuildAndDeployStage.nextSequentialRunOrder()
      )
    );
  }

  private buildAction(
    sourceArtifact: Codepipeline.Artifact,
    buildArtifact: Codepipeline.Artifact,
    runOrder: number
  ): CodepipelineActions.CodeBuildAction {
    return new CodepipelineActions.CodeBuildAction({
      input: sourceArtifact,
      outputs: [buildArtifact],
      runOrder: runOrder,
      actionName: "Build",
      project: new CodeBuild.PipelineProject(this, "StaticSiteBuildProject", {
        projectName: "StaticSiteBuildProject",
        buildSpec: CodeBuild.BuildSpec.fromSourceFilename(
          "frontend/buildspec.yml"
        ),
        environment: {
          buildImage: CodeBuild.LinuxBuildImage.STANDARD_4_0,
        },
      }),
    });
  }

  private deployAction(
    input: Codepipeline.Artifact,
    bucketName: string,
    runOrder: number
  ): CodepipelineActions.S3DeployAction {
    const bucket = Bucket.fromBucketName(this, "WebsiteBucket", bucketName);

    return new CodepipelineActions.S3DeployAction({
      actionName: "Deploy",
      runOrder: runOrder,
      input: input,
      bucket: bucket,
    });
  }
}

The BuildAction will look for a buildspec.yml file in the frontend directory which should specify the steps necessary to build our static website. A minimal version wto build our react based site would look like this:

version: 0.2

phases:
  install:
    runtime-versions:
      nodejs: 12
    commands:
      - cd frontend
      - yarn install
  build:
    commands:
      - yarn build

artifacts:
  base-directory: frontend/build
  files:
    - "**/*"

cache:
  paths:
    - "frontend/node_modules/**/*"

Our pipeline is now able to build and deploy the static site to a S3 bucket. Unortunately this bucket (and everything else to deliver our site) is non-existent right now. Lets fix that!

Defining the Website Infrastructure

Lets recap what we want to setup to host our website: a S3 buket, that hosts the static assets, a CoudFront distribution which delivers and caches the website globally, a Certificate so that our site can be reached via https and a Route53 record which points to the CloudFront distribution.

We’re going to deploy theese components the same way we deployed our pipeline: via CDK. To seperate the hosting components from the CI/CD components, we’re going to build them in their own Stack. Lets create this Stack in infrastructure/lib/stacks/frontend.ts and lets add the S3 bucket right now:

import { Stack, Construct, StackProps } from "@aws-cdk/core";
import { Bucket, BlockPublicAccess } from "@aws-cdk/aws-s3";

export interface FrontendStackProps extends StackProps {
  domainName: string;
  hostedZoneId: string;
}

export class FrontendStack extends Stack {
  constructor(scope: Construct, id: string, props: FrontendStackProps) {
    super(scope, id, props);

    const bucket = new Bucket(this, "FrontendBucket", {
      bucketName: props.domainName,
      blockPublicAccess: BlockPublicAccess.BLOCK_ALL,
    });

    // More components will be added here.
  }
}

Note that, since we’re going to use CloudFront to deliver the site, we configured BlockPublicAccess.BLOCK_ALL: The bucket itself is not reachable via the web.

Next we’ll add the components needed to deliver our site globally and securely to infrastructure/lib/stacks/frontend.ts:

// Previous imports
import { Distribution, ViewerProtocolPolicy } from "@aws-cdk/aws-cloudfront";
import { S3Origin } from "@aws-cdk/aws-cloudfront-origins";
import { DnsValidatedCertificate } from "@aws-cdk/aws-certificatemanager";
import * as Route53 from "@aws-cdk/aws-route53";
import { CloudFrontTarget } from "@aws-cdk/aws-route53-targets";

export class FrontendStack extends Stack {
  constructor(scope: Construct, id: string, props: FrontendStackProps) {
    // S3 Bucket

    const zone = Route53.PublicHostedZone.fromHostedZoneAttributes(
      this,
      "HostedZone",
      {
        hostedZoneId: props.hostedZoneId,
        zoneName: props.domainName,
      }
    );

    const certificate = new DnsValidatedCertificate(this, "FrontendCert", {
      domainName: props.domainName,
      region: "us-east-1",
      hostedZone: zone,
    });

    const distribution = new Distribution(this, "FrontendDistribution", {
      defaultBehavior: {
        origin: new S3Origin(bucket),
        viewerProtocolPolicy: ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
      },
      domainNames: [props.domainName],
      certificate: certificate,
      defaultRootObject: "index.html",
    });

    new Route53.ARecord(this, "AliasRecord", {
      zone,
      target: Route53.RecordTarget.fromAlias(
        new CloudFrontTarget(distribution)
      ),
    });
  }
}

The DnsValidatedCertificate is a special Construct which eases this kind of setup: CloudFront can only use certificates deployed in us-east-1 but CloudFormation Stacks, and therefore CDK Stacks, can only contain resources in a single region. Since we might want to host our Infrastructure in a different region than us-east-1, we’d normally have to deploy another stack which only includes the certificate to us-east-1 The DnsValidatedCertificate works around this limitation by deploying a lambda into the Stacks region which manages the certificate in us-east-1.

Plumbing the parts together

We now have to add the FrontendStack to our pipeline, so that the pipeline manages the Stack. CDK pipelines enables us to do this via ApplicationStages. An ApplicationStage can contain multiple Stacks, which can depend on values from each other. For our setup we don’t need more than one Stack, but once your setup becomes more complex you might want to split you Stacks. Stages can also be used, to deploy the same set of Stacks into multiple regions or accounts, e.g. to enable multi-region failover or to build a dev/qa/prod environment.

Lets define our Stage in infrastructure/lib/stages/website.ts:

import { Construct, Stage, StageProps } from "@aws-cdk/core";
import { FrontendStack } from "../stacks/frontend";

export interface WebsiteStageProps extends StageProps {
  domainName: string;
  hostedZoneId: string;
}

export class WebsiteStage extends Stage {
  constructor(scope: Construct, id: string, props: WebsiteStageProps) {
    super(scope, id, props);

    new FrontendStack(this, "FrontendStack", props);
  }
}

Now we can add this Stage to the pipeline in infrastructure/lib/pipeline.ts. Note that this has to be done earlier than the buildAndDeployStage since this Stage is using the Bucket to deploy the site into it, therefore the Bucket needs to exist first.

// Previous imports
import { WebsiteStage } from "../stages/website";

export class PipelineStack extends Stack {
  constructor(scope: Construct, id: string, props?: StackProps) {
    // Code which creates the Pipeline

    const websiteInfrastructureStage = new WebsiteStage(
      this,
      "WebsiteInfrastructureStage",
      {
        domainName: this.domainName,
        ...props,
      }
    );

    pipeline.addApplicationStage(websiteInfrastructureStage);

    // Code which creates the WebsiteBuildAndDeployStage
  }
}

We also need to edit infrastructure/bin/infrastructure since our files and resources are named different than the bootstrapped CDK code:

#!/usr/bin/env node
import "source-map-support/register";
import * as cdk from "@aws-cdk/core";
import { PipelineStack } from "../lib/stacks/pipeline";

const app = new cdk.App();
new PipelineStack(app, "InfrastructureStack");

Release it

We’re almost done! Now we only need to deploy our code. To do so, first add the code to a repository on GitHub. Than create a personal access token with repo and admin:repo_hook permissions. Add this token into SecretsManager as github-token. For a guide howto create such a token see here.

We now need to deploy our pipeline stack once, which will create the pipeline itself, which then will run and create the infrastrucure for our website, build it and deploy it. Make sure that you have access to your AWS account and the region is set up correctly. Then run yarn cdk deploy.

Das gesamte Setup kann auch auf https://github.com/superluminar-io/static-site eingesehen werden, die Seite ist auf https://static-site.alst.superluminar.io erreichbar.

comments powered by Disqus