Building an Integrated Next.js × AWS CDK Environment: From Local Development with Docker to Production Deployment

  • nextjs
    nextjs
  • docker
    docker
  • aws
    aws
  • ecr
    ecr
  • ecs
    ecs
  • fargate
    fargate
Published on 2024/05/11

Introduction

This article explains how to manage Next.js and AWS CDK in a single repository and build Docker images for both local development and production environments. Specifically, we will go through:

  • Designing the directory structure
  • Setting up Next.js
  • Building a local development environment using Docker
  • Creating a production Docker image
  • Setting up AWS CDK

step by step. This will allow you to maintain consistency across environments and deploy smoothly to production.

Goal for This Article

We will deploy a Next.js project using AWS CDK and implement it to the point where you can access the app via a domain issued by AWS.

Image from Gyazo

Next.js is implemented with SSR (Server Side Rendering), and every time the browser is reloaded, it fetches the latest time and returns HTML.

We will also show how to check in the AWS console what resources are being created.

Image from Gyazo

What is AWS CDK?

AWS CDK (Cloud Development Kit) is an infrastructure management tool that lets you define and manage AWS resources using code.
Normally, AWS infrastructure is configured via the AWS Management Console or CloudFormation (YAML/JSON), but with AWS CDK you can define it using programming languages such as TypeScript or Python.

Features

  • Manage infrastructure with code
    You can define AWS resources (S3, Lambda, ECS, etc.) in code.
    You can use if statements and loops to create resources dynamically.

  • Wrapper around CloudFormation
    AWS CDK internally generates CloudFormation templates.
    It lets you write more concisely than handwritten YAML/JSON.

  • Multi-language support
    Supports TypeScript, JavaScript, Python, Java, C#, and more.

  • Easy to integrate with CI/CD
    You can deploy to AWS with the cdk deploy command.
    Easy to combine with GitHub Actions or AWS CodePipeline.

Basic Concepts

In AWS CDK, you mainly use the following concepts to define resources:

Concept Description
App Entry point of CDK (defined in cdk.json)
Stack CloudFormation stack (multiple resources are defined in one stack)
Construct Smallest unit of AWS resources (S3, Lambda, ECS, etc.)

Benefits

  1. Code-based management
    You can manage everything with Git and easily track change history.

  2. Easy local development
    You can preview the generated CloudFormation YAML with the cdk synth command.

  3. Familiar for developers
    Since you can write in TypeScript or Python, it’s easy to use for both frontend and backend developers.

  4. Easy to reuse
    If you create Constructs (components), you can reuse them across other projects.

System Architecture

This time we will build a configuration of VPC + ALB + ECS (Fargate) + ECR and expose a Next.js container.

We will also fully automate with CDK the process of building an image using a Dockerfile, pushing it to ECR, and starting ECS tasks from that image.

Image from Gyazo

Setting Up Next.js

To manage the Next.js project and the CDK project in a single repository, we’ll use the following directory structure:

  • Under apps: Next.js project
  • Under cdk: CDK project
mkdir nextjs-ecs-cdk
cd nextjs-ecs-cdk
mkdir apps
mkdir cdk

Now set up Next.js.

cd apps
npx create-next-app@14.2.0 .

You can leave all settings as default.

Image from Gyazo

Docker Image for Local Development

Create a Dockerfile to run Next.js locally.

# Development environment
FROM node:20-alpine

WORKDIR /app

# Copy package.json and package-lock.json
COPY package.json package-lock.json ./

RUN npm install

EXPOSE 3000

CMD ["npm", "run", "dev"]

Next, create the docker-compose file.

docker-compose.dev.yml
services:
  nextjs-dev:
    build:
      context: .
      dockerfile: dev.Dockerfile
    ports:
      - "3000:3000"
    volumes:
      - .:/app
      - /app/node_modules
    environment:
      - NODE_ENV=development
    stdin_open: true
    tty: true
services:
  nextjs-dev:
  • services: Defines the services managed by Docker Compose.
  • nextjs-dev: The name of the container that provides the Next.js development environment.
    • When you run docker-compose up, a container with this name is created.
    build:
      context: .
      dockerfile: dev.Dockerfile

