Cloud (AWS / Azure)7 min read·By Liyabona Saki··

Deploying Spring Boot to AWS: ECS Fargate End-to-End

Containerize a Spring Boot app, push to ECR, run on ECS Fargate behind an Application Load Balancer — production-ready in one tutorial.

Lateral Movement via Over-Privileged Task Roles

When a Spring Boot application is compromised—perhaps through an unpatched CVE in a third-party dependency like Log4j or a misconfigured remote code execution (RCE) vector—the attacker first inspects the local environment's metadata service. If your ECS Task Execution Role or Task Role is cluttered with generic AdministratorAccess or broad s3:* permissions, that single compromised container becomes a springboard to wipe your RDS instances or leak your entire customer S3 bucket.

In Fargate, the separation between the Execution Role (what the ECS agent uses to pull images and write logs) and the Task Role (what the Spring Boot code uses to call AWS services) is your primary defense.

To defeat lateral movement, you must define a Task Role that follows the principle of least privilege. If your app only needs to write to one specific S3 bucket, do not give it access to the account.

```hcl
# Terraform snippet for restricted Task Role
resource "aws_iam_role_policy" "spring_app_s3_restricted" {
  name = "SpringAppS3Access"
  role = aws_iam_role.ecs_task_role.id

policy = jsonencode({ Version = "2012-10-17" Statement = [ { Action = ["s3:PutObject", "s3:GetObject"] Effect = "Allow" Resource = "arn:aws:s3:::production-app-assets-0492/*" } ] }) } ```

By explicitly scoping the Resource to a specific ARN, an attacker who gains a shell inside the container via a Java exploit cannot list other buckets or delete the underlying infrastructure.

Credential Siphoning from Environment Variables

A common mistake is passing database passwords and API keys via the environment block in the ECS Task Definition. These values are visible in the AWS Console, available via the aws ecs describe-task-definition CLI command, and readable by any process inside the container via /proc/1/environ. If an attacker gains read access to the process environment, they hibernate your secrets for later use.

The mitigation is to use the secrets section of the ECS Task Definition, referencing AWS Secrets Manager or Parameter Store. This ensures the ECS agent injects the values directly into the container's RAM at runtime without them being stored in the static task definition.

Inside your Spring Boot application.yaml, do not hardcode these. Use the property placeholders:

yaml
spring:
  datasource:
    url: jdbc:postgresql://${DB_HOST}:${DB_PORT}/${DB_NAME}
    username: ${DB_USERNAME}
    password: ${DB_PASSWORD}

In the Task Definition JSON, map these to Secrets Manager:

json
{
  "secrets": [
    {
      "name": "DB_PASSWORD",
      "valueFrom": "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/db/password-AbCd12"
    }
  ]
}

This prevents secrets from leaking into your CI/CD logs or the AWS metadata plane. Only the ECS agent, authorized by the Execution Role, can decrypt these values during the container transition from PENDING to RUNNING.

Container Escape and Host-Level Interference

In a self-managed EC2-based ECS cluster, a privileged container or a kernel-level exploit could allow an attacker to escape the container and gain root access to the underlying Linux host. From there, they can sniff traffic from other containers on the same host or manipulate the Docker daemon.

Fargate defeats this by design. Each Fargate Task runs on its own single-use, dedicated micro-VM. There is no shared kernel with other tenants, and you do not have access to the underlying host. However, you can still harden the container internally.

One specific production lesson: Spring Boot apps should never run as root. By default, many Dockerfiles result in a root user process. If an attacker gains execution, they have full control over the container file system.

```dockerfile
# Hardened Multi-stage Build
FROM eclipse-temurin:21-jdk-alpine AS build
WORKDIR /home/gradle/src
COPY --chown=gradle:gradle . .
RUN ./gradlew bootJar --no-daemon

FROM eclipse-temurin:21-jre-alpine RUN addgroup -S spring && adduser -S spring -G spring USER spring:spring COPY --from=build /home/gradle/src/build/libs/*.jar app.jar ENTRYPOINT ["java", "-jar", "/app.jar"] ```

By switching to USER spring:spring, even a successful RCE attack is trapped in a non-privileged shell, unable to install system-level packages via apk or modify protected system files.

Man-in-the-Middle and Traffic Inspection

Plaintext HTTP traffic between your Application Load Balancer (ALB) and your Fargate tasks is a liability, especially in regulated environments. While the VPC provides isolation, any internal compromise allows an attacker to sniff traffic via packet mirroring or ARP poisoning.

The topology should look like this:

text
Internet -> [IGW] -> [ALB (HTTPS Termination)] 
                        |
                        V
             [Private Subnet: Target Group]
                        |
            +-----------+-----------+
            |           |           |
      [Fargate Task] [Fargate Task] [Fargate Task]
      (Port 8080)    (Port 8080)    (Port 8080)

To prevent MitM, ensure your ALB is only accessible via HTTPS on port 443, using a certificate from AWS Certificate Manager (ACM). More importantly, your Security Groups must be "tight." The Fargate Security Group should only allow ingress on port 8080 from the Security Group ID of the ALB itself.

bash
# Verify the security group restriction
aws ec2 describe-security-groups --group-ids sg-0a1b2c3d4e5f6g7h8 --query "SecurityGroups[0].IpPermissions"

If the UserIdGroupPairs doesn't point specifically to your ALB's security group, you are leaving your application open to any other resource in the VPC. This is how "sidecar" attacks happen—a compromised dev-tool instance in the same VPC scanning for open internal ports.

