Deploy Next.js 14 to AWS ECS with Terraform and Docker

Deploy Nextjs14 to AWS ECS with Terraform and Docker - 2024

I am a staunch advocate for the principle of separation of concerns, which is why Infrastructure as Code (IaC), Docker, and Next.js are particularly appealing to me. These tools and frameworks encourage a modular approach, enabling me to compartmentalize different aspects of the development process, thus making it more manageable and streamlined.

As an enthusiast of Next.js 14, I'm impressed by the new features it introduces, especially the way it delineates backend and frontend components. In this guide, we will delve into why AWS is our preferred platform over Vercel and the advantages of this choice.

Opting for AWS, given that Vercel is built on top of it, can be economically beneficial in the long term if it's set up correctly. My primary reason for choosing AWS is its Virtual Private Cloud (VPC) feature, which fortifies the security of applications by restricting access.

Moreover, encapsulating our application in a Docker container not only facilitates deployment on AWS but also provides the flexibility to migrate to other cloud services if needed in the future. To manage our infrastructure with greater ease and efficiency, we employ Terraform. This tool enables us to rapidly provision all the necessary resources without the need to interact with a complex user interface.

Before commencing this project, ensure you have an AWS account and have installed Docker and Terraform on your system.

To do this you can install the Docker Desktop from https://www.docker.com/products/docker-desktop/

Terraform you can install using the following on a Mac:

brew tap hashicorp/tap
brew install hashicorp/tap/terraform

Now with Terraform installed let's build our nextjs14 app.

Create a new folder for our project:

mkdir my-app
cd my-app

To do this we run the following:

npx create-next-app@latest

Now create a Dockerfile in the root

# Stage 1: Install dependencies and build the app
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json yarn.lock ./
RUN yarn install
COPY . .
RUN yarn build

# Stage 2: Install production dependencies
FROM node:20-alpine AS production-dependencies
WORKDIR /app
COPY package.json yarn.lock ./
RUN yarn install

# Stage 3: Prepare the final image
FROM node:20-alpine
WORKDIR /app
COPY --from=builder /app/.next ./.next
COPY --from=builder /app/public ./public
COPY --from=production-dependencies /app/node_modules ./node_modules
COPY package.json ./
RUN chown -R node:node /app
USER node
EXPOSE 3000
CMD ["yarn", "start"]

This Dockerfile creates a container image for a Node.js application in three stages:

  1. Build stage: Uses node:20-alpine as a base, copies application files, and runs yarn install to install all dependencies, then build the application with yarn build.

  2. Dependencies stage: Again starts with node:20-alpine, copies only the package.json and yarn.lock files, and installs production dependencies with yarn install.

  3. Final image stage: Starts with node:20-alpine again, copies the built application and public files from the build stage, and the production dependencies from the dependencies stage. It sets file ownership to the node user, exposes port 3000, and runs the application with yarn start.

You can now see if your app is working correctly by building it using the docker build command. We use the following in the root directory and tag our Docker Image.

docker build -t my-image .

