Seamless Integration of Next.js 13.4 with AWS SDK: Deploying on Route53, ALB, and ECS using Terraform

Seamless Integration of Next.js 13.4 with AWS SDK: Deploying on Route53, ALB, and ECS using Terraform

9/15/2023, Published in Medium, DevTo and HashNode

2 mins read

200

Discover how to effectively utilize the AWS SDK within a Next.js 13.4 application with server components and deploy it on AWS using Terraform. From Route53's DNS management, ALB's load balancing capabilities to ECS's container orchestration, get an in-depth understanding of each stage. We'll also delve into the significance of secure connections and how to achieve them in your setup

Hello readers! Today, I'll be guiding you step-by-step on a fascinating journey that integrates AWS-SDK with the latest version of Next.js, 13.4. The exciting features of this version, including App Router and the much-anticipated server components, make it an ideal choice for modern web applications. Also, will walk through step by step in deploying the application on AWS using terraform starting from ECR for storing container images, route53's DNS management, ALB load balancing to ECS container orchestration. We will also leverage Github Actions to build, publish application to ECR and deploy to AWS using terraform using terraform actions.

Use case

To make our journey tangible, we will be creating a full-fledged application using Next.js. This application will serve a comprehensive dashboard detailing the currently running ECS tasks for this app. You'll be privy to metrics such as the number of ECS tasks, the Availability Zones they operate within, their connectivity parameters, Launch type, allocated memory, and so much more.

Please refer the following links:

Application URL: https://dev-next.cloudysky.link/

GitHub Project: https://github.com/SandeepKumarYaramchitti/nextjs-ecs-github-actions

Deployment Overview

Our deployment isn't just about getting our application up and running; it's about doing so securely, efficiently, and scalably. We'll set up an SSL configuration using AWS Certificate Manager, master DNS management with Route53, and ensure efficient traffic distribution with ALB. All these while orchestrating our containers using the mighty ECS.

To bring all of these components together, we'll rely on the synergy between Route53, ALB, and ECS, a trifecta that ensures our application isn't just available, but resilient, scalable, and performant and we will do it with Infrastructure as Code with Terraform.

Continuous Deployment with GitHub Actions

But there is more! We will use Github actions for our CI/CD, we'll automate the process of building our Next.js app, publishing it to ECR, and then deploying it seamlessly to AWS using the terraform GitHub action.

Let's Get Started

1. Setting up our Next.js 13.4 Application

Create Next.js application using `create-next-app` and follow the docs here for the step by step process. Once you have the Next.js app up and running, install aws-sdk with npm.

You can also refer my GitHub repo as a starting point - https://github.com/SandeepKumarYaramchitti/nextjs-ecs-github-actions

2.Designing the ECS Dashboard

We will be building our Next.js application that will display the ECS task definition details as a card component with a header in the Next.js application. Refer the actual application for more information.

Application URL: https://dev-next.cloudysky.link/

3. Building the application

As I mentioned, we will creating the above app using server components and App routing; lets get into the code now.

Let us create our API using route handler that can integrate to AWS ECS service using aws-sdk. Route Handlers are only available inside the app directory. They are the equivalent of API Routes inside the pages directory meaning you do not need to use API Routes and Route Handlers together.

We will be using HTTP Method GET with extended helper functions from Next.js i. e NextRequest and NextResponse . Install aws-sdk if you have not done it yet.

import { NextResponse } from 'next/server'
import { ECS } from 'aws-sdk';

type ExtractedTaskData = {
  availabilityZone: string;
  connectivity: string;
  connectivityAt: string;
  containers: {
    name: string;
    image: string;
    lastStatus: string;
    cpu: string;
    memory: string;
  }[];
  cpu: string;
  memory: string;
  launchType: string;
};

const maskAccountId = (imageUrl: string): string => {
  return imageUrl.replace(/(\d{12})\.dkr\.ecr\.us-east-1\.amazonaws\.com/, 'XXXXXXXXXXXX.dkr.ecr.us-east-1.amazonaws.com');
};

