Every IAM error, every Docker rate limit, every Testcontainers gotcha documented. The guide I wish existed when I started.
Most AWS deployment guides show you the happy path. This one shows you the whole road — including the potholes.
This is the complete walkthrough for deploying a Spring Boot app to AWS ECS with a real CI/CD pipeline. GitHub Actions for testing. CodePipeline + CodeBuild for deployment. Every IAM permission. Every command. Every error documented.
What You're Building
GitHub PR → GitHub Actions → tests pass → PR shows ✅
GitHub push to main → CodePipeline
→ CodeBuild (test + build JAR + build Docker image + push to ECR)
→ ECS rolling deploy (zero downtime)
→ SNS email notification
Stack:
Spring Boot 4.0.5 / Java 21 / Maven
MySQL 8 on Amazon RDS
Docker → Amazon ECR
Amazon ECS
GitHub Actions + AWS CodePipeline + CodeBuild
Step 1 — RDS MySQL
Create a db.t3.micro instance (free tier eligible):
Engine : MySQL 8.0
Template : Free tier
Public access : Yes (dev only — lock this down in production)
Port : 3306
Security group: allow 3306 from your local IP and your ECS security group.
properties# application.properties
spring.datasource.url=jdbc:mysql://:3306/yourdb
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
spring.flyway.enabled=true
spring.flyway.locations=classpath:db/migration
spring.jpa.hibernate.ddl-auto=none
Use Flyway for migrations. ddl-auto=none means Hibernate never touches your schema — Flyway owns it. This prevents accidental data loss.
Step 2 — Docker Setup
ECR Public for Base Images (Critical)
If you use Docker Hub images in CodeBuild, you will hit this:
429 Too Many Requests
toomanyrequests: You have reached your unauthenticated pull rate limit
Docker Hub allows 100 anonymous pulls per 6 hours per IP. AWS CodeBuild shares IPs. Your builds randomly fail. The fix is ECR Public — AWS's mirror of Docker Hub official images with no rate limits inside AWS.
dockerfile# ❌ Breaks randomly in CodeBuild
FROM maven:3.9-eclipse-temurin-21 AS builder
FROM eclipse-temurin:21-jdk-alpine
✅ No rate limits inside AWS
FROM public.ecr.aws/docker/library/maven:3.9-eclipse-temurin-21 AS builder
WORKDIR /app
COPY pom.xml .
RUN mvn dependency:go-offline -q
COPY src ./src
RUN mvn package -DskipTests -q
FROM public.ecr.aws/docker/library/eclipse-temurin:21-jdk-alpine
WORKDIR /app
COPY --from=builder /app/target/*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]
COPY --from=builder must match the AS builder name exactly. Using --from=build when the stage is named builder gives a cryptic error: "failed to resolve source metadata for docker.io/library/build:latest"
Create ECR Repository
bashaws ecr create-repository \
--repository-name your-app-backend \
--image-scanning-configuration scanOnPush=true \
--region us-east-1
Step 3 — Testing with Testcontainers
Your app needs real MySQL. H2 won't work with MySQL-specific Flyway migrations. Testcontainers starts a real MySQL Docker container during test execution.
pom.xml — Spring Boot 4.x Gotcha
These artifact IDs don't exist in Spring Boot 4.x:
xml<!-- ❌ WRONG — these don't exist -->
spring-boot-starter-webmvc-test
spring-boot-starter-data-jpa-test
Correct dependencies:
xml<!-- ✅ One artifact covers everything: JUnit 5, Mockito, AssertJ, MockMvc -->
org.springframework.boot
spring-boot-starter-test
test
org.springframework.security
spring-security-test
test
org.springframework.boot
spring-boot-testcontainers
test
org.testcontainers
junit-jupiter
test
org.testcontainers
mysql
test
application-test.properties
properties# jdbc:tc: = Testcontainers intercepts this and starts a Docker MySQL container
spring.datasource.url=jdbc:tc:mysql:8.0:///yourapp_test
spring.datasource.driver-class-name=org.testcontainers.jdbc.ContainerDatabaseDriver
spring.jpa.hibernate.ddl-auto=none
Flyway runs real migrations in test context
spring.flyway.enabled=true
spring.mail.host=localhost
spring.mail.port=3025
app.jwt.secret=test-secret-key-for-ci-only
logging.level.root=WARN
Unit Test (Pure Mockito — ~200ms, no Spring)
java@ExtendWith(MockitoExtension.class)
class EmployeeServiceTest {
@Mock private EmployeeRepository employeeRepository;
@Mock private RoleRepository roleRepository;
@Mock private PasswordEncoder passwordEncoder;
@Mock private IdGeneratorService idGeneratorService;
@Mock private MailService mailService;
@InjectMocks
private EmployeeServiceImpl employeeService;
@Test
@DisplayName("register() → throws when email already exists")
void register_duplicateEmail_throwsException() {
given(employeeRepository.existsByEmail("john@example.com")).willReturn(true);
assertThatThrownBy(() -> employeeService.register(buildRequest()))
.isInstanceOf(DuplicateResourceException.class);
verify(employeeRepository, never()).save(any());
verify(mailService, never()).sendRegistrationEmail(any(), any(), any(), any());
}
}
Controller Slice Test
java@WebMvcTest(EmployeeController.class)
@import(TestSecurityConfig.class) // disables JWT filter
class EmployeeControllerTest {
@Autowired MockMvc mockMvc;
@Autowired ObjectMapper objectMapper;
@MockitoBean EmployeeService employeeService; // @MockitoBean in Spring Boot 4, not @MockBean
@Test
void register_validRequest_returns201() throws Exception {
given(employeeService.register(any())).willReturn(mockResponse());
mockMvc.perform(post("/api/employees/register")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(validRequest())))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.data.generatedId").value("EMP-001"));
}
}
Step 4 — GitHub Actions
yaml# .github/workflows/ci.yml
name: Backend CI
on:
pull_request:
branches: [ main ]
push:
branches: [ main ]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
java-version: '21'
distribution: 'temurin'
cache: maven
- name: Run tests
run: mvn test -Dspring.profiles.active=test --no-transfer-progress
- name: Upload test report
if: always()
uses: actions/upload-artifact@v4
with:
name: test-report
path: target/surefire-reports/
Docker is pre-installed on ubuntu-latest. Testcontainers works out of the box.
Step 5 — CodePipeline + CodeBuild
IAM Roles — Get These Right First
CodePipeline role — the most important permission is iam:PassRole:
json{
"Statement": [
{
"Sid": "PassRoleToECS",
"Effect": "Allow",
"Action": "iam:PassRole",
"Resource": [
"arn:aws:iam::ACCOUNT:role/ecsTaskExecutionRole",
"arn:aws:iam::ACCOUNT:role/YOUR_ACTUAL_TASK_ROLE"
]
}
]
}
Find your exact role ARNs first:
bashaws ecs describe-task-definition \
--task-definition your-task-definition \
--query 'taskDefinition.{exec:executionRoleArn,task:taskRoleArn}'
Both ARNs must be in the PassRole resource list. The role you assume in ECS is often NOT named ecsTaskExecutionRole — it has a generated name. Check before writing the policy.
CodeBuild role — needs ECR Public auth (for rate-limit-free base image pulls):
json{
"Sid": "ECRPublicAuth",
"Effect": "Allow",
"Action": [
"ecr-public:GetAuthorizationToken",
"sts:GetServiceBearerToken"
],
"Resource": "*"
}
buildspec.yml
yamlversion: 0.2
env:
variables:
AWS_REGION: "us-east-1"
ECR_REPOSITORY_URI: "123456789012.dkr.ecr.us-east-1.amazonaws.com/your-app"
ECS_CLUSTER: "your-cluster"
ECS_SERVICE: "your-service"
CONTAINER_NAME: "backend"
phases:
install:
runtime-versions:
java: corretto21
pre_build:
commands:
- aws ecr get-login-password --region $AWS_REGION | docker login --username AWS --password-stdin $ECR_REPOSITORY_URI
- aws ecr-public get-login-password --region us-east-1 | docker login --username AWS --password-stdin public.ecr.aws
- IMAGE_TAG="${CODEBUILD_RESOLVED_SOURCE_VERSION:0:7}"
- mvn test -Dspring.profiles.active=test --no-transfer-progress
build:
commands:
- mvn package -DskipTests --no-transfer-progress
- docker build -t $ECR_REPOSITORY_URI:$IMAGE_TAG .
- docker tag $ECR_REPOSITORY_URI:$IMAGE_TAG $ECR_REPOSITORY_URI:latest
post_build:
commands:
- docker push $ECR_REPOSITORY_URI:$IMAGE_TAG
- docker push $ECR_REPOSITORY_URI:latest
- FULL_IMAGE="$ECR_REPOSITORY_URI:$IMAGE_TAG"
- echo -n "[{\"name\":\"$CONTAINER_NAME\",\"imageUri\":\"$FULL_IMAGE\"}]" > imagedefinitions.json
- python3 -c "import json; json.load(open('imagedefinitions.json')); print('JSON valid')"
- cat imagedefinitions.json
artifacts:
files:
- imagedefinitions.json
discard-paths: yes
Two YAML Gotchas That Cost Hours
Gotcha 1 — No backslash continuation:
yaml# ❌ CodeBuild YAML parser rejects this — "Expected Commands[N] to be string type"
- aws cloudfront create-invalidation \ --distribution-id $ID \ --paths "/*"
✅ One line
- aws cloudfront create-invalidation --distribution-id $ID --paths "/*" Gotcha 2 — imagedefinitions.json spaces: yaml# ❌ printf with continuation injects spaces → "The image URI contains invalid characters"
- printf '[{"name":"%s","imageUri":"%s"}]' \ $CONTAINER_NAME \ $ECR_REPOSITORY_URI:$IMAGE_TAG \ > imagedefinitions.json
✅ echo -n single line — no spaces, no newline
- FULL_IMAGE="$ECR_REPOSITORY_URI:$IMAGE_TAG"
- echo -n "[{\"name\":\"$CONTAINER_NAME\",\"imageUri\":\"$FULL_IMAGE\"}]" > imagedefinitions.json CodeBuild Project — Privileged Mode Without this, Testcontainers fails with Could not find a valid Docker environment: CodeBuild → project → Edit → Environment → ✅ Privileged mode: ON CodeStar Connection — Read This Carefully After creating the connection and doing OAuth, you must ALSO install the GitHub App: github.com/apps/aws-connector-for-github/installations/new → Select your account/org → Grant access to your repositories → Install Without installation, every run fails: [GitHub] No Branch [main] found for FullRepositoryName [user/repo] The branch exists. The connection shows "Available". The installation step is just missing.
Common Error Reference
ErrorRoot causeFix429 Too Many Requests from Docker HubAnonymous pull rate limitUse public.ecr.aws/docker/library/ base imagesiam:PassRole deniedMissing role ARN in PassRole resourceRun describe-task-definition to find exact ARNsExpected Commands[N] to be string typeBackslash continuation in YAMLOne command per lineimage URI contains invalid charactersSpaces in imagedefinitions.jsonUse echo -n on single lineNo Branch [main] foundGitHub App not installed (only authorized)Install at github.com/apps/aws-connector-for-githubCould not find valid Docker environmentPrivileged mode off in CodeBuildEnable Privileged mode in project settingsSwagger 403 after deploy/v3/api-docs/** not in security whitelistAdd to PUBLIC_ENDPOINTS alongside /api-docs/**
The Swagger 403 Nobody Warns You About
After deployment, Swagger UI loads but shows "Failed to load remote configuration."
You have /api-docs/** in your security whitelist. That should be enough. It's not.
SpringDoc internally calls /v3/api-docs/swagger-config on every Swagger UI load — regardless of your custom springdoc.api-docs.path. It's hardcoded in the library.
javapublic static final String[] PUBLIC_ENDPOINTS = {
"/swagger-ui/",
"/swagger-ui.html",
"/api-docs/",
"/v3/api-docs/**", // ← add this
"/actuator/health",
"/error"
};
That's the complete backend setup. For the frontend companion — React/Vite deployed to S3 + CloudFront with the same CodePipeline approach — check my profile.
Drop a ❤️ if this saved you time. Questions? Drop them in the comments.

Top comments (0)