Overview

I used AWS CDK to create a static site with CloudFront + S3. Additionally, I used CloudFront Functions to add Basic authentication and processing to append index.html to requests that do not include a filename or extension in the URL. I also added a custom domain, so this is a memo of the process.

While somewhat incomplete, the source code is available in the following repository.

The intended use is to prepare an .env file like the following and run cdk deploy.

CERT_ARN=arn:aws:acm:xxxx
RECORD_NAME=aaa.bbb.com
BUCKET_NAME=aaa.bbb.com
REGION=us-east-1
ACCOUNT=yyyy
DOMAIN_NAME=bbb.com

The explanation for each item is as follows.

ItemDescriptionExample
CERT_ARNCertificate ARNarn:aws:acm:xxxx
RECORD_NAMEDomain name to configureaaa.bbb.com
BUCKET_NAMES3 bucket name for file storageaaa.bbb.com
REGIONRegion nameus-east-1
ACCOUNTAWS account number (12-digit number)123456789012
DOMAIN_NAMEHosted zone namebbb.com

Stack

The following Stack was created.

import {
  Stack,
  StackProps,
  RemovalPolicy,
  aws_cloudfront,
  aws_cloudfront_origins,
  aws_iam,
} from "aws-cdk-lib";
import { Construct } from "constructs";
import * as s3 from "aws-cdk-lib/aws-s3";
import * as iam from "aws-cdk-lib/aws-iam";
import * as cloudfront from "aws-cdk-lib/aws-cloudfront";
import * as route53 from "aws-cdk-lib/aws-route53";
import * as targets from "aws-cdk-lib/aws-route53-targets";
import { Certificate } from "aws-cdk-lib/aws-certificatemanager";
import * as dotenv from "dotenv";

dotenv.config();

export class StaticBasicStack extends Stack {
  constructor(scope: Construct, id: string, props?: StackProps) {
    super(scope, id, props);

    const recordName = process.env.RECORD_NAME || "";
    const domainName = process.env.DOMAIN_NAME || "";
    const bucketName = process.env.BUCKET_NAME || "";
    const cert = process.env.CERT_ARN || "";

    // ホストゾーンIDを取得
    const hostedZoneId = route53.HostedZone.fromLookup(this, "HostedZoneId", {
      domainName,
    });

    // S3バケットを作成
    const websiteBucket = new s3.Bucket(this, "WebsiteBucket", {
      removalPolicy: RemovalPolicy.DESTROY,
      autoDeleteObjects: true,
      bucketName,
    });

    // CloudFront用のOrigin Access Identityを作成
    const originAccessIdentity = new cloudfront.OriginAccessIdentity(
      this,
      "OriginAccessIdentity",
      {
        comment: `${bucketName}-identity`,
      }
    );

    // S3バケットポリシーを設定
    const webSiteBucketPolicyStatement = new iam.PolicyStatement({
      actions: ["s3:GetObject"],
      effect: iam.Effect.ALLOW,
      principals: [
        new aws_iam.CanonicalUserPrincipal(
          originAccessIdentity.cloudFrontOriginAccessIdentityS3CanonicalUserId
        ),
      ],
      resources: [`${websiteBucket.bucketArn}/*`],
    });

    websiteBucket.addToResourcePolicy(webSiteBucketPolicyStatement);

    // CloudFront Functionの設定
    const cfFunction = new aws_cloudfront.Function(this, "CloudFrontFunction", {
      code: aws_cloudfront.FunctionCode.fromFile({
        filePath: "assets/redirect.js",
      }),
    });

    // 証明書を取得
    const certificate = Certificate.fromCertificateArn(
      this,
      "Certificate",
      cert
    );

    // CloudFrontの設定
    const distribution = new aws_cloudfront.Distribution(this, "distribution", {
      domainNames: [recordName ],
      certificate,
      comment: `${bucketName}-cloudfront`,
      defaultRootObject: "index.html",
      defaultBehavior: {
        allowedMethods: aws_cloudfront.AllowedMethods.ALLOW_GET_HEAD,
        cachedMethods: aws_cloudfront.CachedMethods.CACHE_GET_HEAD,
        cachePolicy: aws_cloudfront.CachePolicy.CACHING_OPTIMIZED,
        viewerProtocolPolicy:
          aws_cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
        origin: new aws_cloudfront_origins.S3Origin(websiteBucket, {
          originAccessIdentity,
        }),
        functionAssociations: [
          {
            function: cfFunction,
            eventType: aws_cloudfront.FunctionEventType.VIEWER_REQUEST,
          },
        ],
      },
      priceClass: aws_cloudfront.PriceClass.PRICE_CLASS_ALL,
    });

    // Route53レコード設定
    new route53.ARecord(this, "Route53RecordSet", {
      // ドメイン指定
      recordName,
      // ホストゾーンID指定
      zone: hostedZoneId,
      // エイリアスターゲット設定
      target: route53.RecordTarget.fromAlias(
        new targets.CloudFrontTarget(distribution)
      ),
    });
  }
}

Summary

There may be some areas where consideration was insufficient, but I was able to experience the convenience of AWS CDK. We hope some parts of this serve as a reference for others.