Tech Blog

Configuration, Operations, and Security for Deploying a Spring Boot + Thymeleaf + PostgreSQL Admin App to AWS ECS/Fargate + RDS

by Tech Writer
Spring Boot AWS ECS Docker

This article summarizes what I particularly struggled with when deploying a Spring Boot + Thymeleaf + PostgreSQL admin app to AWS ECS/Fargate + RDS:

  • Multi-stage Docker builds
  • Initialization scripts
  • application.yml switching with Spring profiles
  • CDK stack implementation
  • NAT/ALB billing awareness
  • Spring profile mismatches

This follows on from the article describing the admin app itself:

Building a DVD Rental Admin App with Spring Boot + Thymeleaf Based on the PostgreSQL dvdrental Sample DB

App Overall Configuration

The app configuration to be deployed is as follows:

  • Spring Boot 4 + Thymeleaf (server-rendered HTML)
  • Spring Security (staff table authentication)
  • Spring Data JPA + NamedParameterJdbcTemplate
  • PostgreSQL (local Docker / AWS RDS PostgreSQL)
  • Flyway (DB migration)

For deployment destination:

  • Container Registry: Amazon ECR
  • Container Execution: Amazon ECS (Fargate)
  • DB: Amazon RDS (PostgreSQL)
  • Load Balancer: Application Load Balancer (ALB)
  • Network: VPC, Public/Private Subnets, NAT Gateway

The overall infrastructure uses CDK (TypeScript).

Dockerfile

A multi-stage build is used to make the image as small as possible.

FROM eclipse-temurin:21-jdk-alpine AS build
WORKDIR /app
COPY . .
RUN ./mvnw -B package -DskipTests

FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
COPY --from=build /app/target/*.jar app.jar
COPY docker/aws/aws-entrypoint.sh /aws-entrypoint.sh
RUN chmod +x /aws-entrypoint.sh
ENTRYPOINT ["/aws-entrypoint.sh"]

Why aws-entrypoint.sh

If the Fargate task starts while RDS isn’t ready yet, the app will error immediately and the task will restart. To handle this gracefully, the startup script is used to wait until the DB is ready before starting the app.

#!/bin/sh
set -e

echo "Waiting for database to be ready..."
until pg_isready -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER"; do
  echo "Database not ready. Retrying in 5 seconds..."
  sleep 5
done

echo "Database is ready. Starting application..."
exec java -jar /app/app.jar

Using pg_isready to poll until DB accepts connections, then starting the app afterward.

application.yml Spring Profile Switching

Two profiles are prepared: local and aws.

# application.yml
spring:
  profiles:
    active: local
# application-local.yml
spring:
  datasource:
    url: jdbc:postgresql://localhost:5432/dvdrental
    username: postgres
    password: postgres
# application-aws.yml
spring:
  datasource:
    url: jdbc:postgresql://${DB_HOST}:${DB_PORT}/${DB_NAME}
    username: ${DB_USER}
    password: ${DB_PASS}

Profile selection works by reading the SPRING_PROFILES_ACTIVE environment variable. In ECS task definitions, this is set to aws via environment variables.

Spring Profile Mismatch Trap

What actually got me was the discrepancy between profile name and filename.

If you name the profile aws-postgres but the filename is application-aws.yml, the profile won’t be read. The filename must match exactly with application-{profile name}.yml.

Additionally, even though SPRING_PROFILES_ACTIVE=aws is set correctly as an environment variable, if there are multiple spring.profiles.active defined in the yml file hierarchy, there’s a risk of unexpected overriding.

CDK Stack Configuration

The CDK stack is defined in TypeScript. The main components configured are:

// dvd-rental-admin-stack.ts (simplified)
export class DvdRentalAdminStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    // VPC
    const vpc = new ec2.Vpc(this, "DvdRentalVpc", {
      maxAzs: 2,
      natGateways: 1,
    });

    // RDS
    const db = new rds.DatabaseInstance(this, "DvdRentalDb", {
      engine: rds.DatabaseInstanceEngine.postgres({
        version: rds.PostgresEngineVersion.VER_15,
      }),
      vpc,
      vpcSubnets: { subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS },
      instanceType: ec2.InstanceType.of(
        ec2.InstanceClass.T3,
        ec2.InstanceSize.MICRO
      ),
    });

    // ECS Cluster
    const cluster = new ecs.Cluster(this, "DvdRentalCluster", { vpc });

    // Task Definition
    const taskDef = new ecs.FargateTaskDefinition(this, "DvdRentalTask", {
      cpu: 512,
      memoryLimitMiB: 1024,
    });

    const container = taskDef.addContainer("DvdRentalContainer", {
      image: ecs.ContainerImage.fromEcrRepository(repo),
      environment: {
        SPRING_PROFILES_ACTIVE: "aws",
        DB_HOST: db.instanceEndpoint.hostname,
        DB_PORT: "5432",
        DB_NAME: "dvdrental",
      },
      secrets: {
        DB_USER: ecs.Secret.fromSecretsManager(dbSecret, "username"),
        DB_PASS: ecs.Secret.fromSecretsManager(dbSecret, "password"),
      },
    });

    // ALB + Fargate Service
    const fargateService =
      new ecs_patterns.ApplicationLoadBalancedFargateService(
        this,
        "DvdRentalService",
        {
          cluster,
          taskDefinition: taskDef,
          publicLoadBalancer: true,
        }
      );
  }
}

DB passwords are managed in Secrets Manager and injected as environment variables at task startup time, without hardcoding in code.

Command Reproducibility

To make deployment work reproducible, the following commands were organized:

# ECR login
aws ecr get-login-password --region ap-northeast-1 | docker login --username AWS --password-stdin {account}.dkr.ecr.ap-northeast-1.amazonaws.com

# Build and push
docker build -t dvd-rental-admin .
docker tag dvd-rental-admin:latest {account}.dkr.ecr.ap-northeast-1.amazonaws.com/dvd-rental-admin:latest
docker push {account}.dkr.ecr.ap-northeast-1.amazonaws.com/dvd-rental-admin:latest

# CDK deploy
cd infra
cdk deploy

Since ECS pulls the latest image from ECR on task restart, just pushing a new image and restarting the task is sufficient.

NAT Gateway and ALB Billing

One thing to note is that NAT Gateway and ALB billing continues even when not in use.

  • NAT Gateway: Approximately $32/month
  • ALB: Approximately $16/month

This totals around $48/month just from infrastructure fees, not counting RDS and Fargate. When verifying temporarily, it’s worth deleting NAT or using NAT Instance to keep costs down.

The minimum cost configuration for AWS deployment is summarized separately:

Minimum Cost Architecture Considerations for AWS ECS Fargate + RDS Deployment

ECS Task Definition Secrets

One thing I was confused about when first assembling the CDK was using Secrets Manager secrets from ecs.Secret.fromSecretsManager. This is a “reference to a value in Secrets Manager” rather than embedding the value directly.

At runtime:

  1. Fargate looks up Secrets Manager at task startup
  2. The value is injected into the container as an environment variable
  3. The app reads it via System.getenv("DB_PASS")

Since the value isn’t visible even in CloudFormation template outputs, there’s no concern about sensitive information leaking.

Security Group Configuration

Since RDS is in a private subnet, it cannot be accessed from the internet. Only traffic from ECS tasks is allowed.

db.connections.allowFrom(
  fargateService.service,
  ec2.Port.tcp(5432)
);

External SSH or port forwarding into RDS requires using AWS Systems Manager Session Manager, which can be done without opening a bastion server.

Flyway Migration

Flyway runs DB migration at app startup. When the container starts, before Spring Boot starts, it references resources/db/migration/V*.sql files and applies only unapplied migrations.

resources/
  db/
    migration/
      V1__create_initial_schema.sql
      V2__add_index.sql

Since dvdrental sample data is already loaded at DB initialization with init.sql, Flyway is used for schema changes after that.

If migration files change during development, the risk of re-applying to an existing DB needs attention.

HTTPS Configuration

Currently operating with HTTP only, but the steps to add HTTPS are:

  1. Issue ACM certificate (us-east-1 or the same region as ALB)
  2. Register listener rule for ALB 443 port
  3. Add HTTP → HTTPS redirect

Since Secure attribute on Spring Session cookies requires HTTPS, this is a prerequisite for production use.

Summary

Deployment to AWS ECS/Fargate + RDS went through several trial and error points:

  • Spring profile name and filename must match exactly
  • DB readiness check before app startup (aws-entrypoint.sh)
  • DB passwords must be managed in Secrets Manager
  • NAT Gateway and ALB billing runs even when not in use

For CDK, defining infrastructure as TypeScript code makes it reproducible with consistent execution, which is a significant advantage.

The admin app itself and what was built is summarized in the following article:

Building a DVD Rental Admin App with Spring Boot + Thymeleaf Based on the PostgreSQL dvdrental Sample DB

Feel free to send a message

Please send a message if you have any technical questions, feedback, or inquiries.