A fairly common pattern used to deploy a serverless website on AWS is CloudFront + API Gateway + Lambdas. In this post, I’ll introduce my take as I realize that I have been using this pattern fairly regularly so I figured I would note it down here so I can copy-paste it for future use. The general approach is as follows:

  • Static site contents deployed to an S3 bucket
  • Backend deployed as lambdas, integrated with API Gateway
  • Everything behind CloudFront for caching
  • Tied together with AWS CDK

Prerequisites

You will need:

  • Node.js & NPM
  • An AWS account with sufficient privileges
  • Some content

Scaffolding

The project root would look something along the lines of the following:

project-root
├── cdk.json      <- CDK metadata
├── handlers/     <- lambda entry points
├── infra/        <- CDK stack spec
├── lib/          <- for a JavaScript / TypeScript project
├── package.json  <- NPM config
├── pkg/          <- for a Go project
├── tsconfig.json <- TypeScript config
└── ui/           <- frontend

Setting up the project

# pick your version of Node
echo "18" > .nvmrc

# dependencies
npm install \
  aws-cdk \
  aws-cdk-lib \
  constructs \
  tsx \
  typescript

# development dependencies
npm install --save-dev \
  @types/node \
  dotenv \
  source-map-support

tsconfig.json

{
  "compilerOptions": {
    "module": "CommonJS",
    "target": "ES2016",
    "strict": true,
    "resolveJsonModule": true,
    "esModuleInterop": true
  },
  "include": ["infra"]
}

cdk.json

Here I also set the bootstrap qualifier to “tookit”, really this depends on how you bootstrapped your AWS account. You may add other context flags here as you see fit.

{
  "app": "tsx infra/index.ts",
  "context": {
    "@aws-cdk/core:bootstrapQualifier": "toolkit"
  }
}

.eslintrc.yaml

Optionally, you may choose to add prettier and eslint to your project

npm install --save-dev \
  @types/node \
  @typescript-eslint/eslint-plugin \
  @typescript-eslint/parser \
  eslint \
  eslint-config-prettier \
  eslint-config-import \
  eslint-plugin-jsx-a11y \
  eslint-plugin-react \
  eslint-plugin-react-hooks \
  prettier

Example .eslintrc.yml
root: true

extends:
  - eslint:recommended
  - plugin:@typescript-eslint/recommended
  - plugin:react/recommended
  - plugin:react-hooks/recommended
  - plugin:jsx-a11y/recommended
  - plugin:prettier/recommended

env:
  node: true
  browser: true

plugins:
  - import
  - "@typescript-eslint"
  - react
  - prettier

parser: "@typescript-eslint/parser"
parserOptions:
  ecmaFeatures:
    jsx: true

settings:
  react:
    version: detect