context: .

  • Specifies the base directory for the build.
  • . means the current directory (where docker-compose.dev.yml is located).

dockerfile: dev.Dockerfile

  • Specifies which Dockerfile to use.
  • By using dev.Dockerfile, you apply the development settings (startup with npm run dev).
    ports:
      - "3000:3000"
  • Binds port 3000 on the host to port 3000 in the container.
  • When npm run dev is running, you can access it from the host browser at http://localhost:3000.
  • The format 3000:3000 is hostPort:containerPort.
    volumes:
      - .:/app
      - /app/node_modules
  • volumes is used to sync files between the container and the host.
  • It’s essential in development so that changes on the host are immediately reflected in the container.

- .:/app

  • Mounts the host’s current directory (.) to /app in the container.
  • This means source code edited on the host is immediately reflected inside the container.

- /app/node_modules

  • This keeps node_modules managed inside the container and separate from the host (to avoid mixing different binaries between host and container environments).
    • By writing /app/node_modules instead of :/app/node_modules, it is treated as an “empty volume,” so the host’s node_modules is not shared with the container.

Reason:

  • The host and container may have different OSes, so dependency binaries may not match.
  • Prevents conflicts between the development environment’s node_modules and the container’s node_modules.
    stdin_open: true
    tty: true

stdin_open: true

  • Keeps standard input open.
  • Since Next.js’s npm run dev uses standard input, setting stdin_open: true helps keep the dev environment stable.

tty: true

  • Enables a terminal inside the container.
  • When you start it with docker-compose up, you can easily stop the container with Ctrl + C.

Start Next.js with the following command:

docker-compose -f docker-compose.dev.yml up

Image from Gyazo

Access http://localhost:3000 and confirm that the page is displayed.

Image from Gyazo

To briefly confirm that SSR is enabled, modify the code so that the server side fetches the current time and renders it.

page.tsx
export const dynamic = "force-dynamic"

export default async function Home() {
  const now = new Date().toLocaleString("ja-JP", { timeZone: "Asia/Tokyo" })

  return (
    <main>
      <h1>Current Time (JST):</h1>
      <p>{now}</p>
    </main>
  );
}

Hot reload will automatically reflect the code changes.

Once the time is displayed, reload the browser and you’ll see the time change as it is re-rendered on the server side.

Image from Gyazo

Docker Image for Production

Next, create a Docker image to push to ECR.
This will build the app and then start Next.js.

# Production environment
FROM node:20-alpine AS builder

WORKDIR /app

COPY package.json package-lock.json ./
RUN npm install

COPY . .

RUN npm run build

# Prepare a runtime container (multi-stage build for a lighter image)
FROM node:20-alpine

WORKDIR /app

COPY --from=builder /app ./

EXPOSE 3000

CMD ["npm", "run", "start"]

We’ll also verify locally that this Dockerfile works correctly.

cd apps 
docker build -t nextjs-app-production .
docker run -d -p 3000:3000 --name nextjs-container-production nextjs-app-production

Once the container is running, access http://localhost:3000 and confirm that the page is displayed.

After verification, you can safely remove the container.

Setting Up CDK

Now that the Next.js side is configured, we’ll set up CDK.

Install the CDK CLI locally so you can use the cdk command:

npm install -g aws-cdk

Set up CDK and install the library used to push images to ECR.

cd cdk
cdk init app --language=typescript

Image from Gyazo

npm i cdk-docker-image-deployment

Delete the cdk/lib/cdk-stack.ts file and create a new docker-image-deployment.ts file to configure deployment of the Docker image to ECR.

cdk/lib/docker-image-deployment.ts
import {Stack, StackProps, RemovalPolicy } from 'aws-cdk-lib';
import { Repository } from 'aws-cdk-lib/aws-ecr';
import * as imagedeploy from 'cdk-docker-image-deployment';
import { Construct } from 'constructs';
import * as path from 'path';

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

    //**************************************************** */
    // ECR
    //**************************************************** */
    const repository = new Repository(this, 'NextjsEcrRepo', {
      repositoryName: 'nextjs-app',
      removalPolicy: RemovalPolicy.RETAIN,
    });

    new imagedeploy.DockerImageDeployment(this, "DeployDockerImage", {
      source: imagedeploy.Source.directory(
        path.join(__dirname, '../../', 'apps')
      ),
      destination: imagedeploy.Destination.ecr(repository, {
        tag: 'latest',
      }),
    });
  }
}

