<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Matt</title>
    <description>The latest articles on DEV Community by Matt (@dspv).</description>
    <link>https://dev.to/dspv</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3782713%2Fa6b49eb1-89f2-4142-97df-c0dc96c281e1.png</url>
      <title>DEV Community: Matt</title>
      <link>https://dev.to/dspv</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/dspv"/>
    <language>en</language>
    <item>
      <title>Why Headroom Breaks on AWS Bedrock — and How to Fix All Four Failures</title>
      <dc:creator>Matt</dc:creator>
      <pubDate>Tue, 07 Jul 2026 11:15:00 +0000</pubDate>
      <link>https://dev.to/dspv/why-headroom-breaks-on-aws-bedrock-and-how-to-fix-all-four-failures-3ij3</link>
      <guid>https://dev.to/dspv/why-headroom-breaks-on-aws-bedrock-and-how-to-fix-all-four-failures-3ij3</guid>
      <description>&lt;p&gt;You're on AWS Bedrock because compliance won't let your data leave AWS. Your agent workflow multiplies tokens, the bill climbs, and you found &lt;a href="https://github.com/headroomlabs-ai/headroom" rel="noopener noreferrer"&gt;Headroom&lt;/a&gt; — the open-source compression proxy that promises 60–95% fewer tokens (their benchmark). You point it at Bedrock, and it silently does almost nothing.&lt;/p&gt;

&lt;p&gt;I wrote the full guide with the exact error strings, the fixes, and measured numbers. Here's the TL;DR.&lt;/p&gt;




&lt;h2&gt;
  
  
  The four ways it silently breaks on Bedrock
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;404 on native routes&lt;/strong&gt; — the proxy 404s &lt;code&gt;/model/{id}/invoke&lt;/code&gt; and &lt;code&gt;/inference-profiles&lt;/code&gt;, so Claude Code's auto-mode classifier fails closed (&lt;a href="https://github.com/headroomlabs-ai/headroom/issues/1589" rel="noopener noreferrer"&gt;issue #1589&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;InvalidSignatureException&lt;/code&gt;&lt;/strong&gt; — SigV4 signs a hash of the request body; compression rewrites the body, so the signature no longer matches (&lt;a href="https://github.com/headroomlabs-ai/headroom/pull/1220" rel="noopener noreferrer"&gt;PR #1220&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dead prompt cache&lt;/strong&gt; — in &lt;code&gt;--mode cache&lt;/code&gt; it prints &lt;code&gt;prefix_frozen&lt;/code&gt; but never injects a &lt;code&gt;cachePoint&lt;/code&gt;, so Bedrock reports &lt;code&gt;savings_pct: 0.0&lt;/code&gt; (&lt;a href="https://github.com/headroomlabs-ai/headroom/issues/1345" rel="noopener noreferrer"&gt;issue #1345&lt;/a&gt;). And Bedrock caps you at 4 cache markers: &lt;code&gt;A maximum of 4 blocks with cache_control may be provided. Found 5.&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;ModuleNotFoundError: botocore&lt;/code&gt;&lt;/strong&gt; — crashes on temporary STS credentials because the image doesn't bundle botocore (&lt;a href="https://github.com/headroomlabs-ai/headroom/issues/1551" rel="noopener noreferrer"&gt;issue #1551&lt;/a&gt;).&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  The savings math (a formula, not a promise)
&lt;/h2&gt;

&lt;p&gt;Bedrock bills &lt;code&gt;input + 1.25×write + 0.1×read&lt;/code&gt;. Caching attacks the read term (0.1× — a 10× discount on the repeated prefix); compression attacks the input term. They stack.&lt;/p&gt;

&lt;p&gt;Measured on a 6-turn agentic session (us-east-1, July 2026, Claude Sonnet 4.5):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;No optimization: &lt;strong&gt;619,865&lt;/strong&gt; token-equivalents&lt;/li&gt;
&lt;li&gt;Cache only (fixed): &lt;strong&gt;257,250&lt;/strong&gt; — &lt;strong&gt;−58.5%&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Cache + compression: &lt;strong&gt;183,672&lt;/strong&gt; — &lt;strong&gt;−70.4%&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One trap: compact JSON (the default from &lt;code&gt;json.dumps&lt;/code&gt; / &lt;code&gt;JSON.stringify&lt;/code&gt; / &lt;code&gt;boto3&lt;/code&gt;) compresses &lt;strong&gt;0%&lt;/strong&gt;. The same data with whitespace compresses &lt;strong&gt;43%&lt;/strong&gt; — token count tracks whitespace.&lt;/p&gt;




&lt;p&gt;The full guide covers the step-by-step fix, how to verify the cache actually hits (&lt;code&gt;cacheReadInputTokens &amp;gt; 0&lt;/code&gt; on the second request), and the whole error-string reference.&lt;/p&gt;

&lt;p&gt;👉 &lt;strong&gt;&lt;a href="https://fortem.dev/headroom-bedrock" rel="noopener noreferrer"&gt;Read the complete guide →&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Caprock is a managed distribution of Headroom that ships all four Bedrock fixes and runs entirely inside your VPC, so nothing leaves your AWS account — &lt;a href="https://caprock.dev" rel="noopener noreferrer"&gt;caprock.dev&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>aws</category>
      <category>bedrock</category>
      <category>llm</category>
      <category>claude</category>
    </item>
    <item>
      <title>How to Put an ALB in Front of ECS Fargate</title>
      <dc:creator>Matt</dc:creator>
      <pubDate>Tue, 07 Jul 2026 11:14:14 +0000</pubDate>
      <link>https://dev.to/dspv/how-to-put-an-alb-in-front-of-ecs-fargate-3fkf</link>
      <guid>https://dev.to/dspv/how-to-put-an-alb-in-front-of-ecs-fargate-3fkf</guid>
      <description>&lt;h1&gt;
  
  
  How to Put an ALB in Front of ECS Fargate
&lt;/h1&gt;

&lt;blockquote&gt;
&lt;p&gt;Originally published at &lt;a href="https://fortem.dev/blog/ecs-load-balancer-guide" rel="noopener noreferrer"&gt;https://fortem.dev/blog/ecs-load-balancer-guide&lt;/a&gt;&lt;br&gt;
Attach an Application Load Balancer to ECS Fargate: the awsvpc ip target-type rule, health checks that pass, the Terraform to wire it, and shared-vs-per-service ALB cost at fleet scale.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;Guide&lt;/p&gt;

&lt;p&gt;The task is RUNNING, but the target group says unhealthy and the URL 503s. Attaching an Application Load Balancer to a Fargate service has three real traps: the awsvpc "ip target type" rule, a health check that never passes, and — at 10+ services — one ALB per service quietly costing more than the compute. Here's the Terraform-native version, with the numbers to debug the 503 and to decide whether to share one ALB.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;  Fargate uses the awsvpc network mode, so the target group MUST use target type "ip", not "instance" — tasks are ENIs, not EC2 instances. This is the #1 first-time error.&lt;/li&gt;
&lt;li&gt;  ECS registers and deregisters tasks with the target group automatically through its service-linked role. You attach the target group to the service; you don't script registration.&lt;/li&gt;
&lt;li&gt;  An unhealthy target is 90% of the time a security-group gap or a health-check path that doesn't return 200. Fix the SG from the ALB to the task port first.&lt;/li&gt;
&lt;li&gt;  One ALB per service is simplest and costs ~$16/month each. At 20 services that's ~$330/month — a shared ALB with host/path routing collapses it to one base charge.&lt;/li&gt;
&lt;li&gt;  Tune deregistration delay (300s→5s) and stop timeout (30s→2s), and trap SIGTERM, for fast zero-downtime deploys.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Ready to use — ALB + target group + listener for a Fargate service&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight terraform"&gt;&lt;code&gt;&lt;span class="c1"&gt;# The load balancer and its security group&lt;/span&gt;
&lt;span class="k"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_lb"&lt;/span&gt; &lt;span class="s2"&gt;"app"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;               &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"app-alb"&lt;/span&gt;
  &lt;span class="nx"&gt;load_balancer_type&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"application"&lt;/span&gt;
  &lt;span class="nx"&gt;subnets&lt;/span&gt;            &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kd"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;public_subnet_ids&lt;/span&gt;
  &lt;span class="nx"&gt;security_groups&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;aws_security_group&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;alb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_security_group"&lt;/span&gt; &lt;span class="s2"&gt;"alb"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"app-alb-sg"&lt;/span&gt;
  &lt;span class="nx"&gt;vpc_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kd"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;vpc_id&lt;/span&gt;
  &lt;span class="nx"&gt;ingress&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;from_port&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;443&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;to_port&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;443&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;protocol&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"tcp"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;cidr_blocks&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"0.0.0.0/0"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nx"&gt;egress&lt;/span&gt;  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;from_port&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="nx"&gt;to_port&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="nx"&gt;protocol&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"-1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="nx"&gt;cidr_blocks&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"0.0.0.0/0"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;# Target group — MUST be target_type = "ip" for Fargate (awsvpc)&lt;/span&gt;
&lt;span class="k"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_lb_target_group"&lt;/span&gt; &lt;span class="s2"&gt;"app"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"app-tg"&lt;/span&gt;
  &lt;span class="nx"&gt;port&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;8080&lt;/span&gt;
  &lt;span class="nx"&gt;protocol&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"HTTP"&lt;/span&gt;
  &lt;span class="nx"&gt;vpc_id&lt;/span&gt;      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kd"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;vpc_id&lt;/span&gt;
  &lt;span class="nx"&gt;target_type&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"ip"&lt;/span&gt;

  &lt;span class="nx"&gt;health_check&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;path&lt;/span&gt;                &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"/health"&lt;/span&gt;
    &lt;span class="nx"&gt;matcher&lt;/span&gt;             &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"200"&lt;/span&gt;
    &lt;span class="nx"&gt;interval&lt;/span&gt;            &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;15&lt;/span&gt;
    &lt;span class="nx"&gt;healthy_threshold&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;
    &lt;span class="nx"&gt;unhealthy_threshold&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;# Drain fast for short-lived requests (default is 300s)&lt;/span&gt;
  &lt;span class="nx"&gt;deregistration_delay&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_lb_listener"&lt;/span&gt; &lt;span class="s2"&gt;"https"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;load_balancer_arn&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_lb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;arn&lt;/span&gt;
  &lt;span class="nx"&gt;port&lt;/span&gt;              &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;443&lt;/span&gt;
  &lt;span class="nx"&gt;protocol&lt;/span&gt;          &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"HTTPS"&lt;/span&gt;
  &lt;span class="nx"&gt;certificate_arn&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kd"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;acm_certificate_arn&lt;/span&gt;
  &lt;span class="nx"&gt;default_action&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"forward"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;target_group_arn&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_lb_target_group&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;arn&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;# The task's SG must allow the ALB's SG on the container port&lt;/span&gt;
&lt;span class="k"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_security_group_rule"&lt;/span&gt; &lt;span class="s2"&gt;"alb_to_task"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;type&lt;/span&gt;                     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"ingress"&lt;/span&gt;
  &lt;span class="nx"&gt;from_port&lt;/span&gt;                &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;8080&lt;/span&gt;
  &lt;span class="nx"&gt;to_port&lt;/span&gt;                  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;8080&lt;/span&gt;
  &lt;span class="nx"&gt;protocol&lt;/span&gt;                 &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"tcp"&lt;/span&gt;
  &lt;span class="nx"&gt;security_group_id&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_security_group&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;task&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;source_security_group_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_security_group&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;alb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;# Attach the target group to the service — ECS registers tasks for you&lt;/span&gt;
&lt;span class="k"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_ecs_service"&lt;/span&gt; &lt;span class="s2"&gt;"app"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;            &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"app"&lt;/span&gt;
  &lt;span class="nx"&gt;cluster&lt;/span&gt;         &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kd"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cluster_arn&lt;/span&gt;
  &lt;span class="nx"&gt;task_definition&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_ecs_task_definition&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;arn&lt;/span&gt;
  &lt;span class="nx"&gt;desired_count&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;
  &lt;span class="nx"&gt;launch_type&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"FARGATE"&lt;/span&gt;

  &lt;span class="nx"&gt;health_check_grace_period_seconds&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;

  &lt;span class="nx"&gt;load_balancer&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;target_group_arn&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_lb_target_group&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;arn&lt;/span&gt;
    &lt;span class="nx"&gt;container_name&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"app"&lt;/span&gt;
    &lt;span class="nx"&gt;container_port&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;8080&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;network_configuration&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;subnets&lt;/span&gt;         &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kd"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;private_subnet_ids&lt;/span&gt;
    &lt;span class="nx"&gt;security_groups&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;aws_security_group&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;task&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The two lines that break most first attempts: &lt;code&gt;target_type = "ip"&lt;/code&gt; and the &lt;code&gt;alb_to_task&lt;/code&gt; security-group rule.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Fargate changes how you attach a load balancer
&lt;/h2&gt;

&lt;p&gt;Fargate tasks use the awsvpc network mode, so each task is an elastic network interface with its own IP — not an EC2 instance. That's why the target group must use target type ip.&lt;/p&gt;

&lt;p&gt;On EC2-backed ECS, a task maps to a host and a port, so the classic target type of &lt;code&gt;instance&lt;/code&gt; works — the load balancer routes to an instance ID and a dynamically-mapped port. Fargate has no host you own. Every task gets its own ENI and private IP, and the load balancer has to route to that IP directly. Register a Fargate service against an &lt;code&gt;instance&lt;/code&gt; target group and nothing registers at all — no error you'd expect, just zero targets and a 503.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"For services with tasks using the awsvpc network mode, when you create a target group for your service, you must choose ip as the target type, not instance. This is because tasks that use the awsvpc network mode are associated with an elastic network interface, not an Amazon EC2 instance."&lt;/p&gt;

&lt;p&gt;— &lt;a href="https://docs.aws.amazon.com/AmazonECS/latest/developerguide/alb.html" rel="noopener noreferrer"&gt;AWS ECS documentation&lt;/a&gt;, verified July 2026&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;One upside of the &lt;code&gt;ip&lt;/code&gt; target type: it supports cross-VPC connectivity, where the &lt;code&gt;instance&lt;/code&gt; type requires the load balancer and tasks in the same VPC.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Terraform to wire it up (target type ip)
&lt;/h2&gt;

&lt;p&gt;One ALB, one target group with target_type = ip, one listener, and a security-group rule from the ALB to the task port. ECS registers tasks automatically through its service-linked role.&lt;/p&gt;

&lt;p&gt;The Ready-to-use block above is the whole thing. The part people miss isn't the ALB — it's the wiring between it and the task. Two security groups: one on the ALB that lets the public in on 443, and a rule on the &lt;em&gt;task's&lt;/em&gt; security group that lets the ALB's security group reach the container port. Reference the ALB's SG as the source, not a CIDR — the task should only accept traffic from the load balancer, never the open internet.&lt;/p&gt;

&lt;p&gt;You never write registration logic. Amazon ECS holds a service-linked IAM role whose whole job is to register a task with the target group when it starts and deregister it when it stops. You declare the &lt;code&gt;load_balancer&lt;/code&gt; block on the service, and ECS keeps the target group in sync with the running tasks as they cycle through deploys and scaling.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why your target is unhealthy (the 503 debug)
&lt;/h2&gt;

&lt;p&gt;An unhealthy target is almost always one of three things: the security group blocks the ALB on the task port, the health-check path returns non-200, or the app starts slower than the grace period.&lt;/p&gt;

&lt;p&gt;AWS maintains an entire knowledge-center article on this one failure, which tells you how common it is. Work the causes in order — security group first, because it's both the most frequent and the easiest to overlook. If the task's SG doesn't allow the ALB's SG on the container port, the health check can't even reach the app, and the target sits unhealthy from the moment it launches.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Root cause&lt;/th&gt;
&lt;th&gt;Symptom&lt;/th&gt;
&lt;th&gt;Fix&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Security group gap&lt;/td&gt;
&lt;td&gt;Target stuck unhealthy from launch&lt;/td&gt;
&lt;td&gt;Allow the ALB's SG inbound on the container port&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Health path returns non-200&lt;/td&gt;
&lt;td&gt;Target flaps or never passes&lt;/td&gt;
&lt;td&gt;Point the check at a path that returns 200 (e.g. /health)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Grace period too short&lt;/td&gt;
&lt;td&gt;Task killed and restarted in a loop&lt;/td&gt;
&lt;td&gt;Set healthCheckGracePeriodSeconds above real startup time&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Wrong target type&lt;/td&gt;
&lt;td&gt;No targets register at all&lt;/td&gt;
&lt;td&gt;Use target_type = "ip" for awsvpc / Fargate&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Check port mismatch&lt;/td&gt;
&lt;td&gt;Unhealthy despite app being up&lt;/td&gt;
&lt;td&gt;Match the target-group port to the container port&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A recurring "unhealthy" that clears and comes back is different from one that never passes — it's usually load or a slow dependency, and that's where metrics earn their keep. Pairing target health with task-level signals is exactly what &lt;a href="https://fortem.dev/blog/ecs-fargate-monitoring/" rel="noopener noreferrer"&gt;monitoring ECS Fargate across a fleet&lt;/a&gt; is for: a flapping target with a memory alarm behind it tells you the health check isn't the bug, the app is.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;KEY INSIGHT:&lt;/strong&gt; A newly-registered target only needs one passing health check to go healthy — the healthy-threshold count only applies when a target recovers from unhealthy back to healthy. So a target that never goes healthy on first launch is almost never a threshold problem; it's a reachability or path problem. Chase the security group and the path, not the thresholds.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  ALB health checks vs container health checks
&lt;/h2&gt;

&lt;p&gt;Different systems. The ALB health check decides if a target gets traffic; the container health check decides if ECS restarts the task. Run both — keep the grace period longer than startup.&lt;/p&gt;

&lt;p&gt;This is a genuinely confusing overlap — practitioners ask on AWS re:Post whether they should use one, the other, or both. The answer is both, because they answer different questions. The target group's health check governs traffic: fail it and the ALB stops routing to that task. The container health check in the task definition governs lifecycle: fail it and ECS kills and replaces the container. A task can be healthy to ECS but pulled from the ALB, or the reverse.&lt;/p&gt;

&lt;p&gt;The one setting that ties them together is &lt;code&gt;health_check_grace_period_seconds&lt;/code&gt; on the service. It tells ECS to ignore failing ALB health checks for the first N seconds after a task starts, so a slow-booting app isn't killed before it's ready. Set it above your real cold-start time — a JVM service that takes 45 seconds to warm up needs a grace period well past that, or every deploy turns into a restart loop.&lt;/p&gt;

&lt;h2&gt;
  
  
  One ALB per service, or a shared ALB?
&lt;/h2&gt;

&lt;p&gt;One ALB per service is simplest but costs about $16/month each. A shared ALB with host- or path-based listener rules serves many services on one base charge — the default past a handful of services.&lt;/p&gt;

&lt;p&gt;Every ALB carries an hourly base charge whether it's serving one request or a million. At roughly $16.43/month per load balancer, the per-service pattern looks free at three services and turns into a line item at twenty. A single ALB can front dozens of services through listener rules — route &lt;code&gt;api.example.com&lt;/code&gt; to one target group and &lt;code&gt;/admin&lt;/code&gt; to another — so you pay one base charge for the whole fleet.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Services&lt;/th&gt;
&lt;th&gt;One ALB each (base)&lt;/th&gt;
&lt;th&gt;One shared ALB (base)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;5 services&lt;/td&gt;
&lt;td&gt;$82/mo&lt;/td&gt;
&lt;td&gt;$16/mo&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;10 services&lt;/td&gt;
&lt;td&gt;$164/mo&lt;/td&gt;
&lt;td&gt;$16/mo&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;20 services&lt;/td&gt;
&lt;td&gt;$329/mo&lt;/td&gt;
&lt;td&gt;$16/mo&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Base ALB charge only ($0.0225/hr, us-east-1, verified July 2026). LCU charges ($0.008/LCU-hr) add on top for both and scale with traffic.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;KEY INSIGHT:&lt;/strong&gt; The shared ALB isn't free savings — it trades dollars for coupling. One listener config now affects every service behind it, and a bad rule change can 503 the whole fleet instead of one app. The rule of thumb: per-service ALBs while you have a handful and want blast-radius isolation; a shared ALB once the base charges outweigh the isolation, with each service still on its own unique target group.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Zero-downtime deploys: deregistration delay and SIGTERM
&lt;/h2&gt;

&lt;p&gt;The default 300-second deregistration delay makes every deploy drain slowly. For sub-second services, drop it to 5s, set ECS_CONTAINER_STOP_TIMEOUT to 2s, and trap SIGTERM to drain in-flight requests.&lt;/p&gt;

&lt;p&gt;When ECS replaces a task, the ALB stops sending it new requests and waits out the deregistration delay before cutting existing connections. The default is 300 seconds — five minutes of a draining task lingering per deploy. AWS's own guidance: if your responses finish in under a second, set &lt;code&gt;deregistration_delay.timeout_seconds&lt;/code&gt; to 5. Don't do this for long-lived requests like slow uploads or streaming — those need the room.&lt;/p&gt;

&lt;p&gt;The other half is graceful shutdown. ECS sends SIGTERM, waits &lt;code&gt;ECS_CONTAINER_STOP_TIMEOUT&lt;/code&gt; seconds (default 30), then SIGKILLs. If your app ignores SIGTERM, it eats the full wait on every deploy. Trap SIGTERM to stop accepting new connections and finish in-flight ones, and a task that drains in 500ms exits cleanly instead of waiting out the timeout.&lt;/p&gt;

&lt;p&gt;javascriptCopy&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Trap SIGTERM so in-flight requests finish before the task exits&lt;/span&gt;
&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;SIGTERM&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;server&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt; &lt;span class="c1"&gt;// stop new conns, drain, then exit&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The traps that bite at fleet scale
&lt;/h2&gt;

&lt;p&gt;Load balancer config can't be changed in the console after the service is created — only via CLI or CloudFormation. Use a unique target group per service; sharing one breaks deployments.&lt;/p&gt;

&lt;p&gt;Two AWS-documented rules cause most of the fleet-scale pain. First: once a service exists, you cannot change its load-balancer configuration from the console — you have to go through the CLI, CloudFormation, or the SDK, and the change forces a new deployment that re-registers every task. Discover this mid-incident and you'll waste time hunting for a console button that isn't there.&lt;/p&gt;

&lt;p&gt;Second: give every service its own target group. Sharing one target group across services "might lead to issues during service deployments," per AWS — the two services fight over the same registration set and deploys go sideways. This is the ingress side of ECS networking; for service-to-service traffic inside the fleet, the internal-ALB and DNS options are a separate decision covered in &lt;a href="https://fortem.dev/blog/ecs-service-discovery-guide/" rel="noopener noreferrer"&gt;which ECS service discovery you should use&lt;/a&gt; — Cloud Map, Service Connect, or an internal ALB for East-West traffic, versus the public ingress ALB here.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you read this, you might also want to know
&lt;/h2&gt;

&lt;p&gt;Do I need an NLB instead of an ALB for gRPC or raw TCP?&lt;/p&gt;

&lt;p&gt;For HTTP/HTTPS and gRPC, the ALB is right — it speaks layer 7 and supports gRPC target groups. For raw TCP/UDP, non-HTTP protocols, or when you need a static IP and ultra-low latency, use a Network Load Balancer. The Fargate wiring is nearly identical: target_type = ip, and ECS registers tasks the same way.&lt;/p&gt;

&lt;p&gt;How do I front multiple Fargate services on one domain?&lt;/p&gt;

&lt;p&gt;Use one ALB with path- or host-based listener rules. /api forwards to the api target group, /billing to the billing target group; or api.example.com and app.example.com split by host. Each service keeps its own unique target group — you're only sharing the load balancer and its listeners, not the target group.&lt;/p&gt;

&lt;p&gt;Can the ALB and the Fargate tasks live in different VPCs?&lt;/p&gt;

&lt;p&gt;Yes, if the target group uses target type ip (which Fargate requires anyway). The ip target type supports cross-VPC connectivity; the instance target type does not. You still need routing and security-group rules that let the ALB reach the task IPs across the VPC boundary.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frequently asked questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Why is my ECS Fargate target unhealthy in the ALB?
&lt;/h3&gt;

&lt;p&gt;Almost always one of three things: the task's security group doesn't allow inbound traffic from the load balancer's security group on the container port, the health-check path returns a non-200 status, or the application takes longer to start than the health-check grace period allows. Check the security group rule first — it's the most common cause.&lt;/p&gt;

&lt;h3&gt;
  
  
  Should I use ALB health checks, container health checks, or both?
&lt;/h3&gt;

&lt;p&gt;They do different jobs, so run both. The ALB (target group) health check decides whether a task receives traffic. The container health check in the task definition decides whether ECS restarts the container. Keep the ALB health-check grace period longer than your container's startup time so a slow boot doesn't get killed before it's ready.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why must Fargate use ip target type instead of instance?
&lt;/h3&gt;

&lt;p&gt;Fargate tasks run in the awsvpc network mode, so each task gets its own elastic network interface and IP address rather than sharing an EC2 instance. The target group must use target type "ip" to register those task IPs. "instance" target type only works for EC2-backed tasks that map to an instance ID.&lt;/p&gt;

&lt;h3&gt;
  
  
  How much does an Application Load Balancer cost?
&lt;/h3&gt;

&lt;p&gt;In us-east-1, $0.0225 per hour (about $16.43/month) plus $0.008 per LCU-hour for capacity used (verified July 2026). The hourly base charge is per ALB, so running one ALB per service at 20 services is roughly $330/month in base charges alone — a shared ALB with host/path routing collapses that to one base charge.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can one ALB serve multiple ECS services?
&lt;/h3&gt;

&lt;p&gt;Yes. A single ALB routes to many services using host-based or path-based listener rules — api.example.com to one target group, /admin to another. Give each service its own unique target group; sharing one target group across services causes problems during deployments.&lt;/p&gt;

&lt;h2&gt;
  
  
  Every ALB, target group, and service — mapped
&lt;/h2&gt;

&lt;p&gt;At 10+ environments, nobody can say which ALB fronts which service, or which target groups are draining money on dead tasks. Fortem maps the whole ECS fleet — load balancers, target groups, and the services behind them — on one screen. Book a 20-minute call and we'll walk yours.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://fortem.dev/book/" rel="noopener noreferrer"&gt;Book a 20-min call&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Worth reading&lt;/p&gt;

&lt;p&gt;&lt;a href="https://fortem.dev/blog/ecs-service-discovery-guide/" rel="noopener noreferrer"&gt;GuideECS Service Discovery: Cloud Map vs Service ConnectThe other half of ECS networking — East-West service-to-service traffic. Cloud Map, Service Connect, or an internal ALB, with a decision table.&lt;/a&gt;&lt;a href="https://fortem.dev/aws-ecs-fargate/" rel="noopener noreferrer"&gt;LandingAWS ECS Fargate: What It Is, How It Works, What It CostsThe head-term reference for ECS Fargate — where networking, the awsvpc mode, and load balancing sit in the bigger picture.&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Map every ALB and target group across your fleet:&lt;/strong&gt; &lt;a href="https://fortem.dev/book" rel="noopener noreferrer"&gt;fortem.dev/book&lt;/a&gt;&lt;/p&gt;

</description>
      <category>aws</category>
      <category>ecs</category>
      <category>fargate</category>
      <category>devops</category>
    </item>
    <item>
      <title>How Do You Monitor ECS Fargate Across 10+ Environments?</title>
      <dc:creator>Matt</dc:creator>
      <pubDate>Tue, 07 Jul 2026 11:14:04 +0000</pubDate>
      <link>https://dev.to/dspv/how-do-you-monitor-ecs-fargate-across-10-environments-58c4</link>
      <guid>https://dev.to/dspv/how-do-you-monitor-ecs-fargate-across-10-environments-58c4</guid>
      <description>&lt;h1&gt;
  
  
  How to Monitor ECS Fargate at Scale
&lt;/h1&gt;

&lt;blockquote&gt;
&lt;p&gt;Originally published at &lt;a href="https://fortem.dev/blog/ecs-fargate-monitoring" rel="noopener noreferrer"&gt;https://fortem.dev/blog/ecs-fargate-monitoring&lt;/a&gt;&lt;br&gt;
Monitor ECS Fargate across 10+ environments: which metrics to alarm on, what Container Insights actually costs per metric, and a Terraform for_each fleet alarm pattern.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;Guide&lt;/p&gt;

&lt;p&gt;A CPU alarm on one service is a five-minute job. The same three alarms on thirty services in five accounts — consistently, and without a Container Insights bill that quietly grows into four figures — is the actual work. This guide covers which metrics are worth paying for, the exact Container Insights cost math at fleet scale, and a Terraform pattern that alarms your whole fleet from one module.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;  Fargate needs no monitoring agent. Container Insights collects CPU, memory, network, and ephemeral-storage metrics with no sidecar — the catch is the per-metric bill.&lt;/li&gt;
&lt;li&gt;  Enhanced observability bills $0.07/metric/month across cluster, service, task-def, task, and container. Enabled everywhere on a 30-service fleet, that runs into four figures a month.&lt;/li&gt;
&lt;li&gt;  Turn enhanced observability ON in prod, OFF in dev. Dev doesn't need container-level metrics — that one split roughly halves the bill.&lt;/li&gt;
&lt;li&gt;  Alarm as code with a Terraform for_each: one module, N services, zero copy-paste. Hand-writing three alarms per service is 90 blocks at 30 services.&lt;/li&gt;
&lt;li&gt;  The metric that catches most incidents isn't CPU — it's RunningTaskCount below desired.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Ready to use — fleet alarms from one Terraform module&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight terraform"&gt;&lt;code&gt;&lt;span class="c1"&gt;# One module, every service. Add a service to the map, get its 3 alarms.&lt;/span&gt;
&lt;span class="k"&gt;variable&lt;/span&gt; &lt;span class="s2"&gt;"services"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;object&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="nx"&gt;cluster&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;string&lt;/span&gt;
    &lt;span class="nx"&gt;desired_count&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;number&lt;/span&gt;
  &lt;span class="p"&gt;}))&lt;/span&gt;
  &lt;span class="c1"&gt;# Example:&lt;/span&gt;
  &lt;span class="c1"&gt;# {&lt;/span&gt;
  &lt;span class="c1"&gt;#   api      = { cluster = "prod", desired_count = 4 }&lt;/span&gt;
  &lt;span class="c1"&gt;#   worker   = { cluster = "prod", desired_count = 2 }&lt;/span&gt;
  &lt;span class="c1"&gt;#   payments = { cluster = "prod", desired_count = 3 }&lt;/span&gt;
  &lt;span class="c1"&gt;# }&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_sns_topic"&lt;/span&gt; &lt;span class="s2"&gt;"alerts"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"ecs-fleet-alerts"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;# CPU &amp;gt; 90% for 5 min&lt;/span&gt;
