Skip to main content
Source

This page is generated from examples/ecs-modernize/liberty-to-ecs/README.md. Edit the source, not this page.

Modernize a WebSphere Liberty App onto Amazon ECS

A hands-on exercise for the ecs-modernize skill. You start with a Java 8 / Java EE 7 Liberty application deliberately built with modernization blockers in every scoring dimension, run the assessment to get a Fit_Score, then execute both paths and re-assess to watch the score move.

The point is the loop, not any single number: assess → decide → execute → re-assess.

Overview

app-before/ Java 8 · Java EE 7 · Open Liberty · blockers in all 6 dimensions

├─ /apex assess ──────────► Fit_Score 23 (< 40) → Replatform recommended
│ must-fix: host-local MQ bindings mode

├─ EXECUTE Replatform ────► the agent GENERATES the environment Terraform
│ (ECS on EC2, bridge, sticky sessions ON) and
│ deploys it — blockers now visible at runtime

├─ EXECUTE Rearchitect ────► the agent TRANSFORMS the code (5 work items,
│ build-verified per item), then deploys on
│ Fargate: state in S3, sticky sessions OFF

└─ /apex re-assess ────────► Fit_Score 85 (>= 70) → Rearchitect recommended

Both scores above are measured: they come from actual runs of the skill against this fixture, not from hand calculation. The skill re-derives every score from evidence, so your run may land a few points either side — what should be stable is the band: app-before below the Replatform threshold (40), app-after at or above the Rearchitect threshold (70). §3 of each generated report shows the per-dimension arithmetic, and the contributions must sum to the reported score within ±1.

How long this takes. The agent-driven parts are the slow ones, because a full assessment reads roughly 250 KB of skill reference material and runs six modules in a fixed prerequisite order. Measured on this fixture:

PartMeasuredNote
Part 1 — assess app-before~9 minat high reasoning effort; produced a 65 KB report
Part 3a — transform the code~15 min5 work items, each build-verified; includes one expected build failure and its fix
Part 4 — re-assessseveral minsame six modules against a smaller, cleaner tree

Budget 40 minutes or so of agent time for the whole exercise, plus AWS provisioning waits. Shortcuts if you are pressed: ask for the scoring module alone ("score the container fit for …") to resolve modules 1→2→3 and skip the two path modules and report generation, which is most of the reading; or skip Part 3a and deploy the reference implementation directly, at the cost of the transformation being the part you most likely came to see.

What This Demonstrates

AssessmentTech-stack detection (Liberty vs Tomcat server.xml by content), blocker detection in 6 categories, a weighted 0–100 Fit_Score with a per-dimension breakdown
DecisionThreshold-based recommendation, both strategies always presented with trade-offs
Code transformationExecution_Gate approval, a Transformation_Plan of ordered work items, the original source left untouched, per-item build verification and checkpoint commits, and honest handling of a verification failure
Environment generationThe Replatform environment is generated by the skill from a shipped skeleton — bridge networking, dynamic host ports, conditioned ALB stickiness — because ecs-build cannot produce that shape. The Rearchitect environment is ecs-build's territory: Part 3b explains the boundary and Part 3c crosses it for real. Both the skeleton and the Express Mode shape have been deployed and torn down against a real account — the skeleton at 26 resources with every blocker below reproducing at runtime, Express Mode at one service resource that pulled an entire ALB-and-autoscaling estate into existence outside Terraform state
ExecutionDockerfile + image build + ECR push, per-action-class confirmations, the pre-apply destroy check, deploy and steady-state verification
Re-assessmentThe same scoring criteria applied to the remediated tree, showing which dimension each fix moved

The Two Source Trees

app-before/ is the input — the application you assess and transform. app-after/ is the reference implementation: the same application already remediated, used as the answer key for Part 3a and as the known-good artifact Part 3b deploys. Both are real, buildable, runnable trees.

app-before/app-after/ (reference)
Java8 (EOL)17
Namespacejavax (Java EE 7)jakarta (Jakarta EE 10)
Liberty featuresjavaee-7.0 (whole platform)restfulWS-3.1, cdi-4.0, concurrent-3.0, mpHealth-4.0
Base imageopen-liberty:full-java8-openj9-ubiopen-liberty:kernel-slim-java17-openj9-ubi
Session stateHttpSession + static map, no <distributable/>none — stateless, cart id from the client
Order archivelocal file under /opt/was/orders/archiveAmazon S3 via the task role
Configendpoints + password committed in application.properties and server.xmlenvironment variables, no secrets in the tree
SchedulerCommonJ TimerManager (WebSphere-only API)ManagedScheduledExecutorService (EE Concurrency)
Vendor descriptoribm-web-bnd.xmlnone
Buildsystem-scoped unmanaged JAR in lib/all dependencies resolve from a repository
Image size (measured)1.64 GB1.29 GB
Cold start (measured, local)~18 s~3 s