Explanation of the code:

const repository = new Repository(this, 'NextjsEcrRepo', {
  repositoryName: 'nextjs-app',
  removalPolicy: RemovalPolicy.RETAIN,
});
  • Creates an ECR (Elastic Container Registry) repository with the name nextjs-app.
  • With removalPolicy: RemovalPolicy.RETAIN, the ECR repository is retained even when the CDK stack is deleted.
new imagedeploy.DockerImageDeployment(this, "DeployDockerImage", {
  source: imagedeploy.Source.directory(
    path.join(__dirname, '../../', 'apps')
  ),
  destination: imagedeploy.Destination.ecr(repository, {
    tag: 'latest',
  }),
});
  • Uses cdk-docker-image-deployment to deploy a Docker image from the local apps directory to the nextjs-app repository with the latest tag.
  • source: specifies the directory where the Dockerfile is stored.

Modify the entry point so that it runs the stack we just created.

cdk/bin/cdk.ts
#!/usr/bin/env node
import * as cdk from 'aws-cdk-lib';
import { DockerImageDeploymentStack } from '../lib/docker-image-deployment';

const app = new cdk.App();
new DockerImageDeploymentStack(app, 'DockerImageDeploymentStack');

The directory structure is a bit complex, so here it is for reference:

tree -I node_modules -I .git -I .next --dirsfirst -a
.
├── apps
│   ├── app
│   │   ├── favicon.ico
│   │   ├── globals.css
│   │   ├── layout.tsx
│   │   └── page.tsx
│   ├── public
│   │   ├── next.svg
│   │   └── vercel.svg
│   ├── .eslintrc.json
│   ├── .gitignore
│   ├── Dockerfile
│   ├── README.md
│   ├── dev.Dockerfile
│   ├── docker-compose.dev.yml
│   ├── next-env.d.ts
│   ├── next.config.mjs
│   ├── package-lock.json
│   ├── package.json
│   ├── postcss.config.mjs
│   ├── tailwind.config.ts
│   └── tsconfig.json
└── cdk
    ├── bin
    │   └── cdk.ts
    ├── lib
    │   └── docker-image-deployment.ts
    ├── test
    │   └── cdk.test.ts
    ├── .gitignore
    ├── .npmignore
    ├── README.md
    ├── cdk.json
    ├── jest.config.js
    ├── package-lock.json
    ├── package.json
    └── tsconfig.json

Now that the CDK configuration for resource creation is ready, let’s run it.

First, you need to run cdk bootstrap to prepare the resources required by CDK.

Specifically, the following resources are created:

  • S3 bucket for CDK deployment (stores CDK assets)
  • IAM roles for CDK (grants AWS the permissions needed for deployment)
  • ECR repository for CDK (when using containers)
cdk bootstrap

Next, run the deployment.

cdk deploy

Deployment completed successfully.

Image from Gyazo

You can confirm in the AWS console that the image has been pushed to ECR.

Image from Gyazo

Creating ECS

Since the diff is large, the entire file is shown.