(One thing to note if on a Mac Metal make sure you add --platform linux/amd64 parameter if you going to upload the image not using Terraform as ECS needs to know what type of architecture the Image was built on.

Once our docker Image is built we can run our docker container and test if it's working:

docker run -p 3000:3000 --name my-container my-image

Let's break down this corrected command:

  • docker run: This is the base command to run a Docker container. Docker containers are standalone, executable packages that include everything needed to run a piece of software, including the code, a runtime, libraries, environment variables, and config files.
  • -p 3000:3000: This option maps the port 3000 of the container to port 3000 on the host machine. The structure is -p host_port:container_port. This means any traffic that comes into the host on port 3000 is forwarded to port 3000 in the container.
  • --name my-container: This assigns the name my-container to the running container. Naming containers is optional, but it's a good practice to manage and reference them easily, especially when you have multiple containers running.
  • my-image: This specifies the image to create the container from. my-image should be the name of an image that is either locally available or can be pulled from an image repository like Docker Hub. This image acts as the base for the container, defining its contents, operating environment, and the commands that will be executed on startup.

Go to your localhost on port 3000 and you should see your NextJS application there.

Shut down your container and now let's build our AWS infrastructure. Ensure that you have installed the AWS CDK to proceed. Follow the link below to get that started.

Getting started with the AWS CDK

Once you have the CDK started we can move on to create the terraform file.

This is a quick look at what we are going to build.

Create a main.tf

This Terraform file will contain instructions to provision and configure resources on AWS for hosting a Next.js application using Elastic Container Service (ECS), Elastic Container Registry (ECR), Application Load Balancer (ALB), and related networking and security configurations. Here's a step-by-step explanation:

  1. Define Terraform Configuration: Specifies the required Terraform version and AWS provider plugin, ensuring compatibility and correct functioning.

  2. Provider Configuration: Configures the AWS provider with us-east-1 as the target region for deploying the resources.

  3. ECR Repository Creation: Creates an Elastic Container Registry (ECR) repository named nextjs14-ecr-repo where Docker images can be stored. It's set to allow mutable image tags and to scan images on push.

  4. Local Execution for Docker Build and Push: Defines local variables and a null resource to trigger a local-exec provisioner, which logs into ECR, builds the Docker image for the Next.js app, and pushes it to the ECR repository. (*Remove -platform linux/amd64 if not on Mac M1/M2 )

  5. Retrieve the Latest Image Data: Defines a data source to get information about the latest image in the created ECR repository, ensuring the latest image is used in deployments.

  6. ECS Cluster Creation: Sets up an Amazon ECS cluster named nextjs14-cluster which will manage the containers running the application.

  7. IAM Role and Policy for ECS Tasks: Creates an IAM role ecsTaskExecutionRole and attaches the AmazonECSTaskExecutionRolePolicy, enabling ECS tasks to make AWS API calls on your behalf.

  8. ECS Task Definition: Defines an ECS task named nextjs14-task-test with container specifications, including the ECR image to use, CPU, memory, and networking configuration tailored for Fargate.

  9. Networking Resources: References default VPC and subnets, preparing the network infrastructure for the ECS service and load balancer.

  10. Application Load Balancer (ALB) Setup: Creates an ALB nextjs14-lb, security groups, target group, and listener to route traffic to the ECS service's containers. The load balancer distributes incoming application traffic across the containers.

  11. ECS Service Creation: Defines an ECS service nextjs14-service that maintains the application's container instances, using the previously defined task definition and network configuration, integrated with the load balancer.

  12. Security Group for ECS Service: Configures a security group nextjs14-service_security_group to control inbound and outbound traffic for the service, allowing it to communicate with the ALB.

  13. Output Configuration: Exposes the DNS name of the load balancer as an output, which can be used to access the deployed Next.js application.

Now here is all the code:

terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 4.16"
}
}

required_version = ">= 1.2.0"
}

provider "aws" {
region = "us-east-1" # region of the user account
}

# Creating an ECR Repository

resource "aws_ecr_repository" "nextjs14-ecr-repo" {
name = "nextjs14-ecr-repo"
image_tag_mutability = "MUTABLE"
force_delete = true

    image_scanning_configuration {
        scan_on_push = true
    }

}

# --- Build & push image ---

locals {
repo_url = aws_ecr_repository.nextjs14-ecr-repo.repository_url
}

resource "null_resource" "image" {
triggers = {
hash = md5(join("-", [for x in fileset("", "./{*.py,*.tsx,Dockerfile}") : filemd5(x)]))
}

provisioner "local-exec" {
command = <<EOF
aws ecr get-login-password | docker login --username AWS --password-stdin ${local.repo_url}
docker build --platform linux/amd64 -t ${local.repo_url}:latest .
docker push ${local.repo_url}:latest
EOF
}
}

data "aws_ecr_image" "latest" {
repository_name = aws_ecr_repository.nextjs14-ecr-repo.name
image_tag = "latest"
depends_on = [null_resource.image]
}

# Creating an ECS cluster

resource "aws_ecs_cluster" "nextjs14-cluster" {
name = "nextjs14-cluster" # Naming the cluster
}

# creating an iam policy document for ecsTaskExecutionRole

data "aws_iam_policy_document" "assume_role_policy" {
statement {
actions = ["sts:AssumeRole"]

    principals {
      type        = "Service"
      identifiers = ["ecs-tasks.amazonaws.com"]
    }

}
}

# creating an iam role with needed permissions to execute tasks

resource "aws_iam_role" "ecsTaskExecutionRole" {
name = "ecsTaskExecutionRole"
assume_role_policy = data.aws_iam_policy_document.assume_role_policy.json
}

# attaching AmazonECSTaskExecutionRolePolicy to ecsTaskExecutionRole

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

# Creating the task definition

resource "aws_ecs_task_definition" "nextjs14-task-test" {
family = "nextjs14-task-test" # Naming our first task
container_definitions = <<DEFINITION
[
{
"name": "nextjs14-container",
"image": "${aws_ecr_repository.nextjs14-ecr-repo.repository_url}",
"essential": true,
"portMappings": [
{
"containerPort": 3000,
"hostPort": 3000
}
],
"memory": 512,
"cpu": 256
}
]
DEFINITION
requires_compatibilities = ["FARGATE"] # Stating that we are using ECS Fargate
network_mode = "awsvpc" # Using awsvpc as our network mode as this is required for Fargate
memory = 512 # Specifying the memory our task requires
cpu = 256 # Specifying the CPU our task requires
execution_role_arn = aws_iam_role.ecsTaskExecutionRole.arn # Stating Amazon Resource Name (ARN) of the execution role
}