rules:
  "@typescript-eslint/no-explicit-any":
    - warn

  "@typescript-eslint/no-unused-vars":
    - warn
    - vars: all
      args: after-used
      ignoreRestSiblings: false
      argsIgnorePattern: ^_

  import/order:
    - warn
    - groups:
        - - builtin
          - external
        - - parent
          - sibling
          - index
      newlines-between: always
      alphabetize:
        order: asc
        caseInsensitive: true
      pathGroups:
        - pattern: ~/**
          group: parent
          position: before

  no-unused-vars:
    - off

  prettier/prettier:
    - error
    - {}
    - usePrettierrc: true

  react/react-in-jsx-scope:
    - off

Configuration

There are some variables you will need to figure out how to introduce into your infrastructure through CDK context, environment variables, SSM, etc. The following code will be referencing the following variables

  • stack - the root stack construct
  • certificateArn - An ACM certificate, needs to be in the us-east-1 region to be compatible with CloudFront.
  • domainName - The public facing domain name
  • hostedZoneId, hostedZoneName - The Route53 hosted zone in which the DNS record will be created. If you manage your DNS elsewhere then this can be ignored
  • siteCompileCommand - the command you would use to generate your site content
  • siteOutputDirectory - the folder in which the site content is rendered into

Apart from stack, these variables can be introduced in many ways:

  • via cdk contexts
    • set through a command line argument --context domainName=example.com or in cdk.json
      {
        "app": "npx tsx infra/index.ts",
        "context": {
          "domainName": "example.com"
        }
      }
      
    • reference with built-in context resolution
      const domainName = stack.tryGetContext("domainName");
      
  • through an SSM parameter
    import * as ssm from "aws-cdk-lib/aws-ssm";
    const domainName = ssm.StringParameter.valueForStringParameter(
      stack,
      "/path/to/domainName"
    );
    
  • through environment variables
    const domainName = process.env.DOMAIN_NAME;
    

The static content

We will make use of the built-in BucketDeployment CDK construct which will copy our site contents into an S3 bucket and automatically invalidate the CloudFront distribution accordingly

import * as acm from "aws-cdk-lib/aws-certificatemanager";
import * as cf from "aws-cdk-lib/aws-cloudfront";
import * as s3 from "aws-cdk-lib/aws-s3";
import * as s3d from "aws-cdk-lib/aws-s3-deployment";
import { spawnSync } from "child_process";

const bucket = new s3.Bucket(stack, {
  // the bucket should only be accessible via cloudfront
  blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
  removalPolicy: cdk.RemovalPolicy.DESTROY,
  autoDeleteObjects: true,
});

// for some reason - must explicitly grant cloudfront read permissions
const oai = new cf.OriginAccessIdentity(stack, "OriginAccessIdentity");
bucket.grantRead(oai);

const distribution = new cf.Distribution(stack, "Distribution", {
  certificate: acm.Certificate.fromCertificateArn(certificateArn),
  minimumProtocolVersion: cf.SecurityPolicyProtocol.TLS_V1_2_2021,
  domainNames: [domainName],
  defaultRootObject: "/index.html",
  // if your site is an SPA - this is required
  errorResponses: [
    {
      httpStatus: 404,
      responseHttpStatus: 200,
      responsePagePath: "/index.html",
    },
  ],
  defaultBehavior: {
    origin: new cfo.S3Origin(bucket, { originAccessIdentity: oai }),
  },
});

// render the site
const [command, ...args] = siteCompileCommand.split(" ");
spawnSync(command, args, {
  cwd: process.cwd(),
  stdio: ["ignore", "inherit", "inherit"],
});

// assert content exists
fs.statSync(siteOutputDirectory);

// stage the content for deployment
new s3d.BucketDeployment(bucket, "BucketDeployment", {
  destinationBucket: bucket,
  sources: [s3d.Source.asset(siteOutputDirectory)],
  distribution: distribution,
  distributionPaths: ["/*"],
});

The backend

The backend will consist of serverless lambdas, what language you use is up to you. I will be utilising the new HTTP API offered by API Gateway as it is much simpler but the original REST API will also work just as well.

If you haven’t already, install the API Gateway CDK construct library and any other libraries that may not have been promoted to stable yet (in this example, I use the GoFunction construct).

npm install \
  @aws-cdk/aws-apigatewayv2-alpha \
  @aws-cdk/aws-apigatewayv2-integrations-alpha \
  @aws-cdk/aws-lambda-go-alpha
import * as api2 from "@aws-cdk/aws-apigateway2-alpha";
import * as api2i from "@aws-cdk/aws-apigatewayv2-integrations-alpha";
import * as go from "@aws-cdk/aws-lambda-go-alpha";

const api = new api2.HttpApi(stack, "HttpApi");

// a helper function to register routes
const addRoute = (
  method: api2.HttpMethod,
  path: string,
  config: go.GoFunctionProps
) => {
  // generate a stable id based on the method and path
  const name = `${method.toUpperCase()} ${path}`;
  const id = name.replace(/[^a-zA-Z0-9]+/g, "-");
  // create the handler and integration
  const handler = new go.GoFunction(api, id, config);
  const integration = new api2i.HttpLambdaIntegration("Integration", handler);
  // register the integration
  api.addRoutes({ methods: [method], path, integration });
};

addRoute(api2.HttpMethod.GET, "/api/world", {
  entry: "handlers/hello_world/main.go",
});

addRoute(api2.HttpMethod.POST, "/api/world", {
  entry: "handlers/goodbye_world/main.go",
});

Once your API is configured, you may attach it as a behavior to your CloudFront distribution so that they share a common domain name, negating the hassle of setting up CORS.

Note: if you want to do this, the API route must be prefixed with the path pattern configured in the distribution behavior. For example, if you want your API to be available under /api/*, your handler must be registered under the route /api/path/to/handler

import * as cfo from "aws-cdk-lib/aws-cloudfront-origins";

distribution.addBehaviour(
  "/api/*":
  new cfo.HttpOrigin(api.domainName, {connectionAttempts: 1}),
  {
    allowedMethods: cf.AllowedMethods.ALLOW_ALL,
    // note: this is required as API Gateway expects the host to match the domain
    originRequestPolicy: cf.OriginRequestPolicy.ALL_VIEWER_EXCEPT_HOST_HEADER,
    responseHeadersPolicy: cf.ResponseHeadersPolicy.SECURITY_HEADERS,
    // note: this is required as API Gateway does not work with HTTP
    viewerProtocolPolicy: cf.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
  }
)

DNS

Now that everything is together, you may want to alias your CloudFront distribution to a nicer custom domain name.

import * as r53 from "aws-cdk-lib/aws-route53";
import * as r53t from "aws-cdk-lib/aws-route53-targets";

const hostedZone = r53.HostedZone.fromHostedZoneAttributes(
  stack,
  "HostedZone",
  { hostedZoneId, zoneName: hostedZoneName }
);

const target = new r53t.CloudFrontTarget(site.distribution);

new r53.ARecord(stack, "ARecord", {
  zone: hostedZone,
  recordName: `${domainName}.`,
  target: r53.RecordTarget.fromAlias(target),
});

Even more

With the infrastructure set up, we can focus on developer experience. I’ve opted to use Go for my backend and Vite for the frontend.

Backend

Initialize your Go workspace.

go mod init github.com/example/project

For each of your handlers, create a file, e.g. handlers/my_handler/main.go

package main

import (
	"context"

	"github.com/aws/aws-lambda-go/events"
	"github.com/aws/aws-lambda-go/lambda"
)

func main() {
	lambda.Start(Handler)
}

func Handler(ctx context.Context, request events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) {
	return events.APIGatewayProxyResponse{
		StatusCode: 200,
		Body:       "hello world",
	}, nil
}

You can now run go mod tidy to generate a go.sum file and download dependencies.

Once you register your handlers within CDK, you can run npx cdk synth to render out a CloudFormation template. Using SAM CLI, we can run a local api based on this template which emulates API Gateway.

# synthesize the template
npx cdk synth
# register it as a script
npm pkg set scripts."api:start"="sam local start-api --template ./cdk.out/MyStack.template.json --warm-containers EAGER --skip-pull-image"
# execute it
npm run api:start

Each time you synthesize the template, SAM will lazily hot-reload your handlers which is not ideal for a developer experience. However, we can use nodemon to trigger reloads whenever we make changes.

npm install nodemon
# register as a script
npm pkg set scripts."api:dev"="nodemon --exec \"npx cdk synth\" --watch \"pkg\" --watch \"handlers\" --ext \"go\" --delay 2000"
# execute it
npm run api:dev

Frontend

Initialize your Vite project.

npm create vite@latest ./ui -- --template react-swc-ts

Within your root package.json, add ./ui as a workspace

{
  // ...
  "workspaces": ["./ui"]
  // ...
}

Within ui/vite.config.ts, add a snippet to proxy your lambdas

export default defineConfig({
  // ...
  server: {
    proxy: {
      "/api": "http://127.0.0.1:3000",
    },
  },
  // ...
});

Finally, add a script to execute the development server from the root

npm pkg set scripts."ui:dev"="npm -w ui run dev"

Tying it all together

To summarize, we’ve added a couple convenience scripts:

  • to launch an instance of SAM CLI local api which will serve our lambdas at their configured paths
  • to rebuild our template whenever we make a change to our backend code which will trigger the SAM local api server to rebuild our lambdas
  • to start the Vite development server

To execute them all at the same time, we can use concurrently

npm install concurrently
npm pkg set scripts.start="concurrently --kill-others \"npm:ui:dev\" \"npm:api:dev\" \"npm:api:start\""