&lt;span class="k"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_cloudwatch_metric_alarm"&lt;/span&gt; &lt;span class="s2"&gt;"cpu"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;for_each&lt;/span&gt;            &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kd"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;services&lt;/span&gt;
  &lt;span class="nx"&gt;alarm_name&lt;/span&gt;          &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"ecs-&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;each&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;-cpu-high"&lt;/span&gt;
  &lt;span class="nx"&gt;namespace&lt;/span&gt;           &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"AWS/ECS"&lt;/span&gt;
  &lt;span class="nx"&gt;metric_name&lt;/span&gt;         &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"CPUUtilization"&lt;/span&gt;
  &lt;span class="nx"&gt;statistic&lt;/span&gt;           &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Average"&lt;/span&gt;
  &lt;span class="nx"&gt;period&lt;/span&gt;              &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;300&lt;/span&gt;
  &lt;span class="nx"&gt;evaluation_periods&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
  &lt;span class="nx"&gt;threshold&lt;/span&gt;           &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;90&lt;/span&gt;
  &lt;span class="nx"&gt;comparison_operator&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"GreaterThanThreshold"&lt;/span&gt;
  &lt;span class="nx"&gt;dimensions&lt;/span&gt;          &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;ClusterName&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;each&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cluster&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ServiceName&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;each&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nx"&gt;alarm_actions&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;aws_sns_topic&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;alerts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;arn&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;# Memory &amp;gt; 80% for 5 min&lt;/span&gt;
&lt;span class="k"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_cloudwatch_metric_alarm"&lt;/span&gt; &lt;span class="s2"&gt;"mem"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;for_each&lt;/span&gt;            &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kd"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;services&lt;/span&gt;
  &lt;span class="nx"&gt;alarm_name&lt;/span&gt;          &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"ecs-&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;each&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;-mem-high"&lt;/span&gt;
  &lt;span class="nx"&gt;namespace&lt;/span&gt;           &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"AWS/ECS"&lt;/span&gt;
  &lt;span class="nx"&gt;metric_name&lt;/span&gt;         &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"MemoryUtilization"&lt;/span&gt;
  &lt;span class="nx"&gt;statistic&lt;/span&gt;           &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Average"&lt;/span&gt;
  &lt;span class="nx"&gt;period&lt;/span&gt;              &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;300&lt;/span&gt;
  &lt;span class="nx"&gt;evaluation_periods&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
  &lt;span class="nx"&gt;threshold&lt;/span&gt;           &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;80&lt;/span&gt;
  &lt;span class="nx"&gt;comparison_operator&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"GreaterThanThreshold"&lt;/span&gt;
  &lt;span class="nx"&gt;dimensions&lt;/span&gt;          &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;ClusterName&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;each&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cluster&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ServiceName&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;each&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nx"&gt;alarm_actions&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;aws_sns_topic&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;alerts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;arn&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;# Running tasks below desired — the incident signal that matters most&lt;/span&gt;
&lt;span class="k"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_cloudwatch_metric_alarm"&lt;/span&gt; &lt;span class="s2"&gt;"running"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;for_each&lt;/span&gt;            &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kd"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;services&lt;/span&gt;
  &lt;span class="nx"&gt;alarm_name&lt;/span&gt;          &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"ecs-&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;each&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;-tasks-low"&lt;/span&gt;
  &lt;span class="nx"&gt;namespace&lt;/span&gt;           &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"ECS/ContainerInsights"&lt;/span&gt;
  &lt;span class="nx"&gt;metric_name&lt;/span&gt;         &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"RunningTaskCount"&lt;/span&gt;
  &lt;span class="nx"&gt;statistic&lt;/span&gt;           &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Average"&lt;/span&gt;
  &lt;span class="nx"&gt;period&lt;/span&gt;              &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;
  &lt;span class="nx"&gt;evaluation_periods&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;
  &lt;span class="nx"&gt;threshold&lt;/span&gt;           &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;each&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;desired_count&lt;/span&gt;
  &lt;span class="nx"&gt;comparison_operator&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"LessThanThreshold"&lt;/span&gt;
  &lt;span class="nx"&gt;treat_missing_data&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"breaching"&lt;/span&gt;
  &lt;span class="nx"&gt;dimensions&lt;/span&gt;          &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;ClusterName&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;each&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cluster&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ServiceName&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;each&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nx"&gt;alarm_actions&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;aws_sns_topic&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;alerts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;arn&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;CPU/memory come from the &lt;code&gt;AWS/ECS&lt;/code&gt; namespace (free vended metrics). &lt;code&gt;RunningTaskCount&lt;/code&gt; comes from &lt;code&gt;ECS/ContainerInsights&lt;/code&gt; — it needs Container Insights on the cluster.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Fargate monitoring is different from EC2
&lt;/h2&gt;

&lt;p&gt;On Fargate you can't SSH to a host or run node_exporter. Every metric comes from CloudWatch or an in-task sidecar — Container Insights is the no-agent default, and it's metered per metric.&lt;/p&gt;

&lt;p&gt;With EC2-backed ECS you own the instance, so you can install the CloudWatch agent, Prometheus node exporter, or any host-level tooling you like, and you pay for the instance either way. Fargate takes the host away. You get no shell, no daemon set, no privileged sidecar reading the host cgroup. What you get instead is CloudWatch: the free vended metrics (&lt;code&gt;CPUUtilization&lt;/code&gt;, &lt;code&gt;MemoryUtilization&lt;/code&gt; at the service level) and, when you turn it on, Container Insights.&lt;/p&gt;

&lt;p&gt;This is genuinely convenient — no agent to patch, no version drift across a hundred tasks — but it changes the cost model. On EC2 your monitoring is bundled into the instance you already pay for. On Fargate, deeper visibility is a separate line item that scales with the number of tasks and containers you run. The rest of this guide is about spending that line item where it earns its keep and not where it doesn't.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;KEY INSIGHT:&lt;/strong&gt; The free service-level CPU and memory metrics in the AWS/ECS namespace are enough to alarm on. You do not need Container Insights to know a service is hot. You need Container Insights when you want to know which task or container inside that service is hot — and for RunningTaskCount and ephemeral-storage metrics.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The metrics that actually matter (and which to skip)
&lt;/h2&gt;

&lt;p&gt;RunningTaskCount below desired catches more incidents than CPU. Alarm on running-task deficit, memory above 80%, and CPU above 90% — leave network and storage on a dashboard.&lt;/p&gt;

&lt;p&gt;Most Fargate monitoring guides open with CPU and memory because those are the metrics everyone recognizes. But CPU is a slow-degradation signal — a throttled service is slow, not down. The signal that actually correlates with "customers are seeing errors" is a service that can't keep its desired number of tasks running: a crash loop, a failed health check, an image that won't pull. That shows up as &lt;code&gt;RunningTaskCount&lt;/code&gt; dropping below &lt;code&gt;DesiredTaskCount&lt;/code&gt;, long before CPU says anything.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Why it matters&lt;/th&gt;
&lt;th&gt;Action&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;RunningTaskCount &amp;lt; desired&lt;/td&gt;
&lt;td&gt;Service can't stay up — the #1 real incident signal&lt;/td&gt;
&lt;td&gt;Alarm always&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MemoryUtilization &amp;gt; 80%&lt;/td&gt;
&lt;td&gt;OOM kills the task; no graceful degradation&lt;/td&gt;
&lt;td&gt;Alarm always&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CPUUtilization &amp;gt; 90%&lt;/td&gt;
&lt;td&gt;Throttling — slow, not down&lt;/td&gt;
&lt;td&gt;Alarm in prod&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;EphemeralStorageUtilized&lt;/td&gt;
&lt;td&gt;Disk fills → task fails silently&lt;/td&gt;
&lt;td&gt;Alarm if you write to disk&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;NetworkRx/TxBytes&lt;/td&gt;
&lt;td&gt;Diagnostic, rarely actionable as an alarm&lt;/td&gt;
&lt;td&gt;Dashboard only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DeploymentCount / TaskSetCount&lt;/td&gt;
&lt;td&gt;Useful during blue/green, noisy otherwise&lt;/td&gt;
&lt;td&gt;Dashboard only&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Metrics are only half of observability — the other half is logs, and the two get correlated during an incident. If your log setup isn't solid, alarms just tell you something broke without telling you what. It's worth getting &lt;a href="https://fortem.dev/blog/aws-ecs-logging-guide/" rel="noopener noreferrer"&gt;how to set up ECS logging the right way&lt;/a&gt; nailed down before you tune alarm thresholds, because a memory alarm with no readable logs behind it is a page you can't act on.&lt;/p&gt;

&lt;p&gt;"EphemeralStorageReserved and EphemeralStorageUtilized ... are only available for tasks that run on Fargate Linux platform version 1.4.0 or later."&lt;/p&gt;

&lt;p&gt;— &lt;a href="https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/Container-Insights-metrics-ECS.html" rel="noopener noreferrer"&gt;AWS Container Insights ECS metrics&lt;/a&gt;, verified June 2026&lt;/p&gt;

&lt;h2&gt;
  
  
  What Container Insights actually costs at fleet scale
&lt;/h2&gt;

&lt;p&gt;Enhanced observability bills $0.07 per metric per month across cluster, service, task-def, task, and container. At 30 services that's thousands of metrics — real money, not a rounding error.&lt;/p&gt;

&lt;p&gt;Here's the arithmetic nobody shows you. AWS bills Container Insights metrics as custom metrics, and enhanced observability (released December 2, 2024) reports them at every level — a handful per cluster and per service, and then, critically, a set &lt;em&gt;per task and per container&lt;/em&gt;. A count in the low teens per container feels harmless in isolation, until you multiply it by a real fleet. Containers are the multiplier that hurts: a 30-service fleet with a couple of containers per task is already at 180+ containers, each reporting its own metric set. The table below models that — the exact number moves with how many containers you pack per task, so treat it as an order-of-magnitude estimate, not a quote.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Fleet&lt;/th&gt;
&lt;th&gt;Metrics reported&lt;/th&gt;
&lt;th&gt;Cost / month&lt;/th&gt;
&lt;th&gt;Cost / year&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;5 services&lt;/td&gt;
&lt;td&gt;475&lt;/td&gt;
&lt;td&gt;$33&lt;/td&gt;
&lt;td&gt;$399&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;15 services&lt;/td&gt;
&lt;td&gt;1,820&lt;/td&gt;
&lt;td&gt;$127&lt;/td&gt;
&lt;td&gt;$1,529&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;30 services&lt;/td&gt;
&lt;td&gt;4,800&lt;/td&gt;
&lt;td&gt;$336&lt;/td&gt;
&lt;td&gt;$4,032&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Modeled estimate. Enhanced observability at $0.07/metric/month (AWS CloudWatch pricing, verified June 2026); per-resource metric counts derived from the AWS enhanced-observability metric table, GPU metrics excluded. Your count varies with containers-per-task.&lt;/p&gt;

&lt;p&gt;And that's just the metric bill. It's separate from log ingestion and storage, which the logging side of observability owns — CloudWatch Logs is $0.50/GB ingested and $0.03/GB stored, and the default Never-Expire retention means storage never stops growing. Watching that side is a different exercise; the mechanics of &lt;a href="https://fortem.dev/blog/cloudwatch-costs-ecs/" rel="noopener noreferrer"&gt;controlling CloudWatch Logs costs on ECS&lt;/a&gt; covers the retention and ingestion levers that stack on top of these metric numbers.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;KEY INSIGHT:&lt;/strong&gt; Standard and enhanced Container Insights bill the same $0.07 per metric. The difference is entirely how many metrics each reports — enhanced adds per-task and per-container granularity, which is exactly where the count (and the bill) explodes. So the question is never "standard or enhanced?" in the abstract. It's "on which resources is per-container granularity worth $0.07 times a dozen-odd metrics each?"&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Turn enhanced observability ON in prod, OFF in dev
&lt;/h2&gt;

&lt;p&gt;Dev environments don't need container-level metrics. Enable enhanced observability on prod clusters only; leave dev on standard or off. That one split roughly halves the Container Insights bill.&lt;/p&gt;

&lt;p&gt;When something breaks in a dev environment, you redeploy it — you don't run a forensic investigation into which container held memory for three seconds too long. The per-container metrics that justify their cost in production are pure waste in dev, where the tasks are often idle or scaled to one. Yet the most common Container Insights mistake is flipping it on cluster-wide, in every account, and never revisiting it.&lt;/p&gt;

&lt;p&gt;Because Container Insights is a per-cluster setting, the split is trivial to express — set it in the Terraform that every environment shares, keyed off the environment name:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight terraform"&gt;&lt;code&gt;&lt;span class="k"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_ecs_cluster"&lt;/span&gt; &lt;span class="s2"&gt;"this"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="kd"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;environment&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;-cluster"&lt;/span&gt;

  &lt;span class="nx"&gt;setting&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"containerInsights"&lt;/span&gt;
    &lt;span class="c1"&gt;# enhanced only in prod; dev/staging get standard (cheaper) or "disabled"&lt;/span&gt;
    &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kd"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;environment&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="s2"&gt;"prod"&lt;/span&gt; &lt;span class="err"&gt;?&lt;/span&gt; &lt;span class="s2"&gt;"enhanced"&lt;/span&gt; &lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"enabled"&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If your dev environments are genuinely throwaway, &lt;code&gt;"disabled"&lt;/code&gt; is a defensible value for them — the free &lt;code&gt;AWS/ECS&lt;/code&gt; service-level CPU and memory metrics still exist without Container Insights, so you keep your CPU and memory alarms. You lose &lt;code&gt;RunningTaskCount&lt;/code&gt; and ephemeral-storage metrics in dev, which is usually an acceptable trade.&lt;/p&gt;

&lt;h2&gt;
  
  
  Alarm as code — the fleet for_each pattern
&lt;/h2&gt;

&lt;p&gt;Hand-writing three alarms per service means 90 blocks at 30 services. A Terraform for_each over a service map creates CPU, memory, and running-task alarms for the whole fleet from one module.&lt;/p&gt;

&lt;p&gt;The Ready-to-use block above is the whole pattern: a &lt;code&gt;map&lt;/code&gt; of services, three &lt;code&gt;for_each&lt;/code&gt; alarm resources, one SNS topic. Adding the thirty-first service is a one-line map entry, not three copied-and-tweaked alarm blocks that drift out of sync the moment someone edits one and forgets the rest. That drift is the real failure mode of click-ops and copy-paste monitoring — not that the alarms are wrong on day one, but that they're inconsistent by month six.&lt;/p&gt;

&lt;p&gt;A few things the pattern gets right that hand-written alarms usually miss. The running-task alarm sets &lt;code&gt;treat_missing_data = "breaching"&lt;/code&gt; — if the metric stops reporting entirely (a service deleted itself, or Container Insights got turned off), that should page you, not silently resolve. It uses &lt;code&gt;evaluation_periods = 3&lt;/code&gt; at 60-second periods so a single blip during a deploy doesn't fire. And every alarm points at one SNS topic, so routing to PagerDuty or Slack is one subscription, not thirty.&lt;/p&gt;

&lt;p&gt;None of this is exotic — the CloudPosse &lt;code&gt;ecs-cloudwatch-sns-alarms&lt;/code&gt; module wraps the same idea, and it's popular precisely because per-service alarm sprawl is a problem enough people hit to want it solved. Whether you use a module or the raw resources above, the principle is the same: the alarm definition lives in one place and applies to the fleet.&lt;/p&gt;

&lt;h2&gt;
  
  
  The newly-created-environment coverage gap
&lt;/h2&gt;

&lt;p&gt;Alarms defined per service don't cover the environment someone spins up next week. Either enforce monitoring in the module every environment uses, or scope a Lambda to auto-alarm any tagged cluster.&lt;/p&gt;

&lt;p&gt;Here's the failure that a service map quietly introduces: it only monitors the services in the map. When a developer spins up a preview environment, or a new team stands up a service in a fresh account, that workload is invisible until someone remembers to add it — and nobody remembers. You find the gap during the incident, when you go looking for the alarm that should have fired and it was never created.&lt;/p&gt;

&lt;p&gt;There are two honest fixes. The first is to make monitoring non-optional in the shared module every environment is built from — if you can't create an ECS service without also creating its alarms, there's no gap to forget. The second, for teams whose environments aren't all built the same way, is AWS's own tag-scoped pattern: a Lambda that watches for new clusters and attaches a standard alarm set to any cluster carrying a given tag. AWS ships this because the gap is real enough to need a system, not discipline.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;KEY INSIGHT:&lt;/strong&gt; Coverage is a fleet property, not a per-service one. The right question isn't "does this service have alarms?" — it's "can a service exist in this org without alarms?" If the answer is yes, your monitoring has a hole shaped like every environment you haven't manually added yet.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Beyond metrics: events and per-environment cost
&lt;/h2&gt;

&lt;p&gt;Metrics tell you a task is unhealthy; EventBridge tells you why it stopped. Route ECS task-state-change events to SNS, and tag every service so per-environment cost shows up in Cost Explorer.&lt;/p&gt;

&lt;p&gt;A metric alarm says &lt;code&gt;RunningTaskCount&lt;/code&gt; dropped. It doesn't say the task was killed by an OOM, failed its health check, or couldn't pull its image. That reason lives in the ECS &lt;code&gt;Task State Change&lt;/code&gt; event, which ECS emits to EventBridge for free. A single EventBridge rule matching &lt;code&gt;stoppedReason&lt;/code&gt; and routing to the same SNS topic turns "a task stopped" into "a task stopped because OutOfMemoryError" — the difference between a page you can act on and one you have to investigate.&lt;/p&gt;

&lt;p&gt;The other blind spot at fleet scale is cost per environment. CloudWatch shows you utilization, not dollars, and AWS bills Fargate at the account level — so a staging environment burning money looks identical to a busy prod one until you've tagged everything. Consistent &lt;code&gt;Environment&lt;/code&gt; and &lt;code&gt;Service&lt;/code&gt; tags on every task, activated as cost-allocation tags, are what make per-environment spend visible in Cost Explorer — and they're the same tags the Lambda coverage pattern keys off. Monitoring and cost attribution end up being the same tagging discipline.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you read this, you might also want to know
&lt;/h2&gt;

&lt;p&gt;Do I need Datadog if I already have Container Insights?&lt;/p&gt;

&lt;p&gt;Not for infrastructure metrics — Container Insights covers CPU, memory, network, storage, and task counts across the fleet. You reach for Datadog (or an OpenTelemetry sidecar) when you need custom application metrics, distributed traces, or a single pane across non-AWS systems. Many teams run Container Insights for infra and a sidecar only on the handful of services that need APM.&lt;/p&gt;

&lt;p&gt;How do I monitor a Fargate task that exits immediately on startup?&lt;/p&gt;

&lt;p&gt;Metrics won't help — a task that dies in seconds never reports a meaningful data point. The signal is the ECS Task State Change event in EventBridge, which carries the stoppedReason (image pull failure, essential container exited, OOM). Route that event to SNS and read the reason; the logs, if the log driver was configured, hold the stack trace.&lt;/p&gt;

&lt;p&gt;Can I alarm on a metric across all environments at once?&lt;/p&gt;

&lt;p&gt;Yes, with a metric-math or aggregate alarm — for example, total RunningTaskCount across a cluster versus total DesiredTaskCount. It's useful for a fleet-health top-line, but keep the per-service alarms too: an aggregate stays green while one critical service is fully down, because the healthy services mask it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frequently asked questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  How much does Container Insights cost for ECS?
&lt;/h3&gt;

&lt;p&gt;$0.07 per metric per month with enhanced observability (verified June 2026). Metrics are reported at the cluster, service, task-definition, task, and container level, so the count scales with your fleet — a 30-service fleet with a couple of containers per task runs into thousands of metrics, pushing the bill into four figures a month if you enable it everywhere.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does ECS Fargate need a monitoring agent?
&lt;/h3&gt;

&lt;p&gt;No. On Fargate you can't install a host agent, but you don't need one — Container Insights collects CPU, memory, network, and ephemeral-storage metrics with no sidecar once you enable it on the cluster. A sidecar (OpenTelemetry, Datadog) is only needed for custom application metrics or traces.&lt;/p&gt;

&lt;h3&gt;
  
  
  What ECS metric should I alarm on first?
&lt;/h3&gt;

&lt;p&gt;RunningTaskCount below DesiredTaskCount. A running-task deficit means a service can't stay up — that catches more real incidents than CPU. Add memory utilization above 80% (out-of-memory kills) and CPU above 90% (throttling) after that.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I monitor ephemeral (disk) storage on Fargate?
&lt;/h3&gt;

&lt;p&gt;Use the EphemeralStorageUtilized and EphemeralStorageReserved metrics. They only exist with Container Insights on Fargate platform version 1.4.0 or later — without Container Insights, disk usage on a Fargate task is invisible until the task fails writing to a full filesystem.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is enhanced observability worth it over standard Container Insights?
&lt;/h3&gt;

&lt;p&gt;In production, yes — enhanced observability adds per-task and per-container metrics that let you find which container in a task is the problem. In dev, no. Both bill $0.07/metric; enhanced just reports far more metrics per resource, so the cost difference is entirely about how many resources you enable it on.&lt;/p&gt;

&lt;h2&gt;
  
  
  Every ECS environment, one view
&lt;/h2&gt;

&lt;p&gt;Container Insights and per-service alarms tell you about one cluster at a time. Fortem puts every ECS Fargate environment — health, running tasks, and idle spend — on one screen, so the gap-shaped hole isn't there to fall into. Book a 20-minute call and we'll walk your fleet.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://fortem.dev/book/" rel="noopener noreferrer"&gt;Book a 20-min call&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Worth reading&lt;/p&gt;

&lt;p&gt;&lt;a href="https://fortem.dev/blog/aws-ecs-logging-guide/" rel="noopener noreferrer"&gt;Guide · How Should You Set Up ECS Logging?awslogs, FireLens, and the three decisions every ECS team gets wrong — the logs half of observability that your alarms correlate against.&lt;/a&gt;&lt;a href="https://fortem.dev/aws-ecs-fargate/" rel="noopener noreferrer"&gt;LandingAWS ECS Fargate: What It Is, How It Works, What It CostsThe head-term reference for ECS Fargate — how tasks, services, and clusters fit together, and where monitoring and cost sit in the bigger picture.&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;See every ECS environment in one place — book a 20-min call:&lt;/strong&gt; &lt;a href="https://fortem.dev/book" rel="noopener noreferrer"&gt;fortem.dev/book&lt;/a&gt;&lt;/p&gt;

