Home Resume
Homeβ€Ί Blogβ€Ί Week 9 - ECS Fargate Self-Service: Ticket-to-Conta…
AWS Weekly Lab AWS Terraform

ECS Fargate Self-Service: Ticket-to-Container in Minutes

Deploying a containerized service today means someone hand-builds an ECS cluster, task definition, target group, listener rule, and auto-scaling policy β€” an hour of work, repeated inconsistently every time. This platform turns a ServiceNow ticket into a running, load-balanced, auto-scaling Fargate service with no server management and no manual ALB wiring.

AWS Platform Engineering Lab Β· Week 09

Why β€” The Problem This Solves

A team wants to deploy a containerized service. Today that means someone hand-builds an ECS cluster, task definition, ALB target group, listener rule, security groups, and auto-scaling policy β€” an hour of console or Terraform work, repeated (and often done inconsistently) every time a new service needs to go live. Every hand-built service is a chance to skip a health check, forget a scaling policy, or wire the security group wrong.

The fix is the same self-service pattern the last eight weeks have been building toward: turn the ticket into the trigger. A ServiceNow ticket with an image URI and a port number becomes a running, load-balanced, auto-scaling Fargate service in minutes β€” no server management, no manual ALB wiring, and every service provisioned the same way every time.

Why a Shared ALB Instead of One per Service