The measured rows come from a local run on this fixture — they are illustrative, not benchmarks.

Planted Blockers

Each one is commented in place with a PLANTED marker, and maps to the dimension it depresses.

#WhereBlockerCategoryClass
1CartServlet.javaCart in HttpSession as the authoritative copy; web.xml has no <distributable/>in_process_sessionreplatform_ok
2CartServlet.javastatic map keyed by session id holding live statein_process_sessionreplatform_ok
3OrderArchiveService.javaBusiness data written to a fixed local pathlocal_statereplatform_ok
4OrderArchiveService.javaHardcoded OS-specific absolute path /opt/was/...os_specific_apireplatform_ok
5application.properties, server.xmlCommitted credential + hardcoded prod endpointshardcoded_credentialsreplatform_ok
6NightlySettlementScheduler.javaCommonJ TimerManager — WebSphere-only API in application codeos_specific_apireplatform_ok
7ibm-web-bnd.xmlVendor binding descriptor (descriptor-level coupling)— (tech_stack.app_servers)
8deploy/install-and-run.shsystemd unit + host cron.d entry supervising the appprocess_modelmust_fix
9deploy/install-and-run.shDeployment is a manual host procedure with in-place config editing— (build_reproducibility)
10pom.xml, lib/system-scoped JAR, unresolvable from any repository— (build_reproducibility)
11application.propertiesMQ connectionMode=bindings — host-local IPC to a co-located queue managerlicensing/other couplingmust_fix

Two must_fix blockers is deliberate. They are what makes the exercise honest: the Replatform path cannot simply promise "no changes at all", and the skill is required to handle every must_fix item explicitly. Blocker 8 is resolved by containerizing (the host supervisor and cron entry are replaced by ECS constructs), and blocker 11 is a genuine user-resolution item — the containerized app cannot reach a bindings-mode queue manager, so the exercise's app never opens that connection and the report should say so rather than paper over it.

Why app-before still runs