# Providing a reference to our default VPC

resource "aws_default_vpc" "default_vpc" {
}

# Providing a reference to our default subnets

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

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

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

# Creating a load balancer

resource "aws_alb" "nextjs14-lb" {
name = "nextjs14-lb" # Naming our load balancer
load_balancer_type = "application"
subnets = [ # Referencing the default subnets
"${aws_default_subnet.default_subnet_a.id}",
"${aws_default_subnet.default_subnet_b.id}",
"${aws_default_subnet.default_subnet_c.id}"
]

# Referencing the security group

security_groups = ["${aws_security_group.nextjs14-lb_security_group.id}"]
}

# Creating a security group for the load balancer:

resource "aws_security_group" "nextjs14-lb_security_group" {
ingress {
from_port = 80
to_port = 80
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"]
}
}

# Creating a target group for the load balancer

resource "aws_lb_target_group" "nextjs14-target_group" {
name = "target-group"
port = 80
protocol = "HTTP"
target_type = "ip"
vpc_id = aws_default_vpc.default_vpc.id # Referencing the default VPC
health_check {
matcher = "200,301,302"
path = "/"
}
}

# Creating a listener for the load balancer

resource "aws_lb_listener" "nextjs14-listener" {
load_balancer_arn = aws_alb.nextjs14-lb.arn # Referencing our load balancer
port = "80"
protocol = "HTTP"
default_action {
type = "forward"
target_group_arn = aws_lb_target_group.nextjs14-target_group.arn # Referencing our target group
}
}

# Creating the service

resource "aws_ecs_service" "nextjs14-service" {
name = "nextjs14-service"
 cluster = aws_ecs_cluster.nextjs14-cluster.id # Referencing our created Cluster
task_definition = aws_ecs_task_definition.nextjs14-task-test.arn # Referencing the task our service will spin up
launch_type = "FARGATE"
desired_count = 1 # Setting the number of containers we want deployed to 3

load_balancer {
target_group_arn = aws_lb_target_group.nextjs14-target_group.arn # Referencing our target group
container_name = "nextjs14-container"
container_port = 3000 # Specifying the container port
}

network_configuration {
subnets = ["${aws_default_subnet.default_subnet_a.id}", "${aws_default_subnet.default_subnet_b.id}", "${aws_default_subnet.default_subnet_c.id}"]
assign_public_ip = true # Providing our containers with public IPs
security_groups = ["${aws_security_group.nextjs14-service_security_group.id}"] # Setting the security group
}
}

# Creating a security group for the service

resource "aws_security_group" "nextjs14-service_security_group" {
ingress {
from_port = 0
to_port = 0
protocol = "-1" # Only allowing traffic in from the load balancer security group
security_groups = ["${aws_security_group.nextjs14-lb_security_group.id}"]
}

egress {
from_port = 0 # Allowing any incoming port
to_port = 0 # Allowing any outgoing port
protocol = "-1" # Allowing any outgoing protocol
cidr_blocks = ["0.0.0.0/0"] # Allowing traffic out to all IP addresses
}
}

output "lb_dns" {
value = aws_alb.nextjs14-lb.dns_name
description = "AWS load balancer DNS Name"
}

We covered a lot above but it is simple to implement the changes from here. Now we just have to initialize Terraform and apply the changes. To do this we run

terraform init
terraform apply

You should see all the resources above being mapped out and should see an output asking if you want to apply, respond by 'yes'

Do you want to perform these actions?
Terraform will perform the actions described above.
Only 'yes' will be accepted to approve.

Enter a value: yes Now this will build all your Terraform resources and your Dockerfile then upload it to ECR. In the end, you should see the output variable with the URL of your ECS service.

lb_dns = "nextjs14-XXXXXXXXXX.us-east-1.elb.amazonaws.com"

If you go to that URL you will see your NextJS application. To finish you can add your new load balancer to Route53 as an A-type and redirect all traffic to your new ECS. So the final domain will be pointing to www.yourdomain.com.

I hope this guide was helpful to you. I wish this resource had been available to me, as I spent many hours troubleshooting a deployment issue that ultimately boiled down to the necessity of specifying the correct Docker platform tag when using an M1/M2 chip.

As we help others we also help ourselves. Feel free to reach out to me on LinkedIn or recently my early adoption of twitter/X.