Configuration, Operations, and Security for Deploying a Spring Boot + Thymeleaf + PostgreSQL Admin App to AWS ECS/Fargate + RDS
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:
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:
- Fargate looks up Secrets Manager at task startup
- The value is injected into the container as an environment variable
- 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:
- Issue ACM certificate (us-east-1 or the same region as ALB)
- Register listener rule for ALB 443 port
- 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: