Heroku put CicloUrbana on the internet in an afternoon, and it also showed us where its limits are: no private network of our own, no control of the runtime environment, a cost per unit of capacity that scales badly, and the database sitting on infrastructure that is not ours. Ribalta council has decided that the municipal bike network is a permanent public service, with personal data subject to the GDPR, and is asking for serious infrastructure: a private network, a database unreachable from the internet, audited secrets, high availability and room to grow.
This lesson builds that environment on AWS. It does not cover the whole of AWS —it is a provider with more than two hundred services— but rather the minimum complete path to take the image from 07-04 into production: an image repository in ECR, managed PostgreSQL in RDS inside private subnets, secrets in Secrets Manager, an ECS Fargate service that runs the image without us administering any server, and a load balancer with a managed certificate that health-checks against the probes from 07-01. And it ends, as always, with the exact list of resources to destroy, because here the bill is real and it arrives on time.
Contents
- The landscape: which AWS service runs a Spring Boot application
- Essential base concepts
- Preparing the image: ECR
- The database: RDS PostgreSQL
- Secrets: Secrets Manager and Parameter Store
- Deploying on ECS Fargate
- The load balancer, health and TLS
- Flyway migrations on AWS
- Autoscaling
- Observability: CloudWatch
- The short route: Elastic Beanstalk
- Infrastructure as code
- Costs and cleanup
- Security: the rules that are not negotiable
- Common Mistakes and Tips
- Exercises
- The landscape: which AWS service runs a Spring Boot application
| Service | What you manage | Effort | Typical cost (month) | When to choose it |
|---|---|---|---|---|
| EC2 | A complete virtual machine: OS, Java, startup, patches, TLS | Very high | 15-80 € | Very specific requirements, or minimum cost with somebody who administers Linux |
| Elastic Beanstalk | You upload a JAR; AWS creates EC2, a load balancer and autoscaling | Low | 40-120 € | The short route from a JAR, without learning ECS |
| ECS + Fargate | An image and its task definition; no servers | Medium-low | 60-200 € | The course's choice: real production without operating an orchestrator |
| EKS (managed Kubernetes) | Manifests, add-ons, cluster versions | High | 200 € + cluster | Many services and teams, portability (08-04) |
| App Runner | Only the image or the repository | Very low | 30-90 € | A PaaS inside AWS; less network control |
| Lambda + SnapStart | Only the function's code | Low | Per invocation | Sporadic workloads; fits badly with JPA and a pool |
The course's reasoned decision is ECS Fargate + RDS, for four reasons in order of weight. There are no servers to administer: with Fargate there are no EC2 instances to patch, size or watch over, you only declare how much CPU and memory each task needs. It is the natural destination of the 07-04 image, without changing the artefact, without buildpacks and without a Procfile. It gives you a genuine private network, with the database in subnets that have no route out to the internet, which is the requirement that got us out of Heroku. And it is markedly simpler than Kubernetes for a single application: if CicloUrbana were the only thing to deploy, setting up EKS would mean paying for the complexity of an orchestrator without the scale that justifies it.
Elastic Beanstalk is left as the short route documented in section 11: if the goal is just "to get the JAR running on AWS this afternoon", it is faster. Lambda is ruled out by design: an application with Hibernate, a connection pool and a Spring context fits badly into a model of ephemeral invocations, and although SnapStart mitigates the cold start, the multiplication of PostgreSQL connections is still a real problem.
- Essential base concepts
| Concept | What it is | In CicloUrbana |
|---|---|---|
| Region | Geographical zone with independent data centres | eu-west-1 (Ireland): data in the EU, a GDPR requirement |
| Availability zone (AZ) | An isolated data centre within a region | At least two, to tolerate one going down |
| VPC | Your own virtual private network, with its address range | 10.20.0.0/16 |
| Public subnet | Has a route to the internet through an Internet Gateway | Where the load balancer lives |
| Private subnet | No inbound route from the internet | Where the tasks and the database live |
| Security group | Stateful firewall, at resource level | One for the ALB, one for the app, one for RDS |
| IAM role | Identity a resource assumes to obtain temporary permissions | The task assumes a role; there are never keys in the code |
| IAM policy | JSON document granting or denying actions | Allowing the reading of one specific secret |
| ALB | Application load balancer (layer 7): routes HTTP and checks health | TLS termination and /actuator/health/readiness |
| Route 53 | Managed DNS | ciclourbana.ribalta.example → ALB |
| ACM | Free, automatically renewed TLS certificates | The certificate for the council's domain |
The complete architecture we are going to build:
flowchart TD
U[Citizens] --> R53[Route 53<br/>ciclourbana.ribalta.example]
R53 --> ALB[ALB · public subnets<br/>TLS with an ACM certificate]
subgraph VPC["VPC 10.20.0.0/16 · eu-west-1"]
subgraph PUB[Public subnets · AZ a and b]
ALB
end
subgraph PRIV[Private subnets · AZ a and b]
T1[Fargate task 1]
T2[Fargate task 2]
RDS[(RDS PostgreSQL 16<br/>Multi-AZ)]
end
end
ALB -->|8080| T1
ALB -->|8080| T2
T1 --> RDS
T2 --> RDS
ECR[(ECR<br/>ciclourbana:2.4.0)] -.image.-> T1
SM[Secrets Manager] -.credentials.-> T1
T1 -.logs.-> CW[CloudWatch Logs]
Three rules of that architecture, which are what separate a serious deployment from an improvised one: only the ALB is in public subnets; RDS has no public address and never will; and each security group only accepts traffic from the previous group, not from loose address ranges.
- Preparing the image: ECR
Elastic Container Registry is AWS's image registry. You could use GHCR (07-04), but ECR is in the same account and region, Fargate tasks access it with IAM permissions and no long-lived credentials, and the pull never leaves AWS's network.
export ACCOUNT=111122223333 # fictional account identifier
export REGION=eu-west-1
export REGISTRY=$ACCOUNT.dkr.ecr.$REGION.amazonaws.com
# 1. Create the repository with vulnerability scanning and immutability
aws ecr create-repository \
--repository-name ciclourbana \
--region $REGION \
--image-scanning-configuration scanOnPush=true \
--image-tag-mutability IMMUTABLE
# 2. Authenticate Docker against ECR (the token lasts 12 hours)
aws ecr get-login-password --region $REGION \
| docker login --username AWS --password-stdin $REGISTRY
# 3. Tag and publish the 07-04 image
docker build -t ciclourbana:2.4.0 .
docker tag ciclourbana:2.4.0 $REGISTRY/ciclourbana:2.4.0
docker push $REGISTRY/ciclourbana:2.4.0Two options in the first command deserve an explanation. scanOnPush=true analyses every published image looking for known vulnerabilities, complementing the docker scout of 07-04 with a second automatic safety net. IMMUTABLE prevents overwriting an already published tag: ciclourbana:2.4.0 will always mean the same image, byte for byte. It is what makes the rollback of 08-01 exact, and it eliminates the classic mistake of latest pointing at something other than what was tested.
It is also worth adding a lifecycle policy that keeps only the last 15 images: the registry is billed by GB stored and grows without limit if nobody prunes it.
Important for Fargate: the image must be of architecture linux/amd64 unless you configure Fargate with Graviton (ARM). If you build on an Apple Silicon laptop, docker build produces arm64 and the task will fail with exec format error. The solution is docker buildx build --platform linux/amd64 ... or building in the pipeline (08-05).
- The database: RDS PostgreSQL
# Subnet group: RDS must live in PRIVATE subnets of at least two AZs
aws rds create-db-subnet-group \
--db-subnet-group-name ciclourbana-private \
--db-subnet-group-description "CicloUrbana private subnets" \
--subnet-ids subnet-0aa11bb22cc33dd44 subnet-0ee55ff66gg77hh88
aws rds create-db-instance \
--db-instance-identifier ciclourbana-prod \
--engine postgres --engine-version 16.4 \
--db-instance-class db.t4g.micro \
--allocated-storage 20 --storage-type gp3 --storage-encrypted \
--db-name ciclourbana \
--master-username ciclourbana_admin \
--manage-master-user-password \
--db-subnet-group-name ciclourbana-private \
--vpc-security-group-ids sg-0bd99ee88ff77aa66 \
--no-publicly-accessible \
--multi-az \
--backup-retention-period 7 \
--preferred-backup-window "02:00-03:00" \
--preferred-maintenance-window "sun:04:00-sun:05:00" \
--deletion-protectionParameter by parameter, and why each one matters:
| Parameter | What it does | Why like this |
|---|---|---|
--engine-version 16.4 |
Pins PostgreSQL 16 | Environment parity (08-01): the same version as Testcontainers in 06-05 |
--db-instance-class db.t4g.micro |
Instance size | Enough to start with; it is changed later with a brief outage |
--storage-encrypted |
Encryption at rest with KMS | Mandatory for personal data; it cannot be enabled afterwards |
--manage-master-user-password |
AWS generates the password and stores it in Secrets Manager | Nobody ever sees the password; it can be rotated |
--no-publicly-accessible |
No public address | The rule that is not negotiable |
--multi-az |
Standby replica in another AZ with automatic failover | Tolerates the loss of a zone; doubles the cost |
--backup-retention-period 7 |
Automatic backups for 7 days with PITR | RPO of seconds, RTO of minutes (08-01) |
--preferred-maintenance-window |
Window for minor upgrades | Early Sunday morning, when Ribalta is asleep |
--deletion-protection |
Prevents accidental deletion | You have to disable it explicitly to remove the instance |
The security group is the critical piece. It must not allow port 5432 from 0.0.0.0/0 nor from your home IP: only from the application's security group.
aws ec2 authorize-security-group-ingress \
--group-id sg-0bd99ee88ff77aa66 \
--protocol tcp --port 5432 \
--source-group sg-0cc44dd55ee66ff77 # the tasks' group, not a CIDRReferencing group to group instead of address ranges has a decisive advantage: Fargate tasks receive new IP addresses every time they are deployed, and with this rule the authorisation stays valid without touching anything.
Why RDS is never reachable from the internet. A PostgreSQL instance with a public address receives automated authentication attempts within minutes of existing, and it holds the personal data of Ribalta's citizens: all it takes is a weak password, a version with a known flaw or a temporary rule somebody forgot to close. When you need to query it from a laptop, the correct way is a bastion with Session Manager (without opening ports or managing SSH keys), never opening the security group "for a moment".
- Secrets: Secrets Manager and Parameter Store
| Secrets Manager | SSM Parameter Store (SecureString) | |
|---|---|---|
| Cost | ~$0.40/secret/month + requests | Standard tier free; advanced tier paid |
| Automatic rotation | Yes, integrated with RDS | No, manual |
| Size | 64 KB | 4 KB (8 KB advanced) |
| Recommended use | Database credentials, API keys | Non-critical configuration, URLs, parameters |
With --manage-master-user-password, the RDS password is already in Secrets Manager. The JWT secret is still missing:
aws secretsmanager create-secret \
--name ciclourbana/prod/jwt \
--description "HS256 signing secret for CicloUrbana's JWT" \
--secret-string "$(openssl rand -base64 48)"
# {"ARN": "arn:aws:secretsmanager:eu-west-1:111122223333:secret:ciclourbana/prod/jwt-Ab3xYz"}There are two ways for that value to reach the application.
Option A (recommended): let ECS inject it as an environment variable. In the task definition you use the secrets block instead of environment; the agent resolves the ARN when starting the container and sets it as a variable. The application does not know AWS exists: it carries on reading ${JWT_SECRET} as in 07-02, so the same artefact works on Heroku, on Kubernetes or on the laptop — the option consistent with 08-01.
Option B: let the application read it directly with spring-cloud-aws-starter-parameter-store or -secrets-manager:
spring:
config:
import:
- aws-secretsmanager:ciclourbana/prod/jwt
- aws-parameterstore:/ciclourbana/prod/It is useful when there are many parameters or when you want to refresh them at runtime, but it couples the artefact to AWS and requires additional IAM permissions.
The task's execution role needs permission to read that secret and no other — least privilege in its most concrete form:
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Action": ["secretsmanager:GetSecretValue"],
"Resource": [
"arn:aws:secretsmanager:eu-west-1:111122223333:secret:ciclourbana/prod/jwt-Ab3xYz",
"arn:aws:secretsmanager:eu-west-1:111122223333:secret:rds!db-9f3a2b1c-Kd7pQr"
]
}]
}Notice that Resource lists specific ARNs. A "Resource": "*" would give the application access to every secret in the account, including those of other council systems.
Warning. Secrets are never set as plain-text environment entries in the task definition: that JSON is versioned in the infrastructure repository and can be read with aws ecs describe-task-definition, which anybody with read permissions can run. And access keys (AWS_ACCESS_KEY_ID) are never created for the application: IAM roles provide temporary, automatically rotated credentials.
- Deploying on ECS Fargate
ECS organises the deployment into three pieces: a cluster (a logical grouping), a task definition (the template of which container to run and how) and a service (which keeps N tasks alive and connects them to the load balancer).
The task definition, annotated field by field:
{
"family": "ciclourbana",
"networkMode": "awsvpc",
"requiresCompatibilities": ["FARGATE"],
"cpu": "512",
"memory": "1024",
"runtimePlatform": { "cpuArchitecture": "X86_64", "operatingSystemFamily": "LINUX" },
"executionRoleArn": "arn:aws:iam::111122223333:role/ciclourbanaExecutionRole",
"taskRoleArn": "arn:aws:iam::111122223333:role/ciclourbanaTaskRole",
"containerDefinitions": [{
"name": "ciclourbana",
"image": "111122223333.dkr.ecr.eu-west-1.amazonaws.com/ciclourbana:2.4.0",
"essential": true,
"portMappings": [{ "containerPort": 8080, "protocol": "tcp" }],
"environment": [
{ "name": "SPRING_PROFILES_ACTIVE", "value": "prod" },
{ "name": "TZ", "value": "Europe/Madrid" },
{ "name": "JAVA_TOOL_OPTIONS", "value": "-XX:MaxRAMPercentage=75 -XX:+ExitOnOutOfMemoryError" },
{ "name": "SPRING_DATASOURCE_URL",
"value": "jdbc:postgresql://ciclourbana-prod.abc123xyz.eu-west-1.rds.amazonaws.com:5432/ciclourbana?sslmode=require" },
{ "name": "SPRING_FLYWAY_ENABLED", "value": "false" },
{ "name": "MANAGEMENT_SERVER_PORT", "value": "8080" }
],
"secrets": [
{ "name": "JWT_SECRET",
"valueFrom": "arn:aws:secretsmanager:eu-west-1:111122223333:secret:ciclourbana/prod/jwt-Ab3xYz" },
{ "name": "SPRING_DATASOURCE_PASSWORD",
"valueFrom": "arn:aws:secretsmanager:eu-west-1:111122223333:secret:rds!db-9f3a2b1c-Kd7pQr:password::" }
],
"logConfiguration": {
"logDriver": "awslogs",
"options": {
"awslogs-group": "/ecs/ciclourbana",
"awslogs-region": "eu-west-1",
"awslogs-stream-prefix": "app",
"awslogs-create-group": "true"
}
},
"stopTimeout": 60
}]
}What you need to understand about this JSON:
cpu: 512andmemory: 1024are 0.5 vCPU and 1 GB. Fargate only accepts specific combinations (0.5 vCPU accepts 1, 2, 3 or 4 GB). WithMaxRAMPercentage=75, the heap comes out at 768 MB and the remaining 256 MB cover metaspace, stacks and threads: the sizing from 08-01 applied.- Two different roles, and it is not a bureaucratic detail. The execution role is used by the ECS agent before starting the container: pulling the image from ECR, resolving the secrets, writing to CloudWatch. The task role is used by the application while running to call other AWS services (S3, SQS). Separating them means the application cannot read secrets on its own even if it is compromised.
secretsversusenvironment: the value never appears in the definition. The:password::suffix extracts a specific field from the JSON of the RDS secret, which containsusernameandpassword.SPRING_FLYWAY_ENABLED=false: the migrations run separately (section 8).MANAGEMENT_SERVER_PORT=8080: in 07-01 we split Actuator onto 8081; here we bring it back to the main port so the ALB can query/actuator/health/readinesswith a single target. The alternative —two target groups— adds complexity with no gain. The protection is still the security chain from 07-01, not the port separation.stopTimeout: 60: seconds betweenSIGTERMandSIGKILL. It must exceed thetimeout-per-shutdown-phaseof the graceful shutdown (01-05), or ECS will kill the process halfway through.
aws ecs register-task-definition --cli-input-json file://ciclourbana-task.json
aws ecs create-service \
--cluster ciclourbana --service-name ciclourbana-web \
--task-definition ciclourbana:1 \
--desired-count 2 \
--launch-type FARGATE \
--network-configuration "awsvpcConfiguration={subnets=[subnet-0aa11bb22cc33dd44,subnet-0ee55ff66gg77hh88],securityGroups=[sg-0cc44dd55ee66ff77],assignPublicIp=DISABLED}" \
--load-balancers "targetGroupArn=arn:aws:elasticloadbalancing:eu-west-1:111122223333:targetgroup/ciclourbana-tg/1a2b3c4d5e6f7g8h,containerName=ciclourbana,containerPort=8080" \
--health-check-grace-period-seconds 90 \
--deployment-configuration "minimumHealthyPercent=100,maximumPercent=200"desired-count 2spreads the tasks across the two AZs: if one zone goes down, the service carries on.assignPublicIp=DISABLEDkeeps the tasks off the internet. They need outbound access to pull the image and talk to Secrets Manager, which requires a NAT Gateway (around 35 €/month) or, cheaper and safer, VPC endpoints for ECR, S3, Secrets Manager and CloudWatch Logs.health-check-grace-period-seconds 90gives the JVM's startup some room before the ALB starts penalising it. Without it, the ALB marks the task unhealthy within a few seconds, ECS kills it and starts another: an infinite loop of tasks that never become ready.minimumHealthyPercent=100, maximumPercent=200defines the rolling update from 08-01: with 2 desired tasks, ECS can go up to 4 and never drops below 2. It starts the new ones, waits for them to be healthy, and only then retires the old ones. No downtime, with version coexistence — hence the requirement for a backwards compatible schema.
- The load balancer, health and TLS
aws elbv2 create-target-group \
--name ciclourbana-tg --protocol HTTP --port 8080 \
--vpc-id vpc-0f1e2d3c4b5a69788 --target-type ip \
--health-check-path /actuator/health/readiness \
--health-check-interval-seconds 15 --health-check-timeout-seconds 5 \
--healthy-threshold-count 2 --unhealthy-threshold-count 3 \
--matcher HttpCode=200This is the moment when 07-01 falls into place. The health check points at /actuator/health/readiness, not at /actuator/health nor at /, and the difference is substantial:
| Path | What it aggregates | Consequence |
|---|---|---|
/ |
Nothing; it is a 404 or the root controller |
It says nothing about whether the app can work |
/actuator/health |
Every indicator, including the non-critical ones | A payment gateway being down would take every task out of rotation |
/actuator/health/readiness |
Only db and the readiness state (07-01) |
The correct choice: whoever cannot serve leaves rotation, with no side effects |
And the registration is closed off with deregistration_delay, the piece that prevents 502s during shutdown:
aws elbv2 modify-target-group-attributes \
--target-group-arn arn:aws:elasticloadbalancing:eu-west-1:111122223333:targetgroup/ciclourbana-tg/1a2b3c4d5e6f7g8h \
--attributes Key=deregistration_delay.timeout_seconds,Value=30During those 30 seconds the ALB stops sending new requests to the task being retired but lets the in-flight ones finish. It is the equivalent of the preStop from 08-01, managed by the load balancer.
The certificate and the listeners:
# Free certificate, DNS-validated and renewed automatically
aws acm request-certificate \
--domain-name ciclourbana.ribalta.example \
--validation-method DNS --region eu-west-1
# Listener on 443 with TLS
aws elbv2 create-listener --load-balancer-arn arn:aws:elasticloadbalancing:eu-west-1:111122223333:loadbalancer/app/ciclourbana-alb/9z8y7x6w5v4u3t2s \
--protocol HTTPS --port 443 \
--certificates CertificateArn=arn:aws:acm:eu-west-1:111122223333:certificate/1234abcd-56ef-78gh-90ij-klmnopqrstuv \
--ssl-policy ELBSecurityPolicy-TLS13-1-2-2021-06 \
--default-actions Type=forward,TargetGroupArn=arn:aws:elasticloadbalancing:eu-west-1:111122223333:targetgroup/ciclourbana-tg/1a2b3c4d5e6f7g8h
# Listener on 80 that only redirects to 443
aws elbv2 create-listener --load-balancer-arn arn:aws:elasticloadbalancing:eu-west-1:111122223333:loadbalancer/app/ciclourbana-alb/9z8y7x6w5v4u3t2s \
--protocol HTTP --port 80 \
--default-actions '[{"Type":"redirect","RedirectConfig":{"Protocol":"HTTPS","Port":"443","StatusCode":"HTTP_301"}}]'Since the ALB terminates TLS and speaks HTTP to the task, we need what 08-01 described, in application-prod.yml:
Without it, the Location of a 201 Created would come out with http:// and a private address. And with it, X-Forwarded-Proto: https arrives from a trusted proxy —the security group guarantees that only the ALB reaches port 8080—, so trusting the header is safe.
Finally, Route 53 with an alias (not a CNAME) pointing at the ALB, which resolves directly and costs no additional queries.
- Flyway migrations on AWS
The task definition disabled Flyway (SPRING_FLYWAY_ENABLED=false). The migrations run as a one-off ECS task using the same image, before updating the service:
aws ecs run-task \
--cluster ciclourbana \
--task-definition ciclourbana-migrations:1 \
--launch-type FARGATE \
--network-configuration "awsvpcConfiguration={subnets=[subnet-0aa11bb22cc33dd44],securityGroups=[sg-0cc44dd55ee66ff77],assignPublicIp=DISABLED}" \
--overrides '{"containerOverrides":[{"name":"ciclourbana","command":["java","-Dspring.flyway.enabled=true","-Dspring.main.web-application-type=none","-jar","/app/ciclourbana.jar"]}]}'web-application-type=none brings up the context without Tomcat: Flyway migrates and the process finishes. The pipeline in 08-05 waits for the task to end and only continues if the exit code is 0.
| One-off task before the deployment | On each task's startup | |
|---|---|---|
| With several tasks | It is already migrated when they start | They all compete for Flyway's lock |
| If it fails | The deployment stops; the service carries on with the previous version | All the new tasks fail and ECS restarts them in a loop |
| Long migration | Its own time, with no health probes on top | It can exhaust the grace period and cause restarts |
| Visibility | A step with its own exit code | Buried in the startup log |
| Permissions | The migration task needs DDL; the application does not | The application always needs DDL |
| Complexity | One more task definition and one step in the pipeline | None |
The one-off task is the correct option as soon as there is more than one replica, which is our case from desired-count 2 onwards. And with ddl-auto: validate (04-08) untouched: if the schema does not match, the task does not start instead of failing query by query.
A reminder from 08-01: during the rolling update the old and the new version coexist against the already-migrated schema, so every migration must be backwards compatible (expand/contract, 04-08). It is the condition for rolling the application back to work.
- Autoscaling
aws application-autoscaling register-scalable-target \
--service-namespace ecs \
--resource-id service/ciclourbana/ciclourbana-web \
--scalable-dimension ecs:service:DesiredCount \
--min-capacity 2 --max-capacity 6
aws application-autoscaling put-scaling-policy \
--service-namespace ecs \
--resource-id service/ciclourbana/ciclourbana-web \
--scalable-dimension ecs:service:DesiredCount \
--policy-name cpu-70 --policy-type TargetTrackingScaling \
--target-tracking-scaling-policy-configuration '{
"TargetValue": 70.0,
"PredefinedMetricSpecification": {"PredefinedMetricType": "ECSServiceAverageCPUUtilization"},
"ScaleInCooldown": 300, "ScaleOutCooldown": 60
}'Target tracking adds or removes tasks to keep average CPU at 70 %. The cooldowns are asymmetric on purpose: grow fast (60 s), because the cost of being short is degraded service, and shrink slowly (300 s) so as not to oscillate on a passing spike. The alternative, ALBRequestCountPerTarget, scales by requests per task and usually fits an API better: it is a more direct signal of the real load than CPU, especially when the time is spent waiting on the database and CPU does not rise.
And the limit to respect before scaling (08-01): max-capacity × maximum-pool-size + headroom < max_connections. A db.t4g.micro supports on the order of 80-100 connections; with 6 tasks and a pool of 10 that is 60, plus migrations and administration: it fits, but with no slack. Raising max-capacity to 12 without touching the pool would cause too many clients right at the traffic peak — the worst possible moment.
- Observability: CloudWatch
The awslogs driver in the task definition sends everything the application writes to stdout into a CloudWatch Logs group. It is factor 11 of 08-01 with the platform doing its part.
aws logs tail /ecs/ciclourbana --follow --since 10m
aws logs tail /ecs/ciclourbana --filter-pattern '"ERROR"' --since 1hTwo settings worth applying from the start:
# Retention: without this, the logs are kept forever and billed forever
aws logs put-retention-policy --log-group-name /ecs/ciclourbana --retention-in-days 30And structured JSON logs, so that CloudWatch Logs Insights can query by field (filter level = "ERROR" and stationId = 3) instead of by text. With the TraceFilter with MDC from 03-06, every line carries its trace identifier and reconstructing a complete request across several tasks is a query, not a manual search.
The full subject —format, correlation, aggregation, retention and cost— is that of 09-05. On metrics, CloudWatch Container Insights provides CPU, memory and task counts, and alarms are defined on HTTPCode_Target_5XX_Count, TargetResponseTime or UnHealthyHostCount. CicloUrbana's business metrics —rentals per minute, stations with no bikes— arrive in 09-03 with Micrometer.
- The short route: Elastic Beanstalk
If the goal is just to get the JAR running on AWS without learning ECS, Beanstalk creates EC2, a load balancer, autoscaling and deployments for you:
eb init ciclourbana --platform "Corretto 21 running on 64bit Amazon Linux 2023" --region eu-west-1
eb create ciclourbana-prod --instance-type t3.small --envvars SPRING_PROFILES_ACTIVE=prod
./mvnw clean package -DskipTests
eb deploy
eb logs && eb openThree things you have to know. The port is 5000, not 8080: the platform's nginx proxy forwards there, and it is solved with SERVER_PORT=5000. Underneath there are real EC2 instances that have to be patched (Beanstalk automates it with managed updates, but the mental model is different from Fargate). And .ebextensions/ contains YAML files that tune the platform —variables, load balancer options, health check—:
# .ebextensions/01-ciclourbana.config
option_settings:
aws:elasticbeanstalk:application:environment:
SERVER_PORT: 5000
SPRING_PROFILES_ACTIVE: prod
JAVA_TOOL_OPTIONS: "-XX:MaxRAMPercentage=75"
aws:elasticbeanstalk:environment:process:default:
HealthCheckPath: /actuator/health/readinessCompared with Fargate, Beanstalk deploys a JAR instead of an image, keeps EC2 instances underneath, gives less parity with the laptop and less network control, and does not fit with what comes in 08-04 and 08-05. For CicloUrbana it is a good second step from Heroku and a poor final destination: it preserves the dependency on the JAR artefact and on a proprietary platform, exactly what the 07-04 image came to eliminate.
- Infrastructure as code
Everything above has been done with commands. It works once; it is not sustainable. The reasons are concrete: in six months nobody will remember why a security group has that rule, there is no peer review of a click, you cannot recreate a pre environment identical to prod, and there is no way to know what changed when something stopped working.
The answer is infrastructure as code: declaring the resources in files versioned in Git.
# infra/ecs.tf — illustrative fragment with Terraform
resource "aws_ecs_service" "ciclourbana" {
name = "ciclourbana-web"
cluster = aws_ecs_cluster.main.id
task_definition = aws_ecs_task_definition.ciclourbana.arn
desired_count = var.replicas # 1 in pre, 2 in prod
launch_type = "FARGATE"
network_configuration {
subnets = aws_subnet.private[*].id
security_groups = [aws_security_group.application.id]
assign_public_ip = false
}
load_balancer {
target_group_arn = aws_lb_target_group.ciclourbana.arn
container_name = "ciclourbana"
container_port = 8080
}
deployment_minimum_healthy_percent = 100
deployment_maximum_percent = 200
health_check_grace_period_seconds = 90
}terraform plan # shows exactly what is going to change, before changing it
terraform apply
terraform destroy # removes EVERYTHING declared: cleanup in one commandThe three usual options: CloudFormation (YAML/JSON, AWS only, with no external tooling and no state to manage), CDK (TypeScript, Java or Python, for those who prefer to program their infrastructure; it generates CloudFormation underneath) and Terraform/OpenTofu (HCL, the most widespread, multi-provider and with a huge ecosystem).
That terraform plan is the decisive argument: seeing the change before applying it, reviewing it in a pull request and keeping the history in Git. And terraform destroy turns the cleanup of the next section into a single reliable command instead of a list of resources you have to remember.
- Costs and cleanup
Prominent warning: RDS and the ALB are billed by the hour from the moment they exist, traffic or no traffic. There is no "sleep mode". A practice environment forgotten for a month is tens of real euros.
| Resource | Configuration | Approx. cost/month |
|---|---|---|
| ECS Fargate | 2 tasks × 0.5 vCPU + 1 GB | ~30 € |
RDS db.t4g.micro |
Single AZ, 20 GB gp3 | ~18 € |
RDS db.t4g.micro Multi-AZ |
Standby replica | ~36 € |
| ALB | One, low traffic | ~18 € + LCU |
| NAT Gateway | One | ~35 € + transfer |
| VPC endpoints | 4 interfaces | ~28 € |
| ECR | 5 GB | ~0.50 € |
| Secrets Manager | 2 secrets | ~0.80 € |
| CloudWatch Logs | 5 GB with 30 days | ~3 € |
| Realistic production total | ~110-140 €/month |
The NAT Gateway surprises everybody: it costs more than the tasks it serves. Alternatives: VPC endpoints for the services you actually use (cheaper, and the traffic never leaves AWS's network), or public subnets with assignPublicIp=ENABLED only in practice environments and never for the database.
Mandatory cleanup when you finish the exercise, in this exact order:
# 1. Drain the service and delete it
aws ecs update-service --cluster ciclourbana --service ciclourbana-web --desired-count 0
aws ecs delete-service --cluster ciclourbana --service ciclourbana-web --force
# 2. Load balancer: listeners first, then the ALB, then the target group
aws elbv2 delete-listener --listener-arn arn:aws:elasticloadbalancing:eu-west-1:111122223333:listener/app/ciclourbana-alb/9z8y7x6w5v4u3t2s/aa11bb22cc33
aws elbv2 delete-load-balancer --load-balancer-arn arn:aws:elasticloadbalancing:eu-west-1:111122223333:loadbalancer/app/ciclourbana-alb/9z8y7x6w5v4u3t2s
aws elbv2 delete-target-group --target-group-arn arn:aws:elasticloadbalancing:eu-west-1:111122223333:targetgroup/ciclourbana-tg/1a2b3c4d5e6f7g8h
# 3. RDS: disable protection and delete (with a final snapshot if you want one)
aws rds modify-db-instance --db-instance-identifier ciclourbana-prod --no-deletion-protection --apply-immediately
aws rds delete-db-instance --db-instance-identifier ciclourbana-prod --final-db-snapshot-identifier ciclourbana-final
# 4. The rest
aws ecs delete-cluster --cluster ciclourbana
aws ecr delete-repository --repository-name ciclourbana --force
aws secretsmanager delete-secret --secret-id ciclourbana/prod/jwt --force-delete-without-recovery
aws logs delete-log-group --log-group-name /ecs/ciclourbana
aws ec2 delete-nat-gateway --nat-gateway-id nat-0a1b2c3d4e5f60718
# and release the associated elastic IP, delete VPC endpoints, subnets and the VPCFinal verification —what genuinely prevents surprises—: go into Billing → Cost Explorer the following day and check that the daily spend has dropped to zero. And set up a budget with an alert of $20 (aws budgets create-budget) before starting any exercise, not afterwards.
The resources most often forgotten while still being billed: the NAT Gateway, unassociated elastic IPs, manual RDS snapshots, orphaned EBS volumes and log groups with no retention.
- Security: the rules that are not negotiable
- Never access keys in the code, in the image or in variables. IAM roles provide temporary, rotated credentials. If you find an
AWS_SECRET_ACCESS_KEYin a repository, rotate it before deleting it: Git history keeps it. - Least privilege, always with specific ARNs.
"Resource": "*"is almost always a mistake. Use IAM Access Analyzer to spot permissions that were granted and never used. - The root account is not used: MFA enabled, put away, and you work with federated identities with mandatory MFA. And CloudTrail enabled from day one: the day something odd happens, it is the only source of truth about who did what.
- Encryption at rest and in transit.
--storage-encryptedon RDS (irreversible afterwards),sslmode=requirein the JDBC URL,ELBSecurityPolicy-TLS13-1-2-2021-06on the listener andIMMUTABLEin ECR. - Nothing exposed that should not be. Only the ALB in public subnets; security groups referencing each other, not address ranges; port 5432 never open to the internet.
- Rotation. The JWT secret and the RDS password can be rotated from Secrets Manager; it is worth scheduling it, and it is mandatory when somebody with access leaves the project.
Common Mistakes and Tips
Building the image on Apple Silicon and deploying it to x86 Fargate. The task dies with exec format error. Use docker buildx build --platform linux/amd64 or build in the pipeline.
A health check pointing at /. It returns 404, the ALB marks the task unhealthy and ECS falls into a loop of startups. It must be /actuator/health/readiness.
No grace period. The JVM takes 20-40 seconds to start; the ALB starts checking immediately and kills the task before it manages to become ready. --health-check-grace-period-seconds 90.
Forgetting outbound internet access for the private subnets. The task cannot pull the image from ECR and fails with CannotPullContainerError. You need VPC endpoints or a NAT Gateway.
Secrets as environment. They are readable in describe-task-definition by anybody with read permissions. Use secrets with an ARN. And a publicly accessible RDS "just to test": it receives automated attacks within minutes and it holds personal data. Never.
Forgetting the NAT Gateway during cleanup. It carries on costing 35 €/month with zero traffic.
Tip: aws ecs describe-services is your diagnosis. The events field explains in plain text why a task will not start: image cannot be pulled, health check failed, no capacity, secret unreachable.
Tip: two environments, one template. pre and prod must come out of the same Terraform code with different variables (replicas, db_instance_class). It is the parity of 08-01 turned into infrastructure.
Tip: tag everything with Project=CicloUrbana. Cost Explorer will then be able to break spend down by project and finding orphaned resources becomes trivial.
Exercises
Exercise 1
Design CicloUrbana's complete network in eu-west-1: VPC, subnets, route tables and the three security groups with their exact inbound and outbound rules. Justify where each component goes (ALB, tasks, RDS) and explain how you would query the database from your laptop without opening port 5432 to the internet.
Exercise 2
The task definition ciclourbana:1 is registered, the service is created with desired-count 2 and no task ever reaches RUNNING. aws ecs describe-services shows, in events, this repeated sequence: service ciclourbana-web has started 1 tasks, then stopped 1 running tasks: Task failed ELB health checks in target-group ciclourbana-tg, and again. The CloudWatch logs show that the application starts correctly and writes Started CicloUrbanaApplication in 31.4 seconds. List the possible causes in order of likelihood, say how to tell them apart and give the fix for each one.
Exercise 3
CicloUrbana has been on ECS Fargate for three months with desired-count 2 and db.t4g.micro. Ribalta's Mobility Week is coming and traffic is expected to quadruple for five days. Design the complete plan: autoscaling, database size, HikariCP pool, deployment strategy during the event, what to watch and how much extra it will cost. Include the plan for returning to normal.
Solutions
Solution 1
Network. VPC 10.20.0.0/16. Six subnets across two AZs (eu-west-1a and eu-west-1b):
| Subnet | CIDR | AZ | Default route | Contains |
|---|---|---|---|---|
public-a |
10.20.0.0/24 |
a | Internet Gateway | ALB |
public-b |
10.20.1.0/24 |
b | Internet Gateway | ALB |
private-app-a |
10.20.10.0/24 |
a | NAT or endpoints | Fargate tasks |
private-app-b |
10.20.11.0/24 |
b | NAT or endpoints | Fargate tasks |
private-db-a |
10.20.20.0/24 |
a | None | Primary RDS |
private-db-b |
10.20.21.0/24 |
b | None | Standby RDS |
Separating the application subnets from the database ones is not essential but it is good practice: their route table has no way out at all, so even if somebody compromised the engine they could not exfiltrate data outwards.
Security groups, chained:
| Group | Inbound | Outbound |
|---|---|---|
sg-alb |
443 and 80 from 0.0.0.0/0 |
8080 towards sg-app |
sg-app |
8080 from sg-alb |
5432 towards sg-rds; 443 towards endpoints/internet |
sg-rds |
5432 from sg-app |
None |
The key point is that no group references address ranges for internal traffic: they reference each other. Since Fargate tasks change IP on every deployment, with CIDR rules you would have to update them constantly; with group references, there is nothing to maintain.
Access from the laptop, without opening anything. The correct approach is a minimal bastion instance in a private subnet with SSM Session Manager and port forwarding:
aws ssm start-session --target i-0a1b2c3d4e5f60718 \
--document-name AWS-StartPortForwardingSessionToRemoteHost \
--parameters '{"host":["ciclourbana-prod.abc123xyz.eu-west-1.rds.amazonaws.com"],"portNumber":["5432"],"localPortNumber":["55432"]}'
# Now: psql -h localhost -p 55432 -U readonly_user ciclourbanaAdvantages: no inbound port is opened (the SSM agent initiates the outbound connection), there are no SSH keys to manage, and CloudTrail records who opened the session and when. And one more layer of least privilege: connect with a read-only PostgreSQL user, not with the application's one nor the master.
Solution 2
The symptom is unambiguous: the application starts fine but the ALB does not consider it healthy. Causes, from most to least likely:
1. The grace period is missing. The app takes 31.4 s to start. Without --health-check-grace-period-seconds, the ALB starts checking as soon as the task registers; with unhealthy-threshold-count 3 and a 15 s interval, it declares it unhealthy about 45 s into the task's life... but the failed checks start much earlier and ECS kills the task. How to tell: the events happen in the first 60 seconds of every task. Fix: --health-check-grace-period-seconds 90.
2. The check path does not return 200. If the target group points at / (which does not exist) or at /actuator/health with a non-critical indicator down, the response is not 200. How to tell: launch a one-off task in the same subnet and curl the task's private IP. Fix: --health-check-path /actuator/health/readiness and check that the security chain from 07-01 leaves that endpoint public; if Spring Security protects it, it will return 401 and the ALB will read that as a failure.
3. Wrong port. The target group points at 8080 and the app listens on 8081 because MANAGEMENT_SERVER_PORT was not adjusted, or portMappings does not match. How to tell: in the logs, the Tomcat started on port(s) line. Fix: align containerPort, portMappings and the real port.
4. Security group. If sg-app does not allow 8080 from sg-alb, the check never arrives and times out. How to tell: in the target group, the reason is Request timed out instead of an HTTP code. Fix: the inbound rule by group reference.
5. Wrong target-type. With awsvpc, the target group must be --target-type ip, not instance. With instance the registration simply does not work. How to tell: there are no targets registered in the target group.
General diagnostic method: look at the target group's "Health status details" column in the console, which literally says whether it was Request timed out (network or port), Health checks failed with these codes: [404] (path), [401] (security) or [503] (the app responds but readiness is DOWN, typically because it cannot reach RDS).
Solution 3
Autoscaling. Raise the ceiling and lower the reaction floor:
min-capacity 4 during the event stops the first morning peak from finding only 2 tasks and having to scale under pressure. And add a request-based policy (ALBRequestCountPerTarget, target 300) alongside the CPU one: in an API that waits on the database, requests are an earlier signal than CPU. With two policies, ECS takes the maximum of both. Reduce ScaleOutCooldown to 30 s.
Database. db.t4g.micro will not withstand four times the traffic: it has burstable CPU credits that run out, and then performance collapses, which is the worst possible failure mode. Move up to db.t4g.medium or db.m6g.large a week beforehand (the change requires a brief outage, which must be done in a night-time window, not during the event). Verify that --multi-az is enabled. And consider a read replica if station availability queries dominate the traffic, pointing the reads at it.
HikariCP pool. The calculation from 08-01 with the new numbers: 12 tasks × pool + headroom < max_connections. A db.t4g.medium supports around 340 connections. With a pool of 10 that is 120 plus headroom: it fits comfortably. With db.t4g.micro (around 85) it would not fit, and that is a second reason, independent of performance, to move up a class. Keep maximum-pool-size: 10 and connection-timeout: 3000 so that, if there were saturation, requests fail fast instead of piling up.
Deployment strategy during the event. Change freeze: nothing to production during the five days except critical fixes. If you have to deploy, minimumHealthyPercent=100 and maximumPercent=200 as always, outside peak hours (early morning), with the migration task run beforehand and no destructive migrations — expand only, never contract (04-08).
What to watch, with alarms created before the event:
| Metric | Threshold | Why |
|---|---|---|
HTTPCode_Target_5XX_Count |
> 1 % of requests | The main signal of degradation |
TargetResponseTime p99 |
> 1.5 s | Perceived latency |
UnHealthyHostCount |
> 0 for 2 min | Tasks going down |
RDS CPUUtilization |
> 80 % | The database is the usual bottleneck |
DatabaseConnections |
> 70 % of the maximum | Early warning of too many clients |
CPUCreditBalance (if still on t4g) |
Falling | The silent failure of burstable instances |
hikaricp.connections.pending |
> 0 sustained | The pool is coming up short (metrics from 09-03) |
Estimated extra cost: going from 2 to an average of ~7 tasks for 5 days is about 12-15 € extra; moving RDS from micro to medium for two weeks, about 25-30 €; plus ALB traffic and logs, a few euros more. Total: 40-50 €, a perfectly defensible figure against the reputational cost of the service going down in the city's flagship week.
Returning to normal. Lower min-capacity to 2 and max-capacity to 6; wait a week with the larger instance before shrinking it, because traffic usually settles above the starting point, and do it in a night-time window; remove the read replica if one was created; review Cost Explorer to confirm everything temporary has disappeared; and write the retrospective —what saturated first, which alarm warned in time and which did not—, which is what makes next year's event routine.
Conclusion
CicloUrbana no longer lives on a borrowed platform: it runs on infrastructure of its own, in a European region, with the database somewhere the internet cannot reach. You know which AWS services can run a Spring Boot application and why the course chooses ECS Fargate + RDS —no servers to administer, the natural destination of the 07-04 image, a genuine private network and far simpler than Kubernetes for a single application—, with Elastic Beanstalk documented as the short route and Lambda ruled out with arguments.
You handle the concepts that hold everything else up: regions and availability zones, a VPC with public and private subnets, security groups referencing each other instead of address ranges, IAM roles and policies with specific ARNs, ALB, Route 53 and ACM. You have published the image in ECR with automatic scanning and immutable tags —which is what makes the rollback of 08-01 exact—, and you know that Fargate expects linux/amd64. You have created RDS PostgreSQL 16 encrypted, Multi-AZ, with seven days of backups, a night-time maintenance window, deletion protection and, above all, with no public address, reachable only from the application's security group and queryable from outside only through Session Manager. The secrets live in Secrets Manager, they enter the container through the secrets block of the task definition —never as plain-text variables— and the application carries on reading ${JWT_SECRET} without ever finding out that AWS exists.
The task definition brings together everything learned: CPU and memory sized with MaxRAMPercentage, two IAM roles with different responsibilities, Ribalta's time zone, logs to CloudWatch through the awslogs driver and a generous stopTimeout so that the graceful shutdown of 01-05 fits inside it. The service keeps two tasks spread across zones, the ALB checks /actuator/health/readiness —the moment the probes of 07-01 find their place—, terminates TLS with an ACM certificate that renews itself, redirects port 80 to 443 and waits 30 seconds before deregistering a task so as not to produce 502s. The deployment is rolling with minimumHealthyPercent=100, the Flyway migrations run as a one-off task before updating the service, and the autoscaling grows fast and shrinks slowly, always within PostgreSQL's connection limit.
And you know what it costs: around 110-140 € a month, with the NAT Gateway costing more than the tasks themselves and everything billed by the hour even if nobody uses the application. That is why you have the exact list of resources to destroy in the correct order, a budget alert created before starting, and the security rules that are not negotiable —zero access keys, least privilege with specific ARNs, encryption at rest and in transit, CloudTrail and scheduled rotation—, together with the underlying note that the web console is no way to manage production: infrastructure as code, reviewed in a pull request and destroyable with one command.
One piece of the 08-01 landscape remains to be walked. When Ribalta's network stops being one application and becomes five, with three teams deploying on their own and the council asking whether this can be moved to another provider, the answer is an orchestrator. The next lesson, Deploying to Kubernetes, tackles it honestly —starting by warning that for a single application like CicloUrbana it is usually unjustified complexity— and with the complete manifests: Deployment, Service, Ingress, ConfigMap, Secret, a Job for the migrations, an HPA for scaling, a Helm chart so that nothing is duplicated, and the three probes —liveness, readiness and startup— pointing at last to the health groups we defined in 07-01.
Spring Boot Course
Module 1: Introduction to Spring Boot
- What Is Spring Boot?
- Setting Up Your Development Environment
- Building Your First Spring Boot Application
- Understanding the Project Structure
- Application Startup and Lifecycle
Module 2: Spring Boot Core Concepts
- Spring Boot Annotations
- Dependency Injection in Spring Boot
- Bean Scope and Lifecycle
- Spring Boot Configuration
- Spring Boot Properties
- Auto-Configuration and Starters from the Inside
Module 3: Building RESTful Web Services
- Introduction to RESTful Web Services
- Creating REST Controllers
- Handling HTTP Methods
- Validating Input Data
- DTOs and Mapping Between Layers
- Exception Handling in REST
- Documenting the API with OpenAPI
Module 4: Data Access with Spring Boot
- Introduction to Spring Data JPA
- Configuring Data Sources
- Creating JPA Entities
- Relationships Between Entities
- Using Spring Data Repositories
- Query Methods in Spring Data JPA
- Transactions and Persistence Management
- Schema Migrations with Flyway
Module 5: Security in Spring Boot
- Introduction to Spring Security
- Configuring Spring Security
- User Authentication and Authorization
- Implementing JWT Authentication
- Method-Level Security and API Hardening
Module 6: Testing in Spring Boot
- Introduction to Testing
- Unit Testing with JUnit
- Mocking with Mockito
- Integration Testing
- Testing with Testcontainers
Module 7: Advanced Spring Boot Features
- Spring Boot Actuator
- Spring Boot Profiles
- Scheduled Tasks and Asynchronous Execution
- Spring Boot with Docker
- Spring Boot and Microservices
- Service Communication and Fault Tolerance
Module 8: Deploying Spring Boot Applications
- Introduction to Deployment
- Deploying to Heroku
- Deploying to AWS
- Deploying to Kubernetes
- Continuous Integration and Delivery
Module 9: Performance and Monitoring
- Performance Tuning
- Caching with Spring Cache
- Monitoring with Spring Boot Actuator
- Using Prometheus and Grafana
- Logging and Log Management
- Distributed Tracing