const extractTaskData = (data: any): ExtractedTaskData[] => {
  if (!data.tasks) return [];

  return data.tasks.map((task: any) => ({
    availabilityZone: task.availabilityZone,
    connectivity: task.connectivity,
    connectivityAt: task.connectivityAt,
    containers: task.containers.map((container: any) => ({
      name: container.name,
      image: maskAccountId(container.image),
      lastStatus: container.lastStatus,
      cpu: container.cpu,
      memory: container.memory,
    })),
    cpu: task.cpu,
    memory: task.memory,
    launchType: task.launchType,
  }));
};
 
export async function GET() {


  const ecs = new ECS({ region: 'us-east-1' });
  
    const data = await ecs.listTasks({ cluster: 'nextjs-cluster' }).promise();

    if (!data.taskArns || data.taskArns.length === 0) {
      throw new Error('No tasks found for the specified cluster.');
    }
    
    const rawtaskDetails = await ecs.describeTasks({
      cluster: 'nextjs-cluster',
      tasks: data.taskArns
    }).promise();
    
    const sanitizedData = extractTaskData(rawtaskDetails);
    return NextResponse.json(sanitizedData)
  
  
}

Imports and Type Definitions:

  • NextResponse from next/server helps send back responses from server-side functions in Next.js.
  • ECS from aws-sdk is the SDK for interacting with AWS's Elastic Container Service.
  • ExtractedTaskData type provides a structured way to shape the data we want to extract from the ECS task response.

Utility Functions:

  • maskAccountId is a helper function that masks AWS account IDs in image URLs for privacy/security reasons.
  • extractTaskData takes the raw data fetched from ECS and transforms it to return a structured and sanitized version.

The main GET function:

  • An instance of the ECS class is created to interact with the ECS service in the us-east-1 region.
  • The listTasks method fetches a list of task ARNs (Amazon Resource Names) for a cluster named nextjs-cluster.
  • If there are no tasks in the cluster, an error is thrown.
  • For each task ARN in the list, detailed task descriptions are fetched using the describeTasks method.
  • The raw task details are sanitized using the extractTaskData function.
  • Finally, the sanitized task data is sent as a JSON response using Next.js's NextResponse.json().

Now, lets move on to building the frontend for the application.

As this is a basic UI, I will be using 3 components as building blocks for my application i. e

Header.tsx

import * as React from 'react'
export default function Header() {
    return (
        <div className="bg-gray-900">
        <div className="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8">
            <h1 className="text-3xl font-bold leading-tight text-white">
            ECS Dashboard
            </h1>
        </div>
        </div>
    )
}       

ECSTasks.tsx

import * as React from 'react'
import TaskCard from './TaskCard'

export default async function ECSTasks() {

    const BASE_URL = process.env.NEXT_PUBLIC_BASE_URL || 'https://dev-next.cloudysky.link'

    const data= await fetch(`${BASE_URL}/api/awsservice`, { cache: 'no-cache' })
    if (!data.ok) {
        return <div>Failed to load</div>
    }
    const ecsDetails = await data.json()

    return (
      <div className='flex items-center justify-center min-h-screen  flex-wrap'>
        {ecsDetails.map((task: ExtractedTaskData, index: number) => (
          <TaskCard task={task} key={index} />
        ))}
      </div>
    )
}   

type ExtractedTaskData = {
  availabilityZone: string;
  connectivity: string;
  connectivityAt: string;
  containers: {
    name: string;
    image: string;
    lastStatus: string;
    cpu: string;
    memory: string;
  }[];
  cpu: string;
  memory: string;
  launchType: string;
};


This code represents a React component named ECSTasks that fetches and displays details of Elastic Container Service (ECS) tasks from a specified URL. Using a defined environment variable or a default URL, it makes an API request to get task data. If the API call is unsuccessful, the component displays an error message. However, upon successful data retrieval, it maps through the array of ECS tasks, presenting each task's details using a component named TaskCard. The expected shape of each ECS task's data is described by the type ExtractedTaskData. This type outlines the properties and structure of the data, such as availability zones, connectivity information, container details, and resource specifications like CPU and memory. The overall design of the component employs a flexbox layout, ensuring that the TaskCard components are centered and wrapped within the viewport.

Taskcard.tsx

import React from 'react';
import { FaServer, FaMemory, FaMicrochip, FaDocker } from 'react-icons/fa';
type ExtractedTaskData = {
  availabilityZone: string;
  connectivity: string;
  connectivityAt: string;
  containers: {
    name: string;
    image: string;
    lastStatus: string;
    cpu: string;
    memory: string;
  }[];
  cpu: string;
  memory: string;
  launchType: string;
};

