Module 58 - Cloud Deployment
What this module covers
Deploying Spring Boot to AWS (ECS Fargate) and GCP (App Engine / GKE), the Heroku 12-factor app methodology, Spring Boot profile-based configuration, Kubernetes liveness and readiness probes, and graceful shutdown. Tests verify config loading, external overrides, health indicators, and shutdown mode.
Project structure
src/main/java/com/javatraining/cloud/
├── CloudDeploymentApplication.java
├── config/
│ └── AppProperties.java # @ConfigurationProperties - 12-factor Factor III
├── health/
│ └── AppHealthIndicator.java # custom HealthIndicator for /actuator/health
└── api/
└── DeploymentController.java # GET /api/deployment/info
src/main/resources/
├── application.properties # base config: graceful shutdown, actuator, app.*
├── application-staging.properties # staging overrides (APP_ENVIRONMENT=staging)
└── application-prod.properties # prod overrides (all secrets via env vars)
deployment/
├── aws/
│ ├── ecs-task-definition.json # Fargate task: env vars, secrets, health check, logging
│ └── buildspec.yml # CodeBuild: test → build image → push to ECR
├── gcp/
│ ├── app.yaml # App Engine Flexible: scaling, probes
│ └── cloudbuild.yaml # Cloud Build: test → build → push to GCR → deploy to GKE
└── k8s/
├── deployment.yaml # Deployment: probes, preStop hook, resource limits
└── service.yaml # LoadBalancer Service
src/test/java/com/javatraining/cloud/
├── DeploymentInfoTest.java # endpoint + actuator/info (2 tests)
├── EnvironmentOverrideTest.java # 12-factor config override (1 test)
├── HealthIndicatorTest.java # custom indicator + actuator/health (2 tests)
└── GracefulShutdownTest.java # server.shutdown=graceful (1 test)
12-factor app - all 12 factors
| # | Factor | How this module applies it |
|---|---|---|
| I | Codebase | One repo, one artifact; environment is a variable, not a branch |
| II | Dependencies | All deps explicit in pom.xml; no system-wide installs |
| III | Config | AppProperties loads from app.*; any property overridable via APP_* env var |
| IV | Backing services | DB URL, passwords come from env vars / secrets manager - swappable without code change |
| V | Build, release, run | mvn package (build) → image tag (release) → container start (run); strictly separate |
| VI | Processes | Stateless: no session data in memory; any pod can handle any request |
| VII | Port binding | server.port=8080; the app IS the server - no external web server needed |
| VIII | Concurrency | Scale out by adding pods; Java 21 virtual threads for within-process concurrency |
| IX | Disposability | server.shutdown=graceful + 30s drain timeout + Kubernetes preStop sleep |
| X | Dev/prod parity | Same Docker image in every environment; only env vars change |
| XI | Logs | Spring Boot logs to stdout by default; CloudWatch / Cloud Logging aggregates |
| XII | Admin processes | mvn flyway:migrate, mvn versions:set run as one-off jobs, not app startup |
Factor III - Config in detail
@ConfigurationProperties record
@ConfigurationProperties(prefix = "app")
@Validated
public record AppProperties(
@NotBlank String name,
@NotBlank String version,
@NotBlank String environment,
String region
) {}
Spring Boot’s relaxed binding maps environment variables to properties automatically:
| Environment variable | Property |
|---|---|
APP_ENVIRONMENT | app.environment |
APP_REGION | app.region |
APP_VERSION | app.version |
Priority (highest wins): env vars > system properties > @TestPropertySource > profile properties > application.properties.
Profile-based config
application.properties ← always loaded
application-staging.properties ← loaded when SPRING_PROFILES_ACTIVE=staging
application-prod.properties ← loaded when SPRING_PROFILES_ACTIVE=prod
Activate at startup:
# Docker / Kubernetes
APP_ENVIRONMENT=prod SPRING_PROFILES_ACTIVE=prod java -jar app.jar
# Local staging test
SPRING_PROFILES_ACTIVE=staging ./mvnw spring-boot:run
Config in ECS task definition
"environment": [
{ "name": "APP_ENVIRONMENT", "value": "prod" },
{ "name": "APP_REGION", "value": "us-east-1" }
],
"secrets": [
{
"name": "APP_DB_PASSWORD",
"valueFrom": "arn:aws:secretsmanager:us-east-1:ACCOUNT_ID:secret:app/db-password"
}
]
environment for non-sensitive values, secrets for passwords/keys - ECS fetches secrets from Secrets Manager at task startup; the app sees them as plain env vars.
Factor IX - Disposability: graceful shutdown
server.shutdown=graceful
spring.lifecycle.timeout-per-shutdown-phase=30s
On SIGTERM, Spring Boot:
- Marks the readiness probe as DOWN - Kubernetes stops routing new traffic immediately
- Lets in-flight requests complete (up to 30 s)
- Closes the Tomcat acceptor and then shuts down the JVM
Kubernetes coordination
lifecycle:
preStop:
exec:
command: ["/bin/sh", "-c", "sleep 5"] # wait for load balancer to deregister
terminationGracePeriodSeconds: 60 # > 30s drain + 5s preStop
Timeline when Kubernetes terminates a pod:
t=0 SIGTERM sent; preStop sleep starts
t=5 Spring receives SIGTERM; stops accepting new requests; readiness → DOWN
t=35 All in-flight requests drained (30s max)
t=35 JVM exits
t=60 Kubernetes force-kills if JVM hasn't exited
Health probes
management.endpoint.health.probes.enabled=true
management.health.livenessState.enabled=true
management.health.readinessState.enabled=true
| Endpoint | Probe type | Fails when | Kubernetes action |
|---|---|---|---|
/actuator/health/liveness | Liveness | JVM is deadlocked / unrecoverable | Restart the pod |
/actuator/health/readiness | Readiness | App is starting or shutting down | Remove pod from LB |
/actuator/health | Aggregate | Any component is DOWN | Informational |
Custom AppHealthIndicator contributes deployment metadata to the aggregate:
{
"status": "UP",
"components": {
"app": {
"status": "UP",
"details": {
"environment": "prod",
"version": "1.0.0",
"region": "us-east-1"
}
}
}
}
Cloud deployment targets
AWS ECS Fargate
Fargate runs containers without managing EC2 instances. Key config:
- Image: pushed to ECR, referenced in the task definition
- Secrets: fetched from Secrets Manager at task start via the
secretsblock - Health check:
wgetcalls/actuator/health; task is replaced if it fails 3× at 30s intervals - Logs:
awslogsdriver streams stdout to CloudWatch Logs - stopTimeout: 35 - Fargate waits 35 s after SIGTERM before force-killing (must exceed drain timeout)
# Deploy a new image
aws ecs update-service \
--cluster production \
--service cloud-deployment-demo \
--force-new-deployment
GCP App Engine Flexible
App Engine manages the VM fleet. Key config in app.yaml:
readiness_check.path: /actuator/health/readiness- traffic only routes to healthy instancesliveness_check.path: /actuator/health/liveness- unhealthy instances are replacedautomatic_scaling.min_num_instances: 1- no cold startstarget_utilization: 0.65- scale out when CPU > 65%
gcloud app deploy deployment/gcp/app.yaml --project=MY_PROJECT
Kubernetes (GKE / EKS)
The deployment/k8s/deployment.yaml covers:
readinessProbe+livenessProbepointing at actuator endpoints- Resource
requestsandlimits(prevent noisy-neighbour problems) preStophook for graceful shutdown coordinationterminationGracePeriodSeconds: 60- buffer over the drain timeout
Tests
| Class | Factor | Tests |
|---|---|---|
DeploymentInfoTest | III, X | 2 |
EnvironmentOverrideTest | III | 1 |
HealthIndicatorTest | - | 2 |
GracefulShutdownTest | IX | 1 |
Run: JAVA_HOME=/opt/homebrew/opt/openjdk@21 mvn test Result: 6/6 pass
Key decisions
| Decision | Reason |
|---|---|
@ConfigurationProperties record over @Value fields | Record makes all config visible as a single type; @Validated catches missing props at startup, not at first call |
server.shutdown=graceful default-on | Zero-downtime rolling deploys depend on it; forgetting it causes 5xx during pod restarts |
Separate application-staging.properties and application-prod.properties | Environment differences are explicit and reviewable in git; no runtime logic branches on env name |
terminationGracePeriodSeconds > drain timeout + preStop sleep | If grace period ≤ drain timeout, Kubernetes force-kills the pod before requests finish - silent data loss |
| Liveness vs readiness as separate probes | Mixing them causes a restart loop during normal startup; readiness DOWN during shutdown is correct, liveness DOWN means the pod is broken |
APP_DB_PASSWORD via ECS secrets / K8s secretKeyRef, not environment block | Plaintext env vars appear in docker inspect, ECS describe-tasks, and process listings; secrets-manager references do not |