</description>
      <category>aws</category>
      <category>ecs</category>
      <category>fargate</category>
      <category>monitoring</category>
    </item>
    <item>
      <title>AWS ECS Fargate Security: What You Actually Configure (and What You Can't)</title>
      <dc:creator>Matt</dc:creator>
      <pubDate>Thu, 02 Jul 2026 09:41:19 +0000</pubDate>
      <link>https://dev.to/dspv/aws-ecs-fargate-security-what-you-actually-configure-and-what-you-cant-43jb</link>
      <guid>https://dev.to/dspv/aws-ecs-fargate-security-what-you-actually-configure-and-what-you-cant-43jb</guid>
      <description>&lt;h1&gt;
  
  
  AWS ECS Fargate Security: What You Configure
&lt;/h1&gt;

&lt;blockquote&gt;
&lt;p&gt;Originally published at &lt;a href="https://fortem.dev/blog/ecs-fargate-container-security" rel="noopener noreferrer"&gt;https://fortem.dev/blog/ecs-fargate-container-security&lt;/a&gt;&lt;br&gt;
ECS Fargate security from the operator's seat: the two IAM roles, read-only root fs, secrets, awsvpc security groups, what Fargate won't let you configure, GuardDuty runtime, and fleet-scale drift.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;Every "Fargate security best practices" list is the same checklist — non-root, read-only filesystem, least-privilege IAM. The list isn't wrong; it's just written for one task. If you run 10–40 ECS environments, the hard part isn't setting these knobs — it's keeping them identical everywhere and proving it for SOC 2. This is Fargate security from the operator's seat: the exact config, what Fargate won't let you touch, and where fleets actually break.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;  Fargate splits security into two IAM roles — the EXECUTION role (agent: pull image, ship logs, fetch secrets) and the TASK role (your app code). Confusing them is the #1 config mistake.&lt;/li&gt;
&lt;li&gt;  The Fargate hardening set is small and fixed: non-root user, readonlyRootFilesystem, secrets via Secrets Manager/SSM (never plaintext env vars), awsvpc security groups per task. Ephemeral storage is already AES-256 encrypted.&lt;/li&gt;
&lt;li&gt;  Fargate REMOVES options on purpose: no privileged containers, no host access, no SSH, CAP_SYS_ADMIN/NET_ADMIN blocked — and you can't run your own runtime agent (Falco DaemonSet). GuardDuty's injected sidecar is the only runtime-threat path.&lt;/li&gt;
&lt;li&gt;  GuardDuty ECS Runtime Monitoring bills per monitored vCPU-hour and won't attach to an already-running task — a real cost and coverage gap at 10+ environments.&lt;/li&gt;
&lt;li&gt;  At fleet scale the risk isn't the config, it's DRIFT: one env with a too-broad task role, a staging secret nobody rotated, a public-subnet task in environment #39.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Ready to use — copy this today&lt;/p&gt;

&lt;p&gt;A hardened task-definition fragment, the writable-&lt;code&gt;/tmp&lt;/code&gt;mount that makes read-only root actually work, and a least-privilege task role scoped to one environment's resources — no wildcards:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="err"&gt;.&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;Hardened&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;container&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;definition&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;(the&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;fields&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;that&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;matter&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;for&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;security)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"app"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"image"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"123456789012.dkr.ecr.us-east-1.amazonaws.com/app:1.4.2"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"user"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"1000:1000"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;                    &lt;/span&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;non-root&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;—&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;matches&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;USER&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;in&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;Dockerfile&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"readonlyRootFilesystem"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;          &lt;/span&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;root&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;FS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;read-only&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"privileged"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;                     &lt;/span&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;(not&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;supported&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;on&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;Fargate&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;anyway)&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"secrets"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;                             &lt;/span&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;secret&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;VALUES&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;never&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;live&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;here&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"DB_PASSWORD"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"valueFrom"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/db-AbCdEf"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"mountPoints"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"sourceVolume"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"tmp"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"containerPath"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"/tmp"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"readOnly"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"logConfiguration"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"logDriver"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"awslogs"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"options"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"awslogs-group"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"/ecs/app"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"awslogs-region"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"us-east-1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"awslogs-stream-prefix"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"app"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="err"&gt;.&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;The&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;writable&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;volume&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;that&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;keeps&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;read-only&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;root&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;from&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;breaking&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;the&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;app.&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;Declared&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;at&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;the&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;task&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;level;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;mounted&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;at&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;/tmp&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;above.&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;Everything&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;else&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;stays&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;RO.&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="nl"&gt;"volumes"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"tmp"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"host"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="err"&gt;.&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;Least-privilege&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;TASK&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;role&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;—&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;scoped&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;to&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;ONE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;environment's&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;bucket,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;no&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;wildcards.&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;This&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;is&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;the&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;role&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;your&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;app&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;code&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;uses.&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;Give&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;each&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;environment&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;its&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;own.&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Version"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2012-10-17"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Statement"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Effect"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Allow"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Action"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"s3:GetObject"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"s3:PutObject"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Resource"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"arn:aws:s3:::acme-prod-uploads/*"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The two IAM roles Fargate security starts with
&lt;/h2&gt;

&lt;p&gt;Fargate uses two IAM roles: the execution role lets the agent pull the image, ship logs, and fetch secrets; the task role is what your app code uses to call AWS. Mixing them up is the top mistake.&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;execution role&lt;/strong&gt; is used by the Fargate agent to set the task up &lt;em&gt;before&lt;/em&gt; your container runs — pull the image from ECR, write logs, and inject any secrets the task definition references. The &lt;strong&gt;task role&lt;/strong&gt; is used by your &lt;em&gt;application&lt;/em&gt; at runtime to reach S3, DynamoDB, and other services. Secret injection and ECR pulls are execution-role permissions; attaching them to the task role is the single most common config error, and a recurring AWS re:Post question.&lt;/p&gt;

&lt;p&gt;One thing worth internalizing: AWS is explicit that "&lt;em&gt;containers are not a security boundary&lt;/em&gt;… each task running on Fargate has its own isolation boundary and does not share the underlying kernel, CPU, memory, or elastic network interface with another task." The isolation you rely on is the task, not the container. Scoping a separate least-privilege task role per environment is exactly the &lt;a href="https://fortem.dev/blog/ecs-fargate-rbac/" rel="noopener noreferrer"&gt;per-environment IAM isolation across the fleet&lt;/a&gt;that keeps one compromised task from reaching another environment's data.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Fargate hardening set (the config that matters)
&lt;/h2&gt;

&lt;p&gt;The Fargate hardening set is short: run as a non-root user, set readonlyRootFilesystem, drop unneeded capabilities. Ephemeral storage is already AES-256 encrypted, so that box is checked for you.&lt;/p&gt;

&lt;p&gt;By default a container runs as &lt;code&gt;root&lt;/code&gt; unless your Dockerfile has a &lt;code&gt;USER&lt;/code&gt;directive. AWS's own guidance is to run as non-root and to lint Dockerfiles in CI, failing the build when the &lt;code&gt;USER&lt;/code&gt;directive is missing — that turns "we run non-root" from a hope into a gate.&lt;/p&gt;

&lt;p&gt;A container's root filesystem is writable by default; you set &lt;code&gt;readonlyRootFilesystem: true&lt;/code&gt; explicitly. Ephemeral storage — the 20 GiB scratch space (up to 200 GiB) each task gets — is encrypted with AES-256 using a Fargate-managed key for any task launched since May 2020, so encryption-at-rest for scratch data needs no action from you. The read-only setting is the one that bites people, which is the next section.&lt;/p&gt;

&lt;h2&gt;
  
  
  Making read-only root filesystem actually work
&lt;/h2&gt;

&lt;p&gt;readonlyRootFilesystem: true breaks any app that writes to /tmp. The fix is a writable volume mounted at /tmp (and any other write path) while the rest of the filesystem stays read-only.&lt;/p&gt;

&lt;p&gt;This is the friction point behind the glib "just make the filesystem read-only" advice. The moment you flip it on, an app that writes a session file, a cache, or a temp upload to &lt;code&gt;/tmp&lt;/code&gt;starts failing — and it fails as a confusing runtime crash, not as an obvious "permission denied on a read-only filesystem." It's a recurring ECS re:Post thread.&lt;/p&gt;

&lt;p&gt;The fix is in the ready-to-use block above: declare an ephemeral volume at the task level and mount it at every path the app writes to (usually &lt;code&gt;/tmp&lt;/code&gt;), keeping the rest of the root filesystem read-only. You end up declaring your writable surface explicitly, which is the whole security point — you now know exactly where the container can write.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;KEY INSIGHT:&lt;/strong&gt; Key insight Read-only root isn't a toggle you flip and forget — it's a contract that forces you to declare every writable path. Test it before prod; a missing &lt;code&gt;/tmp&lt;/code&gt; mount looks like a random app crash, not a security setting.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Secrets: the sanctioned path vs plaintext env vars
&lt;/h2&gt;

&lt;p&gt;Never put secrets in a task definition's plaintext environment block. Reference them in the secrets block from Secrets Manager or SSM; the execution role fetches and injects them at launch.&lt;/p&gt;

&lt;p&gt;Anything in the task definition's &lt;code&gt;environment&lt;/code&gt; block is stored and shown in plaintext in the console and the API — a DB password there is visible to anyone with &lt;code&gt;ecs:DescribeTaskDefinition&lt;/code&gt;. The &lt;code&gt;secrets&lt;/code&gt; block instead holds a reference (an ARN); the execution role resolves it at launch and injects the value as an env var the app sees, without the value ever living in the task definition.&lt;/p&gt;

&lt;p&gt;For that to work, the &lt;strong&gt;execution role&lt;/strong&gt; needs &lt;code&gt;secretsmanager:GetSecretValue&lt;/code&gt; (for Secrets Manager) or &lt;code&gt;ssm:GetParameters&lt;/code&gt; (for Parameter Store), plus &lt;code&gt;kms:Decrypt&lt;/code&gt;if the secret uses a customer-managed key. Note it's the execution role, not the task role — the agent fetches the secret before your code runs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Network isolation with awsvpc and security groups
&lt;/h2&gt;

&lt;p&gt;Fargate only runs in awsvpc mode, so every task gets its own ENI and IP and can carry its own security group. Scope security groups per task and keep tasks in private subnets with no public IP.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;awsvpc&lt;/code&gt;is the only network mode available on Fargate, and it's the mode that gives each task a dedicated elastic network interface with its own private IP. That means a security group attaches to the task itself — you can scope ingress and egress per service instead of sharing one host's rules across everything, the way bridge mode forces on EC2.&lt;/p&gt;

&lt;p&gt;The operational rule: tasks go in private subnets, reach ECR and Secrets Manager over a NAT gateway or VPC endpoints, and never get an auto-assigned public IP. The failure that undoes all of this is a single environment stood up from an older module that drops the task in a public subnet with a permissive security group. It's easy to miss when environments are built one at a time — the fleet-drift problem below.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Fargate won't let you configure (vs EC2)
&lt;/h2&gt;

&lt;p&gt;Fargate removes low-level controls on purpose: no privileged containers, no host access, no SSH, CAP_SYS_ADMIN and CAP_NET_ADMIN restricted. The only capability you can add is CAP_SYS_PTRACE.&lt;/p&gt;

&lt;p&gt;A lot of "container security" advice assumes host-level control you simply don't have on Fargate. Privileged containers aren't supported, so Docker-in-Docker patterns don't run. Additional Linux capabilities like &lt;code&gt;CAP_SYS_ADMIN&lt;/code&gt; and &lt;code&gt;CAP_NET_ADMIN&lt;/code&gt; are restricted to prevent privilege escalation; only &lt;code&gt;CAP_SYS_PTRACE&lt;/code&gt;can be added, for observability and security tooling inside the task. And there's no host access at all — ECS exec is the only sanctioned way to get a shell into a running container.&lt;/p&gt;

&lt;p&gt;ControlEC2 launch typeFargate&lt;/p&gt;

&lt;p&gt;Privileged containersAllowedNot supported&lt;/p&gt;

&lt;p&gt;Docker-in-DockerPossibleNot possible&lt;/p&gt;

&lt;p&gt;Host access / SSHYes (host + ECS exec)No host; ECS exec only&lt;/p&gt;

&lt;p&gt;Custom runtime agent (Falco)DaemonSet on the hostNone — GuardDuty sidecar&lt;/p&gt;

&lt;p&gt;Linux capabilitiesAdd most capsOnly CAP_SYS_PTRACE addable&lt;/p&gt;

&lt;p&gt;Kernel modules / sysctlsHost-level controlLocked down&lt;/p&gt;

&lt;p&gt;Read-only root filesystemConfigurableConfigurable&lt;/p&gt;

&lt;p&gt;Non-root userConfigurableConfigurable&lt;/p&gt;

&lt;p&gt;The bottom two rows are the ones you still own. Everything above them is decided for you — which is the point of Fargate. The consequence that surprises people: you can't run your own runtime sensor, so runtime threat detection works differently.&lt;/p&gt;

&lt;h2&gt;
  
  
  Runtime threat detection = GuardDuty (there's no alternative)
&lt;/h2&gt;

&lt;p&gt;Because you can't run your own agent on Fargate, GuardDuty ECS Runtime Monitoring is the only managed runtime-threat path. It injects a sidecar into each task and bills per monitored vCPU-hour.&lt;/p&gt;

&lt;p&gt;On EC2 you'd run a Falco DaemonSet or a vendor agent on the host to watch process execution, file access, and network connections at runtime. On Fargate there's no host to put it on. AWS closes that gap with GuardDuty ECS Runtime Monitoring: when a task starts, GuardDuty attaches a managed security sidecar container to it. AWS is explicit that on Fargate you cannot manage that agent manually — GuardDuty is the only supported runtime path.&lt;/p&gt;

&lt;p&gt;Two operational gotchas. First, a Fargate task is immutable, so GuardDuty won't attach the sidecar to a task that's _already_running — you stop and restart the task to bring it under monitoring. Second, it's billed per monitored vCPU-hour on a tiered rate (with a 30-day free trial), so across 10+ always-on environments it's a real, and often unpredictable, line item. Check the &lt;a href="https://aws.amazon.com/guardduty/pricing/" rel="noopener noreferrer"&gt;GuardDuty pricing page&lt;/a&gt;for your region's exact rate before you assume it's free.&lt;/p&gt;

&lt;h2&gt;
  
  
  Image supply chain — scanning and immutable tags
&lt;/h2&gt;

&lt;p&gt;Secure the image before it runs: enable ECR scan-on-push, set tag immutability so a tag can't be silently overwritten, and fail the build on HIGH or CRITICAL CVEs. Basic scanning is free.&lt;/p&gt;

&lt;p&gt;Runtime hardening only matters if the image itself is sound. ECR basic scanning is free, uses an AWS-native CVE database (the older Clair-based basic scanning was retired on October 1, 2025), and checks each image on push; enhanced scanning via Amazon Inspector goes deeper into OS and language packages. Tag immutability stops a second push of &lt;code&gt;:v1.4.2&lt;/code&gt; from silently replacing the bytes you already reviewed and deployed.&lt;/p&gt;

&lt;p&gt;The registry is its own security surface — pull IAM, cross-account access, and lifecycle all matter at fleet scale. That's covered in depth in &lt;a href="https://fortem.dev/blog/aws-ecr-guide/" rel="noopener noreferrer"&gt;how ECR works for ECS Fargate teams&lt;/a&gt;; for security specifically, scan-on-push plus immutable tags is the baseline every repo should be born with.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where fleets actually break — security drift at 10+ environments
&lt;/h2&gt;

&lt;p&gt;At fleet scale the config is correct per-env but unaudited across envs. The real risks: one env with a too-broad task role, a secret rotated in prod but not staging, a public-subnet task missed.&lt;/p&gt;

&lt;p&gt;Every control above is easy to set on one task. The problem is that a fleet of 10–40 environments is edited one task definition at a time, in isolation, and nothing shows you all of them at once. So the failures are failures of &lt;em&gt;uniformity&lt;/em&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;Task-role drift.&lt;/strong&gt; Prod's role got scoped to least-privilege during the SOC 2 push; the six-month-old sandbox and demo-eu environments still carry a copy-pasted role with s3:* or a wildcard Resource. Nothing flags it, because each env's task def is edited on its own.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Secrets rotated unevenly.&lt;/strong&gt; The DB credential is rotated in prod, but staging and qa still reference the old secret ARN — or worse, still carry the value in a plaintext environment var from before the migration. The secret is "handled" in one environment.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;The public-subnet task nobody noticed.&lt;/strong&gt; awsvpc and security groups are correct in 38 environments; #39 was stood up from an older Terraform module that drops the task in a public subnet with a permissive SG. It passes every single-env checklist, because that checklist only ever looks at one env.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Uneven read-only / non-root.&lt;/strong&gt; readonlyRootFilesystem and the USER directive are enforced in the environments the platform team built, and skipped in the ones a product squad self-served. The hardening exists as a policy but not as a fact across the fleet.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;GuardDuty on 30 of 40 clusters.&lt;/strong&gt; Runtime Monitoring is on account-wide but excluded via tag on the clusters someone muted during a noisy-alert incident and never re-enabled. Ten environments have zero runtime detection, and no dashboard shows the gap.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Container security on Fargate is not hard to configure — it's hard to keep uniform and prove. The reframing from "is this task hardened?" to "is every environment hardened the same way?" is the whole job at 10+ environments, and it's the thing a per-task checklist structurally can't answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  How this ties into your SOC 2 controls
&lt;/h2&gt;

&lt;p&gt;SOC 2 doesn't ask if you CAN secure a task — it asks you to prove the control holds in every environment. Fleet-wide uniformity is the audit evidence, and it breaks when envs are configured by hand.&lt;/p&gt;

&lt;p&gt;Each item in the hardening set maps to a control an auditor will ask about: least-privilege access (the task role), encryption (ephemeral storage and secrets), change monitoring (who edited a task definition). The gap auditors find isn't that you _can't_do these things — it's the one environment where the control isn't applied. Drift is the finding they circle in red, and proving uniformity across the fleet is the evidence that closes it.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you read this, you might also want to know
&lt;/h2&gt;

&lt;p&gt;Do I need GuardDuty if I already scan images in ECR?&lt;/p&gt;

&lt;p&gt;Yes — they cover different phases. ECR scanning finds known CVEs in the image before it runs (build-time / supply chain). GuardDuty Runtime Monitoring watches behavior while the container runs — process execution, file access, outbound connections — and catches things a clean image can still do at runtime, like credential theft or crypto-mining. Scanning is prevention; runtime monitoring is detection.&lt;/p&gt;

&lt;p&gt;Can I run Falco or my own security agent on Fargate?&lt;/p&gt;

&lt;p&gt;No. Falco and similar tools need host-level access (a DaemonSet or kernel module) that Fargate doesn't give you — there's no host to install them on. GuardDuty ECS Runtime Monitoring, which injects an AWS-managed sidecar, is the supported substitute. If a vendor claims Fargate runtime coverage, they're either using GuardDuty's feed or running in-task with the limited CAP_SYS_PTRACE capability.&lt;/p&gt;

&lt;p&gt;Is the task role or the execution role the one that reads my secrets?&lt;/p&gt;

&lt;p&gt;The execution role. The Fargate agent uses the execution role to fetch a secret referenced in the task definition's secrets block and inject it before your container starts, so the execution role needs secretsmanager:GetSecretValue or ssm:GetParameters. The task role is only for AWS calls your application makes at runtime.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Map every environment's security config in 5 min:&lt;/strong&gt; &lt;a href="https://fortem.dev/audit" rel="noopener noreferrer"&gt;fortem.dev/audit&lt;/a&gt;&lt;/p&gt;

</description>
      <category>aws</category>
      <category>ecs</category>
      <category>fargate</category>
      <category>security</category>
    </item>
    <item>
      <title>AWS ECR: How Container Registry Works for ECS Fargate Teams</title>
      <dc:creator>Matt</dc:creator>
      <pubDate>Tue, 30 Jun 2026 21:51:26 +0000</pubDate>
      <link>https://dev.to/dspv/aws-ecr-how-container-registry-works-for-ecs-fargate-teams-4fa6</link>
      <guid>https://dev.to/dspv/aws-ecr-how-container-registry-works-for-ecs-fargate-teams-4fa6</guid>
      <description>&lt;h1&gt;
  
  
  AWS ECR Guide for ECS Fargate Teams
&lt;/h1&gt;

&lt;blockquote&gt;
&lt;p&gt;Originally published at &lt;a href="https://fortem.dev/blog/aws-ecr-guide" rel="noopener noreferrer"&gt;https://fortem.dev/blog/aws-ecr-guide&lt;/a&gt;&lt;br&gt;
AWS ECR from the ECS Fargate operator's seat: how pulls work, the execution-role IAM, why private-subnet tasks fail, real pricing, and the lifecycle policy that cuts the bill.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;Every ECS Fargate deploy pulls an image from ECR — and ECR is the part nobody owns until it breaks. A task in a private subnet throws &lt;code&gt;ResourceInitializationError&lt;/code&gt;, or five years of untagged images quietly push the bill to $400/month. This is ECR from the ECS operator's seat: how pulls actually work, the IAM the execution role needs, what it costs at fleet scale, and the lifecycle, scanning, and replication settings that matter at 10+ environments — with the AWS-verified pricing nobody else itemizes.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;  ECR is AWS's managed container registry — the default image store for ECS and EKS. Registry → repository → image, with IAM-based access and a short-lived auth token per pull.&lt;/li&gt;
&lt;li&gt;  The #1 ECR failure on Fargate is a private-subnet task that can't pull: it needs either a NAT gateway or three ECR VPC endpoints, plus AmazonECSTaskExecutionRolePolicy on the execution role.&lt;/li&gt;
&lt;li&gt;  ECR storage is $0.10/GB-month; same-region pulls to Fargate are free. The hidden bill is old images — one team went from $400/mo to ~$15/mo with a 30-day lifecycle policy.&lt;/li&gt;
&lt;li&gt;  At fleet scale three settings matter: lifecycle policies (cost), scan-on-push (security), and cross-account replication (multi-account image distribution).&lt;/li&gt;
&lt;li&gt;  For ECR-heavy fleets in private subnets, VPC interface endpoints are often cheaper than routing every pull through a NAT gateway.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Ready to use — copy this today&lt;/p&gt;

&lt;p&gt;Push an image, then a lifecycle policy that keeps the bill flat, then the exact networking + IAM a private-subnet Fargate task needs to pull:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 1. Authenticate Docker to your private ECR registry, then push&lt;/span&gt;
aws ecr get-login-password &lt;span class="nt"&gt;--region&lt;/span&gt; us-east-1 &lt;span class="se"&gt;\&lt;/span&gt;
  | docker login &lt;span class="nt"&gt;--username&lt;/span&gt; AWS &lt;span class="nt"&gt;--password-stdin&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
      123456789012.dkr.ecr.us-east-1.amazonaws.com

docker tag my-app:latest 123456789012.dkr.ecr.us-east-1.amazonaws.com/my-app:latest
docker push 123456789012.dkr.ecr.us-east-1.amazonaws.com/my-app:latest
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="err"&gt;.&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;Lifecycle&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;policy&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;—&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;kill&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;the&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;hidden&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;storage&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;bill.&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;Rule&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;drop&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;untagged&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;layers&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;after&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;day.&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;Rule&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;drop&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;non-prod&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;after&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;days.&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"rules"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"rulePriority"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"description"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Expire untagged images after 1 day"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"selection"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"tagStatus"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"untagged"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"countType"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"sinceImagePushed"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"countUnit"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"days"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"countNumber"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"action"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"expire"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"rulePriority"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"description"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Expire non-prod images after 30 days"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"selection"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"tagStatus"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"tagged"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"tagPrefixList"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"dev"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"staging"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"pr-"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"countType"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"sinceImagePushed"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"countUnit"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"days"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"countNumber"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"action"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"expire"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight terraform"&gt;&lt;code&gt;&lt;span class="c1"&gt;# 3. The exact set a PRIVATE-subnet Fargate task needs to pull from ECR.&lt;/span&gt;
&lt;span class="c1"&gt;# Two interface endpoints (ecr.api, ecr.dkr) + an S3 gateway endpoint&lt;/span&gt;
&lt;span class="c1"&gt;# (ECR stores image layers in S3). No NAT gateway required.&lt;/span&gt;
&lt;span class="k"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_vpc_endpoint"&lt;/span&gt; &lt;span class="s2"&gt;"ecr_api"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;vpc_id&lt;/span&gt;              &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kd"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;vpc_id&lt;/span&gt;
  &lt;span class="nx"&gt;service_name&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"com.amazonaws.&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="kd"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;region&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.ecr.api"&lt;/span&gt;
  &lt;span class="nx"&gt;vpc_endpoint_type&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Interface"&lt;/span&gt;
  &lt;span class="nx"&gt;subnet_ids&lt;/span&gt;          &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kd"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;private_subnet_ids&lt;/span&gt;
  &lt;span class="nx"&gt;security_group_ids&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;aws_security_group&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;endpoints&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;   &lt;span class="c1"&gt;# allow TCP 443 from tasks&lt;/span&gt;
  &lt;span class="nx"&gt;private_dns_enabled&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_vpc_endpoint"&lt;/span&gt; &lt;span class="s2"&gt;"ecr_dkr"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;vpc_id&lt;/span&gt;              &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kd"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;vpc_id&lt;/span&gt;
  &lt;span class="nx"&gt;service_name&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"com.amazonaws.&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="kd"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;region&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.ecr.dkr"&lt;/span&gt;
  &lt;span class="nx"&gt;vpc_endpoint_type&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Interface"&lt;/span&gt;
  &lt;span class="nx"&gt;subnet_ids&lt;/span&gt;          &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kd"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;private_subnet_ids&lt;/span&gt;
  &lt;span class="nx"&gt;security_group_ids&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;aws_security_group&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;endpoints&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="nx"&gt;private_dns_enabled&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_vpc_endpoint"&lt;/span&gt; &lt;span class="s2"&gt;"s3"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;vpc_id&lt;/span&gt;            &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kd"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;vpc_id&lt;/span&gt;
  &lt;span class="nx"&gt;service_name&lt;/span&gt;      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"com.amazonaws.&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="kd"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;region&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.s3"&lt;/span&gt;
  &lt;span class="nx"&gt;vpc_endpoint_type&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Gateway"&lt;/span&gt;
  &lt;span class="nx"&gt;route_table_ids&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kd"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;private_route_table_ids&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;# The task EXECUTION role needs the ECR pull actions (AmazonECSTaskExecutionRolePolicy covers these):&lt;/span&gt;
&lt;span class="c1"&gt;#   ecr:GetAuthorizationToken, ecr:BatchGetImage, ecr:GetDownloadUrlForLayer&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What ECR actually is (for an ECS team)
&lt;/h2&gt;

&lt;p&gt;ECR is AWS's managed container registry — the default image store for ECS and EKS. ECS pulls images at task launch using the execution role's IAM and a short-lived auth token.&lt;/p&gt;

&lt;p&gt;AWS calls ECR "an extension of both services" — meaning if you run ECS Fargate, you already run ECR whether you think about it or not. Every task definition references an image, and that image almost always lives in a private ECR repository in your account. ECR is the boring dependency in the deploy path: invisible when it works, a production incident when it doesn't.&lt;/p&gt;

&lt;p&gt;The generic "what is a container registry" explanation — it's a managed Docker registry, you push and pull over HTTPS, IAM controls access — is true and covered everywhere. The rest of this guide is the part that isn't: how ECR behaves from the seat of someone operating an ECS Fargate fleet, where the failures and the costs actually live.&lt;/p&gt;

&lt;h2&gt;
  
  
  Registry vs repository vs image — the model
&lt;/h2&gt;

&lt;p&gt;One registry per account per region holds many repositories; each repository holds the tagged versions of one image, addressed as&lt;code&gt;account.dkr.ecr.region.amazonaws.com/repo:tag&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The three nouns trip people up because AWS reuses "registry." Your &lt;strong&gt;registry&lt;/strong&gt; is the per-account, per-region namespace. A &lt;strong&gt;repository&lt;/strong&gt; holds one logical image — &lt;code&gt;my-app&lt;/code&gt; — with all its tags and versions. An &lt;strong&gt;image&lt;/strong&gt; is one immutable build, addressed by tag (&lt;code&gt;:latest&lt;/code&gt;, &lt;code&gt;:v2.3.1&lt;/code&gt;) or by digest.&lt;/p&gt;

&lt;p&gt;Repositories are private by default. Public repos exist (ECR Public, for distributing images to anyone), but an ECS fleet pulls from private repos in its own account — which is why every pull needs both a network path and IAM, the two things the next sections fix.&lt;/p&gt;

&lt;h2&gt;
  
  
  How an ECS Fargate task pulls an image
&lt;/h2&gt;

&lt;p&gt;At launch, the Fargate agent uses the task EXECUTION role to pull the image before your container starts. If that pull can't reach ECR or lacks IAM, the task dies with ResourceInitializationError.&lt;/p&gt;

&lt;p&gt;The single most useful distinction in ECS: the &lt;strong&gt;execution role&lt;/strong&gt; and the &lt;strong&gt;task role&lt;/strong&gt; are different identities. The execution role is what the Fargate &lt;em&gt;agent&lt;/em&gt; uses to set the task up — pull the image from ECR, fetch secrets, write logs — all before your code runs. The task role is what your &lt;em&gt;application&lt;/em&gt; uses at runtime to reach S3, DynamoDB, and other services. ECR pulls are an execution-role concern; putting ECR permissions on the task role is a common dead end.&lt;/p&gt;

&lt;p&gt;The pull is the very first thing that happens. That's why an ECR problem shows up as a task that never starts, not an app error — and why it's worth knowing the execution role's exact permissions, which sit alongside &lt;a href="https://fortem.dev/blog/ecs-task-definition-guide/" rel="noopener noreferrer"&gt;every task-definition field including the execution role&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why private-subnet tasks fail to pull (the #1 ECR error)
&lt;/h2&gt;

&lt;p&gt;A private-subnet Fargate task has no path to ECR by default. It needs a NAT gateway or three VPC endpoints (ecr.api, ecr.dkr, S3 gateway) — missing them is the #1 ResourceInitializationError cause.&lt;/p&gt;

&lt;p&gt;This is the failure that fills AWS re:Post: a task in a public subnet pulls fine, you move it to a private subnet for security, and deploys start dying with &lt;code&gt;ResourceInitializationError: unable to pull image&lt;/code&gt;. The image didn't change; the network path disappeared. ECR lives on the public AWS network, and a private subnet has no route to it without help.&lt;/p&gt;

&lt;p&gt;Two ways to give it a path. A &lt;strong&gt;NAT gateway&lt;/strong&gt;routes the task's outbound traffic to the internet, where it reaches ECR — simple, one resource. Or three &lt;strong&gt;VPC endpoints&lt;/strong&gt;: an interface endpoint for &lt;code&gt;ecr.api&lt;/code&gt; (the API), an interface endpoint for &lt;code&gt;ecr.dkr&lt;/code&gt; (the Docker registry), and a _gateway_endpoint for S3 — because ECR stores the actual image layers in S3, and the pull fails silently if the task can't reach S3 too. The endpoint security group must allow TCP 443 from the task's security group.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;KEY INSIGHT:&lt;/strong&gt; The forgotten third endpoint is S3. Teams add the two ECR interface endpoints, see pulls still fail, and assume ECR is broken. ECR hands the agent a pre-signed S3 URL for the layers — no S3 endpoint, no layers, ResourceInitializationError. All three or none.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The execution-role IAM ECR needs
&lt;/h2&gt;

&lt;p&gt;The task execution role needs AmazonECSTaskExecutionRolePolicy — or the equivalent three ecr: pull actions. Without it the pull is denied even with correct networking.&lt;/p&gt;

&lt;p&gt;Three actions do the whole pull. &lt;code&gt;ecr:GetAuthorizationToken&lt;/code&gt; gets the short-lived token Docker uses to log in. &lt;code&gt;ecr:BatchGetImage&lt;/code&gt; fetches the image manifest. &lt;code&gt;ecr:GetDownloadUrlForLayer&lt;/code&gt;gets the pre-signed S3 URLs for each layer. AWS's managed &lt;code&gt;AmazonECSTaskExecutionRolePolicy&lt;/code&gt; bundles all three plus the CloudWatch Logs permissions a task needs — attach it to the execution role and the IAM half is done.&lt;/p&gt;

&lt;p&gt;The diagnostic rule of thumb: if the pull fails the same way from _every_subnet, it's IAM; if it fails only from private subnets, it's networking. The two failures look identical in the task event log, so check both — most wasted hours come from fixing the wrong half.&lt;/p&gt;

&lt;h2&gt;
  
  
  NAT gateway vs VPC endpoints — the cost decision
&lt;/h2&gt;

&lt;p&gt;A NAT gateway is simplest but bills $0.045/hr plus $0.045/GB. For a fleet pulling constantly across many private subnets, three ECR VPC endpoints are often cheaper and keep pulls private.&lt;/p&gt;

&lt;p&gt;The naive choice is a NAT gateway — it's one resource and it fixes the pull. But a NAT gateway is a per-environment fixed cost (roughly $32/month each before data), and image pulls are data-heavy: every task launch drags layers through it at $0.045/GB. A fleet that scales up and down all day, pulling on every launch, can run a surprising NAT data bill that's really just ECR traffic.&lt;/p&gt;

&lt;p&gt;VPC interface endpoints have their own hourly cost, but pull traffic over them stays on the AWS network and avoids the NAT per-GB charge. For an ECR-heavy fleet — many environments, frequent launches — the endpoints usually win, and they remove image pulls as a reason your tasks ever touch the internet. This is the same fixed-vs-usage tradeoff that runs through &lt;a href="https://fortem.dev/blog/aws-fargate-pricing-real-costs/" rel="noopener noreferrer"&gt;the real per-environment cost of Fargate including NAT&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What ECR actually costs
&lt;/h2&gt;

&lt;p&gt;ECR storage is $0.10/GB-month; same-region pulls to Fargate are free; cross-region transfer is $0.09/GB. New accounts get 500 MB free for a year. The real bill is accumulated old images.&lt;/p&gt;

&lt;p&gt;Line itemCostNotes&lt;/p&gt;

&lt;p&gt;Private storage$0.10 / GB-monthThe line that grows with old images&lt;/p&gt;

&lt;p&gt;Pull to Fargate/ECS (same region)freeNo transfer charge in-region&lt;/p&gt;

&lt;p&gt;Cross-region transfer out$0.09 / GBReplication / multi-region pulls&lt;/p&gt;

&lt;p&gt;Free tier (new accounts)500 MB / moPrivate storage, first 12 months&lt;/p&gt;

&lt;p&gt;Verified against the &lt;a href="https://aws.amazon.com/ecr/pricing/" rel="noopener noreferrer"&gt;AWS ECR pricing page&lt;/a&gt;(July 2026). Note what's NOT here: pulling to your Fargate tasks in the same region costs nothing. So the bill isn't your deploys — it's storage that only ever grows. Which is the next section.&lt;/p&gt;

&lt;h2&gt;
  
  
  The hidden ECR bill — old images, and the fix
&lt;/h2&gt;

&lt;p&gt;Untagged and stale images pile up invisibly. One team paid $400/month for five years of old images; a lifecycle policy (untagged after 1 day, non-prod after 30 days) dropped it to ~$15/month.&lt;/p&gt;

&lt;p&gt;Every CI run pushes a new image. Every push of &lt;code&gt;:latest&lt;/code&gt;orphans the previous one as an untagged layer. None of it is ever deleted unless you say so, and nobody opens the ECR console to look. So storage compounds quietly — a few GB a month becomes hundreds of GB over a few years, and the only signal is a bill line that's slowly climbed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lifecycle policies&lt;/strong&gt; fix it for free. You write rules — by tag, age, or count — and ECR deletes the rest automatically. The pair in the ready-to-use block above covers most teams: expire untagged images after 1 day (the orphaned &lt;code&gt;:latest&lt;/code&gt; layers nobody references), and expire dev/staging/PR images after 30 days (so a paused project stops billing). That single pair is the whole $400-to-$15 swing in the &lt;a href="https://erik-ekberg.medium.com/save-money-with-aws-ecr-lifecycle-policies-9149f82597bc" rel="noopener noreferrer"&gt;documented case&lt;/a&gt;above. Test rules in the console's dry-run preview before applying — a too-aggressive prod rule that deletes an image a service still references is its own incident.&lt;/p&gt;

&lt;h2&gt;
  
  
  Image scanning — catching vulnerabilities on push
&lt;/h2&gt;

&lt;p&gt;ECR scan-on-push checks each new image for known CVEs as a per-repository setting. It's the cheapest first line of container vulnerability detection — one toggle per repo, free on the basic tier.&lt;/p&gt;

&lt;p&gt;Turn on scan-on-push and every image gets checked against a CVE database the moment it lands, with results you can read in the console or via the API. Basic scanning is free and historically used the open-source Clair CVE database (AWS now describes it as native technology over the same CVE data); enhanced scanning (powered by Amazon Inspector) goes deeper into OS and language packages and bills per image. For a team heading into a SOC 2 audit, scan-on-push is the cheapest box to tick — it turns "do you scan container images" from a project into a per-repo toggle.&lt;/p&gt;

&lt;h2&gt;
  
  
  Distributing images across accounts
&lt;/h2&gt;

&lt;p&gt;Multi-account ECS fleets need the same image in every account. Two clean paths: ECR cross-account replication (push once, pull locally) or a shared repository policy scoped to named accounts.&lt;/p&gt;

&lt;p&gt;Once prod, staging, and dev live in separate AWS accounts, one image built in a CI account has to reach all of them. &lt;strong&gt;Replication&lt;/strong&gt; copies the image into a local repo in each destination account, so every pull is in-account and in-region (and free). A &lt;strong&gt;shared repository&lt;/strong&gt; keeps one copy and grants pull access via a repository policy naming the specific accounts. The shortcut to avoid is granting access to your whole Organization with &lt;code&gt;aws:PrincipalOrgID&lt;/code&gt; — it works, but it opens the repo to every account in the Org, which is a finding an auditor circles in red. The full cross-account image-distribution mechanics live in the multi-account operating model.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lifecycle, immutability, pull-through cache — fleet settings
&lt;/h2&gt;

&lt;p&gt;Three registry settings matter at scale: lifecycle policies (cost), tag immutability (no silent :latest overwrites), and pull-through cache (mirror public images to dodge rate limits).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tag immutability&lt;/strong&gt; stops a second push of&lt;code&gt;:v1.2.0&lt;/code&gt; from silently replacing the first — so a tag always points at the exact bytes you deployed, which matters for rollbacks and audits. &lt;strong&gt;Pull-through cache&lt;/strong&gt; mirrors an upstream public registry (Docker Hub, the ECR Public gallery) into your private registry on first pull, then keeps it fresh — it dodges Docker Hub rate limits that randomly fail deploys and keeps base-image pulls in-account. And &lt;strong&gt;lifecycle policies&lt;/strong&gt;, from the cost section, are the third — the one setting whose absence shows up on the bill.&lt;/p&gt;

&lt;p&gt;The fleet default&lt;/p&gt;

&lt;p&gt;Set all three as defaults in your account baseline (or a repository-creation template) so every new repo is born with a lifecycle policy, immutable tags, and the pull-through cache rule — instead of each team rediscovering the $400 bill on its own.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you read this, you might also want to know
&lt;/h2&gt;

&lt;p&gt;Do I need a NAT gateway if I use ECR VPC endpoints?&lt;/p&gt;

&lt;p&gt;Not for ECR pulls — the ecr.api + ecr.dkr + S3 endpoints give a private-subnet task everything it needs to pull. You still need a NAT gateway (or other endpoints) if the task itself reaches other internet services at runtime. Many teams drop NAT to endpoints-only and cut both cost and internet exposure.&lt;/p&gt;

&lt;p&gt;What's the difference between the task role and the execution role for ECR?&lt;/p&gt;

&lt;p&gt;The execution role is used by the Fargate agent to set the task up — pull the image from ECR, fetch secrets, write logs — before your container runs. The task role is used by your application code at runtime. ECR pulls are always an execution-role permission; putting ecr:* on the task role does nothing for the pull.&lt;/p&gt;

&lt;p&gt;Does scan-on-push cost extra?&lt;/p&gt;

&lt;p&gt;Basic scanning (Clair-based, OS packages) is free. Enhanced scanning via Amazon Inspector — which adds OS and programming-language package CVEs and continuous re-scanning — bills per image scanned. Most teams start with free basic scanning and upgrade specific repos to enhanced when compliance requires it.&lt;/p&gt;

&lt;p&gt;Can two AWS accounts share one ECR repository?&lt;/p&gt;

&lt;p&gt;Yes — attach a repository policy that grants the ECR pull actions to the specific account IDs that need it, and they pull cross-account. The cleaner pattern at scale is cross-account replication (each account pulls from its own local copy, free and in-region). Avoid granting access to the whole Organization via aws:PrincipalOrgID.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Map your fleet's ECR + NAT spend in 5 min:&lt;/strong&gt; &lt;a href="https://fortem.dev/audit" rel="noopener noreferrer"&gt;fortem.dev/audit&lt;/a&gt;&lt;/p&gt;

</description>
      <category>aws</category>
      <category>ecs</category>
      <category>fargate</category>
      <category>docker</category>
    </item>
    <item>
      <title>ECS Fargate Autoscaling: Target Tracking, Step, and Why It Doesn't Scale When You Expect</title>
      <dc:creator>Matt</dc:creator>
      <pubDate>Tue, 30 Jun 2026 11:17:00 +0000</pubDate>
      <link>https://dev.to/dspv/ecs-fargate-autoscaling-target-tracking-step-and-why-it-doesnt-scale-when-you-expect-1e47</link>
      <guid>https://dev.to/dspv/ecs-fargate-autoscaling-target-tracking-step-and-why-it-doesnt-scale-when-you-expect-1e47</guid>
      <description>&lt;h1&gt;
  
  
  ECS Fargate Autoscaling: Target Tracking &amp;amp; Step Scaling
&lt;/h1&gt;

&lt;blockquote&gt;
&lt;p&gt;Originally published at &lt;a href="https://fortem.dev/blog/ecs-fargate-autoscaling" rel="noopener noreferrer"&gt;https://fortem.dev/blog/ecs-fargate-autoscaling&lt;/a&gt;&lt;br&gt;
ECS Fargate autoscaling explained: target tracking, step scaling, the right cooldowns, and the five reasons it doesn't scale when you expect — per the AWS docs.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;Guide&lt;/p&gt;

&lt;p&gt;You set a CPU target, autoscaling “works” — until a traffic spike it reacts to too slowly, or a service that quiets down won't scale back in. Autoscaling sits on top of the &lt;a href="https://fortem.dev/aws-ecs-fargate/" rel="noopener noreferrer"&gt;ECS Fargate&lt;/a&gt; service and task primitives, and its dynamic scaling follows rules most tutorials skip. This guide covers the three policy types, the settings that matter, and the five reasons it doesn't scale when you expect — each backed by the AWS docs, not by “set the target to 50% and hope.”&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;  Target tracking is the right default: pick one metric (CPU, memory, or ALB requests per target), set one target, and AWS creates and manages the alarms.&lt;/li&gt;
&lt;li&gt;  It scales out fast and in slow on purpose — the managed alarms need ~3 minutes above target to add tasks, ~15 minutes below to remove them.&lt;/li&gt;
&lt;li&gt;  Five things break it: scale-in is off during deployments, ALB request count isn't supported on blue/green, editing the managed alarms, too-slow reaction to spikes, and thrashing from a short scale-in cooldown.&lt;/li&gt;
&lt;li&gt;  When target tracking is too slow for bursts, add a step scaling policy for the spike and keep target tracking for steady state — they coexist.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Quick answer — For most ECS Fargate services, use target tracking on CPU at 50% (or ALBRequestCountPerTarget for request-driven apps), with a ~60s scale-out and ~300s scale-in cooldown.&lt;/strong&gt;AWS creates and manages the CloudWatch alarms — don't edit them. Scale-out happens after ~3 minutes above target; scale-in after ~15 minutes below, and it's turned off entirely during a deployment. If that's too slow for sudden spikes, add a step scaling policy on a steeper alarm and keep target tracking for steady state.&lt;/p&gt;

&lt;p&gt;Ready to use — copy this today&lt;/p&gt;

&lt;p&gt;Target tracking on CPU at 50% for one Fargate service, with sensible cooldowns. Register a scalable target, attach the policy, set min/max. Drop it into your Terraform and the service scales itself.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight terraform"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Register the ECS service as a scalable target&lt;/span&gt;
&lt;span class="k"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_appautoscaling_target"&lt;/span&gt; &lt;span class="s2"&gt;"svc"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;service_namespace&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"ecs"&lt;/span&gt;
  &lt;span class="nx"&gt;resource_id&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"service/&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="kd"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cluster&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="kd"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;service&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="nx"&gt;scalable_dimension&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"ecs:service:DesiredCount"&lt;/span&gt;
  &lt;span class="nx"&gt;min_capacity&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;     &lt;span class="c1"&gt;# floor — never below this&lt;/span&gt;
  &lt;span class="nx"&gt;max_capacity&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;    &lt;span class="c1"&gt;# ceiling — caps your worst-case cost&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;# Target tracking on average CPU at 50%&lt;/span&gt;
&lt;span class="k"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_appautoscaling_policy"&lt;/span&gt; &lt;span class="s2"&gt;"cpu"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;               &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="kd"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;service&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;-cpu-target"&lt;/span&gt;
  &lt;span class="nx"&gt;policy_type&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"TargetTrackingScaling"&lt;/span&gt;
  &lt;span class="nx"&gt;service_namespace&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_appautoscaling_target&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;svc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;service_namespace&lt;/span&gt;
  &lt;span class="nx"&gt;resource_id&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_appautoscaling_target&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;svc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;resource_id&lt;/span&gt;
  &lt;span class="nx"&gt;scalable_dimension&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_appautoscaling_target&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;svc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;scalable_dimension&lt;/span&gt;

  &lt;span class="nx"&gt;target_tracking_scaling_policy_configuration&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;target_value&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;50.0&lt;/span&gt;
    &lt;span class="nx"&gt;predefined_metric_specification&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;predefined_metric_type&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"ECSServiceAverageCPUUtilization"&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nx"&gt;scale_out_cooldown&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;    &lt;span class="c1"&gt;# add tasks quickly&lt;/span&gt;
    &lt;span class="nx"&gt;scale_in_cooldown&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;300&lt;/span&gt;   &lt;span class="c1"&gt;# remove tasks slowly — avoids thrashing&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For request-driven services, swap the metric for &lt;code&gt;ALBRequestCountPerTarget&lt;/code&gt; with a &lt;code&gt;resource_label&lt;/code&gt;pointing at your ALB target group. Don't hand-edit the CloudWatch alarms this creates — AWS manages them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The three ways ECS scales (and which to use)
&lt;/h2&gt;

&lt;p&gt;ECS has three modes: target tracking (hold a metric at a target), step scaling (tiered per-alarm adjustments), and scheduled (calendar). For load, target tracking is the default; step handles bursts.&lt;/p&gt;

&lt;p&gt;All three run on AWS Application Auto Scaling, which adjusts your service's desired task count. They answer different questions. Target tracking asks “keep this metric here.” Step scaling asks “when this alarm breaks by this much, add this many tasks.” Scheduled asks “at this time, set capacity to this.”&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Policy&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;th&gt;When to use&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Target tracking&lt;/td&gt;
&lt;td&gt;Keep a metric (CPU, memory, ALB requests) at one target value&lt;/td&gt;
&lt;td&gt;Steady load — the default&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Step scaling&lt;/td&gt;
&lt;td&gt;Tiered task adjustments per alarm-breach size&lt;/td&gt;
&lt;td&gt;Sudden spikes, custom thresholds&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Scheduled&lt;/td&gt;
&lt;td&gt;Set capacity by date/time (cron)&lt;/td&gt;
&lt;td&gt;Known calendar patterns — covered separately&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;This guide is about scaling to &lt;em&gt;load&lt;/em&gt; — target tracking and step. Scheduled scaling is a different job: turning environments off on a calendar to cut idle spend. If that's what you're after, the full mechanics of &lt;a href="https://fortem.dev/blog/ecs-environment-scheduling/" rel="noopener noreferrer"&gt;scheduled scaling to stop environments off-hours&lt;/a&gt; live in their own guide — we won't repeat them here.&lt;/p&gt;

&lt;h2&gt;
  
  
  Target tracking — the default, and its three metrics
&lt;/h2&gt;

&lt;p&gt;Target tracking holds one of three predefined metrics at a target — CPU, memory, or ALB requests per target — and AWS creates and manages the CloudWatch alarms. You set the number; it does the rest.&lt;/p&gt;

&lt;p&gt;It works like a thermostat. You pick a number; the auto scaler adds or removes tasks to keep the metric near it. The three metrics fit different services:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  CPU*&lt;em&gt;ECSServiceAverageCPUUtilization&lt;/em&gt;* — the safe default for compute-bound services. Works everywhere, but it's a proxy: CPU can sit low while the service is still slow on I/O.&lt;/li&gt;
&lt;li&gt;  MEM*&lt;em&gt;ECSServiceAverageMemoryUtilization&lt;/em&gt;* — for memory-bound workloads. Risky as a sole metric: many apps hold memory flat and never trigger a scale-in.&lt;/li&gt;
&lt;li&gt;  ALB*&lt;em&gt;ALBRequestCountPerTarget&lt;/em&gt;* — the best signal for request-driven APIs. It scales on actual load, not a proxy. Caveat below.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The big convenience: target tracking removes the need to define alarms by hand. AWS builds two — a high alarm to scale out and a low alarm to scale in — and tunes them as load shifts.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;KEY INSIGHT:&lt;/strong&gt; Do not edit or delete the CloudWatch alarms that target tracking creates. Service Auto Scaling owns them — it adjusts them as your load changes and deletes them when you delete the policy. Hand-editing them looks fine until the next adjustment silently reverts your change — and then scaling misbehaves with no obvious cause.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Why it doesn't scale when you expect (5 failure modes)
&lt;/h2&gt;

&lt;p&gt;Five reasons: scale-in is blocked during deployments, the scale-out/scale-in timing asymmetry, ALB request count isn't supported on blue/green, insufficient data never scales in, and editing the managed alarms breaks it.&lt;/p&gt;

&lt;p&gt;Most autoscaling problems aren't bugs — they're documented behavior that surprises you at the wrong moment. Here's the catalog, with the symptom you'll see, the cause, and the fix.&lt;/p&gt;

&lt;p&gt;1My service won't scale in&lt;/p&gt;

&lt;p&gt;Cause — Scale-in is conservative by design. The managed low alarm typically needs ~15 consecutive minutes below the threshold before removing tasks, while scale-out fires after ~3 minutes above. So a service that quiets down still runs extra tasks for a quarter of an hour.&lt;/p&gt;

&lt;p&gt;Fix — Accept it for steady services, or — if you need faster, asymmetric scale-in — disable scale-in on the target tracking policy and add a custom step scaling policy with your own thresholds. Step scaling trades away some of target tracking's churn protection for control.&lt;/p&gt;

&lt;p&gt;2Nothing scaled during my deployment&lt;/p&gt;

&lt;p&gt;Cause — Application Auto Scaling turns off scale-in while an ECS deployment is in progress. Scale-out still happens (unless suspended), but tasks added under load mid-deploy won't be removed until the deployment finishes.&lt;/p&gt;

&lt;p&gt;Fix — Expected behavior — let the deployment finish, scaling resumes after. If you also want to suspend scale-out during deploys, set DynamicScalingOutSuspended on the scalable target, then clear it when the deploy completes.&lt;/p&gt;

&lt;p&gt;3ALBRequestCountPerTarget scaling does nothing on blue/green&lt;/p&gt;

&lt;p&gt;Cause — ALBRequestCountPerTarget is not supported for the blue/green deployment type. The policy exists but never drives scaling.&lt;/p&gt;

&lt;p&gt;Fix — Use CPU or memory target tracking on blue/green services, or scale on request count only on rolling-update services. Don't mix the unsupported metric with blue/green and assume it works.&lt;/p&gt;

&lt;p&gt;4A service with spiky metrics never scales in&lt;/p&gt;

&lt;p&gt;Cause — Target tracking does not scale in on insufficient data — it refuses to read missing datapoints as 'low utilization', to protect availability. A service with gaps in its metric stream stays at its current task count.&lt;/p&gt;

&lt;p&gt;Fix — Make sure the metric reports continuously (a healthy service emits CPU/memory every minute). For request count, ensure the ALB target group is receiving traffic the policy can read.&lt;/p&gt;

&lt;p&gt;5Scaling went weird after someone 'fixed' an alarm&lt;/p&gt;

&lt;p&gt;Cause — Someone hand-edited the CloudWatch alarm target tracking manages. The next automatic adjustment reverts or conflicts with the change, and scaling behaves unpredictably.&lt;/p&gt;

&lt;p&gt;Fix — Never touch the managed alarms. Change behavior through the policy (target value, cooldowns) instead. If you need custom alarm logic, use step scaling, where you own the alarms outright.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cooldowns and thrashing — the settings that matter
&lt;/h2&gt;

&lt;p&gt;Scale-out cooldown ~60s keeps you responsive; scale-in ~300s prevents thrashing. Too short a scale-in cooldown thrashes tasks; a CPU target too high (80%) leaves no headroom to warm up.&lt;/p&gt;

&lt;p&gt;The cooldown is how long Service Auto Scaling waits for a scaling action to take effect before doing more. The two directions want different values, for different reasons.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Setting&lt;/th&gt;
&lt;th&gt;Default&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Metric + target&lt;/td&gt;
&lt;td&gt;CPU at 50% (or ALB requests/target)&lt;/td&gt;
&lt;td&gt;Leave headroom for new tasks to warm up&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Scale-out cooldown&lt;/td&gt;
&lt;td&gt;~60 sec&lt;/td&gt;
&lt;td&gt;Stay responsive under rising load&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Scale-in cooldown&lt;/td&gt;
&lt;td&gt;~300 sec&lt;/td&gt;
&lt;td&gt;Prevent thrashing on dips&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Min / max tasks&lt;/td&gt;
&lt;td&gt;Set both deliberately&lt;/td&gt;
&lt;td&gt;Max caps cost; min holds a floor&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Why asymmetric:&lt;/strong&gt; scaling out should be quick — under rising load you want capacity now, so a short ~60s cooldown is fine. Scaling in should be slow — pull tasks too eagerly and a brief dip removes capacity you need 90 seconds later, so the service adds it back, then removes it again. That cycle is thrashing, and a ~300s scale-in cooldown is what stops it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;On the target value:&lt;/strong&gt;50% is a sane default, not 80%. A high target means tasks only get added once the service is already near saturation — and new Fargate tasks take 30–90 seconds to start and warm up. By the time they're ready, the spike has already hurt latency. Lower target, more headroom, smoother scaling.&lt;/p&gt;

&lt;h2&gt;
  
  
  When target tracking is too slow: add step scaling
&lt;/h2&gt;

&lt;p&gt;Target tracking reacts on ~3-minute datapoints, too slow for sudden spikes. Add a step scaling policy on a steeper alarm to jump capacity fast; keep target tracking for steady state — they coexist.&lt;/p&gt;

&lt;p&gt;Target tracking is smooth but deliberate. For a service that goes from quiet to flooded in seconds — a flash sale, a batch kickoff, an SQS backlog — three-minute datapoints mean you're already dropping requests before it reacts. Step scaling fixes that: you define explicit thresholds (“CPU over 70% → add 4 tasks; over 90% → add 8”) and it jumps capacity the moment the alarm breaks.&lt;/p&gt;

&lt;p&gt;You don't have to choose. A service can run both: target tracking for the steady baseline and a step policy for the spike. When you have multiple policies, Service Auto Scaling prioritizes availability — it scales &lt;strong&gt;out&lt;/strong&gt; if &lt;em&gt;any&lt;/em&gt; policy says to, and scales &lt;strong&gt;in&lt;/strong&gt; only if &lt;em&gt;all&lt;/em&gt; of them agree. So the aggressive step policy can add capacity fast without the cautious target policy ever fighting it.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;KEY INSIGHT:&lt;/strong&gt; The tradeoff with step scaling: you own the alarms, which means you also own the churn. Target tracking has built-in protections against rapid up-down cycling; step scaling does not. Use step for the burst, keep target tracking carrying the steady state, and you get fast reaction without hand-managing thrash control.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  What this looks like across a fleet
&lt;/h2&gt;

&lt;p&gt;One service's autoscaling is a Terraform block. At 10+ services across environments, you maintain scalable targets, policies, and per-service tuning — a surface that grows with every environment.&lt;/p&gt;

&lt;p&gt;Autoscaling on one service is easy. The problem is multiplication. Each service needs its own scalable target, its own metric choice, its own cooldowns, and its own min/max — and the right values differ by service and by environment. A dev environment shouldn't scale to 20 tasks; production shouldn't cap at 4. Keeping that tuned by hand across a fleet is the work nobody budgets for.&lt;/p&gt;

&lt;p&gt;It compounds with the costs you're already carrying. Autoscaling controls compute, but every environment also pays &lt;a href="https://fortem.dev/blog/aws-fargate-pricing-real-costs/" rel="noopener noreferrer"&gt;the fixed overhead each environment already carries&lt;/a&gt; — ALB, NAT Gateway, CloudWatch — which autoscaling can't touch. Scaling well is only part of running a fleet economically.&lt;/p&gt;

&lt;p&gt;Fortem doesn't replace autoscaling — your policies keep doing their job. It gives you one place to see and tune scaling, scheduling, and cost across every ECS environment, so per-service drift doesn't pile up as your fleet grows.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you read this, you might also want to know
&lt;/h2&gt;

&lt;p&gt;Should I scale on CPU or memory?&lt;/p&gt;

&lt;p&gt;CPU is the safer default — most services are compute-bound, and memory often holds flat (so it never triggers scale-in). Use memory only when you know the service is memory-bound, and even then pair it with a CPU or request-count policy so the service can still scale down. For request-driven APIs, ALBRequestCountPerTarget beats both — it scales on real load, not a proxy.&lt;/p&gt;

&lt;p&gt;Can ECS Fargate scale to zero?&lt;/p&gt;

&lt;p&gt;Yes, with a target tracking policy and min_capacity = 0. When capacity is 0 and the metric shows demand, Service Auto Scaling waits for one datapoint, scales out by the minimum amount, then resumes normal scaling from the actual running count. It's useful for spiky non-prod or batch services — but cold-start latency on the first request after zero is the tradeoff.&lt;/p&gt;

&lt;p&gt;Does autoscaling fight my manual desired-count changes?&lt;/p&gt;

&lt;p&gt;Yes. As long as an active scaling policy and alarm exist on the service, Service Auto Scaling can override a desired count you set by hand. If you need to pin capacity temporarily — say, during an incident — suspend scaling on the scalable target rather than fighting it with manual updates.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Why won't my ECS Fargate service scale in?
&lt;/h3&gt;

&lt;p&gt;Three common reasons. (1) Scale-in is conservative by design — the managed alarm typically needs ~15 consecutive minutes below the threshold before removing tasks, versus ~3 minutes to scale out. (2) Application Auto Scaling turns off scale-in entirely while an ECS deployment is in progress. (3) Target tracking treats insufficient metric data as 'do not scale in', so a service with gaps in its metric never scales down. If you need faster or asymmetric scale-in, disable scale-in on the target tracking policy and add a custom step scaling policy.&lt;/p&gt;

&lt;h3&gt;
  
  
  What's the difference between target tracking and step scaling for ECS?
&lt;/h3&gt;

&lt;p&gt;Target tracking keeps a metric at a target value (like CPU at 50%) and AWS creates and manages the CloudWatch alarms for you — it's the easiest mode and the right default for steady load. Step scaling defines explicit alarm thresholds and how many tasks to add or remove at each breach level, so it reacts faster to sudden spikes. They can coexist: target tracking for steady state, a step policy for bursts.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can ECS Fargate autoscale on ALB request count?
&lt;/h3&gt;

&lt;p&gt;Yes — ALBRequestCountPerTarget is one of the three predefined target tracking metrics, alongside ECSServiceAverageCPUUtilization and ECSServiceAverageMemoryUtilization. It's often the best signal for request-driven services because it scales on actual load, not on a proxy like CPU. One caveat: ALBRequestCountPerTarget is not supported for the blue/green deployment type.&lt;/p&gt;

&lt;h3&gt;
  
  
  What cooldown should I use for ECS autoscaling?
&lt;/h3&gt;

&lt;p&gt;Sensible defaults: a short scale-out cooldown (~60 seconds) to stay responsive, and a longer scale-in cooldown (~300 seconds) to prevent thrashing — tasks being added and removed repeatedly. Pair that with a CPU target around 50%, not 80%: too high a target leaves no headroom for new tasks to warm up before the metric spikes again.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does ECS autoscaling work during a deployment?
&lt;/h3&gt;

&lt;p&gt;Partly. Application Auto Scaling turns off scale-in processes while an ECS deployment is in progress, but scale-out continues unless you suspend it. So a service can still add tasks under load mid-deploy, but won't remove them until the deployment finishes. This does not apply to services using an external deployment controller.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;### Running autoscaling across 10+ ECS environments? Per-service scaling drift i&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Worth reading&lt;/p&gt;

&lt;p&gt;&lt;a href="https://fortem.dev/aws-ecs-scheduling/" rel="noopener noreferrer"&gt;LandingECS Environment SchedulingThe other half of scaling: stop non-prod environments off-hours on a calendar. Every scheduling approach and what breaks at fleet scale.&lt;/a&gt;&lt;a href="https://fortem.dev/blog/ecs-fargate-best-practices/" rel="noopener noreferrer"&gt;GuideECS Fargate Best Practices: Running a Fleet of 10+ EnvironmentsNaming, fixed overhead, retention, Spot, quota isolation — the checklist for teams past ten environments, with real numbers.&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Map your fleet in 5 min:&lt;/strong&gt; &lt;a href="https://fortem.dev/audit" rel="noopener noreferrer"&gt;fortem.dev/audit&lt;/a&gt;&lt;/p&gt;

</description>
      <category>aws</category>
      <category>ecs</category>
      <category>fargate</category>
      <category>devops</category>
    </item>
    <item>
      <title>How Do You Set Up RBAC on ECS Fargate Without Breaking Prod?</title>
      <dc:creator>Matt</dc:creator>
      <pubDate>Tue, 30 Jun 2026 11:16:55 +0000</pubDate>
      <link>https://dev.to/dspv/how-do-you-set-up-rbac-on-ecs-fargate-without-breaking-prod-1off</link>
      <guid>https://dev.to/dspv/how-do-you-set-up-rbac-on-ecs-fargate-without-breaking-prod-1off</guid>
      <description>&lt;h1&gt;
  
  
  ECS Fargate RBAC: Scope Developer Access Safely
&lt;/h1&gt;

&lt;blockquote&gt;
&lt;p&gt;Originally published at &lt;a href="https://fortem.dev/blog/ecs-fargate-rbac" rel="noopener noreferrer"&gt;https://fortem.dev/blog/ecs-fargate-rbac&lt;/a&gt;&lt;br&gt;
IAM has no concept of an ECS environment. Build per-environment RBAC with ABAC tags — the working policy, the four ways it silently breaks prod, and where AWS-native IAM hits its ceiling.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;Use Case · June 30, 2026 · 10 min read&lt;/p&gt;

&lt;p&gt;You're the single human gate for ECS ops: developers ship through CI, but they can't restart staging or read a log without pinging you. You want to hand them scoped access — their environments, never prod — and every attempt is an IAM policy that grants too much or too little. This is the working RBAC model: an ABAC policy that scopes developers by environment tag, the four ways AWS-native IAM silently breaks prod, and where the ceiling is.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;  ECS has no "environment" concept in IAM. You build per-environment RBAC with tags (ABAC): a developer's principal tag must match the resource's Environment tag.&lt;/li&gt;
&lt;li&gt;  The working policy gates ecs:UpdateService / StopTask / DeleteService on aws:ResourceTag/Environment, and CreateService / RunTask on aws:RequestTag/Environment.&lt;/li&gt;
&lt;li&gt;  The trap: some ECS List actions ignore tag conditions entirely. Granted open to "*" they leak prod metadata — and PassRole lets a developer escalate past their environment.&lt;/li&gt;
&lt;li&gt;  On EC2 launch type, ECScape shows a low-privilege task can steal another task's credentials (instance-level isolation). Fargate's micro-VM isolation closes this — a real reason to be on Fargate.&lt;/li&gt;
&lt;li&gt;  AWS-native ABAC holds to about ten environments, then tag discipline and policy sprawl become the bottleneck. That's where a per-environment RBAC layer earns its keep.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  ECS has no idea what an "environment" is
&lt;/h2&gt;

&lt;p&gt;IAM has no native concept of an ECS environment. You simulate it: tag every resource with an Environment key, tag each developer's principal, and write policies that only allow an action when the two tags match.&lt;/p&gt;

&lt;p&gt;IAM thinks in ARNs and tags, not environments. There is no &lt;code&gt;ecs:Environment&lt;/code&gt; you can grant a developer "staging" access to. So you build the abstraction yourself with attribute-based access control (ABAC): give every cluster, service, and task an &lt;code&gt;Environment&lt;/code&gt;tag, give each developer's IAM role a matching principal tag, and condition every policy on the two being equal.&lt;/p&gt;

&lt;p&gt;The alternative — listing cluster ARNs in the Resource block — works for three clusters and collapses at thirty. The simplest version of this, &lt;a href="https://fortem.dev/blog/ecs-staging-self-service/" rel="noopener noreferrer"&gt;letting a developer restart staging&lt;/a&gt; with a policy matched to a &lt;code&gt;*-stg-*&lt;/code&gt; cluster pattern, gets you started. ABAC is where it goes when you have real environments and real developers — and where it stays safe past the first handful.&lt;/p&gt;

&lt;h2&gt;
  
  
  Ready to use: the ABAC policy that scopes by environment
&lt;/h2&gt;

&lt;p&gt;Gate ecs:UpdateService, StopTask, and DeleteService on aws:ResourceTag/Environment matching the developer's principal tag; gate RunTask and CreateService on aws:RequestTag/Environment. Add an explicit Deny on prod.&lt;/p&gt;

&lt;p&gt;Tag the developer's IAM role with &lt;code&gt;Environment=staging&lt;/code&gt;, tag every staging resource the same, and this policy lets them operate staging and nothing else. The &lt;code&gt;${aws:PrincipalTag/Environment}&lt;/code&gt; variable means one policy serves every environment — the match is dynamic.&lt;/p&gt;

&lt;p&gt;Ready to use — copy this today&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Version"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2012-10-17"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Statement"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Sid"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"OperateOwnedEnvironment"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Effect"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Allow"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Action"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"ecs:UpdateService"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"ecs:StopTask"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"ecs:DeleteService"&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Resource"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"*"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Condition"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"StringEquals"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"aws:ResourceTag/Environment"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"${aws:PrincipalTag/Environment}"&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Sid"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"CreateTaggedToOwnedEnvironment"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Effect"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Allow"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Action"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"ecs:CreateService"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"ecs:RunTask"&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Resource"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"*"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Condition"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"StringEquals"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"aws:RequestTag/Environment"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"${aws:PrincipalTag/Environment}"&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Sid"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"PassOnlyOwnedEnvironmentRoles"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Effect"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Allow"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Action"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"iam:PassRole"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Resource"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"arn:aws:iam::*:role/ecs/${aws:PrincipalTag/Environment}-*"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Sid"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"NeverProd"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Effect"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Deny"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Action"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ecs:*"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Resource"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"*"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Condition"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"StringEquals"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"aws:ResourceTag/Environment"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"prod"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Why the explicit Deny&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;NeverProd&lt;/code&gt;statement is a backstop, not the primary control. The Allow statements already scope to the developer's own environment — but an explicit Deny on the &lt;code&gt;prod&lt;/code&gt; tag wins over any Allow, anywhere, including a future policy someone attaches by mistake. Defense in depth costs four lines.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which ECS actions actually respect your tags
&lt;/h2&gt;

&lt;p&gt;Not every ECS action honors tag conditions. StopTask, DeleteService, and UpdateService respect aws:ResourceTag; CreateService and RunTask respect both. But List actions like ListClusters and ListServices ignore tags entirely — scope those another way or they leak prod.&lt;/p&gt;

&lt;p&gt;This is the table that decides whether your policy is airtight or quietly open. If you gate an action on &lt;code&gt;aws:ResourceTag&lt;/code&gt;that the action doesn't populate, the &lt;code&gt;StringEquals&lt;/code&gt;condition evaluates to false and the statement simply doesn't grant — it fails closed. The leak is the other direction: to make those tag-blind List and Describe calls usable at all, you end up granting them in a separate statement with no condition — and &lt;em&gt;that&lt;/em&gt; open grant is what reaches every environment:&lt;/p&gt;

&lt;p&gt;ActionResourceRequestScope it by&lt;/p&gt;

&lt;p&gt;ecs:StopTaskyes—scope by resource tag&lt;/p&gt;

&lt;p&gt;ecs:UpdateServiceyes—scope by resource tag&lt;/p&gt;

&lt;p&gt;ecs:DeleteServiceyes—scope by resource tag&lt;/p&gt;

&lt;p&gt;ecs:CreateServiceyesyestag at create time&lt;/p&gt;

&lt;p&gt;ecs:RunTaskyesyestag at create time&lt;/p&gt;

&lt;p&gt;ecs:DescribeServicesyes—scope by resource tag&lt;/p&gt;

&lt;p&gt;ecs:ListClustersno—no tag conditions — leaks prod metadata&lt;/p&gt;

&lt;p&gt;ecs:ListServicesno—no tag conditions — scope another way&lt;/p&gt;

&lt;p&gt;For the actions that ignore tags — the account-level List calls — your only levers are separate AWS accounts or not granting them. A read-only &lt;code&gt;ecs:ListClusters&lt;/code&gt; open to &lt;code&gt;*&lt;/code&gt;won't let a developer change prod, but it shows them every environment's name and shape — metadata a strict RBAC model is supposed to withhold.&lt;/p&gt;

&lt;h2&gt;
  
  
  The four ways this breaks prod
&lt;/h2&gt;

&lt;p&gt;RBAC breaks prod four ways: actions that ignore tags, PassRole escalation, untagged resources defaulting open, and — on EC2 — ECScape credential theft. Each is a specific misconfiguration, not bad luck.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt; 1*&lt;em&gt;Actions that ignore tags.&lt;/em&gt;* List and Describe calls that don't carry an Environment tag can't be scoped by one — your tag condition fails closed, so to make them usable you grant them with no condition, and that open statement reaches every environment's metadata. Audit which actions you've granted unconditioned against the support table above.&lt;/li&gt;
&lt;li&gt; 2*&lt;em&gt;PassRole escalation.&lt;/em&gt;* A developer who can RegisterTaskDefinition and iam:PassRole a powerful role can launch a task that runs as that role — and reach anything that role can. Scope iam:PassRole to only the task and execution roles for their environment, never a wildcard. This is the non-obvious one, and the most dangerous.&lt;/li&gt;
&lt;li&gt; 3*&lt;em&gt;Untagged resources slip the Deny.&lt;/em&gt;* A condition on aws:ResourceTag/Environment only matches resources that HAVE the tag — so an untagged cluster matches neither your scoped Allow nor your NeverProd Deny. It won't be reachable through the ABAC policy, but if any broader Allow exists, an untagged prod resource is no longer blocked by the Deny that was supposed to catch it. Enforce tagging at creation with a RequestTag condition or an SCP.&lt;/li&gt;
&lt;li&gt; 4*&lt;em&gt;ECScape on EC2 launch type.&lt;/em&gt;* On EC2, a low-privilege task can steal the IAM credentials of a more privileged task on the same instance. Your task-level RBAC is real on paper and bypassed in practice. Fargate closes this — see the next section.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;None of these throw an error when you deploy the policy. They're visible only when you test — or when the audit happens. The &lt;a href="https://fortem.dev/blog/ecs-audit-log-compliance/" rel="noopener noreferrer"&gt;audit trail that proves who did what&lt;/a&gt; in CloudTrail is how you catch the escalation after the fact; the policy is how you prevent it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Fargate makes this safer than EC2
&lt;/h2&gt;

&lt;p&gt;On EC2 launch type, ECScape showed a low-privilege task can steal another task's IAM credentials — isolation is instance-level, not task-level. Fargate runs each task in its own micro-VM, so task-level RBAC actually holds.&lt;/p&gt;

&lt;p&gt;The ECScape research is worth understanding because it undercuts an assumption most RBAC models rest on: that a task's IAM role is isolated to that task. On EC2, it isn't. Multiple tasks share one host, ECS delivers their credentials over a channel on that host, and a compromised low-privilege container can impersonate the agent and intercept the credentials destined for every other task on the instance. Your carefully scoped per-task roles become a shared pool.&lt;/p&gt;

&lt;p&gt;On Fargate, each task gets its own micro-VM with isolated credentials and its own IMDS — there is no co-tenant to steal from. The mitigations on EC2 (block container access to IMDS at&lt;code&gt;169.254.169.254&lt;/code&gt;, run privileged tasks on separate instances) are things you don't have to think about on Fargate. If your RBAC model assumes task-level isolation, Fargate is the launch type that makes the assumption true.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;KEY INSIGHT:&lt;/strong&gt; RBAC is only as strong as the isolation underneath it. A perfect ABAC policy on EC2 launch type can still be bypassed at the credential layer; the same policy on Fargate holds because the micro-VM boundary is real. The access model and the isolation model have to agree.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Where AWS-native RBAC hits its ceiling
&lt;/h2&gt;

&lt;p&gt;ABAC works until tag discipline fails. Past about ten environments, every new env needs the tag applied everywhere, every policy re-audited, every untagged resource hunted down. IAM has no per-environment role concept to lean on.&lt;/p&gt;

&lt;p&gt;The ABAC model is correct and it scales — until the thing it depends on, perfect tagging, stops being free. At ten environments, one missing &lt;code&gt;Environment&lt;/code&gt; tag is a hole. At thirty, finding the missing tag is its own job. You end up writing SCPs to enforce tagging, AWS Config rules to flag untagged resources, and a runbook for onboarding each new environment into the policy set — maintaining the simulation of something IAM was never designed to model.&lt;/p&gt;

&lt;p&gt;That's the point where a per-environment RBAC layer earns its keep: instead of hand-maintaining ABAC tags and policies, you grant a developer a role on the environments they own — restart, redeploy, read logs, run a one-off task — and prod is off-limits by construction, not by a condition key you hope is correct. The AWS-native approach is the right place to start; it's not the right place to be at fleet scale.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you read this, you might also want to know
&lt;/h2&gt;

&lt;p&gt;Can I scope ECS access by cluster instead of tags?&lt;/p&gt;

&lt;p&gt;Yes, with a Resource block listing cluster ARNs or a StringLike condition on the cluster name (e.g. *-stg-*). It's simpler to reason about for a few clusters, but you edit every policy each time you add an environment. ABAC tags avoid that — at the cost of needing every resource tagged.&lt;/p&gt;

&lt;p&gt;Does ECS Exec respect the same RBAC?&lt;/p&gt;

&lt;p&gt;ECS Exec (the shell-into-a-container feature) is gated by ecs:ExecuteCommand, which supports the same aws:ResourceTag/Environment condition — so you can scope exec to a developer's own environment the same way. Many teams forget to scope it and leave a path into prod containers wide open.&lt;/p&gt;

&lt;p&gt;How do I stop developers from escalating via PassRole?&lt;/p&gt;

&lt;p&gt;Scope iam:PassRole to a path or ARN pattern that only covers their environment's roles (e.g. role/ecs/staging-*), never a wildcard. Without it, a developer who can register a task definition can pass any role they're allowed to pass and run a task as it — escalating straight past the environment boundary.&lt;/p&gt;

&lt;p&gt;Do I need separate AWS accounts for hard RBAC boundaries?&lt;/p&gt;

&lt;p&gt;For the strongest boundary, yes — an account boundary is the one thing a tag typo can't cross, and it's the only way to fully contain the tag-ignoring List/Describe actions. Most teams keep prod in its own account and share a non-prod account, using ABAC tags for per-environment scoping within each.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Book a 20-min fleet walkthrough:&lt;/strong&gt; &lt;a href="https://fortem.dev/book" rel="noopener noreferrer"&gt;fortem.dev/book&lt;/a&gt;&lt;/p&gt;

</description>
      <category>aws</category>
      <category>ecs</category>
      <category>security</category>
      <category>devops</category>
    </item>
    <item>
      <title>How Do You Prepare ECS Fargate for a SOC 2 Audit?</title>
      <dc:creator>Matt</dc:creator>
      <pubDate>Tue, 30 Jun 2026 11:16:14 +0000</pubDate>
      <link>https://dev.to/dspv/how-do-you-prepare-ecs-fargate-for-a-soc-2-audit-14dl</link>
      <guid>https://dev.to/dspv/how-do-you-prepare-ecs-fargate-for-a-soc-2-audit-14dl</guid>
      <description>&lt;h1&gt;
  
  
  How to Prepare ECS Fargate for SOC 2 Compliance
&lt;/h1&gt;

&lt;blockquote&gt;
&lt;p&gt;Originally published at &lt;a href="https://fortem.dev/blog/ecs-compliance-soc2" rel="noopener noreferrer"&gt;https://fortem.dev/blog/ecs-compliance-soc2&lt;/a&gt;&lt;br&gt;
AWS being SOC 2 certified doesn't make you compliant. The exact ECS Fargate task-definition settings an auditor flags — ECS.4, ECS.5, ECS.8, ECS.20 — and the copy-paste fixes.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;Use Case · June 29, 2026 · 10 min read&lt;/p&gt;

&lt;p&gt;A prospect won't sign without your SOC 2 Type II report. You bought Vanta, the dashboard lit up red — and half the failing controls point at your ECS task definitions. AWS being SOC 2 certified does not make you compliant — it covers the cloud; you own what runs in it. This is the ECS-specific remediation: the exact Fargate settings an auditor's tooling flags, the copy-paste fixes, and why the real work of Type II is six months of evidence, not a one-day config sprint.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;  AWS's SOC 2 covers the cloud (Artifact report, under NDA). Your ECS task definitions, IAM, and logging are yours to prove — that's the shared responsibility line.&lt;/li&gt;
&lt;li&gt;  Security Hub flags specific ECS controls: ECS.4 (non-privileged), ECS.5 (read-only root FS), ECS.8 (no secrets in env vars), ECS.9 (logging), ECS.20 (non-root user), ECS.2 (no public IP).&lt;/li&gt;
&lt;li&gt;  Most ECS findings map to two Trust Services Criteria: CC6 (logical access) and CC7 (monitoring). Fix the task-def parameters and they clear.&lt;/li&gt;
&lt;li&gt;  Type II is a ~6-month observation window. The hard part isn't the one-time fix — it's continuous evidence the controls held across every environment, every day.&lt;/li&gt;
&lt;li&gt;  SOC 2 does NOT require EKS-style admission controllers or a service mesh for ECS. Don't over-build controls the auditor never asked for.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  AWS is SOC 2 certified — so why aren't you?
&lt;/h2&gt;

&lt;p&gt;AWS's SOC 2 covers security of the cloud — datacenters, hypervisor, hardware. You own security in the cloud: your ECS task definitions, IAM, and logging. The auditor only tests your half.&lt;/p&gt;

&lt;p&gt;The single most common SOC 2 misconception on AWS is that running on a SOC 2-certified platform makes you SOC 2-compliant. It doesn't. The shared responsibility model splits the control set: AWS proves the infrastructure is secure, and you prove that what you run on it is secure. You can download AWS's own SOC 2 report from &lt;strong&gt;AWS Artifact&lt;/strong&gt; — the self-service portal in the console, gated behind an NDA — and hand it to your auditor. That lets them _inherit_AWS's infrastructure controls and stop looking at the datacenter.&lt;/p&gt;

&lt;p&gt;What's left is your half — and for an ECS shop, your half is mostly task definitions, IAM scoping, and logging. The auditor will also want the audit trail of &lt;a href="https://fortem.dev/blog/ecs-audit-log-compliance/" rel="noopener noreferrer"&gt;who changed what across your fleet&lt;/a&gt; — CloudTrail is the evidence source for that control. The rest of this guide is the ECS-specific part nobody else writes down.&lt;/p&gt;

&lt;p&gt;AWS proves (inherited)You prove (tested)&lt;/p&gt;

&lt;p&gt;Datacenter physical securityTask-def hardening (ECS.4/.5/.20)&lt;/p&gt;

&lt;p&gt;Hypervisor &amp;amp; host patchingIAM scoping &amp;amp; least privilege&lt;/p&gt;

&lt;p&gt;Fargate platform isolationSecrets handling (SSM / Secrets Manager)&lt;/p&gt;

&lt;p&gt;Network backboneLogging &amp;amp; monitoring (ECS.9/.12)&lt;/p&gt;

&lt;h2&gt;
  
  
  Ready to use: a SOC 2-clean task definition
&lt;/h2&gt;

&lt;p&gt;This Fargate task definition clears the Security Hub ECS controls that flag a task — the high-severity ones (ECS.4, ECS.5, ECS.8, ECS.9, ECS.2) plus the medium-severity non-root and Container Insights checks. Each hardened line is annotated with the control ID it satisfies.&lt;/p&gt;

&lt;p&gt;The whole point: every flagged setting is a task-definition parameter, not an application rewrite. Drop this into your shared ECS module and every environment inherits the same clean baseline.&lt;/p&gt;

&lt;p&gt;Ready to use — copy this today&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight terraform"&gt;&lt;code&gt;&lt;span class="k"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_ecs_task_definition"&lt;/span&gt; &lt;span class="s2"&gt;"app"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;family&lt;/span&gt;                   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"use1-prod-main-app"&lt;/span&gt;
  &lt;span class="nx"&gt;requires_compatibilities&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"FARGATE"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="nx"&gt;network_mode&lt;/span&gt;             &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"awsvpc"&lt;/span&gt;
  &lt;span class="nx"&gt;cpu&lt;/span&gt;                      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"512"&lt;/span&gt;
  &lt;span class="nx"&gt;memory&lt;/span&gt;                   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"1024"&lt;/span&gt;
  &lt;span class="nx"&gt;execution_role_arn&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_iam_role&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;task_exec&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;arn&lt;/span&gt;
  &lt;span class="nx"&gt;task_role_arn&lt;/span&gt;            &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_iam_role&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;task&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;arn&lt;/span&gt;

  &lt;span class="nx"&gt;container_definitions&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;jsonencode&lt;/span&gt;&lt;span class="p"&gt;([{&lt;/span&gt;
    &lt;span class="nx"&gt;name&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"app"&lt;/span&gt;
    &lt;span class="nx"&gt;image&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;aws_ecr_repository&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;repository_url&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="kd"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;image_tag&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

    &lt;span class="nx"&gt;user&lt;/span&gt;                   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"10001"&lt;/span&gt;   &lt;span class="c1"&gt;# ECS.20 — non-root Linux user&lt;/span&gt;
    &lt;span class="nx"&gt;privileged&lt;/span&gt;             &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;     &lt;span class="c1"&gt;# ECS.4  — no elevated privileges&lt;/span&gt;
    &lt;span class="nx"&gt;readonlyRootFilesystem&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;      &lt;span class="c1"&gt;# ECS.5  — read-only root filesystem&lt;/span&gt;

    &lt;span class="c1"&gt;# ECS.8 — secrets via valueFrom, never plain environment vars&lt;/span&gt;
    &lt;span class="nx"&gt;secrets&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt;
      &lt;span class="nx"&gt;name&lt;/span&gt;      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"DB_PASSWORD"&lt;/span&gt;
      &lt;span class="nx"&gt;valueFrom&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_secretsmanager_secret&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;arn&lt;/span&gt;
    &lt;span class="p"&gt;}]&lt;/span&gt;

    &lt;span class="c1"&gt;# ECS.9 — task definition must declare a log configuration&lt;/span&gt;
    &lt;span class="nx"&gt;logConfiguration&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;logDriver&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"awslogs"&lt;/span&gt;
      &lt;span class="nx"&gt;options&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="s2"&gt;"awslogs-group"&lt;/span&gt;         &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"/ecs/use1-prod-main-app"&lt;/span&gt;
        &lt;span class="s2"&gt;"awslogs-region"&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"us-east-1"&lt;/span&gt;
        &lt;span class="s2"&gt;"awslogs-stream-prefix"&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"app"&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}])&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;# ECS.2 — never auto-assign a public IP (ECS.16 is the task-set equivalent)&lt;/span&gt;
&lt;span class="k"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_ecs_service"&lt;/span&gt; &lt;span class="s2"&gt;"app"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;            &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"use1-prod-main-app"&lt;/span&gt;
  &lt;span class="nx"&gt;task_definition&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_ecs_task_definition&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;arn&lt;/span&gt;
  &lt;span class="nx"&gt;launch_type&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"FARGATE"&lt;/span&gt;

  &lt;span class="nx"&gt;network_configuration&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;subnets&lt;/span&gt;          &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kd"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;private_subnet_ids&lt;/span&gt;
    &lt;span class="nx"&gt;assign_public_ip&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If your container needs scratch space&lt;/p&gt;

&lt;p&gt;&lt;code&gt;readonlyRootFilesystem = true&lt;/code&gt; breaks apps that write to &lt;code&gt;/tmp&lt;/code&gt;. Don't revert the control — mount a writable &lt;code&gt;tmpfs&lt;/code&gt; volume for the paths that need it. The root filesystem stays read-only; the auditor still passes ECS.5; your app still writes its temp files.&lt;/p&gt;

&lt;h2&gt;
  
  
  The ECS controls an auditor's tooling flags
&lt;/h2&gt;

&lt;p&gt;Security Hub checks specific ECS controls: ECS.4 non-privileged, ECS.5 read-only root filesystem, ECS.8 no credential keys in env vars, ECS.9 logging, ECS.20 non-root user, ECS.2 / ECS.16 no public IP.&lt;/p&gt;

&lt;p&gt;These are the AWS-published Security Hub CSPM checks for ECS. An auditor — or your own CSPM dashboard, or Vanta/Drata pulling from Security Hub — surfaces failures by control ID. Each row below is a task-definition parameter and the value that fails it:&lt;/p&gt;

&lt;p&gt;ControlFails whenThe fixTSC&lt;/p&gt;

&lt;p&gt;ECS.4privileged = trueprivileged: falseCC6&lt;/p&gt;

&lt;p&gt;ECS.5readonlyRootFilesystem false / absentreadonlyRootFilesystem: trueCC6&lt;/p&gt;

&lt;p&gt;ECS.20Linux user is root or unsetuser: a non-root UIDCC6&lt;/p&gt;

&lt;p&gt;ECS.2AssignPublicIp ENABLEDassign_public_ip: DISABLEDCC6&lt;/p&gt;

&lt;p&gt;ECS.8AWS credential key in environment varssecrets via valueFrom (SSM / Secrets Manager)CC6&lt;/p&gt;

&lt;p&gt;ECS.9no logConfigurationlogConfiguration with awslogs / FireLensCC7&lt;/p&gt;

&lt;p&gt;ECS.12Container Insights off on clusterenable Container InsightsCC7&lt;/p&gt;

&lt;p&gt;The TSC column is the Trust Services Criterion each control maps to — explained below. AWS tags these controls against NIST 800-53 and PCI; the SOC 2 mapping is the one your auditor draws, and it's worth handing them pre-drawn.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix it in the task definition
&lt;/h2&gt;

&lt;p&gt;Every high-severity ECS finding is one task-definition parameter: readonlyRootFilesystem, user, privileged, secrets via valueFrom, logConfiguration, assignPublicIp. Set them once in your module.&lt;/p&gt;

&lt;p&gt;None of these are application changes. They're container-definition fields — the same ones covered in &lt;a href="https://fortem.dev/blog/ecs-task-definition-guide/" rel="noopener noreferrer"&gt;every task-definition field and the common mistakes&lt;/a&gt;. The remediation that trips teams up isn't writing the values; it's applying them to every task definition across every environment without missing one.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;KEY INSIGHT:&lt;/strong&gt; Set the hardened parameters as module-level defaults, not per-service overrides. A shared ECS module that bakes in readonlyRootFilesystem = true, a non-root user, and assign_public_ip = falsemeans every new environment is compliant the day it's created — and you can't forget the setting on environment number twelve.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Map ECS controls to Trust Services Criteria
&lt;/h2&gt;

&lt;p&gt;Most ECS findings map to two criteria: CC6 logical access (non-root, no privilege, no public IP) and CC7 monitoring (logging, Container Insights, GuardDuty). Your auditor wants the mapping spelled out.&lt;/p&gt;

&lt;p&gt;SOC 2's Common Criteria are where ECS lives. &lt;strong&gt;CC6 (Logical and Physical Access Controls — the logical half is yours)&lt;/strong&gt; is the bucket for least privilege: ECS.4 (no privileged containers), ECS.5 (read-only root filesystem), ECS.20 (non-root user), and ECS.2 / ECS.16 (no public IP) all reduce the access an attacker gets if a container is compromised. &lt;strong&gt;CC7 (System Operations)&lt;/strong&gt; is monitoring: ECS.9 (logging configured), ECS.12 (Container Insights), and GuardDuty Runtime Monitoring give you the detection the criterion asks for. CC6 also covers human access — auditors expect &lt;a href="https://fortem.dev/blog/ecs-fargate-rbac/" rel="noopener noreferrer"&gt;per-environment RBAC scoping developer access&lt;/a&gt; so no one but the right people can touch a given environment.&lt;/p&gt;

&lt;p&gt;Handing your auditor the control-to-criterion mapping pre-drawn — "ECS.5 satisfies our CC6.1 least-privilege control" — turns a back-and-forth into a checkbox. They do this mapping anyway; doing it for them shortens the audit.&lt;/p&gt;

&lt;h2&gt;
  
  
  The real work is six months of evidence
&lt;/h2&gt;

&lt;p&gt;Type II isn't a one-day fix — it's a ~6-month observation window. The auditor wants proof the controls held every day, across every environment. One drifted task def in month four is a finding.&lt;/p&gt;

&lt;p&gt;A first SOC 2 Type II runs roughly months 1–3 to implement controls, then a six-month window where the auditor verifies they operated &lt;em&gt;continuously&lt;/em&gt;. Type I is a point-in-time snapshot; Type II is the movie. That distinction is the whole cost: the hardened task definition takes an afternoon, but proving it stayed hardened across eleven environments for six months is the part that consumes the platform team.&lt;/p&gt;

&lt;p&gt;This is where multi-environment ECS shops feel it. Vanta and Drata automate the evidence collection from Security Hub and Config — but they collect whatever state exists. If a developer spins up environment twelve from an older module, or someone toggles a setting at 2am during an incident, the drift is real and the evidence captures it. The control that matters most at fleet scale isn't any single ECS.x check — it's knowing the whole fleet's state, continuously, so drift surfaces the day it happens, not in the auditor's sample three months later.&lt;/p&gt;

&lt;h2&gt;
  
  
  What SOC 2 does NOT require for ECS
&lt;/h2&gt;

&lt;p&gt;SOC 2 is risk-based, not prescriptive. It does not require EKS-style admission controllers, a service mesh, or a specific scanner for ECS. Don't over-build controls the auditor never asked for.&lt;/p&gt;

&lt;p&gt;Unlike a prescriptive standard, SOC 2 lets you define your own controls against the Trust Services Criteria, then proves you operate them. There is no SOC 2 line item that says "run OPA Gatekeeper" or "deploy a service mesh." Teams coming from Kubernetes sometimes import a control set they don't need — admission webhooks, Pod Security Standards, a sidecar mesh — none of which an ECS auditor asks for.&lt;/p&gt;

&lt;p&gt;The trap is scope creep: every extra control you claim in your system description is a control you now have to produce six months of evidence for. Match your stated controls to your actual architecture. For ECS Fargate, the high-severity Security Hub controls plus scoped IAM, logging, and an audit trail cover the Common Criteria an auditor tests. Build those well; skip the rest.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you read this, you might also want to know
&lt;/h2&gt;

&lt;p&gt;Do I need a separate AWS account for SOC 2?&lt;/p&gt;

&lt;p&gt;Not strictly. SOC 2 doesn't mandate account structure — it cares that prod is isolated and access is scoped. A separate prod account is the cleanest way to draw that boundary and makes the auditor's scoping trivial, but a single account with hard IAM separation and tagging can pass. Most teams split prod out anyway, for blast radius as much as for the audit.&lt;/p&gt;

&lt;p&gt;Does Fargate make SOC 2 easier than EC2?&lt;/p&gt;

&lt;p&gt;Yes, on the infrastructure half. Fargate removes the host from your responsibility — no EC2 patching, no host hardening, no SSH access control to evidence. AWS builds the patched Fargate platform versions; you stay current by running platform version LATEST and redeploying — ECS.10 fails if a service is pinned to an older version. You inherit more of the control set, leaving you the task-definition and IAM half. On EC2 launch type, host-level controls land back on you.&lt;/p&gt;

&lt;p&gt;How long does a first SOC 2 Type II take?&lt;/p&gt;

&lt;p&gt;Plan on 9-12 months end to end: roughly 1-3 months to implement and document controls, then a 6-month observation window the auditor reviews, then the report. A Type I (point-in-time) can be done in weeks and is sometimes used as a stepping stone to show progress while the Type II window runs.&lt;/p&gt;

&lt;p&gt;Can Vanta or Drata collect ECS evidence automatically?&lt;/p&gt;

&lt;p&gt;Partly. They integrate with AWS Security Hub and Config to pull control state — including the ECS.x findings — and map them to SOC 2 criteria automatically. What they can't do is fix drift or give you a single operational view of every environment's live state. They report what exists; keeping the fleet itself consistent is still on you.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Book a 20-min fleet walkthrough:&lt;/strong&gt; &lt;a href="https://fortem.dev/book" rel="noopener noreferrer"&gt;fortem.dev/book&lt;/a&gt;&lt;/p&gt;

</description>
      <category>aws</category>
      <category>ecs</category>
      <category>security</category>
      <category>compliance</category>
    </item>
    <item>
      <title>How Do You Manage ECS Fargate Across Multiple AWS Accounts?</title>
      <dc:creator>Matt</dc:creator>
      <pubDate>Tue, 30 Jun 2026 11:16:09 +0000</pubDate>
      <link>https://dev.to/dspv/how-do-you-manage-ecs-fargate-across-multiple-aws-accounts-167i</link>
      <guid>https://dev.to/dspv/how-do-you-manage-ecs-fargate-across-multiple-aws-accounts-167i</guid>
      <description>&lt;h1&gt;
  
  
  Managing ECS Fargate Across Multiple AWS Accounts
&lt;/h1&gt;

&lt;blockquote&gt;
&lt;p&gt;Originally published at &lt;a href="https://fortem.dev/blog/ecs-multi-account-management" rel="noopener noreferrer"&gt;https://fortem.dev/blog/ecs-multi-account-management&lt;/a&gt;&lt;br&gt;
How to operate an ECS Fargate fleet across multiple AWS accounts: cross-account IAM, central ECR, Transit Gateway cost, and the single-pane-of-glass AWS doesn't ship.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;You already split prod, non-prod, and maybe per-region or per-customer into separate AWS accounts — for isolation, blast radius, and clean billing. Now you're paying the operating tax: logging in and out all day, re-deploying the same image to five accounts, and no single screen that shows what's running where. This is the operating model for an &lt;a href="https://fortem.dev/aws-ecs-fargate/" rel="noopener noreferrer"&gt;ECS Fargate&lt;/a&gt; fleet that already lives across accounts — plus the costs that never make the spreadsheet, and the case where splitting accounts isn't worth it.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;  A multi-account AWS Org gives you isolation; it takes away a single pane of glass. AWS has no "Lens for ECS" — you stitch one together from IAM, the CLI, and pipelines.&lt;/li&gt;
&lt;li&gt;  Five operating surfaces to solve: cross-account visibility, IAM access, image distribution (ECR), networking, and deploy.&lt;/li&gt;
&lt;li&gt;  Going multi-account has a real bill: Transit Gateway alone is ~$36/mo per VPC attachment plus $0.02/GB, before any data transfer.&lt;/li&gt;
&lt;li&gt;  The aws:PrincipalOrgID ECR shortcut opens your image repo to every account in the Org — convenient, and a security review waiting to happen.&lt;/li&gt;
&lt;li&gt;  Below ~10 environments, don't split accounts to feel enterprise. The operating tax outweighs the isolation upside until you're at fleet scale.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why managing ECS across accounts is hard
&lt;/h2&gt;

&lt;p&gt;A multi-account AWS Org buys you isolation and clean billing — but AWS ships no single control plane for ECS. There's no "Lens for ECS", so you operate each account by hand.&lt;/p&gt;

&lt;p&gt;This is not a hypothetical complaint. On AWS re:Post, a practitioner running "lots of AWS Accounts" asked the obvious question: is there a way to manage ECS clusters across them "without logging back and forth between the accounts" — ideally a tool like Lens, the Kubernetes IDE. The answer, from an AWS-side responder, was blunt:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"First of all, I don't think there is a tool quite like Lens, for ECS. That is, centrally manage and store credentials of various clusters and provide single plane of glass for cluster management."&lt;/p&gt;

&lt;p&gt;— &lt;a href="https://repost.aws/questions/QUyK-uFyY0Tk23X6kz5BEQ1Q/how-to-manage-ecs-clusters-across-accounts" rel="noopener noreferrer"&gt;AWS re:Post, "How to manage ECS Clusters across accounts?"&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The recommended path is exactly the manual stitching you'd expect: named CLI profiles per account, cross-account IAM roles, and a centralized CI/CD pipeline that assumes into each target account. None of that is wrong. But you — not AWS — are the integration layer, which is the heart of &lt;a href="https://fortem.dev/blog/platform-engineering-ecs/" rel="noopener noreferrer"&gt;the operations gap that opens up at fleet scale&lt;/a&gt;. A "what's running in &lt;code&gt;euw1-stag-main&lt;/code&gt;?" question becomes a context switch, an assume-role, and a fresh console tab.&lt;/p&gt;

&lt;p&gt;This guide assumes you've already decided to go multi-account. If you're still working out &lt;a href="https://fortem.dev/blog/ecs-multi-environment-strategy/" rel="noopener noreferrer"&gt;how to structure those accounts in the first place&lt;/a&gt; — one account per environment group versus a single shared account — that decision comes first, and it's a different article. The short version: prod gets its own account for blast radius; non-prod usually shares one until Fargate quota or audit scope forces a split. Once that's settled, the question becomes operational: how do you run the fleet day to day?&lt;/p&gt;

&lt;h2&gt;
  
  
  Ready to use: a cross-account fleet-viewer role
&lt;/h2&gt;

&lt;p&gt;Deploy one read-only IAM role to every member account, assumable from a single ops account. Now one set of credentials can list and describe ECS across the whole Org — no console hopping.&lt;/p&gt;

&lt;p&gt;Drop this Terraform into each member account (via your account-baseline module or StackSets). The role trusts only your ops account and grants ECS read plus the cost and tag reads you need for a fleet view. From the ops account you then &lt;code&gt;aws sts assume-role&lt;/code&gt; into each account and run &lt;code&gt;aws ecs list-clusters&lt;/code&gt; against all of them with one identity.&lt;/p&gt;

&lt;p&gt;Ready to use — copy this today&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight terraform"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Deploy in EVERY member account. ops_account_id is your single&lt;/span&gt;
&lt;span class="c1"&gt;# operations/admin account that does the cross-account reading.&lt;/span&gt;
&lt;span class="k"&gt;variable&lt;/span&gt; &lt;span class="s2"&gt;"ops_account_id"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;string&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;data&lt;/span&gt; &lt;span class="s2"&gt;"aws_iam_policy_document"&lt;/span&gt; &lt;span class="s2"&gt;"trust"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;statement&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;actions&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"sts:AssumeRole"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="nx"&gt;principals&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;type&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"AWS"&lt;/span&gt;
      &lt;span class="nx"&gt;identifiers&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"arn:aws:iam::&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="kd"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ops_account_id&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:root"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="c1"&gt;# Require an external ID to block the confused-deputy problem.&lt;/span&gt;
    &lt;span class="nx"&gt;condition&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;test&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"StringEquals"&lt;/span&gt;
      &lt;span class="k"&gt;variable&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"sts:ExternalId"&lt;/span&gt;
      &lt;span class="nx"&gt;values&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"fortem-fleet-viewer"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;data&lt;/span&gt; &lt;span class="s2"&gt;"aws_iam_policy_document"&lt;/span&gt; &lt;span class="s2"&gt;"read"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;statement&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;sid&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"EcsFleetRead"&lt;/span&gt;
    &lt;span class="nx"&gt;effect&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Allow"&lt;/span&gt;
    &lt;span class="nx"&gt;actions&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
      &lt;span class="s2"&gt;"ecs:List*"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="s2"&gt;"ecs:Describe*"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="s2"&gt;"ce:GetCostAndUsage"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;        &lt;span class="c1"&gt;# per-account cost for the fleet view&lt;/span&gt;
      &lt;span class="s2"&gt;"tag:GetResources"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;          &lt;span class="c1"&gt;# owner / env tags&lt;/span&gt;
      &lt;span class="s2"&gt;"cloudwatch:GetMetricData"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;# task counts, CPU/mem&lt;/span&gt;
    &lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="nx"&gt;resources&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"*"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_iam_role"&lt;/span&gt; &lt;span class="s2"&gt;"fleet_viewer"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;               &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"fortem-fleet-viewer"&lt;/span&gt;
  &lt;span class="nx"&gt;assume_role_policy&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;aws_iam_policy_document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;trust&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;json&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_iam_role_policy"&lt;/span&gt; &lt;span class="s2"&gt;"fleet_viewer"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"fleet-read"&lt;/span&gt;
  &lt;span class="nx"&gt;role&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_iam_role&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fleet_viewer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;policy&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;aws_iam_policy_document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;read&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;json&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Why read-only, why an external ID&lt;/p&gt;

&lt;p&gt;The fleet view is for answering "what's running where, who owns it, what does it cost" — it never needs write access, so it doesn't get any. The &lt;code&gt;sts:ExternalId&lt;/code&gt; condition stops a third party who happens to learn your ops account ID from assuming the role on your behalf. This is the same role shape a control plane like Fortem uses — least privilege, customer-revocable in two clicks.&lt;/p&gt;

&lt;h2&gt;
  
  
  The five surfaces you have to operate
&lt;/h2&gt;

&lt;p&gt;Operating ECS across accounts means solving five surfaces: fleet visibility, cross-account IAM access, image distribution, networking, and deploy. AWS gives you a primitive for each, not a system.&lt;/p&gt;

&lt;p&gt;Each surface has a native AWS answer. The work — and the cost — is that none of them connect to each other, and you own the glue. Here's the map the rest of this guide follows:&lt;/p&gt;

&lt;p&gt;SurfaceAWS primitiveThe gap it leaves&lt;/p&gt;

&lt;p&gt;Fleet visibilityConsole, one account at a timeNo fleet-wide view of cost, owner, or state&lt;/p&gt;

&lt;p&gt;IAM accessCross-account assume-roleYou build and maintain the role mesh yourself&lt;/p&gt;

&lt;p&gt;Image distributionECR replication / repo policyPrincipalOrgID shortcut over-shares the repo&lt;/p&gt;

&lt;p&gt;NetworkingTransit Gateway / VPC peering~$36/mo per attachment, before data&lt;/p&gt;

&lt;p&gt;DeployCross-account pipeline roleOne image, N accounts — task defs drift&lt;/p&gt;

&lt;h2&gt;
  
  
  Cross-account visibility and IAM access
&lt;/h2&gt;

&lt;p&gt;Cross-account IAM has two halves: a read role for the fleet view, and a per-resource assume-role for tasks that reach into another account. The fleet-viewer role covers the first; an STS trust chain covers the second.&lt;/p&gt;

&lt;p&gt;The visibility half is the fleet-viewer role above. The access half is different: it's when a running task in one account needs a resource that lives in another — an S3 bucket, a DynamoDB table, a secret. The pattern is a role in the resource's account whose trust policy names the task role's account, which the task assumes via STS at runtime.&lt;/p&gt;

&lt;p&gt;The footgun here is registration, not runtime. ECS validates role ARNs when you register a task definition, and a malformed trust policy throws &lt;code&gt;Role is not valid&lt;/code&gt; — a recurring re:Post complaint. The task role must trust &lt;code&gt;ecs-tasks.amazonaws.com&lt;/code&gt;, the ARN must be in the right account, and whoever registers the task def needs &lt;code&gt;iam:PassRole&lt;/code&gt; for it — scope that &lt;a href="https://fortem.dev/blog/ecs-fargate-rbac/" rel="noopener noreferrer"&gt;PassRole grant per environment&lt;/a&gt; or a developer can escalate past their boundary.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;KEY INSIGHT:&lt;/strong&gt; At five accounts, every "what's running where" question is five logins or five assume-role calls. The IAM mesh you build for visibility is the same mesh you build for access — design it once, deploy it to every account through your baseline module, and never wire it per-account by hand.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Distributing images: central ECR done right
&lt;/h2&gt;

&lt;p&gt;Pick one: ECR cross-account replication (push once, pull locally) or a shared repo via repository policy. The aws:PrincipalOrgID shortcut works but exposes the repo to every account in the Org.&lt;/p&gt;

&lt;p&gt;Most teams build once in a shared services or CI account, then need that image in every workload account — the cross-account slice of &lt;a href="https://fortem.dev/blog/aws-ecr-guide/" rel="noopener noreferrer"&gt;how ECR works for ECS Fargate teams&lt;/a&gt;. Two clean answers. &lt;strong&gt;Replication&lt;/strong&gt;: ECR copies the image to a local repo in each destination account, so pulls are in-region, in-account, and cheap — best when you have many accounts. &lt;strong&gt;Shared repository&lt;/strong&gt;: one repo with a repository policy granting pull to specific account IDs — simplest when you have a handful.&lt;/p&gt;

&lt;p&gt;The tempting shortcut is to skip the account list and grant pull to your whole Organization with a single condition. It works. It's also exactly what a re:Post answer flagged:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Beware: this allows all accounts in an Organization to access the ECR repository! Double check with your security team if this is allowed!"&lt;/p&gt;

&lt;p&gt;— &lt;a href="https://repost.aws/questions/QUR3J3xS9TSymVZ-NG4bk1yg/central-ecr-for-ecs-in-multiple-accounts" rel="noopener noreferrer"&gt;AWS re:Post, "Central ECR for ECS in multiple accounts"&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;For a regulated buyer — the kind tracking &lt;a href="https://fortem.dev/blog/aws-fargate-pricing-real-costs/" rel="noopener noreferrer"&gt;the fixed overhead every environment carries&lt;/a&gt;down to the dollar — "every account in the Org can pull this image" is the kind of finding an auditor circles in red. Name the accounts explicitly, or use replication.&lt;/p&gt;

&lt;h2&gt;
  
  
  Networking across accounts — and what it costs
&lt;/h2&gt;

&lt;p&gt;AWS recommends Transit Gateway first for cross-account ECS traffic. It's the clean answer and a real line item: ~$36/mo per VPC attachment plus $0.02/GB — multiply by account count.&lt;/p&gt;

&lt;p&gt;AWS's own guidance on networking ECS services across accounts says to &lt;a href="https://docs.aws.amazon.com/AmazonECS/latest/developerguide/networking-connecting-services-crossaccount.html" rel="noopener noreferrer"&gt;consider Transit Gateway first&lt;/a&gt;, then VPC peering, shared VPC, or PrivateLink. The quick decision:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;Transit Gateway&lt;/strong&gt; — hub-and-spoke for many accounts. Clean routing, but each VPC attachment bills hourly.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;VPC peering&lt;/strong&gt; — one or two account pairs. No hourly fee, but a full mesh of N accounts is N²/2 peerings to manage.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Shared VPC&lt;/strong&gt; — one VPC, subnets shared into member accounts. Cheapest, but it softens the account boundary you split for.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;PrivateLink&lt;/strong&gt; — expose one service, not full reachability. Per-service, not per-network.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The number nobody puts in the spreadsheet: Transit Gateway charges &lt;a href="https://aws.amazon.com/transit-gateway/pricing/" rel="noopener noreferrer"&gt;$0.05 per VPC attachment-hour&lt;/a&gt; — about $36.50/month per attachment ($0.05 × 730 hrs) — plus $0.02/GB processed. In a hub-and-spoke, you pay an attachment for the hub VPC and one per spoke:&lt;/p&gt;

&lt;p&gt;TopologyVPC attachmentsMonthly (attachments only)&lt;/p&gt;

&lt;p&gt;Hub + 1 spoke2 attachments~$73/mo&lt;/p&gt;

&lt;p&gt;Hub + 3 spokes4 attachments~$146/mo&lt;/p&gt;

&lt;p&gt;Hub + 5 spokes6 attachments~$219/mo&lt;/p&gt;

&lt;p&gt;That's before a single gigabyte crosses the gateway. Cross-account data processing at $0.02/GB adds up fast for chatty services. It's not a reason to avoid multi-account — it's a reason to know the bill before the architecture review, not after the invoice.&lt;/p&gt;

&lt;h2&gt;
  
  
  Deploying the same service to N accounts
&lt;/h2&gt;

&lt;p&gt;One image, N accounts means one pipeline assuming a deploy role per target account. Parameterize the account ID and role ARN; never copy a pipeline per account — that's how environments drift.&lt;/p&gt;

&lt;p&gt;The centralized model is the one AWS recommends and the one that scales: the pipeline lives in a single CI/CD account, and each stage assumes a deploy role in the target account to register the task definition and update the service. The image is built once and pulled from your central or replicated ECR. The task-definition template is one file; the account ID, role ARN, and environment-specific variables are inputs.&lt;/p&gt;

&lt;p&gt;The anti-pattern is a copy of the pipeline per account. It feels faster on day one and it guarantees drift by month three — one account gets a CPU bump, another a new env var, a third a hotfix that never makes it back to the template. Multi-account already costs you a single pane of glass; don't also give up a single source of truth for what you deploy.&lt;/p&gt;

&lt;p&gt;The drift test&lt;/p&gt;

&lt;p&gt;Pick any two accounts running "the same" service and diff their live task definitions: &lt;code&gt;aws ecs describe-task-definition&lt;/code&gt; in each, assumed via the fleet-viewer role. If they differ in anything but account-specific values, your per-account pipelines have already drifted — and nothing was watching.&lt;/p&gt;

&lt;h2&gt;
  
  
  When multi-account isn't worth it
&lt;/h2&gt;

&lt;p&gt;Below ~10 environments, the operating tax — Transit Gateway cost, IAM sprawl, per-account deploys — usually outweighs the isolation upside. Split prod out for blast radius; keep non-prod together until it hurts.&lt;/p&gt;

&lt;p&gt;Multi-account is the right call at scale and a self-inflicted wound at three environments. If you run a prod, a staging, and a dev, splitting them into five accounts to look enterprise buys you an IAM mesh, a Transit Gateway bill, and five places to deploy — to protect blast radius you could get from one account with disciplined tagging and SCPs.&lt;/p&gt;

&lt;p&gt;The honest threshold: split prod into its own account early — that boundary is worth it for almost everyone. Hold non-prod in one shared account until something concrete forces a split: a regulated customer who needs hard isolation, a Fargate vCPU quota that dev load tests keep exhausting, or an audit scope you need to draw a clean line around. Add accounts because a requirement demands one, not because the org chart has room.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you read this, you might also want to know
&lt;/h2&gt;

&lt;p&gt;Can one ECS cluster span multiple AWS accounts?&lt;/p&gt;

&lt;p&gt;No. An ECS cluster lives in exactly one account and one region. "Managing across accounts" always means N clusters in N accounts, coordinated from the outside — via cross-account IAM, a central pipeline, and a fleet view you assemble. There is no cluster object that straddles an account boundary.&lt;/p&gt;

&lt;p&gt;How do I see total ECS cost across all my accounts in one place?&lt;/p&gt;

&lt;p&gt;Enable Cost Explorer at the AWS Organization level from the management account, and tag every environment consistently so you can group by it. That gives you Org-wide cost but not per-environment ECS state. To tie cost to a running service and its owner across accounts, you need a tool that joins billing data with live ECS describe calls — which is what the fleet-viewer role's ce:GetCostAndUsage permission is for.&lt;/p&gt;

&lt;p&gt;Should each customer get its own AWS account for ECS?&lt;/p&gt;

&lt;p&gt;Only if isolation is a contractual or regulatory requirement. Per-customer accounts give the hardest blast-radius and data boundary, but they multiply every operating surface in this article by your customer count. Most B2B SaaS use shared accounts with per-tenant isolation inside the application until a specific customer's compliance posture forces a dedicated account.&lt;/p&gt;

&lt;p&gt;Does AWS Organizations give me a single ECS dashboard?&lt;/p&gt;

&lt;p&gt;No. Organizations handles account creation, consolidated billing, and service control policies — not a cross-account ECS view. AWS has no native single-pane-of-glass for ECS the way Lens works for Kubernetes; the cross-account visibility layer is something you build or buy.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Book a 20-min fleet walkthrough:&lt;/strong&gt; &lt;a href="https://fortem.dev/book" rel="noopener noreferrer"&gt;fortem.dev/book&lt;/a&gt;&lt;/p&gt;

</description>
      <category>aws</category>
      <category>ecs</category>
      <category>fargate</category>
      <category>devops</category>
    </item>
    <item>
      <title>AWS Fargate vs Lambda: When Does Lambda Stop Being Cheaper?</title>
      <dc:creator>Matt</dc:creator>
      <pubDate>Tue, 30 Jun 2026 11:15:28 +0000</pubDate>
      <link>https://dev.to/dspv/aws-fargate-vs-lambda-when-does-lambda-stop-being-cheaper-5b1p</link>
      <guid>https://dev.to/dspv/aws-fargate-vs-lambda-when-does-lambda-stop-being-cheaper-5b1p</guid>
      <description>&lt;h1&gt;
  
  
  AWS Fargate vs Lambda: When Lambda Stops Being Cheaper
&lt;/h1&gt;

&lt;blockquote&gt;
&lt;p&gt;Originally published at &lt;a href="https://fortem.dev/blog/fargate-vs-lambda" rel="noopener noreferrer"&gt;https://fortem.dev/blog/fargate-vs-lambda&lt;/a&gt;&lt;br&gt;
AWS Fargate vs Lambda: the cost line is set by execution duration, not traffic. Breakeven math, hidden Lambda costs, and what the June 2026 MicroVMs launch changes.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;Versus&lt;/p&gt;

&lt;p&gt;Lambda is not categorically cheaper than Fargate, and Fargate is not categorically cheaper than Lambda. There is a crossover point, and it is set mostly by how long each invocation runs — not by how much traffic you get. Most comparisons stop at a feature table. This one gives you the breakeven in dollars, the hidden costs that move it, and what the new Lambda MicroVMs (launched June 22, 2026) change — and what they don't.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;  Lambda wins on short, spiky, event-driven work. Fargate wins on long-running, steady services. The line is duration × frequency, not raw traffic.&lt;/li&gt;
&lt;li&gt;  Real breakeven: a 200ms API crosses ~6–8M invocations/mo; a 2s background job crosses ~1M/mo; at 5s+ duration Lambda almost never wins at scale.&lt;/li&gt;
&lt;li&gt;  Per-request charges are only ~20–40% of a serverless bill. API Gateway ($3.50/M), CloudWatch Logs ($50–150/mo), NAT, and provisioned concurrency are the rest.&lt;/li&gt;
&lt;li&gt;  June 22, 2026: Lambda MicroVMs lifted the runtime limit from 15 min to 8 hours (16 vCPU / 32 GB, Firecracker) — but they target isolated sandboxes for AI and untrusted code, not always-on web services.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Quick answer — Lambda is cheaper for short, spiky workloads; Fargate is cheaper for long-running, steady ones.&lt;/strong&gt; The crossover is set by execution duration: a 200ms API endpoint stays cheaper on Lambda up to roughly 6–8M invocations/month (including API Gateway and CloudWatch), while a 2s background job crosses to Fargate at about 1M/month. At 5s+ average duration, Lambda almost never wins at scale. Fargate compute runs $0.04048/vCPU-hr + $0.004445/GB-hr; Fargate Spot is ~68% cheaper. A monthly Lambda bill above ~$1,000 is a strong signal that at least one workload belongs on Fargate.&lt;/p&gt;

&lt;p&gt;Ready to use — copy this today&lt;/p&gt;

&lt;p&gt;The two cost formulas, side by side. Swap your own numbers in and you get the monthly figure for each service — then compare. Lambda bills GB-seconds plus a per-request fee; Fargate bills allocated vCPU and memory for the hours the task runs.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# ---- Lambda monthly cost ----
#   memory_gb     = function memory / 1024     e.g. 0.5
#   duration_s    = avg execution seconds      e.g. 0.2
#   invocations   = requests per month         e.g. 5_000_000
#
gb_seconds   = memory_gb * duration_s * invocations
compute      = gb_seconds * 0.00001667         # $/GB-second
requests     = invocations * 0.00000020        # $0.20 per 1M requests
api_gateway  = invocations * 0.0000035         # $3.50 per 1M (if used)
lambda_total = compute + requests + api_gateway

# ---- Fargate monthly cost (one always-on task) ----
#   vcpu = 0.5   mem_gb = 1   hours = 730 (24/7) or ~217 (business hrs)
fargate_total = (vcpu * 0.04048 + mem_gb * 0.004445) * hours
# Fargate Spot: multiply the compute rates by ~0.319 (≈68% off)

# ---- Worked example: 0.5 GB, 0.2s, 5M invocations ----
# Lambda  : 500,000 GB-s -&amp;gt; $8.34 compute + $1.00 requests + $17.50 API GW = ~$26.84/mo
# Fargate : (0.5*0.04048 + 1*0.004445) * 730                              = ~$18.02/mo  (24/7)
# At 5M invocations of a 200ms API, one always-on Fargate task already wins.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Rates: Lambda $0.00001667/GB-s + $0.20/1M requests; API Gateway $3.50/1M; Fargate $0.04048/vCPU-hr + $0.004445/GB-hr (Linux/x86, us-east-1, verified June 2026). Real systems need more than one Fargate task for redundancy — adjust hours and task count for your setup.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where Lambda wins, where Fargate wins
&lt;/h2&gt;

&lt;p&gt;Lambda bills per millisecond of execution and fits spiky, event-driven work; Fargate bills for allocated vCPU and memory and wins on long-running, steady services. The split is duration, not app type.&lt;/p&gt;

&lt;p&gt;The framing “serverless is cheaper” hides the mechanism underneath. Lambda charges for the time your code runs, rounded to the millisecond, times the memory you assigned. When code runs rarely and briefly, you pay almost nothing between invocations. When it runs constantly, you pay for each of those milliseconds — and there are 2.6 billion of them in a month.&lt;/p&gt;

&lt;p&gt;Fargate is the inverse. You pay for a task's vCPU and memory for as long as it exists, whether it serves one request or ten thousand per second. Idle time is wasted money; saturated time is a bargain.&lt;/p&gt;

&lt;p&gt;Reach for Lambda&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;   S3-triggered file processing&lt;/li&gt;
&lt;li&gt;   Webhook and HTTP handlers with bursty traffic&lt;/li&gt;
&lt;li&gt;   Scheduled (cron) jobs that run briefly&lt;/li&gt;
&lt;li&gt;   Queue and stream consumers with variable load&lt;/li&gt;
&lt;li&gt;   Anything that needs to scale from zero instantly&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Reach for Fargate&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;   Long-lived microservices and APIs&lt;/li&gt;
&lt;li&gt;   Services holding connections (WebSocket, gRPC)&lt;/li&gt;
&lt;li&gt;   Batch and ETL jobs past the 15-minute mark&lt;/li&gt;
&lt;li&gt;   Steady traffic where utilization stays high&lt;/li&gt;
&lt;li&gt;   Workloads needing precise CPU/memory control&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;KEY INSIGHT:&lt;/strong&gt; A “serverless” service that runs 24/7 under steady load is paying Lambda's premium for elasticity it never uses. Elasticity is only free when your traffic is spiky. If your invocation graph is a flat line, you are buying the wrong abstraction.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The real cost breakeven
&lt;/h2&gt;

&lt;p&gt;Breakeven is set by function duration: a 200ms API crosses around 6–8M invocations a month, a 2s background job around 1M. The longer the function runs, the sooner Fargate wins.&lt;/p&gt;

&lt;p&gt;Invocation count is the number most teams reach for, but it is the wrong axis on its own. Duration multiplies it. A 200ms function and a 2s function at the same invocation count have a 10× difference in GB-seconds — and GB-seconds are what Lambda bills. That is why the breakeven for a longer function lands at a fraction of the invocations.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Workload&lt;/th&gt;
&lt;th&gt;Lambda config&lt;/th&gt;
&lt;th&gt;Compute only&lt;/th&gt;
&lt;th&gt;+ API GW &amp;amp; logs&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;API endpoint&lt;/td&gt;
&lt;td&gt;512 MB · 200 ms&lt;/td&gt;
&lt;td&gt;~10M / mo&lt;/td&gt;
&lt;td&gt;~6–8M / mo&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Background processor&lt;/td&gt;
&lt;td&gt;1024 MB · 2 s&lt;/td&gt;
&lt;td&gt;~1.5M / mo&lt;/td&gt;
&lt;td&gt;~1M / mo&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Data pipeline&lt;/td&gt;
&lt;td&gt;2048 MB · 500 ms&lt;/td&gt;
&lt;td&gt;~5M / mo&lt;/td&gt;
&lt;td&gt;~4M / mo&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Breakeven = invocations/month above which Fargate is cheaper. Based on a 2026 third-party analysis of production workloads (LeanOps). The longer the function runs, the lower the breakeven.&lt;/p&gt;

&lt;p&gt;Put a concrete profile through it. At 5 million invocations of a 200ms function with 512 MB, Lambda's compute plus an API Gateway in front already exceeds the cost of a single always-on Fargate task doing the same work. The Fargate bar is stacked: its own compute, plus the slice of the shared NAT Gateway it actually uses.&lt;/p&gt;

&lt;p&gt;$27/mo&lt;/p&gt;

&lt;p&gt;$22/mo&lt;/p&gt;

&lt;p&gt;$10/mo&lt;/p&gt;

&lt;p&gt;Lambda + API Gateway&lt;/p&gt;

&lt;p&gt;5M × 200ms × 0.5 GB&lt;/p&gt;

&lt;p&gt;Fargate (1 always-on task)&lt;/p&gt;

&lt;p&gt;0.5 vCPU + 1 GB · 24/7&lt;/p&gt;

&lt;p&gt;Fargate Spot&lt;/p&gt;

&lt;p&gt;same task · ~68% off&lt;/p&gt;

&lt;p&gt;Fargate computeShared NAT share&lt;/p&gt;

&lt;p&gt;Monthly cost — 200ms API at 5M invocations/moFargate −19%&lt;/p&gt;

&lt;p&gt;The NAT Gateway is a per-VPC resource that almost every AWS account already runs and shares across all tasks and environments in the VPC — so a single service carries only its slice (~$4/mo here), not the full ~$66/mo. Loading the whole NAT onto one Fargate task would overstate its cost. Lambda outside a VPC needs no NAT at all; Lambda inside a VPC shares the same gateway, so this overhead is roughly a wash between the two. Fargate Spot ($0.01291/vCPU-hr + $0.001417/GB-hr) is for fault-tolerant, stateless workloads, not strict-uptime prod APIs — shown for the cost picture, not as a drop-in here.&lt;/p&gt;

&lt;p&gt;That comparison uses one Fargate task for clarity. Production needs at least two for redundancy, plus the rest of the fixed overhead an ECS environment carries — &lt;a href="https://fortem.dev/blog/aws-fargate-pricing-real-costs/" rel="noopener noreferrer"&gt;what a real Fargate environment costs&lt;/a&gt; once you count the ALB and CloudWatch alongside that NAT share. Fold it in and the crossover shifts, but the direction holds: the longer and busier the workload, the more Fargate pulls ahead.&lt;/p&gt;

&lt;p&gt;These thresholds come from a 2026 third-party analysis of production workloads, not from AWS. Treat them as a starting estimate and confirm with your own numbers using the formulas above — your memory size, duration, and whether you front Lambda with API Gateway all move the line.&lt;/p&gt;

&lt;h2&gt;
  
  
  The hidden costs that move the line
&lt;/h2&gt;

&lt;p&gt;Per-request charges are only 20–40% of a serverless bill. API Gateway ($3.50/M, often more than the Lambda itself), CloudWatch Logs ($50–150/mo at 1M+/day), NAT, and provisioned concurrency make up the rest.&lt;/p&gt;

&lt;p&gt;The Lambda line item on your bill is the part most teams model. The rest hides in adjacent services that the function can't run without. A fair comparison has to count them, because Fargate either avoids them or pays them differently.&lt;/p&gt;

&lt;p&gt;API Gateway — $3.50 per million requests&lt;/p&gt;

&lt;p&gt;Most HTTP Lambdas sit behind API Gateway. At high request volume, that per-request fee routinely exceeds the Lambda compute cost itself. A Fargate service behind an Application Load Balancer pays a flat ~$22/month instead, regardless of request count.&lt;/p&gt;

&lt;p&gt;CloudWatch Logs — $50–150/mo at 1M+ invocations/day&lt;/p&gt;

&lt;p&gt;Every invocation writes a log stream. At a million-plus invocations a day, ingestion alone runs $50–150/month. Both platforms log, but Lambda's per-invocation granularity multiplies the line count fast.&lt;/p&gt;

&lt;p&gt;Provisioned concurrency — billed whether or not it runs&lt;/p&gt;

&lt;p&gt;The standard fix for cold starts keeps warm instances on standby and charges for them around the clock — the always-on cost model you chose Lambda to avoid.&lt;/p&gt;

&lt;p&gt;CPU tied to memory&lt;/p&gt;

&lt;p&gt;Lambda scales CPU with the memory setting. A CPU-bound function forces you to over-provision memory you don't need to get more cores. Fargate lets you set vCPU and memory independently.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;KEY INSIGHT:&lt;/strong&gt; If your monthly Lambda-related bill clears ~$1,000, moving the heaviest function group to Fargate is likely your highest-ROI infrastructure task this quarter. The savings rarely come from the function line alone — they come from dropping the API Gateway and CloudWatch surcharges that ride along with it.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The twist: Lambda MicroVMs move the boundary (June 2026)
&lt;/h2&gt;

&lt;p&gt;On June 22, 2026 AWS shipped Lambda MicroVMs: up to 8 hours, 16 vCPU, 32 GB, Firecracker isolation. It removes the 15-minute limit — but it targets isolated sandboxes for AI and untrusted code, not always-on web services.&lt;/p&gt;

&lt;p&gt;For years, the 15-minute timeout was the cleanest reason to leave Lambda: if a job ran longer, you moved it to Fargate or Batch. MicroVMs change that specific fact. Each session runs in its own dedicated MicroVM — Firecracker virtualization, no shared kernel, no shared resources with other sessions — and can hold state across user interactions for up to eight hours.&lt;/p&gt;

&lt;p&gt;Max runtime&lt;/p&gt;

&lt;p&gt;8 hours&lt;/p&gt;

&lt;p&gt;vs 15 min standard&lt;/p&gt;

&lt;p&gt;Max vCPU&lt;/p&gt;

&lt;p&gt;16&lt;/p&gt;

&lt;p&gt;per MicroVM&lt;/p&gt;

&lt;p&gt;Max memory&lt;/p&gt;

&lt;p&gt;32 GB&lt;/p&gt;

&lt;p&gt;per MicroVM&lt;/p&gt;

&lt;p&gt;Max disk&lt;/p&gt;

&lt;p&gt;32 GB&lt;/p&gt;

&lt;p&gt;per MicroVM&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;“Each session runs in its own dedicated MicroVM with no shared kernel and no shared resources between users, so untrusted code supplied by one user is contained to their execution environment.”&lt;/p&gt;

&lt;p&gt;— &lt;a href="https://aws.amazon.com/blogs/aws/run-isolated-sandboxes-with-full-lifecycle-control-aws-lambda-introduces-microvms/" rel="noopener noreferrer"&gt;AWS News Blog: Lambda MicroVMs&lt;/a&gt;, June 2026&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The intended use cases tell you who this is for: AI coding assistants, interactive code environments, data analytics platforms, vulnerability scanners, and game servers that run user-supplied scripts. The common thread is &lt;em&gt;running code you don't trust&lt;/em&gt; in a hard isolation boundary, with full lifecycle control over each session.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The part most takes will get wrong:&lt;/strong&gt; MicroVMs are not Lambda becoming Fargate. A MicroVM is a lifecycle-managed session you launch, use, and tear down — not an always-on listener answering a steady stream of HTTP requests. For a long-lived web service or API, the right tool is still Fargate. What MicroVMs displace is the pattern where teams spun up a Fargate task as a sandbox to run untrusted or AI-generated code — that niche now has a purpose-built home.&lt;/p&gt;

&lt;p&gt;If your isolation need is bigger than a single sandbox — a full copy of a service with its dependencies — that is still a container problem, closer to &lt;a href="https://fortem.dev/blog/ecs-environment-clone/" rel="noopener noreferrer"&gt;cloning a full environment instead of a single sandbox&lt;/a&gt; than to a MicroVM session.&lt;/p&gt;

&lt;p&gt;On cost: AWS prices MicroVMs across three dimensions — compute (per-second, on baseline and peak usage), snapshot operations and storage, and data transfer. AWS has not published a flat per-second rate, so there is no clean number to drop into the breakeven math yet. MicroVMs is available in US East (N. Virginia and Ohio), US West (Oregon), Asia Pacific (Tokyo), and Europe (Ireland).&lt;/p&gt;

&lt;h2&gt;
  
  
  Cold start and latency reality
&lt;/h2&gt;

&lt;p&gt;Lambda cold starts run from tens to hundreds of milliseconds, worse inside a VPC or with large packages. Provisioned concurrency removes them — but then you pay around the clock, which erodes Lambda's main cost edge.&lt;/p&gt;

&lt;p&gt;A cold start is the time Lambda needs to spin up a new execution environment when no warm one is available. For a small function it's tens of milliseconds; in a VPC, or with a heavy runtime and large deployment package, it climbs into the hundreds. For a user-facing API that occasionally idles, that tail latency is the complaint that shows up in your dashboards.&lt;/p&gt;

&lt;p&gt;Fargate has its own startup cost — 30 to 90 seconds to launch a task — but it pays that once. After that the task stays warm and per-request latency is steady, because there is no per-invocation environment to create.&lt;/p&gt;

&lt;p&gt;The catch is what fixing Lambda's cold start does to the bill. Provisioned concurrency keeps instances warm and charges for them whether or not a request arrives. That is the always-on cost model — the one Lambda was supposed to let you skip. Once you're paying for warm capacity full time, you've recreated Fargate's economics without Fargate's resource control.&lt;/p&gt;

&lt;h2&gt;
  
  
  The decision: a checklist
&lt;/h2&gt;

&lt;p&gt;Pick Lambda for spiky or unpredictable traffic, short tasks under ~1s, and low volume. Pick Fargate for steady load, functions 5s+, fine CPU/memory control, or a Lambda bill above ~$300–1,000/mo.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Dimension&lt;/th&gt;
&lt;th&gt;AWS Lambda&lt;/th&gt;
&lt;th&gt;AWS Fargate&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Billing unit&lt;/td&gt;
&lt;td&gt;Per ms of execution (GB-seconds)&lt;/td&gt;
&lt;td&gt;Per second of allocated vCPU + GB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Best traffic shape&lt;/td&gt;
&lt;td&gt;Spiky, unpredictable, event-driven&lt;/td&gt;
&lt;td&gt;Steady, sustained, always-on&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Short tasks (&amp;lt;1s)&lt;/td&gt;
&lt;td&gt;Wins on cost&lt;/td&gt;
&lt;td&gt;Overkill&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Long tasks (5s+)&lt;/td&gt;
&lt;td&gt;Loses fast at scale&lt;/td&gt;
&lt;td&gt;Wins on cost&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Runtime ceiling&lt;/td&gt;
&lt;td&gt;15 min (8 hr via MicroVMs)&lt;/td&gt;
&lt;td&gt;Unbounded&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Resource control&lt;/td&gt;
&lt;td&gt;CPU tied to memory&lt;/td&gt;
&lt;td&gt;vCPU + memory set independently&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cold start&lt;/td&gt;
&lt;td&gt;Tens–hundreds of ms&lt;/td&gt;
&lt;td&gt;30–90s once, then steady&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Scale to zero&lt;/td&gt;
&lt;td&gt;Native, instant&lt;/td&gt;
&lt;td&gt;Manual (scheduling / desiredCount 0)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Reduced to a few if/then rules:&lt;/p&gt;

&lt;p&gt;LAMBDATraffic is spiky or unpredictable, and idle periods are real.&lt;/p&gt;

&lt;p&gt;LAMBDAEach invocation is short (under ~1s) and total volume is modest.&lt;/p&gt;

&lt;p&gt;FARGATELoad is steady, or invocations average 5 seconds or more.&lt;/p&gt;

&lt;p&gt;FARGATEYou need independent CPU and memory control, or runtime past 15 minutes for a service (not a sandbox).&lt;/p&gt;

&lt;p&gt;FARGATEYour Lambda-related bill (compute + API Gateway + logs) is past ~$300–1,000/mo for one workload.&lt;/p&gt;

&lt;p&gt;MICROVMYou need a hard isolation boundary to run untrusted or AI-generated code for up to 8 hours.&lt;/p&gt;

&lt;p&gt;Most teams don't pick one and stop. A typical setup keeps spiky glue on Lambda, runs the steady services on Fargate, and now has MicroVMs for the sandbox case. The mistake isn't mixing them — it's leaving a workload on the wrong one after its traffic shape has changed.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you read this, you might also want to know
&lt;/h2&gt;

&lt;p&gt;What if my workload is partly spiky and partly steady?&lt;/p&gt;

&lt;p&gt;Split it. Run the steady core — the part that handles baseline traffic 24/7 — on Fargate, and keep the spiky overflow or event-driven glue on Lambda. The mistake is forcing one runtime onto a workload with two traffic shapes. A Fargate service for the baseline plus Lambda for bursts is usually cheaper than either alone scaled to cover both.&lt;/p&gt;

&lt;p&gt;Where does EC2 fit between Lambda and Fargate on cost?&lt;/p&gt;

&lt;p&gt;EC2 sits below Fargate on raw compute price at high, steady utilization, because Reserved Instances and Savings Plans cut 30–50% — but you take on AMI patching, scaling, and capacity planning. The order at steady state is roughly EC2 &amp;lt; Fargate &amp;lt; Lambda on cost, and roughly Lambda &amp;lt; Fargate &amp;lt; EC2 on operational burden. Fargate is the middle: more expensive than tuned EC2, far less to operate.&lt;/p&gt;

&lt;p&gt;Are MicroVMs cheaper than running a Fargate task as a sandbox?&lt;/p&gt;

&lt;p&gt;There's no clean answer yet — AWS prices MicroVMs on per-second compute plus snapshot storage and data transfer, with no published flat rate. The likely advantage is operational, not only dollar: MicroVMs give per-session isolation and lifecycle control out of the box, where a Fargate sandbox makes you build task launch, teardown, and isolation yourself. Compare on total effort, not the compute line alone.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Is Fargate cheaper than Lambda?
&lt;/h3&gt;

&lt;p&gt;It depends on execution duration, not just traffic. Lambda is cheaper for short, spiky workloads; Fargate is cheaper for long-running, steady ones. For a 200ms API the crossover is around 6–8M invocations/month once you include API Gateway and CloudWatch. For a 2s background job it drops to about 1M/month. At 5s+ average duration, Lambda almost never wins at meaningful scale.&lt;/p&gt;

&lt;h3&gt;
  
  
  At what point does Lambda become more expensive than Fargate?
&lt;/h3&gt;

&lt;p&gt;By one 2026 analysis of production workloads: a 512MB/200ms API crosses around 6–8M invocations/month, a 1024MB/2s background processor around 1M/month. The longer each invocation runs, the lower the breakeven. A practical signal: if your monthly Lambda-related bill clears $1,000, at least one function group is almost certainly cheaper on Fargate.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can AWS Lambda run longer than 15 minutes now?
&lt;/h3&gt;

&lt;p&gt;Yes, in a specific form. Standard Lambda functions still cap at 15 minutes. But Lambda MicroVMs, launched June 22 2026, support up to 8 hours of runtime with 16 vCPU, 32 GB memory, and 32 GB disk per MicroVM, isolated via Firecracker. They are built for isolated sandboxes running AI-generated or untrusted code — not as a replacement for always-on web services, which still belong on Fargate.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do I need API Gateway with Fargate?
&lt;/h3&gt;

&lt;p&gt;No. Fargate services typically sit behind an Application Load Balancer (~$22/month per environment), not API Gateway. Lambda's HTTP path usually requires API Gateway at $3.50 per million requests, which often exceeds the Lambda compute cost itself. Removing that per-request charge is one reason high-traffic APIs get cheaper on Fargate.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can Fargate Spot beat Lambda on cost?
&lt;/h3&gt;

&lt;p&gt;For fault-tolerant, steady workloads, yes — by a wide margin. Fargate Spot runs at $0.01291/vCPU-hr + $0.001417/GB-hr, about 68% below on-demand. A stateless service that restarts cleanly on a 2-minute interruption notice runs far cheaper on Spot than on Lambda once traffic is steady. Spot is wrong for production APIs with strict uptime needs.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;### Running a fleet of always-on Fargate environments? Once you've chosen Fargat&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Worth reading&lt;/p&gt;

&lt;p&gt;&lt;a href="https://fortem.dev/fargate-vs-ec2/" rel="noopener noreferrer"&gt;LandingFargate vs EC2: When Each Launch Type Makes SenseThe other half of the compute decision — bin-packing math, Spot, and the ~60% utilization line where EC2 overtakes Fargate.&lt;/a&gt;&lt;a href="https://fortem.dev/blog/aws-fargate-pricing-real-costs/" rel="noopener noreferrer"&gt;Guide · What Does AWS Fargate Actually Cost Per Environment?AWS says $0.04048/vCPU-hr. Here's the real per-environment cost once you count ALB, NAT Gateway, CloudWatch, and data transfer.&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;See your real per-env cost:&lt;/strong&gt; &lt;a href="https://fortem.dev/ecs-cost-calculator" rel="noopener noreferrer"&gt;fortem.dev/ecs-cost-calculator&lt;/a&gt;&lt;/p&gt;

</description>
      <category>aws</category>
      <category>fargate</category>
      <category>lambda</category>
      <category>serverless</category>
    </item>
    <item>
      <title>How to Find and Kill Orphaned ECS Environments Before They Drain Your Budget</title>
      <dc:creator>Matt</dc:creator>
      <pubDate>Tue, 30 Jun 2026 11:15:22 +0000</pubDate>
      <link>https://dev.to/dspv/how-to-find-and-kill-orphaned-ecs-environments-before-they-drain-your-budget-1mn4</link>
      <guid>https://dev.to/dspv/how-to-find-and-kill-orphaned-ecs-environments-before-they-drain-your-budget-1mn4</guid>
      <description>&lt;h1&gt;
  
  
  How to Find and Kill Orphaned ECS Environments
&lt;/h1&gt;

&lt;blockquote&gt;
&lt;p&gt;Originally published at &lt;a href="https://fortem.dev/blog/ecs-orphaned-environments" rel="noopener noreferrer"&gt;https://fortem.dev/blog/ecs-orphaned-environments&lt;/a&gt;&lt;br&gt;
A stopped ECS service costs $0 in compute — but the ALB ($16/mo) and NAT Gateway ($32/mo) keep billing. Here's how to find and delete orphaned environments before they drain your budget.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;Use Case&lt;/p&gt;

&lt;p&gt;Every team with 10+ ECS environments has at least one nobody uses anymore. The Fargate tasks stopped when the feature shipped — or didn't. But the ALB kept running. The NAT Gateway kept running. Six months later you're looking at a $400 line item on the bill and nobody can explain it.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;  A stopped ECS environment (desired=0) still costs $48–65/mo in ALB + NAT Gateway overhead.&lt;/li&gt;
&lt;li&gt;  Fargate is honest — it bills $0 when desired=0. ALB and NAT Gateway don't know and don't care.&lt;/li&gt;
&lt;li&gt;  3 CLI commands surface every orphan in your account in under 5 minutes.&lt;/li&gt;
&lt;li&gt;  Kill order matters: tasks → service → target group → ALB → NAT Gateway → log groups.&lt;/li&gt;
&lt;li&gt;  5 forgotten environments = ~$3,900/year in pure waste, no compute running.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Ready to use — run this today&lt;/p&gt;

&lt;p&gt;Find every ECS service at desired=0 across all clusters in the current AWS account:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# List all clusters&lt;/span&gt;
aws ecs list-clusters &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s1"&gt;'clusterArns[]'&lt;/span&gt; &lt;span class="nt"&gt;--output&lt;/span&gt; text | &lt;span class="nb"&gt;tr&lt;/span&gt; &lt;span class="s1"&gt;'\t'&lt;/span&gt; &lt;span class="s1"&gt;'\n'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
| &lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="nb"&gt;read &lt;/span&gt;cluster&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
    &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"=== &lt;/span&gt;&lt;span class="nv"&gt;$cluster&lt;/span&gt;&lt;span class="s2"&gt; ==="&lt;/span&gt;
    aws ecs list-services &lt;span class="nt"&gt;--cluster&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$cluster&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
      &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s1"&gt;'serviceArns[]'&lt;/span&gt; &lt;span class="nt"&gt;--output&lt;/span&gt; text | &lt;span class="nb"&gt;tr&lt;/span&gt; &lt;span class="s1"&gt;'\t'&lt;/span&gt; &lt;span class="s1"&gt;'\n'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    | xargs &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="nt"&gt;-P4&lt;/span&gt; &lt;span class="nt"&gt;-I&lt;/span&gt;&lt;span class="o"&gt;{}&lt;/span&gt; aws ecs describe-services &lt;span class="se"&gt;\&lt;/span&gt;
        &lt;span class="nt"&gt;--cluster&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$cluster&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--services&lt;/span&gt; &lt;span class="o"&gt;{}&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
        &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s1"&gt;'services[?desiredCount==`0`].[serviceName,desiredCount,runningCount]'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
        &lt;span class="nt"&gt;--output&lt;/span&gt; table
  &lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Requires: AWS CLI v2, credentials with &lt;code&gt;ecs:ListClusters&lt;/code&gt;, &lt;code&gt;ecs:ListServices&lt;/code&gt;, &lt;code&gt;ecs:DescribeServices&lt;/code&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What makes an ECS environment orphaned
&lt;/h2&gt;

&lt;p&gt;An ECS environment is orphaned when its desired count hits 0 but the supporting infrastructure — ALB, NAT Gateway, log groups — keeps running and billing.&lt;/p&gt;

&lt;p&gt;Three patterns cause this. The first is the feature branch that shipped (or got cancelled): someone set desiredCount=0 to "pause" the environment, meant to delete it later, and never did. The ECS console shows &lt;code&gt;0/0 tasks&lt;/code&gt; — looks fine, no alarms fire, nobody notices. This is different from deliberately using a calendar to &lt;a href="https://fortem.dev/aws-ecs-scheduling/" rel="noopener noreferrer"&gt;schedule ECS environments off nights and weekends&lt;/a&gt;: a paused-and-forgotten environment never comes back, and nobody is tracking it.&lt;/p&gt;

&lt;p&gt;The second is the deprecated microservice. The team migrated to a new service, pointed traffic at it, and left the old one running at zero. It still has an ALB. It still has a NAT Gateway routing its (nonexistent) outbound traffic. The Terraform state still references it.&lt;/p&gt;

&lt;p&gt;The third pattern is specific to EC2-backed ECS clusters: an instance fails to register with the cluster — misconfigured IAM role, broken ECS agent, VPC networking issue — and sits in the Auto Scaling group in a healthy state while ECS has no idea it exists. &lt;a href="https://containersonaws.com/blog/2024/orphaned-instances/" rel="noopener noreferrer"&gt;AWS's own documentation&lt;/a&gt; describes it: "the instance will just sit there, idling along doing nothing in an unregistered orphaned state."&lt;/p&gt;

&lt;p&gt;All three share the same symptom: the ECS console looks clean. No errors. No alerts. Just a steady, invisible charge on the monthly bill.&lt;/p&gt;

&lt;h2&gt;
  
  
  The real cost of a dead environment
&lt;/h2&gt;

&lt;p&gt;One orphaned Fargate environment with zero running tasks costs $48–65/month: ALB $16.43 + NAT Gateway $32.40 + CloudWatch log storage. No compute, but the infrastructure meter runs.&lt;/p&gt;

&lt;p&gt;Orphaned environment — monthly cost with 0 running tasks&lt;/p&gt;

&lt;p&gt;ALB (base)$16.43/mo&lt;/p&gt;

&lt;p&gt;NAT Gateway$32.40/mo&lt;/p&gt;

&lt;p&gt;CloudWatch logs$8.00/mo&lt;/p&gt;

&lt;p&gt;ECR storage$9.00/mo&lt;/p&gt;

&lt;p&gt;Total infrastructure overhead$65.83/mo&lt;/p&gt;

&lt;p&gt;Fargate compute: $0.00 — desired count is 0, no tasks run. The infrastructure doesn't care.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;KEY INSIGHT:&lt;/strong&gt; Fargate is honest — it bills $0 when desiredCount is 0, because no tasks are running. ALB and NAT Gateway aren't connected to ECS service state. They bill by the hour, unconditionally. An environment at zero is indistinguishable from an environment at 100 tasks as far as those services are concerned.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The ALB base rate is &lt;a href="https://aws.amazon.com/elasticloadbalancing/pricing/" rel="noopener noreferrer"&gt;$0.0225/hr (verified June 2026)&lt;/a&gt; — $16.43/month whether or not a single request passes through it. NAT Gateway is &lt;a href="https://aws.amazon.com/vpc/pricing/" rel="noopener noreferrer"&gt;$0.045/hr (verified June 2026)&lt;/a&gt; — $32.40/month per AZ. If your environment spans two AZs, that's $64.80/month just in NAT Gateway overhead.&lt;/p&gt;

&lt;p&gt;At 5 forgotten environments, that's $3,900/year in pure infrastructure waste. No compute. No traffic. No one using it.&lt;/p&gt;

&lt;p&gt;The number teams miss when auditing is also the &lt;a href="https://fortem.dev/blog/aws-fargate-pricing-real-costs/" rel="noopener noreferrer"&gt;fixed overhead per environment&lt;/a&gt; that persists regardless of task count. An environment costs money from the moment you create the ALB and NAT Gateway — not from the moment tasks start running.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to find orphaned environments
&lt;/h2&gt;

&lt;p&gt;Three AWS CLI commands surface every ECS service with zero desired count and their associated infrastructure across all clusters — no third-party tools, no console clicking.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Command 1 — find all zero-desired services.&lt;/strong&gt; The script in the ready-to-use block above lists every service at desiredCount=0. Run it in each region you use. Filter by cluster name to narrow the scope.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Command 2 — find ALBs with no healthy targets.&lt;/strong&gt; A stopped environment's target group shows 0 healthy targets. This is the fastest way to cross-reference which ALBs are attached to dead environments:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws elbv2 describe-target-groups &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s1"&gt;'TargetGroups[*].[TargetGroupName,TargetGroupArn]'&lt;/span&gt; &lt;span class="nt"&gt;--output&lt;/span&gt; text &lt;span class="se"&gt;\&lt;/span&gt;
| &lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="nb"&gt;read &lt;/span&gt;name arn&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
    &lt;/span&gt;&lt;span class="nv"&gt;health&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;aws elbv2 describe-target-health &lt;span class="nt"&gt;--target-group-arn&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$arn&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
      &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s1"&gt;'TargetHealthDescriptions[?TargetHealth.State==`healthy`] | length(@)'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
    &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$health&lt;/span&gt;&lt;span class="s2"&gt; healthy  &lt;/span&gt;&lt;span class="nv"&gt;$name&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="k"&gt;done&lt;/span&gt; | &lt;span class="nb"&gt;sort&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Target groups with 0 healthy targets are candidates for deletion — but check Command 3 first before acting.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Command 3 — find log groups with no recent writes.&lt;/strong&gt; CloudWatch log groups that haven't received a write in 30+ days are orphaned log storage. They cost $0.50/GB/month to store and accumulate silently:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Find log groups with no writes in the last 30 days&lt;/span&gt;
aws cloudwatch get-metric-statistics &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--namespace&lt;/span&gt; AWS/Logs &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--metric-name&lt;/span&gt; IncomingLogEvents &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--dimensions&lt;/span&gt; &lt;span class="nv"&gt;Name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;LogGroupName,Value&lt;span class="o"&gt;=&lt;/span&gt;/ecs/your-service &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--start-time&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'30 days ago'&lt;/span&gt; +%Y-%m-%dT%H:%M:%SZ&lt;span class="si"&gt;)&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--end-time&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; +%Y-%m-%dT%H:%M:%SZ&lt;span class="si"&gt;)&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--period&lt;/span&gt; 2592000 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--statistics&lt;/span&gt; Sum &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s1"&gt;'Datapoints[0].Sum'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A return value of &lt;code&gt;null&lt;/code&gt; or &lt;code&gt;0.0&lt;/code&gt; means the log group is dead. Automate this check across all &lt;code&gt;/ecs/*&lt;/code&gt; log groups to build a full orphan list.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to check before you delete
&lt;/h2&gt;

&lt;p&gt;Before deleting any environment, verify three things: no scheduled job points at it, no CI/CD pipeline references the cluster name, and the ALB isn't shared between multiple services.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;EventBridge scheduled rules.&lt;/strong&gt; Nightly jobs, weekly reports, scheduled ECS tasks — all reference a cluster and service by name. Check for rules targeting your environment before deleting:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws events list-rules &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s1"&gt;'Rules[*].[Name,ScheduleExpression,State]'&lt;/span&gt; &lt;span class="nt"&gt;--output&lt;/span&gt; table
&lt;span class="c"&gt;# Then for each relevant rule:&lt;/span&gt;
aws events list-targets-by-rule &lt;span class="nt"&gt;--rule&lt;/span&gt; &amp;lt;rule-name&amp;gt; &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s1"&gt;'Targets[*].EcsParameters'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Terraform state.&lt;/strong&gt; If the environment was created with Terraform, its state file still references the service, cluster, ALB, and target group. Deleting resources manually without running &lt;code&gt;terraform destroy&lt;/code&gt; first will leave Terraform in a broken state on the next plan. Either run &lt;code&gt;terraform destroy -target&lt;/code&gt; per resource or remove the state entries manually with &lt;code&gt;terraform state rm&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Shared ALBs.&lt;/strong&gt; Some teams route multiple environments through a single ALB using listener rules and host-based routing. Check whether your ALB has multiple listener rules before deleting it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws elbv2 describe-listeners &lt;span class="nt"&gt;--load-balancer-arn&lt;/span&gt; &amp;lt;alb-arn&amp;gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s1"&gt;'Listeners[*].ListenerArn'&lt;/span&gt; &lt;span class="nt"&gt;--output&lt;/span&gt; text &lt;span class="se"&gt;\&lt;/span&gt;
| xargs &lt;span class="nt"&gt;-I&lt;/span&gt;&lt;span class="o"&gt;{}&lt;/span&gt; aws elbv2 describe-rules &lt;span class="nt"&gt;--listener-arn&lt;/span&gt; &lt;span class="o"&gt;{}&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s1"&gt;'Rules[*].[Priority,Conditions[0].Values[0]]'&lt;/span&gt; &lt;span class="nt"&gt;--output&lt;/span&gt; table
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If only one rule exists (the default forward rule), the ALB is dedicated to this environment and safe to delete. Multiple rules mean other services depend on it — remove only the rules and target groups belonging to the orphaned service, leave the ALB intact.&lt;/p&gt;

&lt;p&gt;Also check the &lt;a href="https://fortem.dev/blog/ecs-audit-log-compliance/" rel="noopener noreferrer"&gt;CloudTrail audit log to see who last touched the environment&lt;/a&gt; — and when. An environment last modified 8 months ago by a developer who left the company is safe to kill. One touched last week by a CI/CD pipeline is not.&lt;/p&gt;

&lt;h2&gt;
  
  
  Kill order: the right sequence
&lt;/h2&gt;

&lt;p&gt;Delete in this order: set desiredCount=0 → drain tasks → delete ECS service → delete target group → delete ALB listener rule → delete ALB → delete NAT Gateway → delete log groups. Wrong order causes dependency errors and leaves billing running.&lt;/p&gt;

&lt;p&gt;Step 1Scale to zero and drain&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws ecs update-service &lt;span class="nt"&gt;--cluster&lt;/span&gt; &amp;lt;cluster&amp;gt; &lt;span class="nt"&gt;--service&lt;/span&gt; &amp;lt;service&amp;gt; &lt;span class="nt"&gt;--desired-count&lt;/span&gt; 0
&lt;span class="c"&gt;# Lower drain timeout first to avoid waiting 5 minutes:&lt;/span&gt;
aws elbv2 modify-target-group-attributes &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--target-group-arn&lt;/span&gt; &amp;lt;tg-arn&amp;gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--attributes&lt;/span&gt; &lt;span class="nv"&gt;Key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;deregistration_delay.timeout_seconds,Value&lt;span class="o"&gt;=&lt;/span&gt;30
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Step 2Delete the ECS service&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws ecs delete-service &lt;span class="nt"&gt;--cluster&lt;/span&gt; &amp;lt;cluster&amp;gt; &lt;span class="nt"&gt;--service&lt;/span&gt; &amp;lt;service&amp;gt; &lt;span class="nt"&gt;--force&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Step 3Delete target group and ALB&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Remove listener rules first, then target group, then ALB&lt;/span&gt;
aws elbv2 delete-rule &lt;span class="nt"&gt;--rule-arn&lt;/span&gt; &amp;lt;rule-arn&amp;gt;
aws elbv2 delete-target-group &lt;span class="nt"&gt;--target-group-arn&lt;/span&gt; &amp;lt;tg-arn&amp;gt;
&lt;span class="c"&gt;# Disable deletion protection if set:&lt;/span&gt;
aws elbv2 modify-load-balancer-attributes &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--load-balancer-arn&lt;/span&gt; &amp;lt;alb-arn&amp;gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--attributes&lt;/span&gt; &lt;span class="nv"&gt;Key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;deletion_protection.enabled,Value&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;false
&lt;/span&gt;aws elbv2 delete-load-balancer &lt;span class="nt"&gt;--load-balancer-arn&lt;/span&gt; &amp;lt;alb-arn&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Step 4Delete NAT Gateway and release EIP&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws ec2 delete-nat-gateway &lt;span class="nt"&gt;--nat-gateway-id&lt;/span&gt; &amp;lt;ngw-id&amp;gt;
&lt;span class="c"&gt;# Wait for deletion, then release the Elastic IP:&lt;/span&gt;
aws ec2 release-address &lt;span class="nt"&gt;--allocation-id&lt;/span&gt; &amp;lt;eip-alloc-id&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Step 5Delete CloudWatch log groups&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# List and delete all log groups for this service:&lt;/span&gt;
aws logs describe-log-groups &lt;span class="nt"&gt;--log-group-name-prefix&lt;/span&gt; /ecs/&amp;lt;service-name&amp;gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s1"&gt;'logGroups[*].logGroupName'&lt;/span&gt; &lt;span class="nt"&gt;--output&lt;/span&gt; text &lt;span class="se"&gt;\&lt;/span&gt;
| &lt;span class="nb"&gt;tr&lt;/span&gt; &lt;span class="s1"&gt;'\t'&lt;/span&gt; &lt;span class="s1"&gt;'\n'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
| xargs &lt;span class="nt"&gt;-I&lt;/span&gt;&lt;span class="o"&gt;{}&lt;/span&gt; aws logs delete-log-group &lt;span class="nt"&gt;--log-group-name&lt;/span&gt; &lt;span class="o"&gt;{}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Common error&lt;/p&gt;

&lt;p&gt;Deleting an ALB while its listener rules still reference target groups throws a dependency error. Always delete rules before target groups, and target groups before the ALB. If you see &lt;code&gt;ResourceInUse&lt;/code&gt;, run the listener rules describe command above to find what's still attached.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to prevent orphans from accumulating
&lt;/h2&gt;

&lt;p&gt;Tag every environment at creation with &lt;code&gt;owner&lt;/code&gt;, &lt;code&gt;created-by&lt;/code&gt;, and &lt;code&gt;ttl&lt;/code&gt;. A weekly Lambda that flags services where TTL has passed costs $0 to run and surfaces every stale environment before it accumulates 6 months of charges.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tagging convention.&lt;/strong&gt; Apply these tags to every ECS service, ALB, target group, and NAT Gateway at creation time. Without consistent tags, the audit script above has no way to determine ownership or expected lifetime:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Terraform example — tag every resource at creation&lt;/span&gt;
&lt;span class="nx"&gt;locals&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;env_tags&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;owner&lt;/span&gt;      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"platform-team"&lt;/span&gt;
    &lt;span class="nx"&gt;created-by&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"terraform"&lt;/span&gt;
    &lt;span class="nx"&gt;env-type&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"staging"&lt;/span&gt;          &lt;span class="c1"&gt;# feature | staging | prod&lt;/span&gt;
    &lt;span class="nx"&gt;ttl&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"2026-09-01"&lt;/span&gt;       &lt;span class="c1"&gt;# ISO date — when this env expires&lt;/span&gt;
    &lt;span class="nx"&gt;service&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"payments-v2"&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_ecs_service"&lt;/span&gt; &lt;span class="s2"&gt;"this"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;# ...&lt;/span&gt;
  &lt;span class="nx"&gt;tags&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env_tags&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_lb"&lt;/span&gt; &lt;span class="s2"&gt;"this"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;# ...&lt;/span&gt;
  &lt;span class="nx"&gt;tags&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env_tags&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Weekly janitor Lambda.&lt;/strong&gt; An EventBridge rule triggers a Lambda every Monday. The Lambda lists all ECS services, checks the &lt;code&gt;ttl&lt;/code&gt; tag against today's date, and posts a Slack message for every service that's past its TTL or has been at desiredCount=0 for more than 7 days. No auto-deletion — just surfacing. The team decides what to kill.&lt;/p&gt;

&lt;p&gt;Fortem does this automatically: the dashboard shows per-environment cost, flags services that have been at zero desired count for more than N days, and lets you kill them from the UI without touching the AWS console. For teams managing 20+ environments, the manual audit above gets expensive in engineer time quickly.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you read this, you might also want to know
&lt;/h2&gt;

&lt;p&gt;What if my orphaned environment is in a different AWS account?&lt;/p&gt;

&lt;p&gt;Run the same CLI commands with --profile . If you use AWS Organizations, the easiest cross-account audit is AWS Config aggregator — it surfaces resources tagged with ttl across all member accounts without logging into each one.&lt;/p&gt;

&lt;p&gt;Does deleting an ECS service also delete the underlying ECR images?&lt;/p&gt;

&lt;p&gt;No. ECR images are independent of ECS services. Deleting the service leaves all images in ECR intact. Images cost $0.10/GB/month to store — a separate cleanup. Use aws ecr describe-images --repository-name  to list images and aws ecr batch-delete-image to remove old ones.&lt;/p&gt;

&lt;p&gt;How do I know if an ALB is shared between multiple ECS environments?&lt;/p&gt;

&lt;p&gt;Check listener rules: aws elbv2 describe-rules --listener-arn . More than one non-default rule means multiple services share the ALB. Count the target groups attached — one per environment. Delete only the rules and target group for the orphaned service, leave the ALB.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Does ECS charge for a service when desired count is 0?
&lt;/h3&gt;

&lt;p&gt;Fargate compute is $0 when desired count is 0 — you only pay for running tasks. But the supporting infrastructure bills regardless: an ALB costs $0.0225/hr ($16.43/mo) and a NAT Gateway costs $0.045/hr ($32.40/mo) whether or not any tasks are running.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I find all ECS services with zero desired count across all clusters?
&lt;/h3&gt;

&lt;p&gt;Run: aws ecs list-clusters --query 'clusterArns[]' --output text | tr '\t' '\n' | while read c; do aws ecs list-services --cluster "$c" --output text --query 'serviceArns[]' | tr '\t' '\n' | xargs -I{} aws ecs describe-services --cluster "$c" --services {} --query 'services[?desiredCount==&lt;code&gt;0&lt;/code&gt;].[serviceName,clusterArn]' --output text; done&lt;/p&gt;

&lt;h3&gt;
  
  
  Does deleting an ECS service also delete its load balancer?
&lt;/h3&gt;

&lt;p&gt;No. Deleting an ECS service does not delete the ALB, target groups, or listener rules. You must delete them separately after the service is gone. Check for deletion protection on the ALB first — it will block deletion with a cryptic error if enabled.&lt;/p&gt;

&lt;h3&gt;
  
  
  How long does ECS service drain take before I can delete it?
&lt;/h3&gt;

&lt;p&gt;The default deregistration delay is 300 seconds (5 minutes). You can lower it to 30 seconds on the target group before deleting the service: aws elbv2 modify-target-group-attributes --target-group-arn  --attributes Key=deregistration_delay.timeout_seconds,Value=30&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I automate orphan detection without a third-party tool?
&lt;/h3&gt;

&lt;p&gt;Yes — an EventBridge rule + Lambda that runs weekly, lists all ECS services with desiredCount=0, cross-references their age via tags or CloudTrail, and posts to Slack costs effectively $0 to run. The full pattern is covered in the prevention section above.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;### Stop guessing which environments are costing you money Fortem shows per-envi&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Worth reading&lt;/p&gt;

&lt;p&gt;&lt;a href="https://fortem.dev/aws-ecs-scheduling/" rel="noopener noreferrer"&gt;LandingECS Environment SchedulingYour environments run 168 hours a week. Your team works 40. See all four scheduling approaches and what breaks at fleet scale.&lt;/a&gt;&lt;a href="https://fortem.dev/blog/ecs-fargate-cost-visibility/" rel="noopener noreferrer"&gt;Use Case · Why Can't You See Per-Environment AWS Costs?Cost Explorer doesn't break down by environment by default. Here's the tagging strategy and Cost Explorer config that fixes it.&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Map your fleet in 5 min:&lt;/strong&gt; &lt;a href="https://fortem.dev/audit" rel="noopener noreferrer"&gt;fortem.dev/audit&lt;/a&gt;&lt;/p&gt;

</description>
      <category>aws</category>
      <category>ecs</category>
      <category>fargate</category>
      <category>finops</category>
    </item>
    <item>
      <title>Why Do AWS Staging Environments Cost So Much?</title>
      <dc:creator>Matt</dc:creator>
      <pubDate>Sun, 21 Jun 2026 15:01:58 +0000</pubDate>
      <link>https://dev.to/dspv/why-do-aws-staging-environments-cost-so-much-38nf</link>
      <guid>https://dev.to/dspv/why-do-aws-staging-environments-cost-so-much-38nf</guid>
      <description>&lt;h1&gt;
  
  
  Why AWS Staging Environments Cost So Much (2026 Guide)
&lt;/h1&gt;

&lt;blockquote&gt;
&lt;p&gt;Originally published at &lt;a href="https://fortem.dev/blog/aws-staging-environment-cost" rel="noopener noreferrer"&gt;https://fortem.dev/blog/aws-staging-environment-cost&lt;/a&gt;&lt;br&gt;
AWS staging environments run 168 hours a week. Your team works 40. Here's where the money goes on ECS Fargate — and how to cut it without touching production.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;Guide&lt;/p&gt;

&lt;p&gt;You have 10 ECS environments. Most of them are staging, QA, or dev. No one is using them at 2am on Saturday. But Fargate bills by the second, and by the time the monthly invoice arrives the number is larger than expected. This isn't an infrastructure design problem — it's an idle compute problem. Here's where the money goes, and what moves the needle. For the full per-environment math, see the &lt;a href="https://fortem.dev/blog/aws-fargate-pricing-real-costs/" rel="noopener noreferrer"&gt;AWS Fargate pricing breakdown&lt;/a&gt;, or model your own fleet in the &lt;a href="https://fortem.dev/ecs-cost-calculator/" rel="noopener noreferrer"&gt;cost calculator&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;  01Non-prod ECS environments run 168 hours a week. Your team works 40. That's 128 hrs/week of idle compute per environment.&lt;/li&gt;
&lt;li&gt;  02Fargate compute is ~68% of your ECS bill. The rest (CloudWatch Logs, ALB baseline) doesn't stop when the environment sits idle.&lt;/li&gt;
&lt;li&gt;  03NAT Gateway, VPC, and often ALB are shared across environments — that overhead doesn't multiply. Compute does.&lt;/li&gt;
&lt;li&gt;  04Fargate Spot cuts non-prod compute by up to 70% for fault-tolerant tasks. Not suitable for demo environments or shared QA sessions.&lt;/li&gt;
&lt;li&gt;  05Business-hours scheduling (Mon–Fri 09:00–19:00) cuts active compute time to ~30% of the 24/7 baseline with zero architecture changes.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Ready to use — drop this into your Terraform today&lt;/p&gt;

&lt;p&gt;ECS Application Auto Scaling scheduled actions — stops all tasks at 19:00 and restarts at 09:00, Mon–Fri. No Lambda required. Replace &lt;code&gt;your-cluster&lt;/code&gt; and &lt;code&gt;your-service&lt;/code&gt; with your values. Repeat the &lt;code&gt;aws_appautoscaling_*&lt;/code&gt; blocks for each service.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight terraform"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Register the ECS service as a scalable target&lt;/span&gt;
&lt;span class="k"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_appautoscaling_target"&lt;/span&gt; &lt;span class="s2"&gt;"staging_svc"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;max_capacity&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;
  &lt;span class="nx"&gt;min_capacity&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
  &lt;span class="nx"&gt;resource_id&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"service/your-cluster/your-service"&lt;/span&gt;
  &lt;span class="nx"&gt;scalable_dimension&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"ecs:service:DesiredCount"&lt;/span&gt;
  &lt;span class="nx"&gt;service_namespace&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"ecs"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;# Stop at 19:00 UTC Mon–Fri&lt;/span&gt;
&lt;span class="k"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_appautoscaling_scheduled_action"&lt;/span&gt; &lt;span class="s2"&gt;"stop_evening"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;               &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"stop-staging-evening"&lt;/span&gt;
  &lt;span class="nx"&gt;service_namespace&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_appautoscaling_target&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;staging_svc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;service_namespace&lt;/span&gt;
  &lt;span class="nx"&gt;resource_id&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_appautoscaling_target&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;staging_svc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;resource_id&lt;/span&gt;
  &lt;span class="nx"&gt;scalable_dimension&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_appautoscaling_target&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;staging_svc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;scalable_dimension&lt;/span&gt;
  &lt;span class="nx"&gt;schedule&lt;/span&gt;           &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"cron(0 19 ? * MON-FRI *)"&lt;/span&gt;

  &lt;span class="nx"&gt;scalable_target_action&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;min_capacity&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
    &lt;span class="nx"&gt;max_capacity&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;# Restart at 09:00 UTC Mon–Fri&lt;/span&gt;
&lt;span class="k"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_appautoscaling_scheduled_action"&lt;/span&gt; &lt;span class="s2"&gt;"start_morning"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;               &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"start-staging-morning"&lt;/span&gt;
  &lt;span class="nx"&gt;service_namespace&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_appautoscaling_target&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;staging_svc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;service_namespace&lt;/span&gt;
  &lt;span class="nx"&gt;resource_id&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_appautoscaling_target&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;staging_svc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;resource_id&lt;/span&gt;
  &lt;span class="nx"&gt;scalable_dimension&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_appautoscaling_target&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;staging_svc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;scalable_dimension&lt;/span&gt;
  &lt;span class="nx"&gt;schedule&lt;/span&gt;           &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"cron(0 9 ? * MON-FRI *)"&lt;/span&gt;

  &lt;span class="nx"&gt;scalable_target_action&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;min_capacity&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
    &lt;span class="nx"&gt;max_capacity&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;# Optional: Fargate Spot capacity provider for non-prod&lt;/span&gt;
&lt;span class="k"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_ecs_service"&lt;/span&gt; &lt;span class="s2"&gt;"staging_svc"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;# ... your existing service config ...&lt;/span&gt;

  &lt;span class="nx"&gt;capacity_provider_strategy&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;capacity_provider&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"FARGATE_SPOT"&lt;/span&gt;
    &lt;span class="nx"&gt;weight&lt;/span&gt;            &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;capacity_provider_strategy&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;capacity_provider&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"FARGATE"&lt;/span&gt;
    &lt;span class="nx"&gt;weight&lt;/span&gt;            &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
    &lt;span class="nx"&gt;base&lt;/span&gt;              &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Monthly compute cost — 10 non-prod environments (80 services, 0.5 vCPU each)&lt;/p&gt;

&lt;p&gt;us-east-1, Linux x86, on-demand rates June 2026&lt;/p&gt;

&lt;p&gt;24/7 on-demand&lt;/p&gt;

&lt;p&gt;$1,442/mo&lt;/p&gt;

&lt;p&gt;Business hours on-demand&lt;/p&gt;

&lt;p&gt;-70%$428/mo&lt;/p&gt;

&lt;p&gt;Business hours + Fargate Spot&lt;/p&gt;

&lt;p&gt;-91%$128/mo&lt;/p&gt;

&lt;p&gt;Business hours = Mon–Fri 09:00–19:00 (50 hrs/wk, ~217 hrs/mo). Fargate Spot at 70% discount. Shared infrastructure (NAT Gateway, VPC, ALB) not included — shared cost does not multiply per environment.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why non-prod spend stays invisible
&lt;/h2&gt;

&lt;p&gt;Non-prod costs get lumped into a single “infrastructure” line item with no per-environment breakdown. No one owns the number, so it doesn't get fixed.&lt;/p&gt;

&lt;p&gt;Production gets optimized after a big bill. Staging gets the same config it had when the second engineer joined and no one has touched it since. The reason isn't negligence — it's visibility. AWS Cost Explorer shows you ECS as a service total. Without &lt;a href="https://fortem.dev/blog/ecs-fargate-cost-visibility/" rel="noopener noreferrer"&gt;per-environment cost allocation tags&lt;/a&gt;, there's no way to see that your staging environment costs more than your QA environment, or that three dev environments have been running since February with no active work behind them.&lt;/p&gt;

&lt;p&gt;The result: non-prod spend is invisible in reviews, gets absorbed into the overall AWS bill, and deferred indefinitely with “it's just staging, we'll fix it later.”&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;KEY INSIGHT:&lt;/strong&gt; Key insight “Nobody noticed because staging bills get lumped into ‘infrastructure costs’ and nobody questions them.” — practitioner, dev.to&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Where the money goes on Fargate
&lt;/h2&gt;

&lt;p&gt;Fargate compute is ~68% of a typical ECS bill at $0.04048/vCPU-hr and $0.004445/GB-hr. The remaining 32% — CloudWatch Logs at $0.50/GB ingested, ALB baseline at $0.0225/hr — doesn't scale to zero when tasks are idle.&lt;/p&gt;

&lt;p&gt;The big number is compute, and compute is the lever. But a few non-obvious charges compound the problem for non-prod environments specifically:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;01&lt;/p&gt;

&lt;p&gt;CloudWatch Logs — verbose by default&lt;/p&gt;

&lt;p&gt;Non-prod environments often run at DEBUG log level. A service generating 1 GB/day of logs costs $15/month in ingestion alone. Multiply by 8 services and 10 environments and you have a meaningful line item that has nothing to do with compute.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;02&lt;/p&gt;

&lt;p&gt;Container Insights — charged per observation&lt;/p&gt;

&lt;p&gt;Container Insights is on by default on many clusters. For non-prod, it adds cost without adding value. Turn it off on dev and staging clusters.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;03&lt;/p&gt;

&lt;p&gt;ALB dedicated to one environment&lt;/p&gt;

&lt;p&gt;If each environment has its own ALB, the $0.0225/hr base charge ($16.43/mo) runs regardless of traffic. Teams running 10 environments with dedicated ALBs pay $164/mo in ALB base charges before a single request is processed.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The 168-hour problem
&lt;/h2&gt;

&lt;p&gt;A non-prod environment running 24/7 runs 168 hours a week. Your team works 40. That gap — 128 hours per week of idle compute per environment — is the real cost driver on Fargate.&lt;/p&gt;

&lt;p&gt;Let's do the math on a realistic fleet. Ten non-prod environments, each running 8 services at 0.5 vCPU and 1 GB memory:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Scenario&lt;/th&gt;
&lt;th&gt;Hrs/mo active&lt;/th&gt;
&lt;th&gt;Compute/mo&lt;/th&gt;
&lt;th&gt;vs 24/7&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;24/7 on-demand&lt;/td&gt;
&lt;td&gt;730&lt;/td&gt;
&lt;td&gt;$1,442&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Business hours on-demand&lt;/td&gt;
&lt;td&gt;~217&lt;/td&gt;
&lt;td&gt;$428&lt;/td&gt;
&lt;td&gt;−70%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Business hours + Spot&lt;/td&gt;
&lt;td&gt;~217&lt;/td&gt;
&lt;td&gt;~$128&lt;/td&gt;
&lt;td&gt;−91%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;80 services × 0.5 vCPU × $0.04048/hr + 80 × 1 GB × $0.004445/hr. Business hours = Mon–Fri 09:00–19:00 UTC (~217 hrs/mo).&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;KEY INSIGHT:&lt;/strong&gt; Key insight The compute in a non-prod environment doesn't know it's 2am on Sunday. It charges the same rate as a Tuesday afternoon.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Fargate bills by the second with no minimum charge. A task stopped at 19:00 pays nothing until it restarts at 09:00. That's not an approximation — it's how the billing model works. The savings from scheduling are immediate and exact.&lt;/p&gt;

&lt;h2&gt;
  
  
  What shared infrastructure changes (and doesn't change)
&lt;/h2&gt;

&lt;p&gt;NAT Gateway, VPC, and often ALB are shared across environments. That overhead doesn't multiply per environment. What multiplies is compute — one set of running tasks per environment, billed independently.&lt;/p&gt;

&lt;p&gt;A well-structured ECS fleet shares:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  —&lt;strong&gt;NAT Gateway&lt;/strong&gt; — one per VPC, ~$32.85/mo base. Shared across all environments. $3.29/env at 10 environments.&lt;/li&gt;
&lt;li&gt;  —&lt;strong&gt;ALB with host-based routing&lt;/strong&gt; — one ALB routes to all environments via hostname rules. $16.43/mo base total, not per environment.&lt;/li&gt;
&lt;li&gt;  —&lt;strong&gt;VPC, subnets, security groups&lt;/strong&gt; — no per-environment charge.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What doesn't share: Fargate task hours, CloudWatch Logs ingestion per environment, and ECR image pull data. These are the numbers that multiply at fleet scale — and they're all driven by idle compute.&lt;/p&gt;

&lt;p&gt;This is why the fix is scheduling tasks, not redesigning network architecture. Once you understand that shared infra is already cheap per environment, the question becomes: how do you stop paying for 128 idle compute hours per week?&lt;/p&gt;

&lt;p&gt;You can set up &lt;a href="https://fortem.dev/blog/aws-cost-anomaly-detection-ecs/" rel="noopener noreferrer"&gt;per-environment cost allocation tags with AWS Cost Anomaly Detection&lt;/a&gt; to get alerted when any single environment deviates from its historical spend baseline — useful once you have scheduling in place and want to catch drift.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fargate Spot for non-prod: when it works, when it doesn't
&lt;/h2&gt;

&lt;p&gt;Fargate Spot runs non-prod tasks on spare AWS capacity at up to 70% off on-demand rates. It works well for dev and QA. Avoid it for environments used for customer demos or with stateful in-memory work that can't tolerate a restart.&lt;/p&gt;

&lt;p&gt;The mechanics: AWS gives 2 minutes' warning via SIGTERM before reclaiming Spot capacity. ECS marks the task as &lt;code&gt;SPOT_INTERRUPTION&lt;/code&gt;and, if desired count is still &amp;gt; 0, launches a replacement.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Environment type&lt;/th&gt;
&lt;th&gt;Fargate Spot?&lt;/th&gt;
&lt;th&gt;Reason&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Dev environments&lt;/td&gt;
&lt;td&gt;✓ Yes&lt;/td&gt;
&lt;td&gt;Stateless, restartable, no active users&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Feature branch preview&lt;/td&gt;
&lt;td&gt;✓ Yes&lt;/td&gt;
&lt;td&gt;Ephemeral, restartable on interrupt&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CI / integration tests&lt;/td&gt;
&lt;td&gt;✓ Yes&lt;/td&gt;
&lt;td&gt;Short-lived tasks, retry on failure&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;QA (automated)&lt;/td&gt;
&lt;td&gt;✓ Yes&lt;/td&gt;
&lt;td&gt;Tests restart automatically on failure&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;QA (live session)&lt;/td&gt;
&lt;td&gt;✗ Risky&lt;/td&gt;
&lt;td&gt;Interrupt kills active QA session&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Demo environment&lt;/td&gt;
&lt;td&gt;✗ No&lt;/td&gt;
&lt;td&gt;Customer impact if interrupted&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Staging (production-like)&lt;/td&gt;
&lt;td&gt;✗ Usually not&lt;/td&gt;
&lt;td&gt;Used for final validation, needs stability&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The capacity provider strategy in the Terraform block above sets &lt;code&gt;FARGATE_SPOT weight=1, FARGATE weight=0&lt;/code&gt; — pure Spot. For environments that need occasional stability, set Spot weight to 3 and on-demand weight to 1 to prefer Spot but fall back automatically.&lt;/p&gt;

&lt;h2&gt;
  
  
  Business-hours scheduling: the fastest ROI
&lt;/h2&gt;

&lt;p&gt;Scheduling ECS tasks to stop at 19:00 and restart at 09:00 Mon–Fri cuts active compute time from 730 hours/month to ~217 hours — a 70% reduction with no architecture changes required.&lt;/p&gt;

&lt;p&gt;The AWS-native approach uses ECS Application Auto Scaling scheduled actions. No Lambda function, no custom scheduler, no third-party tool — this is a first-class ECS feature. The Terraform block at the top of this article implements it exactly. For the full picture of every approach to &lt;a href="https://fortem.dev/aws-ecs-scheduling/" rel="noopener noreferrer"&gt;scheduling ECS environments off nights and weekends&lt;/a&gt;, including where the native path breaks down at fleet scale, see the dedicated guide.&lt;/p&gt;

&lt;p&gt;A few operational details worth knowing before you deploy:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  —&lt;strong&gt;Deregistration delay.&lt;/strong&gt; ALB target groups have a default 300-second deregistration delay. Reduce this to 30 seconds on non-prod target groups so environments stop promptly at 19:00 instead of draining for 5 minutes.&lt;/li&gt;
&lt;li&gt;  —&lt;strong&gt;Stateful services.&lt;/strong&gt; RDS and ElastiCache run independently — they're not stopped by this config. Data persists across task restarts. EFS mounts reattach on task start.&lt;/li&gt;
&lt;li&gt;  —&lt;strong&gt;Timezone offset.&lt;/strong&gt; EventBridge cron uses UTC. Mon–Fri 09:00–19:00 ET is 13:00–23:00 UTC. Adjust the cron expressions for your team's timezone.&lt;/li&gt;
&lt;li&gt;  —&lt;strong&gt;Override capability.&lt;/strong&gt; The scheduled action sets desired count — any engineer can manually set it back to 1 for an after-hours session. The schedule resumes as normal the next morning.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  At 10+ environments, this math becomes unavoidable
&lt;/h2&gt;

&lt;p&gt;One staging environment running 24/7 is an annoyance. Ten of them is a line item that starts appearing in board decks. The fix doesn't scale manually.&lt;/p&gt;

&lt;p&gt;Manual scheduling via the AWS console or one-off Terraform blocks works at 1–2 environments. At 10+, the operational overhead compounds:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  —Schedule drift — different engineers set different start/stop times, no one audits&lt;/li&gt;
&lt;li&gt;  —Environment-specific hours — the ML team needs their env at 6am, QA needs theirs until 9pm&lt;/li&gt;
&lt;li&gt;  —On-demand overrides — “can you keep staging up tonight, we have a client demo” — sent in Slack, forgotten in Terraform&lt;/li&gt;
&lt;li&gt;  —New environments inherit no schedule by default — the next dev environment someone spins up runs 24/7 until someone notices&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is where fleet-level tooling pays for itself. Fortem manages scheduling across all non-prod environments from one interface — with override capability per environment, audit log of who changed what, and defaults that apply to new environments automatically.&lt;/p&gt;

&lt;p&gt;See which environments in your fleet are burning budget right now.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://fortem.dev/book/" rel="noopener noreferrer"&gt;Talk to us about your fleet&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Questions this article doesn't answer
&lt;/h2&gt;

&lt;p&gt;How do I actually see which environment is costing what in AWS?+&lt;/p&gt;

&lt;p&gt;Enable cost allocation tags for your environment key in the AWS Billing console, then use Cost Explorer with a Group by filter on that tag. You'll see per-environment spend broken out as individual rows. Our article on per-environment cost visibility walks through the exact steps.&lt;/p&gt;

&lt;p&gt;Can I automatically stop ECS environments when there's no active deployment or open PR?+&lt;/p&gt;

&lt;p&gt;Not with native ECS scheduling alone — you'd need to wire EventBridge to your CI/CD events. A GitHub Actions workflow can call the ECS UpdateService API to set desired count to 0 when a PR is closed and back to 1 when a new deployment completes. Some teams add this to their deploy pipeline directly.&lt;/p&gt;

&lt;p&gt;What's the difference between desired count = 0 and deleting the ECS service entirely?+&lt;/p&gt;

&lt;p&gt;Setting desired count to 0 stops all running tasks but preserves the service definition, IAM roles, capacity provider strategies, and auto-scaling rules. The service restarts exactly as configured. Deleting the service removes all of this and you'd need to recreate it from Terraform. For scheduling, use desired count = 0 — not service deletion.&lt;/p&gt;

&lt;p&gt;Does stopping and restarting ECS tasks affect RDS or other stateful services?+&lt;/p&gt;

&lt;p&gt;RDS, ElastiCache, and other stateful services run independently of ECS task count. Stopping tasks at 19:00 has no effect on your database — it continues running (and billing) until you separately stop it. Data persists across task restarts. EFS volumes reattach automatically when tasks start again.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Is Fargate Spot available for ECS services or only tasks?
&lt;/h3&gt;

&lt;p&gt;Fargate Spot is available for ECS services through capacity provider strategies. You set FARGATE_SPOT as a capacity provider with a weight in your ECS service definition. Tasks get scheduled on Spot capacity when available. If AWS needs the capacity back, tasks receive a SIGTERM with a 2-minute warning before SIGKILL.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does setting ECS desired count to 0 stop billing immediately?
&lt;/h3&gt;

&lt;p&gt;Yes. When desired count reaches 0 and running tasks drain and stop, Fargate billing stops within seconds — Fargate charges by the second with no minimum. However, other resources associated with the environment (ALB if dedicated, CloudWatch Log Groups, RDS) continue to incur charges independently.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I set up a schedule to stop ECS services on nights and weekends?
&lt;/h3&gt;

&lt;p&gt;Use ECS Application Auto Scaling scheduled actions — no Lambda required. Create a scalable target for each ECS service, then add two scheduled actions: one to set desired count to 0 at your stop time and one to restore it in the morning. EventBridge cron expressions handle the schedule. Terraform example is included in this article.&lt;/p&gt;

&lt;h3&gt;
  
  
  Will reducing non-prod ECS task size break anything?
&lt;/h3&gt;

&lt;p&gt;It depends on what the task does. For services that only handle QA traffic or automated tests, dropping from 1 vCPU to 0.5 vCPU rarely causes issues. The risk is for tasks that run build pipelines, data migrations, or integration tests under time constraints — those may fail or time out. Right-size based on actual observed CPU and memory utilization, not on what production uses.&lt;/p&gt;

&lt;h3&gt;
  
  
  How does Fargate Spot handle interruptions in ECS?
&lt;/h3&gt;

&lt;p&gt;AWS sends a SIGTERM to the task 2 minutes before reclaiming capacity, then sends SIGKILL. ECS marks the task as stopped with reason SPOT_INTERRUPTION. If the ECS service has a desired count greater than 0, it will launch a replacement task — on Spot if available, falling back to on-demand if not (depending on your capacity provider strategy weights).&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;See your real per-env cost:&lt;/strong&gt; &lt;a href="https://fortem.dev/ecs-cost-calculator" rel="noopener noreferrer"&gt;fortem.dev/ecs-cost-calculator&lt;/a&gt;&lt;/p&gt;

</description>
      <category>aws</category>
      <category>ecs</category>
      <category>fargate</category>
      <category>finops</category>
    </item>
  </channel>
</rss>