export default function TaskCard({ task }: { task: ExtractedTaskData }) {
  return (
    <div className="bg-white p-6 m-4 rounded-lg shadow-md w-96">
      <h2 className="text-xl font-bold mb-4 text-gray-700"><FaServer className="inline-block mr-2" /> Task Details</h2>
      
      <div className="mb-4 text-gray-600">
        <span className="font-bold text-gray-600">Availability Zone:</span> {task.availabilityZone}
      </div>
      
      <div className="mb-4 text-gray-600">
        <span className="font-bold">Connectivity:</span> {task.connectivity}
      </div>

      <div className="mb-4 text-gray-600">
        <span className="font-bold">Connectivity At:</span> {task.connectivityAt}
      </div>

      <div className="mb-4 text-gray-600">
        <span className="font-bold"><FaDocker className="inline-block mr-2" /> Launch Type:</span> {task.launchType}
      </div>

      <div className="mb-4 text-gray-600">
        <span className="font-bold"><FaMicrochip className="inline-block mr-2" /> CPU:</span> {task.cpu}
      </div>

      <div className="mb-4 text-gray-600">
        <span className="font-bold"><FaMemory className="inline-block mr-2" /> Memory:</span> {task.memory}
      </div>

      {task.containers.map((container, index) => (
        <div key={index} className="mb-4 border-t pt-4 text-gray-600">
          <h3 className="font-semibold text-md mb-2">{container.name}</h3>
          <p><span className="font-bold"><FaDocker className="inline-block mr-2" /> Image:</span> {container.image}</p>
          <p><span className="font-bold">Status:</span> {container.lastStatus}</p>
          <p><span className="font-bold"><FaMicrochip className="inline-block mr-2" /> CPU:</span> {container.cpu}</p>
          <p><span className="font-bold"><FaMemory className="inline-block mr-2" /> Memory:</span> {container.memory}</p>
        </div>
      ))}
    </div>
  );
}

The code defines a React component named TaskCard that visually represents the details of an Elastic Container Service (ECS) task. Leveraging a combination of text and icons from the 'react-icons' library, it displays information about the task, including availability zones, connectivity, launch type, CPU, and memory. Furthermore, for each container within the task, the component showcases container-specific details, such as the image, status, and resource allocations.

As a last step, lets look at the page.tsx which imports the ECSTasks component to display in on the home page.

import Image from 'next/image'
import ECSTasks from './components/ECSTasks';


export default async function Home() {

  return (
    <main className="flex min-h-screen max-w-7xl flex-col items-center justify-between p-15">
      <ECSTasks />
    </main>
  )
}


3. Building and Storing with ECR

With our app ready, it's time to containerize it. Using Docker, create an image of your application. Once done:

  • Set up an ECR repository on AWS. (You can refer the AWS documentation to create this. )

Now, lets create the Github Actions workflow to build and publish image to ECR. Please setup the required secrets in your github repo.

name: Build and Publish to ECS

on:
  workflow_dispatch:

  push:
    branches:
      - main  

env:
  AWS_REGION: us-east-1  
  ECR_REPOSITORY: nextjs-ecr-repository  

jobs:
  deploy:
    runs-on: ubuntu-latest

    steps:
    - name: Checkout code
      uses: actions/checkout@v3

    - name: Configure AWS credentials
      uses: aws-actions/configure-aws-credentials@v2
      with:
        aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
        aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
        aws-region: ${{ env.AWS_REGION }}

    - name: Install dependencies
      run: npm install

    - name: Run build
      run: npm run build  # Replace with your build command

    - name: Login to Amazon ECR
      uses: aws-actions/amazon-ecr-login@v1

    - name: Build Docker image
      run: |
        docker build -t ${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.${{ env.AWS_REGION }}.amazonaws.com/nextjs-ecr-repository:latest .

    - name: Push Docker image to ECR
      run: |
        docker push ${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.${{ env.AWS_REGION }}.amazonaws.com/nextjs-ecr-repository:latest

4. Terraforming Our Infrastructure

  • Initialize your Terraform environment.
  • Create resources for ECS, ALB, and Route53.
  • Incorporate SSL via AWS Certificate Manager.
  • Apply the Terraform scripts to build out your infrastructure.

provider "aws" {
  region = var.region
}

# VPC & Subnets
resource "aws_default_vpc" "main_vpc" {}

resource "aws_default_subnet" "subnet_us_east_1a" {
  availability_zone = "us-east-1a"
}

resource "aws_default_subnet" "subnet_us_east_1b" {
  availability_zone = "us-east-1b"
}

resource "aws_default_subnet" "subnet_us_east_1c" {
  availability_zone = "us-east-1c"
}

# SSL Certificate
resource "aws_acm_certificate" "ssl_cert" {
  domain_name       = var.custom_domain_name
  validation_method = "DNS"
  tags = {
    Name = "cloudysky.link-cert"
  }
}

resource "aws_route53_record" "ssl_cert_validation_record" {
  zone_id = var.hosted_zone_id
  name    = tolist(aws_acm_certificate.ssl_cert.domain_validation_options)[0].resource_record_name
  type    = tolist(aws_acm_certificate.ssl_cert.domain_validation_options)[0].resource_record_type
  records = [tolist(aws_acm_certificate.ssl_cert.domain_validation_options)[0].resource_record_value]
  ttl     = 60
}

resource "aws_acm_certificate_validation" "ssl_cert_validation" {
  certificate_arn         = aws_acm_certificate.ssl_cert.arn
  validation_record_fqdns = [aws_route53_record.ssl_cert_validation_record.fqdn]
}

data "aws_ecr_repository" "nextjs_repo" {
  name = "nextjs-ecr-repository"
}

# ECS Resources
resource "aws_ecs_cluster" "nextjs_cluster" {
  name = "nextjs-cluster"
}

resource "aws_ecs_task_definition" "nextjs_task" {
  family                = "nextjs-task"
  container_definitions = <<-DEFINITION
  [
    {
      "name": "nextjs-container",
      "image": "${data.aws_ecr_repository.nextjs_repo.repository_url}",
      "essential": true,
      "portMappings": [
        {
          "containerPort": 8080,
          "hostPort": 8080
        }
      ],
      "memory": 512,
      "cpu": 256,
      "environment": [
        {
          "name": "NEXT_PUBLIC_BASE_URL",
          "value": "https://dev-next.cloudysky.link"
        }
      ]
    }
  ]
  DEFINITION

  requires_compatibilities = ["FARGATE"]
  network_mode             = "awsvpc"
  memory                   = 512
  cpu                      = 256
  execution_role_arn       = aws_iam_role.ecs_execution_role.arn
}

data "aws_iam_policy_document" "ecs_role_assumption" {
  statement {
    actions = ["sts:AssumeRole"]
    principals {
      type        = "Service"
      identifiers = ["ecs-tasks.amazonaws.com"]
    }
  }
}

resource "aws_iam_role" "ecs_execution_role" {
  name               = "NextJS-ECSTaskExecutionRole"
  assume_role_policy = data.aws_iam_policy_document.ecs_role_assumption.json
}

resource "aws_iam_role_policy_attachment" "ecs_execution_role_policy_attach" {
  role       = aws_iam_role.ecs_execution_role.name
  policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy"
}

# ALB & Security Groups
resource "aws_security_group" "alb_sg" {
  name = "ALB-SecurityGroup"
  ingress {
    from_port   = 443
    to_port     = 443
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }
  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }
}

resource "aws_alb" "nextjs_alb" {
  name               = "NextJS-ALB"
  load_balancer_type = "application"
  subnets = [
    aws_default_subnet.subnet_us_east_1a.id,
    aws_default_subnet.subnet_us_east_1b.id,
    aws_default_subnet.subnet_us_east_1c.id
  ]
  security_groups = [aws_security_group.alb_sg.id]
}

resource "aws_lb_target_group" "nextjs_tg" {
  name        = "NextJS-TargetGroup"
  port        = 80
  protocol    = "HTTP"
  target_type = "ip"
  vpc_id      = aws_default_vpc.main_vpc.id
}

resource "aws_lb_listener" "https_listener" {
  load_balancer_arn = aws_alb.nextjs_alb.arn
  port              = 443
  protocol          = "HTTPS"
  ssl_policy        = "ELBSecurityPolicy-2016-08"
  certificate_arn   = aws_acm_certificate_validation.ssl_cert_validation.certificate_arn

  default_action {
    type             = "forward"
    target_group_arn = aws_lb_target_group.nextjs_tg.arn
  }
}

# Route53 Record
resource "aws_route53_record" "nextjs_domain_record" {
  zone_id = var.hosted_zone_id
  name    = var.custom_domain_name
  type    = "A"

  alias {
    name                   = aws_alb.nextjs_alb.dns_name
    zone_id                = aws_alb.nextjs_alb.zone_id
    evaluate_target_health = false
  }
}

# ECS Service
resource "aws_ecs_service" "nextjs_service" {
  name                 = "NextJS-Service"
  cluster              = aws_ecs_cluster.nextjs_cluster.id
  task_definition      = aws_ecs_task_definition.nextjs_task.arn
  launch_type          = "FARGATE"
  desired_count        = 3
  force_new_deployment = true

  load_balancer {
    target_group_arn = aws_lb_target_group.nextjs_tg.arn
    container_name   = "nextjs-container"
    container_port   = 8080
  }

  network_configuration {
    subnets          = [aws_default_subnet.subnet_us_east_1a.id, aws_default_subnet.subnet_us_east_1b.id, aws_default_subnet.subnet_us_east_1c.id]
    assign_public_ip = true
    security_groups  = [aws_security_group.ecs_service_sg.id]
  }
}

resource "aws_security_group" "ecs_service_sg" {
  name = "ECS-Service-SecurityGroup"
  ingress {
    from_port       = 0
    to_port         = 0
    protocol        = "-1"
    security_groups = [aws_security_group.alb_sg.id]
  }
  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }
}
  1. AWS Provider: Specifies that the AWS cloud provider is being used, with the AWS region being sourced from a variable.
  2. VPC & Subnets: The default VPC is being used. Three default subnets are defined for three availability zones in the us-east-1 region.
  3. SSL Certificate: An AWS Certificate Manager (ACM) SSL certificate is created for a custom domain. A Route53 DNS record is used for domain validation.
  4. ECS Resources: A new Elastic Container Service (ECS) cluster and task definition for a Next.js application is defined. The task uses a container image from an ECR repository and runs on AWS Fargate. An execution IAM role for the ECS task is also created.
  5. ALB & Security Groups: An Application Load Balancer (ALB) is set up with its security group. The ALB listens on HTTPS port 443 and forwards traffic to a target group. The target group uses HTTP and targets the ECS service.
  6. Route53 Record: A DNS A record is created in Route53, pointing to the ALB, thus linking the custom domain to the load balancer.
  7. ECS Service: Defines an ECS service that runs the Next.js tasks on Fargate. The service is linked to the previously defined ALB and is configured to run three instances of the task. Another security group specific to the ECS service is also established.

