π In This Post
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.
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.
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.
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.
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).
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
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.
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.
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):
ecr:CreateRepositoryβ per-service image isolation, scan-on-push, immutable tagslogs:CreateLogGroupβ/ecs/<project>-<service>ecs:RegisterTaskDefinitionβawsvpcnetwork mode,FARGATE, shared execution roleelbv2:CreateTargetGroupβtarget_type=ip, health check path set to/<service>/elbv2:CreateRuleβ path-pattern/<service>/*on the shared ALB's existing listenerecs:CreateServiceβ private subnets, ALB-only security group, no public IPapplication-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:
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:
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:
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:
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 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:
Step 9 β Hit the Live URL
Requested the service through the shared ALB, the same path an end user would take:
curl -i http://$ALB_DNS_NAME/demo-nginx/
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:
Challenges β What Actually Went Wrong
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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
| Control | Implementation | Why It Matters |
|---|---|---|
| Webhook authentication | HMAC-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 tasks | ECS tasks run in private subnets, reachable only from the shared ALB's security group | A compromised container can't reach the internet or be reached directly |
| No default task permissions | Shared execution role (pull image, write logs) only β no task role by default | Self-service tasks get zero AWS permissions unless a future ticket field explicitly adds one |
| Least-privilege provisioning role | fargate_provisioner's IAM role is scoped to this project's resource-name prefix everywhere the underlying AWS API supports resource-level ARNs | The Lambda that creates infrastructure can't touch resources outside this platform's naming convention |
| Image integrity | ECR scan-on-push and immutable tags on every per-service repository | Vulnerabilities are flagged at push time; a tag can't be silently swapped after deploy |
| Secrets hygiene | terraform.tfvars and .servicenow.env gitignored, never committed | Webhook 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.
| Resource | Rate | Notes |
|---|---|---|
| Fargate compute (0.5 vCPU / 1GB, per task, 24/7) | ~$17.87/month per running task | Scales with the number of live self-service tasks |
| Shared ALB | ~$24/month | Fixed 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-month | Per private repo, one repo per service |
| Step Functions / Lambda / API Gateway | Well within free tier | Lab-scale ticket volume |
| Pipeline infra + test services destroyed | $0 | Β |
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
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:
# 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)
References
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