The exercise needs a container that actually starts, so two blockers are declawed just enough to be observable at runtime instead of fatal at startup:

  • NightlySettlementScheduler.java is excluded from compilation in pom.xml (the commonj.* packages ship with traditional WebSphere and are not on Open Liberty's classpath). It stays in the tree because the assessment reads source, which is exactly where code-level vendor coupling shows up.
  • The unmanaged JAR is on the compile classpath but, as a system-scoped dependency, is not packaged into the WAR — so GET /orders/orders returns "taxRate":"unavailable". That runtime hole is the build_reproducibility finding.

Prerequisites

  • AWS account with permissions for ECS, ECR, EC2, ELB, IAM, S3, CloudWatch Logs

  • Terraform >= 1.5, AWS CLI v2, jq

  • The Session Manager plugin for the AWS CLI — only needed for the optional aws ecs execute-command step in Part 2

  • A container runtime (Docker Desktop, Colima, Podman aliased to docker)

  • Maven and a JDK 17+ (javac --release 8 is used to make the legacy JAR)

  • One of: Claude Code or Kiro

  • APEX skills installed, so the agent can find ecs-modernize:

    npx apex-skills # first time
    npx apex-skills --update # already installed

    See Quick Start for the other install options. The installer clones the repo to ~/.apex-skills/ and symlinks every skill into place; confirm ecs-modernize landed with:

    ls ~/.claude/skills/ecs-modernize # Claude Code
    ls ~/.kiro/skills/ecs-modernize # Kiro

Cost. Each environment is an ALB (~$0.03/h) plus either two t3.small container instances (Replatform) or two 0.5 vCPU Fargate tasks (Rearchitect), with negligible S3 and log charges. Both use public subnets and no NAT gateway on purpose to keep the bill near zero.

Note that Parts 2 and 3b build two independent environments — the skill's Replatform generation creates its own network rather than assuming one exists, which is the honest default for a migration from outside AWS. Running both at once therefore means two ALBs. Expect well under $1/hour either way; destroy the Replatform environment before starting Part 3b if you would rather not pay for both, at the cost of not being able to compare them live. The optional Part 3c is a third environment, whose ALB is created and owned by ECS rather than by your Terraform.

Security posture of the exercise environment. The ALB is plain HTTP with no TLS and, by default, open to 0.0.0.0/0 so the exercise works from anywhere. Narrow ingress_cidr to your own address if you leave it up, and do not send real data through it. Every credential in the fixture is a fabricated placeholder on an RFC 2606 example domain.

Part 1 — Assess

Nothing is deployed yet; the assessment is read-only and free.

Open your agent from the repo root (so it does not read this README and spoil the answers):

Claude Code
cd ../../.. # repo root
claude

Then ask:

Assess examples/ecs-modernize/liberty-to-ecs/app-before for migration to Amazon ECS.
Kiro CLI
cd ../../..
kiro-cli chat

Then ask the same question.

What to look for in the report:

  1. A Fit_Score with all six dimensions broken out — score, weight, contribution — and the contributions summing to the total.
  2. state_management as the lowest dimension (two blocker categories present at once).
  3. Both must_fix blockers listed in the recommendation grounds.
  4. Liberty identified as the application server from the content of server.xml, not its filename.
  5. Both strategies presented with advantages, drawbacks, and an effort tier — even though the score points firmly at one.
  6. All three Rearchitect compute-model candidates — ECS Express Mode, ECS on Fargate, ECS Managed Instances — each carrying an applicability judgment grounded in a named finding, including any candidate ruled out and the finding that rules it out. Part 3b deploys the Fargate one; Part 3c is the Express Mode one.
  7. The report written outside the source tree.

Part 2 — Execute the Replatform path

This is the "containerize as-is" route: ECS on EC2, fixed task count, no autoscaling, sticky sessions compensating for the in-process session.

The skill builds this environment — there is no pre-written Terraform for it in this repository. That is not a shortcut taken for the exercise; it is how the skill works. ecs-build, the sibling that generates ECS Terraform, emits awsvpc task definitions exclusively and services that always carry a capacity-provider strategy, and it has no notion of ALB stickiness, bridge networking, or dynamic host port mapping. Those four are the Replatform shape, so ecs-modernize generates it itself from a verified skeleton it ships at skills/ecs-modernize/assets/replatform-terraform/.

Copying a skeleton rather than improvising each time is what makes the output predictable: two runs of the same assessment differ only in terraform.tfvars, so you can diff them.

Step 1 — approve the Execution_Gate

Back in the agent, with the Part 1 assessment in context:

I approve the Replatform strategy, targeting ECS on EC2. Execute it: build the
image, push it to ECR, generate the environment Terraform, and deploy.

The gate needs two things — a completed assessment (Part 1) and your explicit approval of a strategy and target path. Watch what happens next, because each action class asks separately: image push names the destination repository, file generation names the output directory, and terraform apply names the directory to apply. Approving the strategy is not approving the apply.

Expect the agent to ask you for two values the source tree cannot supply, and to refuse to invent them:

  • the current number of application instancesdesired_count derives from parity with what serves the workload today
  • the per-instance CPU/memory footprint — the container sizing derives from the current environment, since an unmodified application's appetite does not change by being containerized

Answer 2 instances and 512 CPU units / 900 MiB if you have nothing real to supply — enough to run two tasks on t3.small instances.

Step 2 — read what it generated

The agent copies the skeleton, writes terraform.tfvars from the assessment findings, then runs terraform fmt and terraform validate before reporting success. Read the tfvars first — every value should carry its origin as a comment:

# BLK-002 (in_process_session): cart held in HttpSession, web.xml has no <distributable/>
session_affinity_required = true

Then check the four elements that define this path, and why each is what it is:

Look forExpectedWhy
network_mode"bridge"The app is not being adapted to per-task ENIs
hostPort0Dynamic mapping, so several tasks share one instance
target_type"instance"The correct pairing with bridge + dynamic ports
Scaling policynoneThe fixed fleet and fixed count are the static configuration

Two details worth noticing because they are easy to get wrong by hand:

  • The container-instance security group admits the ephemeral port range (32768–65535) from the ALB, not 9080. Narrow it to the container port and every task after the first becomes unreachable.
  • There is no EFS volume, and that is a decision rather than an omission: the local_state blocker's write target was classified temporary for this exercise. Had it been persistent, the skeleton's optional fragment would have been added and the task definition given a mount point.

Step 3 — image build, push, and apply

The agent runs these, pausing at each action-class confirmation. Nothing here should surprise you after Part 1, but two things are worth watching:

  • --platform linux/amd64 on the build. The ECS-optimized AMI is x86, so an image built natively on an Apple Silicon laptop fails to start with an exec-format error.
  • The pre-apply destroy check. Before every apply the skill inspects the plan and refuses to proceed if it would delete an existing AWS resource. On a first deploy the plan is all creates, so it passes silently — but that is the guardrail that stops a later re-run from quietly replacing something.

If you would rather drive the AWS calls yourself, decline the terraform apply confirmation: the skill hands over the exact directory and commands instead of applying on your behalf, and then waits for you to report the outcome.

Step 4 — steady state

The skill verifies steady state with read-only APIs and reports the result. ECS on EC2 waits for the instances to register with the cluster and for tasks to pass health checks, so a few minutes is normal. When it stalls, these are the two commands that explain why:

aws ecs describe-services --cluster "$CLUSTER" --services "$SERVICE" --region "$REGION" \
| jq -r '.services[0].events[:10][].message'
aws logs tail "$(terraform output -raw log_group)" --region "$REGION" --since 10m

Step 5 — capture the outputs for the next steps

cd <the directory the agent generated into>
ALB=$(terraform output -raw alb_url)
CLUSTER=$(terraform output -raw cluster_name)
SERVICE=$(terraform output -raw service_name)
REGION=<your region>

Step 6 — observe the blockers biting

With desired_count = 2:

Using the variables from step 5:

# Build up a cart. The cookie jar holds two cookies: JSESSIONID (the app's
# session) and AWSALB* (the ALB's stickiness cookie pinning you to one task).
curl -s -c /tmp/j "$ALB/orders/cart?sku=A"
curl -s -b /tmp/j -c /tmp/j "$ALB/orders/cart?sku=B" # items = 2

# The unmanaged JAR is missing from the WAR — the build_reproducibility blocker,
# visible at runtime:
curl -s "$ALB/orders/orders" | jq .taxRate # "unavailable"

Showing the session blocker requires care. Simply dropping all cookies always returns items = 0, even against the same task, because a brand-new session is created — that proves nothing. The blocker is that the session is unknown to the other task. To show it, keep JSESSIONID and drop only the ALB stickiness cookie, so the load balancer is free to route you elsewhere:

JSID=$(grep -i JSESSIONID /tmp/j | awk '{print $7}')

# Same session id, no stickiness cookie — repeat until you land on the other
# task. Responses alternate between your real cart and an empty new session.
for i in $(seq 1 10); do
curl -s -H "Cookie: JSESSIONID=$JSID" "$ALB/orders/cart" \
| jq -c '{items, sessionId: .sessionId[0:8]}'
done

Responses showing items: 0 with a different sessionId are requests that reached the task which never saw your cart. That is precisely why the Replatform path turns stickiness on — and why it can only ever be a workaround.

If every response comes back items: 2, both targets are probably not healthy yet. An ALB only routes to healthy targets, so while one task is still registering, "drop the stickiness cookie" cannot reach the other one and the demonstration silently appears to pass. Check before concluding anything:

TG=$(aws elbv2 describe-target-groups --names "${CLUSTER}-tg" --region "$REGION" \
--query 'TargetGroups[0].TargetGroupArn' --output text)
aws elbv2 describe-target-health --target-group-arn "$TG" --region "$REGION" \
--query 'TargetHealthDescriptions[].{port:Target.Port,state:TargetHealth.State}'

Both must read healthyinitial means still registering. Measured on a real deployment: with one target healthy, 8/8 requests returned the intact cart; with both healthy, the same loop split 5/5 between the real cart and a new empty session. The port values are also worth a look: they are ephemeral (32768 and up), not 9080, which is dynamic host port mapping visible from the outside.

For the control case, keep the whole cookie jar (-b /tmp/j) and stickiness pins you to one task — 10/10 responses returned the same session in the same measured run.

# Order archives go to the task's own ephemeral disk. Force a replacement and
# the archive goes with it:
curl -s "$ALB/orders/orders?orderId=ORD-1" | jq .archivedLocally # "true"

aws ecs update-service --cluster "$CLUSTER" --service "$SERVICE" \
--region "$REGION" --force-new-deployment >/dev/null
aws ecs wait services-stable --cluster "$CLUSTER" --services "$SERVICE" --region "$REGION"
# ...the file that ORD-1 wrote is gone: the replacement tasks started from the
# image, whose archive directory is empty.

To see that directly rather than by inference, get a shell in a task (the instance role grants SSM, and the task definition permits ECS Exec):

TASK=$(aws ecs list-tasks --cluster "$CLUSTER" --service-name "$SERVICE" \
--region "$REGION" --query 'taskArns[0]' --output text)
aws ecs execute-command --cluster "$CLUSTER" --task "$TASK" --container app \
--region "$REGION" --interactive --command "ls -1 /opt/was/orders/archive"

The state-loss demonstration is about task replacement, not restart. A restarted container keeps its filesystem; a replacement task starts from the image with an empty archive directory. Verified locally: a docker restart still showed 1 archive file, while a fresh container from the same image showed 0.

Part 3 — Execute the Rearchitect path

This part has two halves: the agent performs the code transformation (3a), then you deploy the result (3b). 3a is the part most worth watching — it is where the skill stops advising and starts editing code.

3a — Let the agent transform the code

Ask the agent, from the repo root:

I approve the Rearchitect strategy targeting ECS on Fargate. Run the code
transformation for examples/ecs-modernize/liberty-to-ecs/app-before.

This is the Execution_Gate in action. Before touching anything the agent must present the Transformation_Plan and obtain your confirmation of the "code transformation start" action class — including the working location, because the rails forbid modifying the original source. Expect it to propose a new branch or a copied working directory; app-before/ itself stays untouched, and you can verify that afterwards with git status.

The plan should come out as roughly five work items in dependency order. This is what a real run produced, with the per-item build verification the skill mandates:

ItemChangeVerified
W1Java 8 → 17 (maven.compiler.release)build ✅
W2javaxjakarta — sources, jakartaee-api dependency, web.xml schema, and the Liberty feature level, all in one incrementbuild ✅
W3CommonJ TimerManager → EE Concurrency ManagedScheduledExecutorService; the pom.xml compiler exclusion is removedbuild ✅
W4State externalized to S3; HttpSession and the static map deletedbuild ❌ → fixed → ✅
W5Config and secrets externalized; unmanaged JAR, vendor descriptor, and host scripts removedbuild ✅

Watch for these four behaviours — they are the skill's discipline made visible, and they are the reason this part is worth doing rather than skipping to app-after:

  1. W2 lands as one increment, not four. A classpath mixing javax.* and jakarta.* variants of the same API fails at runtime, so the namespace sweep and the dependency/feature updates must land together. An agent that changes the imports and defers the pom.xml would produce a build that compiles and then breaks.
  2. javax.crypto / javax.net / javax.sql are not renamed. Those stay in the JDK. Only Jakarta-governed packages move.
  3. W4's build failure is expected, and how it is handled is the point. Moving the archive to S3 changes archive() from throwing IOException to throwing RuntimeException, which leaves the caller's catch unreachable — a compile error. The skill requires the agent to report the failure, fix it inside the working location, re-verify, and only then checkpoint. A run that silently swallowed this, or that "fixed" it by editing the original source, is not following the rails.
  4. Each item ends in a checkpoint commit. Those commits are your recovery points. Ask for the log at the end (git log --oneline in the working location) and check that items are not batched together — an item without its own commit has no recovery point.

When it finishes, compare against app-after/, which is the reference implementation of the same transformation. Differences are expected and instructive: app-after uses JAX-RS resources where a minimal transformation keeps the existing servlets, since converting a working servlet to JAX-RS is a modernization choice rather than a required remediation. What should match is the blockers being gone.

3b — Deploy the rearchitected app

This is the one part of the exercise with pre-written Terraform, and the asymmetry is the point: the Rearchitect shape (Fargate, awsvpc, no stickiness) is ecs-build's territory, so a verified implementation lives in terraform/ to keep the deploy cheap and reproducible. Part 2's shape is not, which is why the skill generated it there. Part 3c runs the real ecs-build hand-off if you want to see it.

The deploy uses the app-after reference implementation so everyone lands on the same known-good artifact regardless of how 3a went. This environment is independent of Part 2's — its own VPC, ALB, and cluster — so you can leave the Replatform deployment up and compare them side by side, or destroy it first to halve the cost.

cd examples/ecs-modernize/liberty-to-ecs
export SUFFIX=demo
export TF="-var suffix=$SUFFIX" # health_check_path defaults to /api/health

# 1. build
cd app-after && mvn -B package

# 2. network, ALB, cluster, ECR repository — image_uri empty means "no service yet"
cd ../terraform
terraform init
terraform plan $TF -var 'image_uri=' # compare this against Part 2's generated plan
terraform apply $TF -var 'image_uri='

# 3. push
REPO=$(terraform output -raw ecr_repository_url)
REGION=$(echo "$REPO" | cut -d. -f4)
aws ecr get-login-password --region "$REGION" \
| docker login --username AWS --password-stdin "${REPO%%/*}"
cd ../app-after
docker build --platform linux/amd64 -t "$REPO:v1" .
docker push "$REPO:v1"

# 4. task definition + service
cd ../terraform
terraform apply $TF -var "image_uri=$REPO:v1"

# 5. wait
CLUSTER=$(terraform output -raw cluster_name)
SERVICE=$(terraform output -raw service_name)
aws ecs wait services-stable --cluster "$CLUSTER" --services "$SERVICE" --region "$REGION"

Compare this environment against the one the skill generated in Part 2. The diff is the Replatform/Rearchitect decision expressed as infrastructure — and note that every row on the left was chosen by the skill from an assessment finding, not from a preference:

Part 2 — skill-generatedPart 3b — pre-written
ComputeASG of t3.small container instancesFargate — no instances at all
network_modebridge with hostPort = 0awsvpc
Target group target_typeinstance (dynamic host ports)ip (task ENIs)
stickiness.enabledtrue — from the in_process_session blockerfalse — no server-side session exists
Task roleSSM channel only (for ECS Exec)S3 PutObject on one bucket prefix
Scalingnone — fixed count, by designnone in this fixture, but Fargate makes it cheap to add
Extra resourcesS3 bucket, versioned and encrypted

The stickiness row is the one worth dwelling on. Both environments have the block; they differ in whether it is enabled, and in both cases that value traces to a finding. An environment with stickiness silently absent would look identical to one where the question was never asked.

ALB=$(cd terraform && terraform output -raw alb_url)
BUCKET=$(cd terraform && terraform output -raw archive_bucket)

# No server-side session: the cart id comes from the client, so any task serves
# any request and no cookie pins you anywhere.
curl -s "$ALB/api/cart?cartId=C1&sku=A" | jq

# The archive goes to S3 through the task role — it survives task replacement
# and every replica sees it.
curl -s "$ALB/api/orders?orderId=ORD-1" | jq
aws s3 ls "s3://$BUCKET/orders/"

# Stickiness is off, and the app does not need it:
cd terraform && terraform output sticky_sessions_enabled # false

3c — (optional) the same app on ECS Express Mode

Fargate is one of three Rearchitect compute models the assessment presents, and Express Mode is the one that pushes the real question — not how much work, but how much infrastructure you want to operate — to its limit. You hand ECS an image and three IAM roles; ECS provisions and owns the ALB, target groups, listener, security groups, an ACM certificate, autoscaling and alarms — plus the cluster, unless one is already there to join — and hands back an HTTPS URL. It is also AWS's recommended target for App Runner users, since App Runner closed to new customers on 2026-04-30.

This is also where the delegation boundary gets crossed for real. Express Mode is ecs-build's territory, so ask for it and watch the hand-off:

Generate the ECS environment Terraform for the rearchitected Orders app,
targeting ECS Express Mode.

The agent should hand off to ecs-build with the structured input list — image URI, container port, health check path (/api/health), sizing, and the task-role need — rather than writing the IaC inside ecs-modernize. Ask for targeting Fargate instead and you get the shape Part 3b pre-wrote; doing that once and diffing the generated code against terraform/ is a cheap way to see how much of that file was forced by findings.

The whole service is one resource, and this app needs nothing more. It is tempting to assume the rearchitected app has outgrown the simple path — it needs a task role for the S3 archive and the bucket name as an environment variable, which sounds like a job for a task definition you write yourself. It is not. aws_ecs_express_gateway_service carries task_role_arn at the top level and environment / secret blocks inside primary_container, so the entire environment is one resource plus the three IAM roles:

resource "aws_ecs_express_gateway_service" "orders" {
service_name = "orders"
execution_role_arn = aws_iam_role.execution.arn
infrastructure_role_arn = aws_iam_role.infrastructure.arn
task_role_arn = aws_iam_role.task.arn # S3 PutObject, as in Part 3b
health_check_path = "/api/health"

primary_container {
image = "${aws_ecr_repository.app.repository_url}:v1"
container_port = 9080

environment {
name = "ORDERS_ARCHIVE_BUCKET"
value = aws_s3_bucket.archive.bucket
}
}

wait_for_steady_state = true
}

ECS registers the task definition itself, and names the container Main to satisfy its own contract — you can see that in the log stream name, ecs/Main/<task-id>. Part 3b's container is named orders; here you never name it at all.

Custom task definitions exist, but not in Terraform yet. The Express Mode API accepts a task definition you manage (taskDefinitionArn) — Fargate-compatible, one container named Main, exactly one TCP port mapping with a container port and a port name — which is how you would add sidecars or take full task-level control. That argument is absent from aws_ecs_express_gateway_service in AWS provider 6.59.0: primary_container is the only container input the resource exposes (checked with terraform providers schema -json). So from Terraform today the managed path is the only path, and a sidecar requirement means calling the API outside Terraform. Re-check the provider before assuming this is still true.

The infrastructure role is broad by design. ECS acts on your behalf through a role trusting ecs.amazonaws.com and carrying arn:aws:iam::aws:policy/service-role/AmazonECSInfrastructureRoleforExpressGatewayServices — ELB, EC2 security group, ACM and Application Auto Scaling rights. That breadth is the price of the delegation; scope and review it deliberately rather than pasting it in.

What ECS creates that your Terraform does not. Measured on a real deployment of this app (us-west-2, provider 6.59.0, 2026-08-13): Terraform managed ten resources, exactly one of which was the service — the other nine being an ECR repository, an S3 bucket with its public-access block, and three IAM roles with their policies. Everything below appeared alongside them, owned by ECS:

ResourceWhat it came out as
Application Load Balancer + HTTPS:443 listener + listener ruleecs-express-gateway-alb-<hash>, internet-facing
Two target groupsip type, port 9080, health check /api/health — two because traffic shifting needs somewhere to shift to
ACM certificateAMAZON_ISSUED for ex-<hash>.ecs.us-west-2.on.aws, in your account
Two security groupsone for the ALB, one for the service, plus their rules
CloudWatch Logs group/aws/ecs/default/<service>-<hash>
Auto scalingscalable target min 1 / max 20, target tracking on AVERAGE_CPU at 60, with its AlarmHigh / AlarmLow
Deployment guardraila RollbackAlarm on the service
Task definitionregistered by ECS, container named Main
Clusternone created — the test account already had a default cluster, so the service joined it

Two details from that run are worth carrying into your own governance:

  • The tags propagate. Every resource above carried AmazonECSManaged=true and the provider's default_tags. So the resources ECS owns are still discoverable by tag, which is what makes the cleanup below auditable.
  • Networking was left to ECS. The test omitted network_configuration entirely; ECS placed the service across all four default-VPC subnets and built the security groups itself. Supply it when you care where the tasks land.

What you can no longer see, and why that is the point. Part 2 had to turn ALB stickiness on and Part 3b had to turn it off, and both values traced to a finding. Express Mode never exposes the question — which is only safe because the Rearchitect work removed the server-side session. Run 3b's probes against the AWS-provided URL (ingress_paths on the resource carries it) and the behaviour is indistinguishable, except that it is HTTPS without you having handled a certificate:

curl -s "$EXPRESS_URL/api/health" # {"status":"UP"}
curl -s "$EXPRESS_URL/api/cart?cartId=C1&sku=A" | jq # serverSideSession: false
curl -s "$EXPRESS_URL/api/orders?orderId=ORD-2" | jq # archivedTo: s3://...

The deeper trade-off is that the managed resources are not in Terraform state. On the measured run, terraform plan -destroy listed exactly the 10 resources Terraform owned and not one of the ALB, certificate, security groups, log group, scaling target, or alarms. The plan stops being an audit of the blast radius — for applies and, more importantly, for destroys. If your governance rests on reviewing plans, that is the deciding factor, not the convenience.

Verified against a real account, unlike an agent-generated file you have only read: this shape was deployed and torn down once (single run, one region, one provider version — treat the timings as indicative). terraform apply with wait_for_steady_state = true took 5m42s to reach a serving HTTPS URL; terraform destroy took 8m26s because the service drains first. The app behaved exactly as in Part 3b: health UP, a stateless cart, and the order archive landing in S3 through the task role. If Part 3b is still up, reuse its bucket (terraform output -raw archive_bucket) instead of creating a second one.

Part 4 — Re-assess and compare

Back in the agent, from the repo root. If Part 3a produced a transformed tree, assess that — it is your own work, and the interesting question is whether it cleared the blockers:

Re-assess <your Part 3a working location> and compare the Fit_Score to the
app-before assessment. Which dimensions moved, and why?

Otherwise assess the reference implementation, which produces the numbers tabulated below:

Re-assess examples/ecs-modernize/liberty-to-ecs/app-after and compare the
Fit_Score to the app-before assessment. Which dimensions moved, and why?

Measured movement — each dimension traceable to specific fixes. These are the scores two real runs produced against this fixture:

DimensionWeightbeforeafterWhat moved it
state_management25%1592Session and local-file state both externalized to S3
os_host_dependency20%2085CommonJ API replaced, vendor descriptor removed, host cron and MQ gone
config_externalization15%1593Env-driven config, no committed secret, no properties file inside the WAR
framework_modernity15%3555Java 8 → 17 lifts the EOL cap; Jakarta EE stays tier 3 (see below)
dependency_coupling15%2588Host-local MQ bindings mode gone; only S3 over HTTPS remains
build_reproducibility10%3592Unmanaged system-scoped JAR gone, no manual host procedure
Fit_Score2385Replatform band → Rearchitect band

Two things in that table are worth dwelling on, because both are cases where the skill's criteria are stricter than they first look:

os_host_dependency and config_externalization start very low (20 and 15), not mid-band. Both before values sit in the 0–29 band for specific reasons the criteria spell out. Any must_fix finding puts os_host_dependency in 0–29 outright — and app-before has two (host cron, host-local MQ). For config_externalization, the decisive fact is not merely that a credential is committed (that alone would be 30–59) but that application.properties is packaged inside the WAR and read from the classpath, so no container-boundary intake method can override it and the same build genuinely cannot move between environments.

framework_modernity only reaches 55, and that is the ceiling. Jakarta EE sits on tier 3 of the modernity ladder (band 41–59). Java 8 → 17 matters — it lifts the EOL legacy cap of 40 — but the modern floor of 60 is reserved for Spring Boot, ASP.NET Core, and .NET 5+, so Jakarta EE cannot reach it however current the version is. 55 is near the top of tier 3, which is the correct reading for Jakarta EE 10 on an LTS runtime. Moving above 59 requires changing framework family (a Spring Boot rewrite — large effort), and the report should present that as an optional item, not schedule it: it would raise a 15%-weighted dimension from 55 to ~80, worth about 4 Fit_Score points.

The after report also lands on zero required modernization items, which is the honest outcome of the "no finding → no item" rule — the health endpoint already exists, and Liberty handles SIGTERM as a platform behavior. Both strategies come out small effort, so the real choice is not how much work but how much infrastructure you want to operate.

Cleanup

There are two independent environments to clean up if you ran Parts 2 and 3b, each with its own Terraform state — three if you also ran Part 3c.

The Rearchitect environment (Part 3b, pre-written):

cd examples/ecs-modernize/liberty-to-ecs/terraform
terraform plan -destroy -var suffix=$SUFFIX # read this first
terraform destroy -var suffix=$SUFFIX

The Replatform environment (Part 2, skill-generated) — in whichever directory the agent generated into:

cd <the directory the agent generated into>
terraform plan -destroy # values come from the terraform.tfvars it wrote
terraform destroy

You can also ask the agent to tear it down; it will show you the destroy plan and ask before applying. Either way, read the destroy plan — it should list only ex-liberty-$SUFFIX* resources. The ECR repository (force_delete) and the S3 bucket (force_destroy) are removed with their contents, so the images and archived objects go too.

The Express Mode environment (Part 3c, if you ran it) — in whichever directory the agent generated into:

cd <the directory the agent generated into>
terraform plan -destroy # the ECS-managed resources will NOT appear here
terraform destroy # ~8-9 min: the service drains before it goes

Destroying the service resource is what releases the infrastructure ECS created for it. Measured on the run described in Part 3c, terraform destroy took the ALB, both target groups, the listener, the ACM certificate, both security groups, the scalable target and all three alarms with it — and left the default cluster alone, because another service was still using it. That conditional is the one to remember: the cluster goes only when nothing else needs it.

Two things survive the destroy and are yours to remove:

# 1. the CloudWatch Logs group — it outlived the service, with retention "never expire"
aws logs delete-log-group --log-group-name /aws/ecs/default/<service>-<hash> --region "$REGION"

# 2. the task definition ECS registered — free, but it stays ACTIVE
aws ecs deregister-task-definition --task-definition default-<service>:1 --region "$REGION"

Because none of the ECS-owned resources appear in the destroy plan, verify by tag rather than by reading the plan. The ALB does not carry the ex-liberty prefix the check below filters on, but it does carry your tags:

aws resourcegroupstaggingapi get-resources --tag-filters Key=AmazonECSManaged,Values=true \
--region "$REGION" --query 'ResourceTagMappingList[].ResourceARN'

Expect the tagging index to lag: on the measured run it still listed already-deleted security-group rules, the INACTIVE service, and the stopped task for a while afterwards. Confirm anything suspicious with a direct describe-* call before concluding a resource is still there.

If you lost the generated directory, the resources are still findable by tag: aws resourcegroupstaggingapi get-resources --tag-filters Key=ManagedBy,Values=ecs-modernize.

Then confirm nothing chargeable survived. The ALB and the EC2 instances are what cost money if they linger:

aws elbv2 describe-load-balancers --region "$REGION" \
--query "LoadBalancers[?starts_with(LoadBalancerName,'ex-liberty')].LoadBalancerName"
aws ec2 describe-instances --region "$REGION" \
--filters "Name=tag:Example,Values=apex-ecs-modernize-liberty-to-ecs" \
"Name=instance-state-name,Values=running" \
--query 'Reservations[].Instances[].InstanceId'

Both should return an empty list. Local build output (target/, app-before/lib/*.jar) is untouched and git-ignored; remove it with git clean -xdn examples/ecs-modernize/liberty-to-ecs to preview, then -xdf to delete.

Further Reading