Building a Resilient Static Website on AWS

January 15, 2023 • AWS, Architecture, CloudFront, S3, Cloud Resiliency

Do you remember the AWS outage in December 2021? I do. My client at work had a P1 incident. Many of their applications were unresponsive or returned errors that weren’t user-friendly. It drove home the importance of fault-tolerant redundant architectures when using a cloud provider. If my client’s application had been built to be redundant across regions and not just availability zones the application could have survived the outage with limited impact.

At the time of the outage this website was hosted on Netlify, so I wasn’t affected by the outage, but it got me thinking about what it would take to deploy the site to AWS in a fault-tolerant and redundant configuration. “How difficult was it to host a static site on AWS with a multi-region configuration?” Turns out, it isn’t that difficult. The tricky part was deploying everything with the AWS CDK.

I knew I needed to use Amazon CloudFront for a CDN to deliver the content. Exposing an S3 bucket website can be more expensive, and caching is a beneficial feature. If you’re interested in the price difference, I’ve put together an estimate. I hadn’t used the feature before, but CloudFront’s Origin Groups turned out to be a key part of creating the redundancy I wanted. To have two origins, I knew I would need two S3 buckets in different regions. To save on trying to upload to both placed, I knew I could replicate objects from one bucket to the other.

AWS architectural diagram for jasonbutz.info

Figuring out how to deploy this architecture using the CDK took some experimentation, the architecture builds on itself which means you need to consider the dependencies. In the end, I have one stack for the CloudFront distribution, one stack for the primary S3 bucket, and one stack for the secondary S3 bucket. The trick is they are deployed in the opposite of that order. First, the secondary bucket gets deployed, fulfilling a dependency for the primary bucket and enabling replication to be configured. Those two buckets being deployed allows the distribution to be created and to point to both buckets.

To reduce duplication I was able to use inheritance for the primary bucket’s stack. It inherits from the secondary bucket’s stack and layers on the bucket replication.

Here is an example of what my stack looks like to create the S3 bucket and prepare it for replication and use with CloudFront. Versioning is required for replication, but I don’t want to have useless object versions building up, so I provide a lifecycle rule for cleanup.

import { Duration, RemovalPolicy, Stack, StackProps } from 'aws-cdk-lib';
import {
  AccountRootPrincipal,
  Effect,
  PolicyStatement,
} from 'aws-cdk-lib/aws-iam';
import { Bucket, IBucket } from 'aws-cdk-lib/aws-s3';
import { Construct } from 'constructs';

export type CommonStackProps = StackProps & {
  resourceKey: string;
};

export class CommonBucketStack extends Stack {
  public readonly bucket: IBucket;

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

    this.bucket = new Bucket(this, 'bucket', {
      bucketName: `YOUR-BUCKET-NAME-${this.region}`,
      removalPolicy: RemovalPolicy.RETAIN,
      versioned: true,
      lifecycleRules: [
        {
          enabled: true,
          noncurrentVersionExpiration: Duration.days(1),
          expiredObjectDeleteMarker: true,
          abortIncompleteMultipartUploadAfter: Duration.days(1),
        },
      ],
      websiteIndexDocument: 'index.html',
      websiteErrorDocument: '404.html',
    });

    this.bucket.addToResourcePolicy(
      new PolicyStatement({
        effect: Effect.ALLOW,
        principals: [new AccountRootPrincipal()],
        actions: ['s3:*'],
        resources: [this.bucket.bucketArn, this.bucket.arnForObjects('*')],
      })
    );

    this.bucket.grantPublicAccess();
  }
}

There surprisingly isn’t a CDK L2 Construct to help with replication, so you have to make do with the L1 constructs, below is an example of how I accomplished it.

import {
  Effect,
  Policy,
  PolicyStatement,
  Role,
  ServicePrincipal,
} from 'aws-cdk-lib/aws-iam';
import { Bucket, CfnBucket } from 'aws-cdk-lib/aws-s3';
import { Construct } from 'constructs';
import {
  CommonBucketStack,
  CommonBucketStackProps,
} from './common-bucket-stack';

export type PrimaryStackProps = CommonBucketStackProps & {
  secondaryBucketArn: string;
};