cdk/lib/docker-image-deployment.ts
import {
  Stack, 
  StackProps, 
  RemovalPolicy,
  aws_ec2 as ec2,
  aws_ecs as ecs,
  aws_logs as logs,
  aws_iam as iam,
  aws_elasticloadbalancingv2 as elbv2,
  Duration,
} from 'aws-cdk-lib';
import { Repository } from 'aws-cdk-lib/aws-ecr';
import * as imagedeploy from 'cdk-docker-image-deployment';
import { Construct } from 'constructs';
import * as path from 'path';

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

    //**************************************************** */
    // ECR
    //**************************************************** */
    const repository = new Repository(this, 'NextjsEcrRepo', {
      repositoryName: 'nextjs-app',
      removalPolicy: RemovalPolicy.RETAIN,
    });

    new imagedeploy.DockerImageDeployment(this, "DeployDockerImage", {
      source: imagedeploy.Source.directory(
        path.join(__dirname, '../../', 'apps')
      ),
      destination: imagedeploy.Destination.ecr(repository, {
        tag: 'latest',
      }),
    });

    //**************************************************** */
    // VPC
    //**************************************************** */
    const vpc = new ec2.Vpc(this, 'NextjsVpc', {
      maxAzs: 2,
    });

    //**************************************************** */
    // ECS
    //**************************************************** */
    const cluster = new ecs.Cluster(this, 'NextjsCluster', { 
      vpc,
      clusterName: 'nextjs-cluster',
    });

    const logGroup = new logs.LogGroup(this, "LogGroup", {
      logGroupName: '/aws/ecs/nextjs-cluster',
      removalPolicy: RemovalPolicy.DESTROY,
    });

    //**************************************************** */
    // ALB(Application Load Balancer)
    //**************************************************** */
    const albSecurityGroup = new ec2.SecurityGroup(this, "AlbSecurityGroup", {
      vpc,
      allowAllOutbound: true,
    });

    albSecurityGroup.addIngressRule(
      ec2.Peer.anyIpv4(), // Allow external HTTP access
      ec2.Port.tcp(80),
      "Allow HTTP traffic from the internet"
    );

    const alb = new elbv2.ApplicationLoadBalancer(this, 'alb', {
      internetFacing: true, // Whether to allow access from the internet
      loadBalancerName: 'nextjs-alb',
      securityGroup: albSecurityGroup, // Assign the created security group
      vpc   
    });

    const listener = alb.addListener('Listener', {
      port: 80,
      open: true,
    });

    //**************************************************** */
    // Fargate Service
    //**************************************************** */
    const serviceSecurityGroup = new ec2.SecurityGroup(this, "ServiceSecurityGroup", {
      vpc,
      allowAllOutbound: true,
    });

    serviceSecurityGroup.addIngressRule(
      albSecurityGroup,
      ec2.Port.tcp(3000),
      "Allow traffic from ALB"
    );

    const taskDefinition = new ecs.FargateTaskDefinition(this, "TaskDef", {
      cpu: 256,
      memoryLimitMiB: 512,
      family: 'nextjs-task-family', 
      taskRole: new iam.Role(this, 'TaskRole', { 
        assumedBy: new iam.ServicePrincipal('ecs-tasks.amazonaws.com') 
      }), 
    });

    taskDefinition.addContainer("NextjsContainer", {
      containerName: 'nextjs-container',
      image: ecs.ContainerImage.fromEcrRepository(repository, "latest"),
      portMappings: [{ containerPort: 3000 }],
      logging: ecs.LogDrivers.awsLogs({
        streamPrefix: `container`,
        logGroup,
      }),
      command: ["npm", "run", "start"],
    });

    const service = new ecs.FargateService(this, "FargateService", {
      cluster,
      serviceName: 'nextjs-service',
      taskDefinition: taskDefinition,
      desiredCount: 2,
      assignPublicIp: true,
      securityGroups: [serviceSecurityGroup],
    });

    listener.addTargets('EcsTargetGroup', {
      port: 3000,
      protocol: elbv2.ApplicationProtocol.HTTP,
      targets: [service],
      healthCheck: {
        path: "/",
        interval: Duration.seconds(30),
      },
    });
  }
}

Below is a detailed description of the resources being created.

VPC

const vpc = new ec2.Vpc(this, 'NextjsVpc', {
  maxAzs: 2,
});
  • Creates a VPC (virtual network) and deploys it across up to two Availability Zones (AZs).

ECS Cluster

const cluster = new ecs.Cluster(this, 'NextjsCluster', { 
  vpc,
  clusterName: 'nextjs-cluster',
});
  • Creates an ECS cluster named nextjs-cluster inside the vpc defined above.

Log Group

const logGroup = new logs.LogGroup(this, "LogGroup", {
  logGroupName: '/aws/ecs/nextjs-cluster',
  removalPolicy: RemovalPolicy.DESTROY,
});
  • Creates a CloudWatch Logs log group.
  • With removalPolicy: RemovalPolicy.DESTROY, the log group is deleted when the stack is deleted.

ALB (Application Load Balancer)

const albSecurityGroup = new ec2.SecurityGroup(this, "AlbSecurityGroup", {
  vpc,
  allowAllOutbound: true,
});

