~/post/deploy-springboot-on-aws-ecs-using-cdktf
How to deploy a SpringBoot API on AWS ECS using CDKTF?
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:

AWS ECS Architecture

From this diagram, we can break down the architecture into 03 layers:

  1. Network:
    • VPC
    • Public and private subnets
    • Internet Gateway
    • NAT Gateways
  2. Infrastructure:
    • Application Load Balancer (ALB)
    • Listeners
    • ECS Cluster
  3. 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:

  1. /ping: Returns the string "pong". This endpoint is useful for testing the API's responsiveness. It also increments a Prometheus counter metric for monitoring.
  2. /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.
  3. /hello: Accepts a name 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 --from=builder /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.pyfile:

#!/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
Network Map

Our VPC is ready as shown in the image below:

Network Map

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.

ALB URL

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
List Stacks

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:

Github Actions

The service was successfully deployed as shown in the image below:

ECS Service Deployment

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.