export class PrimaryStack extends CommonBucketStack {
  constructor(scope: Construct, id: string, props: PrimaryStackProps) {
    super(scope, id, props);

    const sourceBucket = this.bucket;
    const destinationBucket = Bucket.fromBucketArn(
      this,
      'destination-bucket',
      props.secondaryBucketArn
    );

    const replicationRole = new Role(this, 'bucket-replication-role', {
      assumedBy: new ServicePrincipal('s3.amazonaws.com'),
    });

    replicationRole.attachInlinePolicy(
      new Policy(this, 'replication-policy', {
        statements: [
          new PolicyStatement({
            effect: Effect.ALLOW,
            actions: [
              's3:GetObjectVersionForReplication',
              's3:GetObjectVersionAcl',
              's3:GetObjectVersionTagging',
            ],
            resources: [sourceBucket.arnForObjects('*')],
          }),
          new PolicyStatement({
            effect: Effect.ALLOW,
            actions: ['s3:ListBucket', 's3:GetReplicationConfiguration'],
            resources: [sourceBucket.bucketArn],
          }),
          new PolicyStatement({
            effect: Effect.ALLOW,
            actions: [
              's3:ReplicateObject',
              's3:ReplicateDelete',
              's3:ReplicateTags',
            ],
            resources: [destinationBucket.arnForObjects('*')],
          }),
        ],
      })
    );

    const cfnBucket = sourceBucket.node.defaultChild as CfnBucket;

    cfnBucket.replicationConfiguration = {
      role: replicationRole.roleArn,
      rules: [
        {
          destination: {
            bucket: destinationBucket.bucketArn,
          },
          priority: 1,
          deleteMarkerReplication: {
            status: 'Enabled',
          },
          filter: {
            prefix: '',
          },
          status: 'Enabled',
        },
      ],
    };
  }
}

Once you have all that sorted out, defining a stack for the CloudFront distribution takes a little trial and error to get the origins and behaviors properly configured but is much easier than everything else.

import { Stack, StackProps } from 'aws-cdk-lib';
import {
  Certificate,
  CertificateValidation,
} from 'aws-cdk-lib/aws-certificatemanager';
import {
  AllowedMethods,
  Distribution,
  OriginProtocolPolicy,
  PriceClass,
  ViewerProtocolPolicy,
} from 'aws-cdk-lib/aws-cloudfront';
import { HttpOrigin, OriginGroup } from 'aws-cdk-lib/aws-cloudfront-origins';
import { Construct } from 'constructs';

export type CdnStackProps = StackProps & {
  primaryBucketWebsite: string;
  secondaryBucketWebsite: string;
  cnames: string[];
};

export class CdnStack extends Stack {
  readonly distribution: Distribution;
  constructor(scope: Construct, id: string, props: CdnStackProps) {
    super(scope, id, props);

    const primaryBucketOrigin = new HttpOrigin(props.primaryBucketWebsite, {
      protocolPolicy: OriginProtocolPolicy.HTTP_ONLY,
    });

    const secondaryBucketOrigin = new HttpOrigin(props.secondaryBucketWebsite, {
      protocolPolicy: OriginProtocolPolicy.HTTP_ONLY,
    });

    const originGroup = new OriginGroup({
      primaryOrigin: primaryBucketOrigin,
      fallbackOrigin: secondaryBucketOrigin,
      fallbackStatusCodes: [400, 403, 416, 500, 502, 503, 504],
    });

    this.distribution = new Distribution(this, 'cdn', {
      enabled: true,
      priceClass: PriceClass.PRICE_CLASS_100,
      defaultBehavior: {
        allowedMethods: AllowedMethods.ALLOW_GET_HEAD,
        origin: originGroup,
        viewerProtocolPolicy: ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
        compress: true,
        cachedMethods: AllowedMethods.ALLOW_GET_HEAD,
      },
      domainNames: props.cnames,
      certificate: new Certificate(this, 'Certificate', {
        domainName: props.cnames[0],
        subjectAlternativeNames: props.cnames.slice(1) || [],
        validation: CertificateValidation.fromDns(), // Records must be added manually
      }),
      defaultRootObject: 'index.html',
      errorResponses: [
        {
          httpStatus: 404,
          responseHttpStatus: 404,
          responsePagePath: '/404.html',
        },
      ],
    });
  }
}

All of these code snippets are a bit different than what I have in use, where possible I broke things out into their own constructs to help with composing the architecture but they are enough to help give you an idea.

Overall this architecture should be able to withstand regional outages for either bucket. I already have ideas on how to make the architecture even more resilient, but I don’t need for them yet. I expect I’ll write more about that idea later. I think it has significant potential for improving user experience in some situations.


Jason Butz's profile picture

Jason Butz is a software engineer and cloud architect with a strong focus on JavaScript, TypeScript, Node, and AWS. Jason has a love for open-soure software and a passion for building scalable, secure, and reliable applications.