In essence, the script sets up an environment where a Next.js application is containerized, then served via ECS running on Fargate, with traffic routed through an ALB, and the domain managed through Route53.


5. Automating Deployment with GitHub Actions

  • Create a workflow file for GitHub Actions.
  • Define jobs to build your Next.js app, push to ECR, and then trigger Terraform for deployment.
  • Commit and watch GitHub Actions work its magic!

name: Next.js ECS Deploy

on:
  workflow_dispatch:

jobs:
  terraform:
    name: 'Terraform'
    runs-on: ubuntu-latest

    steps:
      - name: Checkout code
        uses: actions/checkout@v3

      - name: List out directory
        run: ls

      - name: Set up Terraform
        uses: hashicorp/setup-terraform@v2
        with:
          cli_config_credentials_token: ${{ secrets.TF_API_TOKEN }}
          terraform_wrapper: false

      - name: Terraform init
        run: terraform init
      
      - name: Terraform Apply
        env:
          AWS_DEFAULT_REGION: us-east-1 
          TF_VAR_custom_domain_name: ${{ secrets.CUSTOM_DOMAIN_NAME }}
          TF_VAR_hosted_zone_id: ${{ secrets.HOSTED_ZONE_ID }}
          # Add other variables as needed
        run: terraform apply -auto-approve

In Conclusion

By the end of this journey, you'd have not only integrated the AWS-SDK with the latest version of Next.js but also deployed it to a robust AWS infrastructure using Terraform, all while automating the process using GitHub Actions.

Thank you for coming along on this journey. Watch out this space for more blogs...

IaCFullstack appTerraformAWS

ALL SYSTEMS ONLINE