Effortless Deployment: Next.js Static WebApp on S3 & CloudFront with AWS CDK and CodePipeline Tutorial

dkmostafa

Mostafa Dekmak

Posted on March 23, 2024

Effortless Deployment: Next.js Static WebApp on S3 & CloudFront with AWS CDK and CodePipeline Tutorial

Github Link : https://github.com/dkmostafa/dev-samples

AWS-CDK Infrastructure Branch : https://github.com/dkmostafa/dev-samples/tree/infra
NextJs Application Branch : https://github.com/dkmostafa/dev-samples/tree/next-js-static-branch

Goal :

The objective of this article is to employ CI/CD to deploy a Next.js Single Page Application to an S3 bucket and host it via CloudFront.

Introduction

Modern web development often requires not just creating beautiful and functional applications but also ensuring they are deployed efficiently, securely, and with high performance. In this tutorial, we'll delve into the world of serverless architecture and infrastructure as code (IaC) with AWS CDK. We'll explore how to seamlessly deploy a Next.js static web application to an S3 bucket and serve it through CloudFront, Amazon's Content Delivery Network (CDN).

AWS CDK (Cloud Development Kit) provides a powerful way to define cloud infrastructure using familiar programming languages such as TypeScript or Python. Combined with AWS CodePipeline, a fully managed continuous integration and continuous delivery (CI/CD) service, we can automate the deployment process, making it easy to update and scale our web applications.

Whether you're new to serverless architecture or looking to optimize your Next.js app deployment, this guide will walk you through each step, from setting up your project to automating the deployment pipeline. By the end, you'll have a robust, performant, and easily scalable Next.js web application running on AWS infrastructure.

Let's dive in!

Prerequisites

Before we begin, make sure you have the following:

First Step : Setting Up S3 Bucket and CloudFront Distribution with AWS CDK:

Creating an S3 Bucket and CloudFront Distribution with AWS CDK
In this first step, we'll use AWS CDK to set up an S3 bucket and a CloudFront distribution to host our Next.js static web application. This will lay the foundation for serving our app with high availability and low latency through Amazon's Content Delivery Network (CDN).

We'll be using a custom construct, S3CloudFrontStaticWebHostingConstruct, to simplify the creation of these resources. This construct encapsulates the logic for creating an S3 bucket with specific configurations and a CloudFront distribution that points to this bucket.

Here's the TypeScript code for the construct:

s3CloudFrontStaticWebHosting.construct.ts

import { Construct } from "constructs";
import { BlockPublicAccess, Bucket } from "aws-cdk-lib/aws-s3";
import { Duration, RemovalPolicy } from "aws-cdk-lib";
import { AllowedMethods, Distribution, SecurityPolicyProtocol, ViewerProtocolPolicy } from "aws-cdk-lib/aws-cloudfront";
import { S3Origin } from "aws-cdk-lib/aws-cloudfront-origins";

interface IS3BucketConfig {
    bucketId: string,
    bucketName: string,
}

interface ICloudFrontDistribution {
    cloudFrontId: string,
}

export interface IS3CloudFrontStaticWebHostingConstructProps {
    s3BucketConfig: IS3BucketConfig,
    cloudFrontDistribution: ICloudFrontDistribution
}

export class S3CloudFrontStaticWebHostingConstruct extends Construct {
    constructor(scope: Construct, id: string, _props: IS3CloudFrontStaticWebHostingConstructProps) {
        super(scope, id);

        const bucket = this.createS3Bucket(_props.s3BucketConfig);
        const cloudFrontDistribution: Distribution = this.createCloudFrontDistribution(_props.cloudFrontDistribution, bucket);
    }

    private createS3Bucket(_props: IS3BucketConfig) {
        const bucket: Bucket = new Bucket(this, _props.bucketId, {
            bucketName: _props.bucketName,
            blockPublicAccess: BlockPublicAccess.BLOCK_ALL,
            publicReadAccess: false,
            removalPolicy: RemovalPolicy.DESTROY,
        });

        return bucket;
    }

    private createCloudFrontDistribution(_props: ICloudFrontDistribution, s3Bucket: Bucket) {
        const distribution = new Distribution(this, _props.cloudFrontId, {
            defaultBehavior: {
                allowedMethods: AllowedMethods.ALLOW_GET_HEAD_OPTIONS,
                compress: true,
                origin: new S3Origin(s3Bucket),
                viewerProtocolPolicy: ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
            },
            defaultRootObject: "index.html",
            errorResponses: [
                {
                    httpStatus: 403,
                    responseHttpStatus: 403,
                    responsePagePath: "/error.html",
                    ttl: Duration.minutes(30),
                },
            ],
            minimumProtocolVersion: SecurityPolicyProtocol.TLS_V1_2_2019,
        });

        return distribution;
    }
}