Exhaustion Attacks (DoS/OOM)

Spring Boot applications are notorious for memory consumption, particularly the JVM's Metaspace and Heap. In a Fargate environment, if you do not strictly define your CPU and Memory limits, or if you don't communicate these limits to the JVM, the kernel will kill the container with an Out of Memory (OOM) Error or the app will suffer from "STW" (Stop-The-World) GC pauses as it fights for resources.

An attacker can trigger this by sending a flood of requests to an unoptimized endpoint (e.g., a search with a huge result set).

The fix is twofold. First, set hard limits in your Task Definition (e.g., 2GB RAM, 1 vCPU). Second, tell the JVM to respect these limits. As of Java 10+, the JVM is "container-aware," but you should still explicitly set its ratios.

One hard-won lesson from production: Never let the JVM calculate its own heap if you are close to the Fargate limit. We saw p99 latency drop from 480ms to 90ms after explicitly setting -XX:MaxRAMPercentage=75.0. This prevents the JVM from over-allocating and being unceremoniously killed by the Fargate agent (Exit Code 137), which causes 502 Bad Gateway errors at the Load Balancer while the service tries to restart.

Image Poisoning and Supply Chain Taints

If your CI/CD pipeline pushes to an ECR repository that permits image tag mutability, an attacker with access to your CI environment could overwrite the latest tag with a malicious image containing a backdoored JAR.

To defeat this, set your ECR repository to IMMUTABLE.

bash
aws ecr create-repository \
    --repository-name spring-boot-app \
    --image-tag-mutability IMMUTABLE \
    --encryption-configuration encryptionType=KMS

When tags are immutable, the only way to deploy a new version is to push a unique tag (like a Git commit SHA). This creates a cryptographically verifiable trail of what code is running. Additionally, enable "Scan on push" for ECR. This uses the Clair engine or Amazon Inspector to scan your Spring Boot layers for known vulnerabilities (CVEs) before they ever reach the Fargate cluster. If a scan returns "High" or "Critical" vulnerabilities, your CI/CD bridge (like GitHub Actions or GitLab Runner) should fail the build and block the deployment.

Unauthorized API Discovery

Exposing local Spring Boot Actuator endpoints (/actuator/env, /actuator/heapdump, /actuator/configprops) to the public internet is an information disclosure vulnerability. These endpoints can reveal your internal classpaths, environment variables (even if masked, partial data leaks), and memory state.

You must ensure that while /actuator/health is available to the ALB for health checks, the rest of the management port is blocked or moved to a different port that the ALB doesn't route to.

properties
# application.properties
management.endpoints.web.exposure.include=health,info
management.endpoint.health.show-details=always
# Better: Move management to a different port
management.server.port=8081

If your application listens on 8080 for traffic and 8081 for management, and your ALB only points to the 8080 target group, then /actuator is effectively unreachable from the internet. This creates a "management plane" isolation that prevents scanners from mapping your application's internals.

DNS Hijacking and Egress Control

Even if your container is hardened, once an attacker gains minimal code execution, they will attempt to "call home" to a Command and Control (C2) server to download second-stage payloads. By default, Fargate tasks in a public subnet with a public IP can talk to the entire internet.

To defeat this, deploy your Fargate tasks in Private Subnets. They should only reach the internet through a NAT Gateway or, even better, through VPC Endpoints (PrivateLink).

By using VPC Endpoints for S3, ECR, and CloudWatch, your traffic never leaves the AWS backbone. If you go a step further and implement a CloudWatch Network Firewall or a squid proxy, you can whitelist only the specific domains your Spring app needs (e.g., api.stripe.com).

This "Egress Filtering" is the final layer. If the application is compromised, the attacker's shell will be unable to reach their C2 server, effectively neutralizing the breach. In production, we observed that by moving to 100% VPC Endpoints and removing the 0.0.0.0/0 route, we eliminated 99.9% of "background noise" in our VPC flow logs, making it significantly easier to spot actual anomalies.

A sharp, production-ready Fargate deployment is defined not by how easily it runs, but by how difficult it is to exploit when the inevitable application-level vulnerability occurs. Every layer—from the non-root Docker user to the immutable ECR tags and the scoped IAM Task Role—serves to shrink the blast radius of a single controller failure.

Cost considerations most first-time deploys miss

The AWS bill for a small Spring Boot service is dominated not by compute but by three line items people forget to price: NAT Gateway at roughly \$32/month per AZ before you have moved a single byte, CloudWatch Logs ingestion at \$0.50/GB (a chatty INFO-level Spring service on Kubernetes can generate 10–30GB/month per pod), and inter-AZ data transfer which is silently added to every RDS-to-app-server call across zones. A realistic minimum for a two-AZ HA Spring Boot deployment behind an ALB, with RDS and a modest log volume, lands between \$120 and \$200/month before you have served real traffic. Two moves that shrink this fast: turn WARN into the default log level in production, and route only egress that actually needs internet through the NAT — a VPC endpoint for S3 costs cents by comparison.

Go deeper

Further reading

Source Code

Get the full project on GitHub

View repo →
#AWS#ECS#Fargate#Spring Boot

Stay in the Loop

Get the next tutorial in your inbox

Continue reading in Cloud (AWS / Azure)

Deploy Spring Boot to AWS ECS Fargate — End to End

Containerize a Spring Boot service and ship it to AWS ECS Fargate behind an Application Load Balancer.

Related tutorials