albSecurityGroup.addIngressRule(
  ec2.Peer.anyIpv4(),
  ec2.Port.tcp(80),
  "Allow HTTP traffic from the internet"
);
  • Creates a security group for the ALB and allows external HTTP access on port 80.
const alb = new elbv2.ApplicationLoadBalancer(this, 'alb', {
  internetFacing: true,
  loadBalancerName: 'nextjs-alb',
  securityGroup: albSecurityGroup,
  vpc   
});
  • Configures the ALB to be accessible from the internet.
const listener = alb.addListener('Listener', {
  port: 80,
  open: true,
});

Fargate

const serviceSecurityGroup = new ec2.SecurityGroup(this, "ServiceSecurityGroup", {
  vpc,
  allowAllOutbound: true,
});

serviceSecurityGroup.addIngressRule(
  albSecurityGroup,
  ec2.Port.tcp(3000),
  "Allow traffic from ALB"
);
  • Creates a security group for Fargate and allows port 3000 access from the ALB.
const taskDefinition = new ecs.FargateTaskDefinition(this, "TaskDef", {
  cpu: 256,
  memoryLimitMiB: 512,
  family: 'nextjs-task-family',
  taskRole: new iam.Role(this, 'TaskRole', { 
    assumedBy: new iam.ServicePrincipal('ecs-tasks.amazonaws.com') 
  }),
});
  • Creates a Fargate task definition with CPU 256 and memory 512 MiB.
taskDefinition.addContainer("NextjsContainer", {
  containerName: 'nextjs-container',
  image: ecs.ContainerImage.fromEcrRepository(repository, "latest"),
  portMappings: [{ containerPort: 3000 }],
  logging: ecs.LogDrivers.awsLogs({
    streamPrefix: `container`,
    logGroup,
  }),
  command: ["npm", "run", "start"],
});
  • Adds a container named nextjs-container using the image stored in ECR.
  • Starts the Next.js app with npm run start.
const service = new ecs.FargateService(this, "FargateService", {
  cluster,
  serviceName: 'nextjs-service',
  taskDefinition: taskDefinition,
  desiredCount: 2,
  assignPublicIp: true,
  securityGroups: [serviceSecurityGroup],
});
  • Creates a Fargate service named nextjs-service and runs two tasks.
listener.addTargets('EcsTargetGroup', {
  port: 3000,
  protocol: elbv2.ApplicationProtocol.HTTP,
  targets: [service],
  healthCheck: {
    path: "/",
    interval: Duration.seconds(30),
  },
});
  • Registers nextjs-service as a target group for the ALB.
  • Performs health checks on /.
  • Checks every 30 seconds.

Verification

First, check in the AWS console that the resources have been created correctly.

  • ECS Cluster
    Status is Active.

Image from Gyazo

  • ECS Service
    You can see that two tasks associated with the service are running.

Image from Gyazo

  • ECS Task
    Created with CPU: 0.25 and Memory: 0.5 GB.

Image from Gyazo

From the logs, you can see that Next.js is being started.

Image from Gyazo

  • Load Balancer
    It is configured to accept traffic on port 80, and a DNS name is defined for internet access.

Image from Gyazo

These are the IP addresses and ports of the two containers that the load balancer targets.
You can see that the Availability Zones are split between A and C.

Image from Gyazo

From the resource mapping, you can confirm that requests received on port 80 are being forwarded to the Next.js containers.

Image from Gyazo

Copy the address shown in the DNS field and access it from your browser.

In this setup we accept requests over HTTP, but by obtaining an SSL certificate with ACM (AWS Certificate Manager), you can also accept requests over HTTPS.

Image from Gyazo

Conclusion

This article explained how to set up a project that combines Next.js and AWS CDK. In particular, we built both a local development environment and a production environment using Docker, and prepared for deployment to AWS using CDK, enabling a scalable architecture.

Going forward, if you further explore implementing CDK code for deployment to ECS and building a deployment pipeline, you can create an even more practical environment. Let’s continue to build an efficient development environment and aim for smooth deployments.

Xでシェア
Facebookでシェア
LinkedInでシェア

Questions about this article 📝

If you have any questions or feedback about the content, please feel free to contact us.
Go to inquiry form