Enter fullscreen mode Exit fullscreen mode

Explanation:

We define an interface IS3BucketConfig to specify the properties needed for our S3 bucket, such as bucketId and bucketName.
Similarly, the ICloudFrontDistribution interface defines the properties required for the CloudFront distribution, such as cloudFrontId.
The IS3CloudFrontStaticWebHostingConstructProps interface combines these configurations into a single object that we'll pass to our construct.
Inside the S3CloudFrontStaticWebHostingConstruct constructor, we create an S3 bucket using the createS3Bucket method, which configures the bucket with properties like blockPublicAccess and removalPolicy.
We then create a CloudFront distribution using the createCloudFrontDistribution method. This distribution specifies the behavior for requests, default root object, error responses, and minimum TLS protocol version.
This construct simplifies the process of creating an S3 bucket and CloudFront distribution with the necessary configurations for hosting a Next.js static web application. In the next steps, we'll integrate this construct into our CDK stack and continue building our deployment pipeline.

Stay tuned for the next step where we'll explore integrating this construct into our AWS CDK stack!

Second Step: Establishing the CodePipeline with AWS CDK for Deploying Our Next.js Application:

In this stage, we'll construct an AWS CodePipeline to retrieve our source code from GitHub, build it using our buildspec.yml file, and then deploy it to an S3 bucket. Following the deployment to the S3 bucket, we'll proceed to invalidate the CloudFront cache to ensure that the latest updates to our application are reflected.

Code :

