- Published on
How to deploy a SpringBoot API on AWS ECS using CDKTF?
- 12 mins read
When a Java developer asked me how to deploy their Spring Boot API on AWS ECS, I saw it as the perfect chance to dive into the latest updates on the CDKTF (Cloud Development Kit for Terraform) project.
In a previous article, I introduced CDKTF, a framework that allows you to write Infrastructure as Code (IaC) using general-purpose programming languages such as Python. Since then, CDKTF has reached its first GA release, making it the perfect time to revisit it. In this article, we’ll walk through deploying a Spring Boot API on AWS ECS using CDKTF.
Find the code of this article on my github repo.
Table of Contents
Architecture Overview
Before diving into the implementation, let’s review the architecture we aim to deploy:
From this diagram, we can break down the architecture into 03 layers:
- Network:
- VPC
- Public and private subnets
- Internet Gateway
- NAT Gateways
- Infrastructure:
- Application Load Balancer (ALB)
- Listeners
- ECS Cluster
- Service Stack:
- Target Groups
- ECS Service
- Task Definitions
Step 1: Containerize your Spring Boot Application
The Java API we’re deploying is available on GitHub.
It defines a simple REST API with three endpoints:
/ping
: Returns the string "pong". This endpoint is useful for testing the API's responsiveness. It also increments a Prometheus counter metric for monitoring./healthcheck
: Returns "ok", serving as a health check endpoint to ensure the application is running correctly. Like/ping
, it updates a Prometheus counter for observability./hello
: Accepts aname
query parameter (defaults to "World") and returns a personalized greeting, e.g., "Hello, [name]!". This endpoint also integrates with the Prometheus counter.
Let’s add the Dockerfile:
FROM maven:3.9-amazoncorretto-21 AS builder
WORKDIR /app
COPY pom.xml .
COPY src src
RUN mvn clean package
# amazon java distribution
FROM amazoncorretto:21-alpine
COPY /app/target/*.jar /app/java-api.jar
EXPOSE 8080
ENTRYPOINT ["java","-jar","/app/java-api.jar"]
Our application is ready to be deployed!
Step 2: Set up AWS CDKTF
AWS CDKTF allows you to define and manage AWS resources using Python.
1. Prerequisites
2. Install CDKTF and Dependencies
Ensure you have the necessary tools by installing CDKTF and its dependencies:
$ npm install -g cdktf-cli@latest
This installs the cdktf CLI that allows to spin up new projects for various languages.
3. Initialize Your CDKTF Application
We can scaffold a new python project by running:
# init the project using aws provider
$ mkdir samples-fargate
$ cd samples-fargate && cdktf init --template=python --providers=aws
There are many files created by default and all the dependencies are installed.
Below is the initial main.py
file:
#!/usr/bin/env python
from constructs import Construct
from cdktf import App, TerraformStack
class MyStack(TerraformStack):
def __init__(self, scope: Construct, id: str):
super().__init__(scope, id)
# define resources here
app = App()
MyStack(app, "aws-cdktf-samples-fargate")
app.synth()
Step 3: Building Layers
A stack
represents a group of infrastructure resources that CDK for Terraform (CDKTF) compiles into a distinct Terraform configuration. Stacks enable separate state management for different environments within an application. To share resources across layers, we will utilize Cross-Stack references.
1. Network Layer
Add the network_stack.py
file to your project
$ mkdir infra
$ cd infra && touch network_stack.py
Add the following code to create all the network resources:
from constructs import Construct
from cdktf import S3Backend, TerraformStack
from cdktf_cdktf_provider_aws.provider import AwsProvider
from cdktf_cdktf_provider_aws.vpc import Vpc
from cdktf_cdktf_provider_aws.subnet import Subnet
from cdktf_cdktf_provider_aws.eip import Eip
from cdktf_cdktf_provider_aws.nat_gateway import NatGateway
from cdktf_cdktf_provider_aws.route import Route
from cdktf_cdktf_provider_aws.route_table import RouteTable
from cdktf_cdktf_provider_aws.route_table_association import RouteTableAssociation
from cdktf_cdktf_provider_aws.internet_gateway import InternetGateway
class NetworkStack(TerraformStack):
def __init__(self, scope: Construct, ns: str, params: dict):
super().__init__(scope, ns)
self.region = params["region"]
# configure the AWS provider to use the us-east-1 region
AwsProvider(self, "AWS", region=self.region)
# use S3 as backend
S3Backend(
self,
bucket=params["backend_bucket"],
key=params["backend_key_prefix"] + "/network.tfstate",
region=self.region,
)
# create the vpc
vpc_demo = Vpc(self, "vpc-demo", cidr_block="192.168.0.0/16")
# create two public subnets
public_subnet1 = Subnet(
self,
"public-subnet-1",
vpc_id=vpc_demo.id,
availability_zone=f"{self.region}a",
cidr_block="192.168.1.0/24",
)
public_subnet2 = Subnet(
self,
"public-subnet-2",
vpc_id=vpc_demo.id,
availability_zone=f"{self.region}b",
cidr_block="192.168.2.0/24",
)
# create. the internet gateway
igw = InternetGateway(self, "igw", vpc_id=vpc_demo.id)
# create the public route table
public_rt = Route(
self,
"public-rt",
route_table_id=vpc_demo.main_route_table_id,
destination_cidr_block="0.0.0.0/0",
gateway_id=igw.id,
)
# create the private subnets
private_subnet1 = Subnet(
self,
"private-subnet-1",
vpc_id=vpc_demo.id,
availability_zone=f"{self.region}a",
cidr_block="192.168.10.0/24",
)
private_subnet2 = Subnet(
self,
"private-subnet-2",
vpc_id=vpc_demo.id,
availability_zone=f"{self.region}b",
cidr_block="192.168.20.0/24",
)
# create the Elastic IPs
eip1 = Eip(self, "nat-eip-1", depends_on=[igw])
eip2 = Eip(self, "nat-eip-2", depends_on=[igw])
# create the NAT Gateways
private_nat_gw1 = NatGateway(
self,
"private-nat-1",
subnet_id=public_subnet1.id,
allocation_id=eip1.id,
)
private_nat_gw2 = NatGateway(
self,
"private-nat-2",
subnet_id=public_subnet2.id,
allocation_id=eip2.id,
)
# create Route Tables
private_rt1 = RouteTable(self, "private-rt1", vpc_id=vpc_demo.id)
private_rt2 = RouteTable(self, "private-rt2", vpc_id=vpc_demo.id)
# add default routes to tables
Route(
self,
"private-rt1-default-route",
route_table_id=private_rt1.id,
destination_cidr_block="0.0.0.0/0",
nat_gateway_id=private_nat_gw1.id,
)
Route(
self,
"private-rt2-default-route",
route_table_id=private_rt2.id,
destination_cidr_block="0.0.0.0/0",
nat_gateway_id=private_nat_gw2.id,
)
# associate routes with subnets
RouteTableAssociation(
self,
"public-rt-association",
subnet_id=private_subnet2.id,
route_table_id=private_rt2.id,
)
RouteTableAssociation(
self,
"private-rt1-association",
subnet_id=private_subnet1.id,
route_table_id=private_rt1.id,
)
RouteTableAssociation(
self,
"private-rt2-association",
subnet_id=private_subnet2.id,
route_table_id=private_rt2.id,
)
# terraform outputs
self.vpc_id = vpc_demo.id
self.public_subnets = [public_subnet1.id, public_subnet2.id]
self.private_subnets = [private_subnet1.id, private_subnet2.id]
Then, edit the main.py
file:
#!/usr/bin/env python
from constructs import Construct
from cdktf import App, TerraformStack
from infra.network_stack import NetworkStack
ENV = "dev"
AWS_REGION = "us-east-1"
BACKEND_S3_BUCKET = "blog.abdelfare.me"
BACKEND_S3_KEY = f"{ENV}/cdktf-samples"
class MyStack(TerraformStack):
def __init__(self, scope: Construct, id: str):
super().__init__(scope, id)
# define resources here
app = App()
MyStack(app, "aws-cdktf-samples-fargate")
network = NetworkStack(
app,
"network",
{
"region": AWS_REGION,
"backend_bucket": BACKEND_S3_BUCKET,
"backend_key_prefix": BACKEND_S3_KEY,
},
)
app.synth()
Generate the terraform configuration files by running the following command:
$ cdktf synth
Deploy the network stack with this:
$ cdktf deploy network
Our VPC is ready as shown in the image below:
2. Infrastructure Layer
Add the infra_stack.py
file to your project
$ cd infra && touch infra_stack.py
Add the following code to create all the infrastructure resources:
from constructs import Construct
from cdktf import S3Backend, TerraformStack
from cdktf_cdktf_provider_aws.provider import AwsProvider
from cdktf_cdktf_provider_aws.ecs_cluster import EcsCluster
from cdktf_cdktf_provider_aws.lb import Lb
from cdktf_cdktf_provider_aws.lb_listener import (
LbListener,
LbListenerDefaultAction,
LbListenerDefaultActionFixedResponse,
)
from cdktf_cdktf_provider_aws.security_group import (
SecurityGroup,
SecurityGroupIngress,
SecurityGroupEgress,
)
class InfraStack(TerraformStack):
def __init__(self, scope: Construct, ns: str, network: dict, params: dict):
super().__init__(scope, ns)
self.region = params["region"]
# Configure the AWS provider to use the us-east-1 region
AwsProvider(self, "AWS", region=self.region)
# use S3 as backend
S3Backend(
self,
bucket=params["backend_bucket"],
key=params["backend_key_prefix"] + "/load_balancer.tfstate",
region=self.region,
)
# create the ALB security group
alb_sg = SecurityGroup(
self,
"alb-sg",
vpc_id=network["vpc_id"],
ingress=[
SecurityGroupIngress(
protocol="tcp", from_port=80, to_port=80, cidr_blocks=["0.0.0.0/0"]
)
],
egress=[
SecurityGroupEgress(
protocol="-1", from_port=0, to_port=0, cidr_blocks=["0.0.0.0/0"]
)
],
)
# create the ALB
alb = Lb(
self,
"alb",
internal=False,
load_balancer_type="application",
security_groups=[alb_sg.id],
subnets=network["public_subnets"],
)
# create the LB Listener
alb_listener = LbListener(
self,
"alb-listener",
load_balancer_arn=alb.arn,
port=80,
protocol="HTTP",
default_action=[
LbListenerDefaultAction(
type="fixed-response",
fixed_response=LbListenerDefaultActionFixedResponse(
content_type="text/plain",
status_code="404",
message_body="Could not find the resource you are looking for",
),
)
],
)
# create the ECS cluster
cluster = EcsCluster(self, "cluster", name=params["cluster_name"])
self.alb_arn = alb.arn
self.alb_listener = alb_listener.arn
self.alb_sg = alb_sg.id
self.cluster_id = cluster.id
Edit the main.py
file:
...
CLUSTER_NAME = "cdktf-samples"
...
infra = InfraStack(
app,
"infra",
{
"vpc_id": network.vpc_id,
"public_subnets": network.public_subnets,
},
{
"region": AWS_REGION,
"backend_bucket": BACKEND_S3_BUCKET,
"backend_key_prefix": BACKEND_S3_KEY,
"cluster_name": CLUSTER_NAME,
},
)
...
Deploy the infra stack with this:
$ cdktf deploy network infra
Note the DNS name of the ALB, we will use it later.
3. Service Layer
Add the service_stack.py
file to your project
$ mkdir apps
$ cd apps && touch service_stack.py
Add the following code to create all the ECS Service resources:
from constructs import Construct
import json
from cdktf import S3Backend, TerraformStack, Token, TerraformOutput
from cdktf_cdktf_provider_aws.provider import AwsProvider
from cdktf_cdktf_provider_aws.ecs_service import (
EcsService,
EcsServiceLoadBalancer,
EcsServiceNetworkConfiguration,
)
from cdktf_cdktf_provider_aws.ecr_repository import (
EcrRepository,
EcrRepositoryImageScanningConfiguration,
)
from cdktf_cdktf_provider_aws.ecr_lifecycle_policy import EcrLifecyclePolicy
from cdktf_cdktf_provider_aws.ecs_task_definition import (
EcsTaskDefinition,
)
from cdktf_cdktf_provider_aws.lb_listener_rule import (
LbListenerRule,
LbListenerRuleAction,
LbListenerRuleCondition,
LbListenerRuleConditionPathPattern,
)
from cdktf_cdktf_provider_aws.lb_target_group import (
LbTargetGroup,
LbTargetGroupHealthCheck,
)
from cdktf_cdktf_provider_aws.security_group import (
SecurityGroup,
SecurityGroupIngress,
SecurityGroupEgress,
)
from cdktf_cdktf_provider_aws.cloudwatch_log_group import CloudwatchLogGroup
from cdktf_cdktf_provider_aws.data_aws_iam_policy_document import (
DataAwsIamPolicyDocument,
)
from cdktf_cdktf_provider_aws.iam_role import IamRole
from cdktf_cdktf_provider_aws.iam_role_policy_attachment import IamRolePolicyAttachment
class ServiceStack(TerraformStack):
def __init__(
self, scope: Construct, ns: str, network: dict, infra: dict, params: dict
):
super().__init__(scope, ns)
self.region = params["region"]
# Configure the AWS provider to use the us-east-1 region
AwsProvider(self, "AWS", region=self.region)
# use S3 as backend
S3Backend(
self,
bucket=params["backend_bucket"],
key=params["backend_key_prefix"] + "/" + params["app_name"] + ".tfstate",
region=self.region,
)
# create the service security group
svc_sg = SecurityGroup(
self,
"svc-sg",
vpc_id=network["vpc_id"],
ingress=[
SecurityGroupIngress(
protocol="tcp",
from_port=params["app_port"],
to_port=params["app_port"],
security_groups=[infra["alb_sg"]],
)
],
egress=[
SecurityGroupEgress(
protocol="-1", from_port=0, to_port=0, cidr_blocks=["0.0.0.0/0"]
)
],
)
# create the service target group
svc_tg = LbTargetGroup(
self,
"svc-target-group",
name="svc-tg",
port=params["app_port"],
protocol="HTTP",
vpc_id=network["vpc_id"],
target_type="ip",
health_check=LbTargetGroupHealthCheck(path="/ping", matcher="200"),
)
# create the service listener rule
LbListenerRule(
self,
"alb-rule",
listener_arn=infra["alb_listener"],
action=[LbListenerRuleAction(type="forward", target_group_arn=svc_tg.arn)],
condition=[
LbListenerRuleCondition(
path_pattern=LbListenerRuleConditionPathPattern(values=["/*"])
)
],
)
# create the ECR repository
repo = EcrRepository(
self,
params["app_name"],
image_scanning_configuration=EcrRepositoryImageScanningConfiguration(
scan_on_push=True
),
image_tag_mutability="MUTABLE",
name=params["app_name"],
)
EcrLifecyclePolicy(
self,
"this",
repository=repo.name,
policy=json.dumps(
{
"rules": [
{
"rulePriority": 1,
"description": "Keep last 10 images",
"selection": {
"tagStatus": "tagged",
"tagPrefixList": ["v"],
"countType": "imageCountMoreThan",
"countNumber": 10,
},
"action": {"type": "expire"},
},
{
"rulePriority": 2,
"description": "Expire images older than 3 days",
"selection": {
"tagStatus": "untagged",
"countType": "sinceImagePushed",
"countUnit": "days",
"countNumber": 3,
},
"action": {"type": "expire"},
},
]
}
),
)
# create the service log group
service_log_group = CloudwatchLogGroup(
self,
"svc_log_group",
name=params["app_name"],
retention_in_days=1,
)
ecs_assume_role = DataAwsIamPolicyDocument(
self,
"assume_role",
statement=[
{
"actions": ["sts:AssumeRole"],
"principals": [
{
"identifiers": ["ecs-tasks.amazonaws.com"],
"type": "Service",
},
],
},
],
)
# create the service execution role
service_execution_role = IamRole(
self,
"service_execution_role",
assume_role_policy=ecs_assume_role.json,
name=params["app_name"] + "-exec-role",
)
IamRolePolicyAttachment(
self,
"ecs_role_policy",
policy_arn="arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy",
role=service_execution_role.name,
)
# create the service task role
service_task_role = IamRole(
self,
"service_task_role",
assume_role_policy=ecs_assume_role.json,
name=params["app_name"] + "-task-role",
)
# create the service task definition
task = EcsTaskDefinition(
self,
"svc-task",
family="service",
network_mode="awsvpc",
requires_compatibilities=["FARGATE"],
cpu="256",
memory="512",
task_role_arn=service_task_role.arn,
execution_role_arn=service_execution_role.arn,
container_definitions=json.dumps(
[
{
"name": "svc",
"image": f"{repo.repository_url}:latest",
"networkMode": "awsvpc",
"healthCheck": {
"Command": ["CMD-SHELL", "echo hello"],
"Interval": 5,
"Timeout": 2,
"Retries": 3,
},
"portMappings": [
{
"containerPort": params["app_port"],
"hostPort": params["app_port"],
}
],
"logConfiguration": {
"logDriver": "awslogs",
"options": {
"awslogs-group": service_log_group.name,
"awslogs-region": params["region"],
"awslogs-stream-prefix": params["app_name"],
},
},
}
]
),
)
# create the ECS service
EcsService(
self,
"ecs_service",
name=params["app_name"] + "-service",
cluster=infra["cluster_id"],
task_definition=task.arn,
desired_count=params["desired_count"],
launch_type="FARGATE",
force_new_deployment=True,
network_configuration=EcsServiceNetworkConfiguration(
subnets=network["private_subnets"],
security_groups=[svc_sg.id],
),
load_balancer=[
EcsServiceLoadBalancer(
target_group_arn=svc_tg.id,
container_name="svc",
container_port=params["app_port"],
)
],
)
TerraformOutput(
self,
"ecr_repository_url",
description="url of the ecr repo",
value=repo.repository_url,
)
Update the main.py
(for the last time 😁):
...
java_api = ServiceStack(
app,
"java_api",
{
"vpc_id": network.vpc_id,
"private_subnets": network.private_subnets,
},
{
"alb_sg": infra.alb_sg,
"alb_listener": infra.alb_listener,
"cluster_id": infra.cluster_id,
},
{
"region": AWS_REGION,
"backend_bucket": BACKEND_S3_BUCKET,
"backend_key_prefix": BACKEND_S3_KEY,
"app_name": "java-api",
"app_port": 8080,
"desired_count": 1,
},
)
...
Deploy the service stack with this:
$ cdktf deploy network infra service
Here we go!
We successfully created all the resources to deploy a new service on AWS ECS Fargate.
Run the following to get the list of your stacks
$ cdktf list
Step 4: Github Actions Workflow
To automate deployments, let’s integrate a GitHub Actions workflow to our java-api
. After enabling Github Actions, setting the secrets and variables for your repository, create the .github/workflows/deploy.yml
file and add the content below:
name: Java API deployment
on:
workflow_dispatch:
push:
branches:
- main
pull_request:
types:
- opened
- reopened
branches:
- main
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
test:
name: Build job
runs-on: ubuntu-latest
# concurrency:
# group: ${{ github.job }}
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up JDK 21
uses: actions/setup-java@v4
with:
distribution: corretto
java-version: 21
- name: Build with Maven
run: mvn clean package
build:
runs-on: ubuntu-latest
needs: test
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: ${{ vars.AWS_REGION }}
- name: Login to Amazon ECR
id: login-ecr
uses: aws-actions/amazon-ecr-login@v2
- name: Build, tag, and push image to Amazon ECR
id: build-image
env:
ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
IMAGE_TAG: ${{ github.sha }}
IMAGE_NAME: ${{ vars.IMAGE_NAME }}
run: |
# Build a docker container and push it to ECR so that it can be deployed to ECS.
docker build -t $ECR_REGISTRY/$IMAGE_NAME:$IMAGE_TAG .
docker push $ECR_REGISTRY/$IMAGE_NAME:$IMAGE_TAG
echo "image=$ECR_REGISTRY/$IMAGE_NAME:$IMAGE_TAG" >> $GITHUB_OUTPUT
- name: Fill in the new image ID in the Amazon ECS task definition
id: task-def
uses: aws-actions/amazon-ecs-render-task-definition@v1
with:
task-definition-arn: ${{ vars.TASK_DEFINITION }}
container-name: ${{ vars.CONTAINER_NAME }}
image: ${{ steps.build-image.outputs.image }}
- name: Deploy Amazon ECS task definition
uses: aws-actions/amazon-ecs-deploy-task-definition@v2
with:
task-definition: ${{ steps.task-def.outputs.task-definition }}
service: ${{ vars.SERVICE_NAME }}
cluster: ${{ vars.CLUSTER_NAME }}
wait-for-service-stability: true
Our workflow is working well:
The service was successfully deployed as shown in the image below:
Step 5: Validate the Deployment
Test your deployment using the following script (replace the ALB URL with yours):
ALB_FQDN="tf-lb-20250118223346384400000002-528284851.us-east-1.elb.amazonaws.com"
until curl -Is --max-time 5 http://$ALB_FQDN/ping | grep "HTTP/1.1 200" >/dev/null 2>&1
do
echo "Waiting for ALB to serve traffic of java-api (/ping)..."
sleep 5
done
printf "\nALB is now ready to serve traffic:\n"
printf "http://$ALB_FQDN\n"
The ALB is now ready to serve traffic!
Final Thoughts
By leveraging AWS CDKTF, we can write clean, maintainable IaC code using Python. This approach simplifies deploying containerized applications like a Spring Boot API on AWS ECS Fargate.
CDKTF’s flexibility, combined with Terraform’s robust capabilities, makes it an excellent choice for modern cloud deployments.
While the CDKTF project offers many interesting features for infrastructure management, I have to admit that I find it somewhat too verbose at times.
Do you have any experience with CDKTF? Have you used it in production?
Feel free to share your experience with us.