An Application Load Balancer costs about $24/month regardless of how many services sit behind it. Standing up a dedicated ALB per self-service ticket would multiply that cost by the number of services running. This platform uses one shared ALB with path-based routing (/<service-name>/*) β€” every new service just adds a listener rule to the existing ALB, not a new one.

The Business Value

This platform delivers a self-service path from ticket to running container, with the same operational guardrails every time: private-subnet-only tasks, target-tracking auto-scaling, and a per-ticket Step Functions execution history for auditability.

1

Ticket to Running Service, No Console Work

A ServiceNow ticket supplies service_name, image_uri, container_port, cpu, memory, and desired_count. Everything downstream β€” ECR repo, task definition, ECS service, ALB rule, auto-scaling β€” is provisioned automatically.

2

One Shared ALB, Path-Based Routing

Every service is reachable at http://<shared-alb-dns-name>/<service-name>/. New services add a listener rule to the existing ALB β€” the $24/month cost is shared across every service on the platform, not multiplied per service.

3

No NAT Gateway

Fargate tasks run in private subnets with zero internet route. They reach private ECR and CloudWatch Logs through VPC interface endpoints instead of a ~$32/month NAT Gateway β€” a real constraint that shapes what image registries this platform can pull from (more on that in Challenges).

4

Auditable by Design

Step Functions gives every ticket its own visual execution graph β€” exactly which Lambda ran, what it did, and whether it succeeded. There's no digging through CloudWatch Logs across three functions to reconstruct what happened for a given ticket.

What You Need to Know β€” Skills & Tools

ECS Fargate vs. EC2 Launch Type

Fargate removes the underlying EC2 instance entirely β€” you specify CPU/memory per task, and AWS runs the container on infrastructure you never see or patch. The tradeoff is cost per vCPU/GB versus a comparable EC2 instance, but for a self-service platform where tasks come and go per ticket, not managing a cluster's worth of EC2 capacity is the whole point.

Path-Based Routing vs. Host-Based Routing

Host-based routing

Each service gets its own subdomain (demo-nginx.example.com). This is AWS's own ECS Express Mode pattern (launched Nov 2025) β€” but it needs an owned Route53 hosted zone to mint a subdomain per service.

Path-based routing (used here)

Every service is reachable at /<service-name>/* on the one shared ALB DNS name. No DNS dependency β€” but the ALB does not rewrite paths, so every app must be written to serve content under its own prefix. See Challenges.

Key Concepts

ECS Fargate launch type
ALB path-based routing
VPC Interface & Gateway Endpoints
Task execution role vs. task role
Step Functions Retry/Catch
Idempotent Lambda retries
Application Auto Scaling target tracking
IAM service-linked roles
Step Functions Retries Demand Idempotent Lambdas

A Step Functions Retry block re-invokes the same Lambda on transient failure β€” but if the first invocation partially succeeded (say, it created the ECR repo before failing on the target group), the retry will try to create that same ECR repo again and get an AlreadyExists-style error. Every AWS-resource-creating function in this pipeline had to be rewritten to check-before-create. This bit us for real β€” see Challenges.

Architecture β€” How It Fits Together

A ServiceNow ticket triggers an HMAC-signed webhook to API Gateway, which hands off to Step Functions. Step Functions runs fargate_provisioner (creates every AWS resource for the ticket) and then status_notifier (closes the ticket). The resulting service lives in a private subnet with no internet route, reachable only through the shared ALB.

Week 9 β€” ECS Fargate Self-Service Architecture A ServiceNow ticket flows through API Gateway, a webhook_receiver Lambda, and Step Functions, which invokes fargate_provisioner to create an ECR repo, ECS Fargate service, and ALB routing rule inside a VPC with no NAT Gateway, then status_notifier closes the ticket. An end user reaches the running service through the shared ALB, which forwards traffic to the ECS task in a private subnet. AWS official architecture icons mark each managed service. ServiceNow ticket submitted API Gateway HMAC-signed webhook webhook_receiver validates signature Step Functions orchestrates workflow fargate_provisioner creates AWS resources status_notifier ↻ closes ServiceNow ticket VPC β€” private subnets, no NAT Gateway Public Subnet Load Balancer path-based routing Private Subnet ECS Fargate Task runs the container VPC Endpoints ECR Β· Logs Β· S3 End user requests the URL External actor Compute Networking Orchestration

Why Two Lambdas Instead of One

fargate_provisioner and status_notifier run as separate Step Functions states rather than one combined function. Splitting them means a provisioning failure and a ServiceNow-notification failure show up as distinct, individually-retryable states in the execution graph β€” you can tell at a glance whether the infrastructure itself failed or just the ticket-closing call did.

Why Step Functions Instead of Lambda Calling Lambda

The same reasoning that put Step Functions in front of Week 2's db_provisioner applies here: the workflow has real branching, retry, and catch semantics that a Lambda-calling-Lambda chain would have to hand-roll. The Retry block on ProvisionService retries on States.TaskFailed; the Catch routes to a NormalizeFailure state so a failed ticket still gets a clean, readable close-out instead of an unhandled exception.

How We Built It β€” Step by Step

Step 1 β€” Terraform Structure

Eight Terraform modules under week-09-ecs-fargate-self-service/terraform/modules/: vpc, security-groups, ecs-cluster, alb, iam, lambda, step-functions, and api-gateway β€” 50 resource blocks total. VPC endpoints (S3 gateway, ECR api/dkr, CloudWatch Logs) are defined directly in the root main.tf rather than inside the vpc module, to avoid a circular dependency with the security-groups module.

Step 2 β€” The Shared ALB

One ALB, one default listener with a static fixed-response 404 action. No target groups are created in Terraform β€” every per-service target group and listener rule is created imperatively by fargate_provisioner when a ticket arrives.

HCL
resource "aws_lb_listener" "http" {
  load_balancer_arn = aws_lb.shared.arn
  port              = 80
  protocol          = "HTTP"

  default_action {
    type = "fixed-response"
    fixed_response {
      content_type = "text/plain"
      message_body = "Not Found"
      status_code  = "404"
    }
  }
}

Step 3 β€” fargate_provisioner: The Per-Ticket Pipeline

Seven boto3 calls per ticket, each idempotent (check-before-create β€” see Challenges for why that mattered):

  1. ecr:CreateRepository β€” per-service image isolation, scan-on-push, immutable tags
  2. logs:CreateLogGroup β€” /ecs/<project>-<service>
  3. ecs:RegisterTaskDefinition β€” awsvpc network mode, FARGATE, shared execution role
  4. elbv2:CreateTargetGroup β€” target_type=ip, health check path set to /<service>/
  5. elbv2:CreateRule β€” path-pattern /<service>/* on the shared ALB's existing listener
  6. ecs:CreateService β€” private subnets, ALB-only security group, no public IP
  7. application-autoscaling:RegisterScalableTarget + PutScalingPolicy β€” target-tracking on CPU

Step 4 β€” Deploy via HCP Terraform

week-09-dev is a VCS-connected HCP Terraform workspace, same pattern as Weeks 5–8 β€” Terraform runs remotely from the GitHub repo, not from local CLI:

HCL
cloud {
  organization = "Katta"
  workspaces {
    name = "week-09-dev"
  }
}

The first apply attempt errored β€” see Challenges for the IAM and idempotency fixes that followed. The HCP run history below shows all four real runs against this workspace, including the one that errored:

HCP Terraform run history for week-09-dev showing four runs including one errored and three applied
HCP Terraform run history: four real runs against week-09-dev β€” one errored (IAM), three applied cleanly

Step 5 β€” Verify the Platform Baseline

Confirmed the shared ECS cluster came up with both capacity providers (FARGATE and FARGATE_SPOT) and Container Insights enabled, before submitting any tickets against it:

ECS cluster overview in the AWS console showing the shared Fargate cluster
Shared ECS cluster: FARGATE + FARGATE_SPOT capacity providers, Container Insights enabled, zero services running before the first ticket

Step 6 β€” Submit a Real ServiceNow Ticket

With the platform baseline live, submitted an actual self-service catalog request β€” not a synthetic curl test β€” requesting a demo-nginx service:

ServiceNow self-service catalog item order form for the Fargate service request
ServiceNow catalog item: service_name, image_uri, container_port, cpu, memory, and desired_count as ticket fields

Step 7 β€” Watch the Pipeline Execute

The webhook fired, Step Functions started, and the execution graph for this specific ticket (RITM0010026) showed both states completing green β€” ProvisionService then NotifySuccess:

Step Functions execution graph for RITM0010026 showing ProvisionService and NotifySuccess both completed successfully
Step Functions execution for RITM0010026: ProvisionService β†’ NotifySuccess, both green β€” this is the per-ticket audit trail Step Functions gives you for free

Step 8 β€” Confirm the ECS Service Is Healthy

Checked the ECS service's target group health directly rather than trusting the pipeline's own success signal:

ECS service health page showing 1 healthy target and 0 unhealthy targets
ECS service health: 1 healthy target, 0 unhealthy β€” the task passed the path-aware health check after the fix described in Challenges

Step 9 β€” Hit the Live URL

Requested the service through the shared ALB, the same path an end user would take:

bash
curl -i http://$ALB_DNS_NAME/demo-nginx/
Live nginx welcome page response served through the shared ALB URL
Real HTTP response from the live ALB URL β€” the nginx welcome page, served by the Fargate task this ticket created

Step 10 β€” Confirm the Ticket Closed Itself

Back in ServiceNow, RITM0010026 had already closed β€” status_notifier wrote the real work note the moment the ECS API calls succeeded, without anyone touching the ticket:

Closed ServiceNow ticket RITM0010026 showing the Provisioning SUCCEEDED work note
RITM0010026 closed automatically with a real "Provisioning SUCCEEDED" work note β€” full loop confirmed end to end, ticket to running container back to ticket

Challenges β€” What Actually Went Wrong

1
Missing iam:CreateServiceLinkedRole for Application Auto Scaling

The first apply's test ticket failed at the auto-scaling step with a ValidationException on RegisterScalableTarget. Application Auto Scaling needs its own service-linked role (AWSServiceRoleForApplicationAutoScaling_ECSService) to exist before it can manage a scalable target, and nothing in the stack had ever created one in this account for ECS.

Fix

Added an explicit iam:CreateServiceLinkedRole statement to fargate_provisioner's IAM role, scoped with a Condition restricting it to iam:AWSServiceName = ecs.application-autoscaling.amazonaws.com β€” the Lambda can create exactly that one service-linked role and nothing else.

Why this matters: Service-linked roles are a one-time-per-account prerequisite that's easy to miss in local testing (where a developer's own IAM user may already have created it once, incidentally, from something else) but breaks cleanly for the first real ticket in a fresh account.

2
fargate_provisioner Wasn't Idempotent Under Step Functions Retry

A transient failure triggered Step Functions' Retry block, which re-invoked fargate_provisioner from the top. The retry hit InvalidParameterException: Creation of service was not idempotent β€” the first invocation had already created the ECS service before failing on a later step, and the retry tried to create it again.

Fix

Rewrote create_target_group, create_path_rule, and create_ecs_service to check for an existing resource first β€” describe_target_groups, matching listener rules by path-pattern condition, and describe_services (calling update_service instead of create_service if an ACTIVE service already exists) β€” before attempting to create anything.

Key learning: Any Lambda sitting behind a Step Functions Retry block has to assume it might run twice for the same ticket, including partially. Idempotency isn't optional once retries are in the design.

3
No NAT Gateway Rules Out public.ecr.aws Images

A test ticket pointed at a public.ecr.aws image. The ECS task failed to start: CannotPullContainerError: ... dial tcp ... i/o timeout.

Root cause: Fargate tasks run in private subnets with zero internet route. They reach private ECR and CloudWatch Logs through VPC interface endpoints, plus an S3 gateway endpoint since ECR image layers live in S3. But AWS's Public ECR Gallery (public.ecr.aws) is a separate service with no VPC endpoint of its own β€” pulling from it needs real internet access this design deliberately doesn't have.

Fix

Pushed the demo image to this platform's own private ECR repo instead (the same repo fargate_provisioner creates per service) and pointed the ticket's image_uri there.

Why this matters: Every ticket's image_uri must point at an image already in private ECR β€” this constraint is a direct, load-bearing consequence of the no-NAT-Gateway design decision, not an incidental bug.

4
ALB Doesn't Rewrite Paths β€” Health Check Killed a Genuinely Healthy Task

A path-aware demo app was genuinely running and reachable (confirmed via its Server: nginx response header) but ECS kept killing the task as unhealthy.

Root cause: Unlike NGINX Ingress, Traefik, or API Gateway, an ALB has no "strip prefix" listener rule action β€” the full path (/demo-nginx/) is forwarded to the container unmodified. The target group's health check was still hardcoded to /, which the app never served once it was made path-aware.

Fix

Set create_target_group's HealthCheckPath to f"/{service_name}/" instead of a hardcoded /, matching the same prefix the app actually serves.

Why this matters: Every app deployed behind this platform has to be written to serve its content under its own /<service_name>/ prefix, not assume it owns / β€” this is a real constraint on what "just deploy any container" means here, not a footnote.

5
ServiceNow Ticket Close Failed: sys_id vs. Display Number

status_notifier's PATCH request to close the ticket returned HTTP 404 even though the ticket clearly existed.

Root cause: The ServiceNow Table API's record endpoint (/api/now/table/sc_req_item/{sys_id}) needs the internal sys_id, not the human-readable ticket number (RITM0010026) shown in the UI. The pipeline was passing the display number.

Fix

Threaded a separate ticket_sys_id field through the whole pipeline (webhook payload β†’ Step Functions state β†’ status_notifier), alongside the existing ticket_id used for display and logging.

Why this matters: This was Week 4's own already-documented lesson, not cross-referenced when this pipeline was first built β€” a reminder that "we solved this before" only helps if the earlier lesson gets checked against the new build, not just written down once.

6
Scripting the ServiceNow Side Hit Two Undocumented API Quirks

Following this project's "script it, don't click it" philosophy, the ServiceNow catalog item, Business Rule, and Outbound REST Message were provisioned via the Table API rather than by hand in the UI. Two field-level bugs only surfaced from real API responses, not from documentation.

Bug A: The generated Business Rule script called HexUtil.convertByteArrayToHex() for HMAC signature verification β€” a pattern documented in ServiceNow community forum posts as the standard approach. The real instance's syslog returned "HexUtil" is not defined.

Bug B: Creating a sys_rest_message_fn record (an Outbound REST Message method) via the Table API used a field named name. The API accepted the write silently β€” no error β€” but the method never worked, because the actual field is function_name. Found by inspecting the raw record's JSON directly, since the write gave no error to go on.

Fix

Replaced the HexUtil call with a plain-JavaScript byte-to-hex loop. Corrected the field name to function_name. Both fixes were also retroactively applied to Weeks 2 and 3's documentation, since they used the same Business Rule pattern originally.

Key learning: Community forum posts aren't a reliable verification source for ServiceNow's scripting APIs the way official AWS documentation is for AWS β€” and a silently-accepted wrong field name is worse than an outright error, because nothing tells you it failed until you go looking.

Security β€” Controls at Every Layer

ControlImplementationWhy It Matters
Webhook authenticationHMAC-SHA256 signature validation on the ServiceNow webhook (same pattern as prior weeks)Only requests signed with the shared secret reach webhook_receiver
No internet route for tasksECS tasks run in private subnets, reachable only from the shared ALB's security groupA compromised container can't reach the internet or be reached directly
No default task permissionsShared execution role (pull image, write logs) only β€” no task role by defaultSelf-service tasks get zero AWS permissions unless a future ticket field explicitly adds one
Least-privilege provisioning rolefargate_provisioner's IAM role is scoped to this project's resource-name prefix everywhere the underlying AWS API supports resource-level ARNsThe Lambda that creates infrastructure can't touch resources outside this platform's naming convention
Image integrityECR scan-on-push and immutable tags on every per-service repositoryVulnerabilities are flagged at push time; a tag can't be silently swapped after deploy
Secrets hygieneterraform.tfvars and .servicenow.env gitignored, never committedWebhook secret and ServiceNow credentials never land in git history

Cost

All prices us-east-1, verified July 2026. Check aws.amazon.com/fargate/pricing and aws.amazon.com/elasticloadbalancing/pricing for current rates.

ResourceRateNotes
Fargate compute (0.5 vCPU / 1GB, per task, 24/7)~$17.87/month per running taskScales with the number of live self-service tasks
Shared ALB~$24/monthFixed regardless of how many services sit behind it
VPC Interface Endpoints (ECR api + dkr + Logs)~$0.01/hour each~$21.90/month combined if left running β€” the tradeoff for skipping a NAT Gateway
ECR storage$0.10/GB-monthPer private repo, one repo per service
Step Functions / Lambda / API GatewayWell within free tierLab-scale ticket volume
Pipeline infra + test services destroyed$0Β 
The Shared ALB Is the Cost Lever

A dedicated ALB per service would add ~$24/month for every service on the platform. With ten services behind the shared ALB, that's the difference between $24/month total and $240/month β€” the entire reason this design routes on path instead of standing up one load balancer per ticket.

Cleanup

Self-Service Resources Aren't in Terraform State

Every ticket's ECR repo, task definition, ECS service, target group, and listener rule were created imperatively via boto3 β€” they are not tracked by Terraform and will not be removed by a destroy run.

Delete any test services first, or the shared ALB/cluster/VPC destroy will fail on dependent target groups and ENIs still attached to a running service:

bash
# Walks through the pre-destroy checklist for imperatively-created resources
bash week-09-ecs-fargate-self-service/scripts/cleanup.sh

# Then trigger destroy from HCP Terraform UI: week-09-dev β†’ Actions β†’ Start destroy plan
# (local terraform destroy fails on a VCS-connected workspace, same as deploy)

Key Takeaways

Idempotency isn't optional behind a Retry block

Any Lambda that a Step Functions state retries has to assume it might run twice for the same input, including partially. Check-before-create on every resource-creating call, not just error handling around it.

No NAT Gateway is a design constraint, not just a cost saving

Skipping the NAT Gateway in favor of VPC endpoints saves real money, but it also means every image this platform runs has to already live in private ECR β€” public registries are unreachable by design, confirmed by a real timeout, not assumed.

ALB path-based routing pushes a real constraint onto every app

No path-stripping means every app behind this platform must be written to serve its own /<service_name>/ prefix β€” and the health check path must match exactly, or a genuinely healthy task gets killed.

A documented lesson only helps if it gets checked

The sys_id-vs-display-number bug was already solved and written down in Week 4. It resurfaced here anyway because the new build wasn't checked against the old lesson before shipping.

Service-linked roles are an easy miss in a fresh account

Application Auto Scaling needs its own one-time service-linked role per account. It's invisible in local testing if that role happens to already exist from something else β€” it only breaks cleanly on a truly fresh account's first real ticket.

A silent wrong field name is worse than an error

ServiceNow's Table API accepted a wrong field name (name instead of function_name) without complaint. Nothing failed loudly β€” the record just didn't work, and the only way to catch it was inspecting the raw JSON directly.

Comments

How was your experience?
Your feedback helps improve this site.
PoorExcellent