Building an Integrated Next.js × AWS CDK Environment: From Local Development with Docker to Production Deployment
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.
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.
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 thecdk deploycommand.
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
-
Code-based management
You can manage everything with Git and easily track change history. -
Easy local development
You can preview the generated CloudFormation YAML with thecdk synthcommand. -
Familiar for developers
Since you can write in TypeScript or Python, it’s easy to use for both frontend and backend developers. -
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.
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.
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.
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.
- When you run
build:
context: .
dockerfile: dev.Dockerfile
context: .
- Specifies the base directory for the build.
.means the current directory (wheredocker-compose.dev.ymlis located).
dockerfile: dev.Dockerfile
- Specifies which Dockerfile to use.
- By using
dev.Dockerfile, you apply the development settings (startup withnpm run dev).
ports:
- "3000:3000"
- Binds port 3000 on the host to port 3000 in the container.
- When
npm run devis running, you can access it from the host browser athttp://localhost:3000. - The format
3000:3000ishostPort:containerPort.
volumes:
- .:/app
- /app/node_modules
volumesis 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/appin the container. - This means source code edited on the host is immediately reflected inside the container.
- /app/node_modules
- This keeps
node_modulesmanaged inside the container and separate from the host (to avoid mixing different binaries between host and container environments).- By writing
/app/node_modulesinstead of:/app/node_modules, it is treated as an “empty volume,” so the host’snode_modulesis not shared with the container.
- By writing
Reason:
- The host and container may have different OSes, so dependency binaries may not match.
- Prevents conflicts between the development environment’s
node_modulesand the container’snode_modules.
stdin_open: true
tty: true
stdin_open: true
- Keeps standard input open.
- Since Next.js’s
npm run devuses standard input, settingstdin_open: truehelps 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 withCtrl + C.
Start Next.js with the following command:
docker-compose -f docker-compose.dev.yml up
Access http://localhost:3000 and confirm that the page is displayed.
To briefly confirm that SSR is enabled, modify the code so that the server side fetches the current time and renders it.
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.
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 /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
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.
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-deploymentto deploy a Docker image from the localappsdirectory to thenextjs-apprepository with thelatesttag. source:specifies the directory where the Dockerfile is stored.
Modify the entry point so that it runs the stack we just created.
#!/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.
You can confirm in the AWS console that the image has been pushed to ECR.
Creating ECS
Since the diff is large, the entire file is shown.
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-clusterinside thevpcdefined 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-containerusing 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-serviceand runs two tasks.
listener.addTargets('EcsTargetGroup', {
port: 3000,
protocol: elbv2.ApplicationProtocol.HTTP,
targets: [service],
healthCheck: {
path: "/",
interval: Duration.seconds(30),
},
});
- Registers
nextjs-serviceas 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 isActive.
- ECS Service
You can see that two tasks associated with the service are running.
- ECS Task
Created with CPU: 0.25 and Memory: 0.5 GB.
From the logs, you can see that Next.js is being started.
- Load Balancer
It is configured to accept traffic on port 80, and a DNS name is defined for internet access.
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.
From the resource mapping, you can confirm that requests received on port 80 are being forwarded to the Next.js containers.
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.
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.
Questions about this article 📝
If you have any questions or feedback about the content, please feel free to contact us.Go to inquiry form
Related Articles
Robust Authorization Design for GraphQL and REST APIs: Best Practices for RBAC, ABAC, and OAuth 2.0
2024/05/13Introduction to Automating Development Work: A Complete Guide to ETL (Python), Bots (Slack/Discord), CI/CD (GitHub Actions), and Monitoring (Sentry/Datadog)
2024/02/12Complete Cache Strategy Guide: Maximizing Performance with CDN, Redis, and API Optimization
2024/03/07Chat App (with Image/PDF Sending and Video Call Features)
2024/07/15CI/CD Strategies to Accelerate and Automate Your Development Flow: Leveraging Caching, Parallel Execution, and AI Reviews
2024/03/12Practical Component Design Guide with React × Tailwind CSS × Emotion: The Optimal Approach to Design Systems, State Management, and Reusability
2024/11/22Management Dashboard Features (Graph Display, Data Import)
2024/06/02Cloud Security Measures in Practice with AWS & GCP: Optimizing WAF Configuration, DDoS Protection, and Access Control
2024/04/02