s3CloudFrontStaticWebHosting.construct.ts

 private buildingS3BucketPipeline(_props:IPipelineConfig,webSiteS3Bucket:Bucket,cloudFrontDistribution:Distribution):Pipeline {

        const outputSources: Artifact = new Artifact();
        const outputWebsite: Artifact = new Artifact();

        const sourceAction: GitHubSourceAction = new GitHubSourceAction({
            actionName: 'GitHub_Source',
            owner: _props.githubConfig.owner,
            repo: _props.githubConfig.repo,
            oauthToken: SecretValue.secretsManager(_props.githubConfig.oAuthSecretManagerName),
            output: outputSources,
            branch: _props.githubConfig.branch,
            trigger: GitHubTrigger.WEBHOOK
        })
        const buildAction: CodeBuildAction = new CodeBuildAction({
            actionName: "BuildWebsite",
            project: new PipelineProject(this, "BuildWebsite", {
                projectName: "BuildWebsite",
                buildSpec: BuildSpec.fromSourceFilename(_props.buildSpecLocation),
                environment: {
                    buildImage: LinuxBuildImage.STANDARD_7_0
                }
            }),
            input: outputSources,
            outputs: [outputWebsite],
        });
        const deploymentAction : S3DeployAction =new S3DeployAction({
            actionName:"S3WebDeploy",
            input: outputWebsite,
            bucket: webSiteS3Bucket,
            runOrder:1,
        });
        const invalidateBuildProject = new PipelineProject(this, `InvalidateProject`, {
            buildSpec: BuildSpec.fromObject({
                version: '0.2',
                phases: {
                    build: {
                        commands:[
                            'aws cloudfront create-invalidation --distribution-id ${CLOUDFRONT_ID} --paths "/*"',
                        ],
                    },
                },
            }),
            environmentVariables: {
                CLOUDFRONT_ID: { value: cloudFrontDistribution.distributionId },
            },
        });
        const distributionArn = `arn:aws:cloudfront::${_props.account}:distribution/${cloudFrontDistribution.distributionId}`;
        invalidateBuildProject.addToRolePolicy(new PolicyStatement({
            resources: [distributionArn],
            actions: [
                'cloudfront:CreateInvalidation',
            ],
        }));
        const invalidateCloudFrontAction: CodeBuildAction = new CodeBuildAction({
            actionName: 'InvalidateCache',
            project: invalidateBuildProject,
            input: outputWebsite,
            runOrder: 2,
        });

        const pipeline: Pipeline = new Pipeline(this,_props.pipelineId , {
            pipelineName: _props.pipelineName,
            stages:[
                {
                    stageName:"Source",
                    actions:[sourceAction],
                },
                {
                    stageName:"Build",
                    actions:[buildAction],
                },
                {
                    stageName:"S3Deploy",
                    actions:[deploymentAction,invalidateCloudFrontAction],
                }
            ]
        });

        return pipeline;
Enter fullscreen mode Exit fullscreen mode

Explanation :

his code defines a function buildingS3BucketPipeline that constructs an AWS CodePipeline for deploying a Next.js application to an S3 bucket and invalidating the CloudFront cache to reflect updates. Here's a breakdown of the key components:

Artifacts:

Two artifacts are created:
outputSources: Artifact to hold the source code retrieved from GitHub.
outputWebsite: Artifact to store the built website files.
GitHub Source Action:

A GitHub source action (sourceAction) is defined using the GitHubSourceAction class.
It fetches the source code from a GitHub repository.
Parameters include the GitHub repository owner, repository name, OAuth token for authentication, output artifact (outputSources), branch to monitor for changes, and the trigger type (GitHubTrigger.WEBHOOK).
Build Action with CodeBuild:

A build action (buildAction) is created using the CodeBuildAction class.
It builds the application using an AWS CodeBuild project.
Parameters include the action name, the CodeBuild project (defined inline with PipelineProject), input artifact (outputSources from the GitHub source action), and output artifact (outputWebsite to store built files).
The CodeBuild project is configured with a build specification file (_props.buildSpecLocation) and an environment (using LinuxBuildImage.STANDARD_7_0).
Deployment Action to S3:

A deployment action (deploymentAction) using the S3DeployAction class is defined.
It deploys the built website to the S3 bucket (webSiteS3Bucket).
Parameters include the action name, input artifact (outputWebsite), target S3 bucket (webSiteS3Bucket), and the run order.
CloudFront Cache Invalidation:

A CodeBuild project (invalidateBuildProject) is created to invalidate the CloudFront cache.
The build project runs a set of commands, including the aws cloudfront create-invalidation command, which invalidates all objects in the CloudFront distribution (${CLOUDFRONT_ID} represents the CloudFront distribution ID).
The project is configured with an environment variable (CLOUDFRONT_ID) containing the distribution ID of our CloudFront distribution (cloudFrontDistribution.distributionId).
IAM Policy for Cache Invalidation:

An IAM policy statement is defined to allow the CodeBuild project to create invalidations for the CloudFront distribution.
The policy statement grants permissions to the CodeBuild project to perform the cloudfront:CreateInvalidation action on the specific CloudFront distribution (distributionArn).
Invalidation CloudFront Action with CodeBuild:

Another CodeBuild action (invalidateCloudFrontAction) is defined to execute the cache invalidation process.
This action uses the invalidateBuildProject to run the cache invalidation commands.
The input artifact for this action is outputWebsite, and it has a run order of 2, ensuring it runs after the deployment action.
Creating the CodePipeline:

Finally, the function constructs the CodePipeline using the Pipeline class.
The pipeline is defined with stages:
Source Stage: Includes the sourceAction to fetch code from GitHub.
Build Stage: Contains the buildAction to build the application.
S3Deploy Stage: Combines the deploymentAction to deploy to S3 and invalidateCloudFrontAction to invalidate the CloudFront cache.
Each stage represents a step in the pipeline, ensuring the source is fetched, the application is built, and then deployed with cache invalidation.
This entire setup automates the process of updating and deploying the Next.js application from source code changes in GitHub to a live, updated website hosted on Amazon S3 with CloudFront CDN. This pipeline ensures efficient and reliable delivery of the application to end-users while handling the necessary steps for deployment and cache management seamlessly.

Last step : Setting Up the Next.js Application:

Given that we're utilizing our Next.js Application as a Single Page Application (SPA), it's crucial to adhere to the following guidelines:

We'll avoid incorporating any server-side components within our application.
It's necessary to implement the 'use client' directive at the top of our pages.
We should include the following configuration in our next.config.mjs file:


/** @type {import('next').NextConfig} */
const nextConfig = {
    output: 'export',
    reactStrictMode:false
};

export default nextConfig;

Enter fullscreen mode Exit fullscreen mode

4 - add the following buildspec.yml file to our root

version: 0.2

phases:
  install:
    runtime-versions:
      nodejs: latest
    commands:
      - echo  Nodejs version  ` node --version `
      - echo Installing dependency...
      - npm install -g next
      - npm install -g typescript
      - cd nextjs-static-webapp-sample
      - npm install
      - echo Dependency Installed

  build:
    commands:
      - echo Build started on `date`
      - echo Compiling the Node.js code
      - npm run build
      - echo Next App is built successfully
artifacts:
  files:
    - '**/*'
  base-directory: 'nextjs-static-webapp-sample/out'

Enter fullscreen mode Exit fullscreen mode
💖 💪 🙅 🚩
dkmostafa
Mostafa Dekmak

Posted on March 23, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related