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

  1. The landscape: which AWS service runs a Spring Boot application
  2. Essential base concepts
  3. Preparing the image: ECR
  4. The database: RDS PostgreSQL
  5. Secrets: Secrets Manager and Parameter Store
  6. Deploying on ECS Fargate
  7. The load balancer, health and TLS
  8. Flyway migrations on AWS
  9. Autoscaling
  10. Observability: CloudWatch
  11. The short route: Elastic Beanstalk
  12. Infrastructure as code
  13. Costs and cleanup
  14. Security: the rules that are not negotiable
  15. Common Mistakes and Tips
  16. Exercises

  1. 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.

  1. 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.

  1. 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.0

Two 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).

  1. 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-protection

Parameter 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 CIDR

Referencing 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".

  1. 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.

  1. 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).

aws ecs create-cluster --cluster-name ciclourbana \
  --capacity-providers FARGATE --region eu-west-1

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: 512 and memory: 1024 are 0.5 vCPU and 1 GB. Fargate only accepts specific combinations (0.5 vCPU accepts 1, 2, 3 or 4 GB). With MaxRAMPercentage=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.
  • secrets versus environment: the value never appears in the definition. The :password:: suffix extracts a specific field from the JSON of the RDS secret, which contains username and password.
  • 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/readiness with 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 between SIGTERM and SIGKILL. It must exceed the timeout-per-shutdown-phase of 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 2 spreads the tasks across the two AZs: if one zone goes down, the service carries on.
  • assignPublicIp=DISABLED keeps 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 90 gives 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=200 defines 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.

  1. 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=200

This 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=30

During 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:

server:
  forward-headers-strategy: framework

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.

  1. 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.

  1. 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.

  1. 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 1h

Two 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 30

And 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.

  1. 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 open

Three 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/readiness

Compared 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.

  1. 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 command

The 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.

  1. 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 VPC

Final 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.

  1. 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_KEY in 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-encrypted on RDS (irreversible afterwards), sslmode=require in the JDBC URL, ELBSecurityPolicy-TLS13-1-2-2021-06 on the listener and IMMUTABLE in 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 ciclourbana

Advantages: 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:

aws application-autoscaling register-scalable-target ... --min-capacity 4 --max-capacity 12

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

Module 2: Spring Boot Core Concepts

Module 3: Building RESTful Web Services

Module 4: Data Access with Spring Boot

Module 5: Security in Spring Boot

Module 6: Testing in Spring Boot

Module 7: Advanced Spring Boot Features

Module 8: Deploying Spring Boot Applications

Module 9: Performance and Monitoring

Module 10: Best Practices and Tips

© Copyright 2026. All rights reserved