<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>ECS |</title><link>https://kristianeschenburg.netlify.app/tag/ecs/</link><atom:link href="https://kristianeschenburg.netlify.app/tag/ecs/index.xml" rel="self" type="application/rss+xml"/><description>ECS</description><generator>Source Themes Academic (https://sourcethemes.com/academic/)</generator><language>en-us</language><lastBuildDate>Wed, 05 Nov 2025 09:00:00 -0800</lastBuildDate><image><url>https://kristianeschenburg.netlify.app/img/Bayes.jpg</url><title>ECS</title><link>https://kristianeschenburg.netlify.app/tag/ecs/</link></image><item><title>Deploying Dagster to AWS ECS, Part 2: Pipelines</title><link>https://kristianeschenburg.netlify.app/post/deploying-dagster-to-ecs-2-pipelines/</link><pubDate>Wed, 05 Nov 2025 09:00:00 -0800</pubDate><guid>https://kristianeschenburg.netlify.app/post/deploying-dagster-to-ecs-2-pipelines/</guid><description>&lt;p>Here is the second of two posts on running Dagster in ECS. The
&lt;a href="https://kristianeschenburg.netlify.app/post/deploying-dagster-to-ecs-1-platform/">first&lt;/a> dealt with the platform side: the Daemon, the two Webservers, the ALB and Cognito wiring, the three security groups, the Cloud Map namespace, and the IAM that lets the Daemon launch anything at all.&lt;/p>
&lt;p>The first component I referred to as &lt;code>dagster-platform&lt;/code>. This one is about what I&amp;rsquo;m referring to as &lt;code>dagster-pipeline&lt;/code>, the module you instantiate once per pipeline. Platform infrastructure gets deployed infrequently. Pipelines get deployed several times a day by whoever happens to be working on them. The reason I separated the two is that pushing a new job should never touch a security group, an IAM policy, or a load balancer rule.&lt;/p>
&lt;hr>
&lt;h2 id="the-contract">The Contract&lt;/h2>
&lt;p>
&lt;a href="https://kristianeschenburg.netlify.app/post/deploying-dagster-to-ecs-1-platform/">Part One&lt;/a> ended with the four things the platform expects from a pipeline:&lt;/p>
&lt;ol>
&lt;li>Register a service in the Cloud Map namespace, so the Daemon and Webserver can resolve it by DNS on port 4000.&lt;/li>
&lt;li>Attach the usercode service to the platform&amp;rsquo;s usercode security group, so that traffic is actually allowed.&lt;/li>
&lt;li>Tag its run roles with &lt;code>dagster:component&lt;/code> and &lt;code>dagster:managed-by&lt;/code>, so the Daemon is permitted to pass them to ECS.&lt;/li>
&lt;li>Get its code location into &lt;code>workspace.yaml&lt;/code> in SSM, so the Webserver and Daemon know it exists.&lt;/li>
&lt;/ol>
&lt;p>The platform defines the security group and the pipeline looks it up by name:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-hcl" data-lang="hcl">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># dagster-pipeline/ecs.tf
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="k">data&lt;/span> &lt;span class="s2">&amp;#34;aws_security_group&amp;#34; &amp;#34;usercode&amp;#34;&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> name&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;${var.usercode_sg}-${var.platform_env}&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">}&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Here is the whole module and everything it touches. Blue is the platform, which this module does not create and only attaches to. Green is what the pipeline module builds.&lt;/p>
&lt;pre>&lt;code class="language-mermaid">graph LR
Daemon[daemon]
Web[webserver]
UcSvc[usercode service]
RunTD(run task def)
Run[run task]
CM(Cloud Map)
ECR(ECR)
SM(Secrets Manager)
PG(Postgres)
S3(S3)
SrcDB(source DB)
Web --&amp;gt;|4000| UcSvc
Daemon --&amp;gt;|4000| UcSvc
UcSvc -.-&amp;gt;|register| CM
CM -.-&amp;gt;|resolve| Daemon
UcSvc -.-&amp;gt;|names| RunTD
Daemon --&amp;gt;|RunTask| RunTD
RunTD -.-&amp;gt;|instantiates| Run
ECR -.-&amp;gt;|image| UcSvc
ECR -.-&amp;gt;|image| Run
SM --&amp;gt;|exec role| UcSvc
SM --&amp;gt;|task role| Run
UcSvc --&amp;gt;|5432| PG
Run --&amp;gt;|5432| PG
Run --&amp;gt;|443| S3
Run --&amp;gt;|query| SrcDB
classDef platform fill:#e8f0fe,stroke:#4285f4
classDef pipeline fill:#e6f4ea,stroke:#34a853
classDef ephemeral fill:#fef7e0,stroke:#f9ab00
classDef backend fill:#f1f3f4,stroke:#9aa0a6
class Daemon,Web platform
class UcSvc,CM,RunTD pipeline
class Run ephemeral
class ECR,SM,PG,S3,SrcDB backend&lt;/code>&lt;/pre>&lt;p>The run task definition is a &lt;em>definition&lt;/em>, not a running thing: the usercode container names it in an environment variable, and the daemon is what actually instantiates it. The arrows pointing into the Secrets Manager land on different roles, because the usercode service has its credentials injected by ECS before it starts while the run task reads them itself at runtime.&lt;/p>
&lt;hr>
&lt;h2 id="the-two-task-definition-pattern">The Two-Task-Definition Pattern&lt;/h2>
&lt;p>Each pipeline module creates &lt;strong>two&lt;/strong> task definitions:&lt;/p>
&lt;p>&lt;strong>&lt;code>usercode&lt;/code> task definition&lt;/strong>: the always-on code server. This runs &lt;code>dagster code-server start&lt;/code> and stays alive, serving job definitions, sensors, schedules, and so on to the Daemon. It registers with Cloud Map so the platform can discover it.&lt;/p>
&lt;p>&lt;strong>&lt;code>run&lt;/code> task definition&lt;/strong>: the task that executes a job. When a run is triggered, the EcsRunLauncher spins up a new Fargate task using this definition. It&amp;rsquo;s ephemeral, meaning it starts, runs the job, and exits.&lt;/p>
&lt;p>The two definitions point at the same image and differ mainly in CPU/memory and in which IAM roles they use. The &lt;code>run&lt;/code> definition&amp;rsquo;s &lt;code>command&lt;/code> doesn&amp;rsquo;t really matter, since the launcher overrides it with the actual run command, but I set it to the same code-server command so that starting the task by hand does something reasonable. The usercode container needs to know about the run task definition so it can tell Dagster which task to launch, which can be achieved with two environment variables.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-hcl" data-lang="hcl">&lt;span class="line">&lt;span class="cl">&lt;span class="k">locals&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> dagster_current_image_env&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> { name&lt;/span> &lt;span class="o">=&lt;/span>&lt;span class="n"> &amp;#34;DAGSTER_CURRENT_IMAGE&amp;#34;, value&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">data&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">aws_ecr_image&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">usercode&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">image_uri&lt;/span> }
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> dagster_container_context&lt;/span> &lt;span class="o">=&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> ecs&lt;/span> &lt;span class="o">=&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> task_definition_arn&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">aws_ecs_task_definition&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">run&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">arn&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> container_name&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;run&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> }
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> }
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> dagster_container_context_env&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> { name&lt;/span> &lt;span class="o">=&lt;/span>&lt;span class="n"> &amp;#34;DAGSTER_CONTAINER_CONTEXT&amp;#34;, value&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">jsonencode&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="k">local&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">dagster_container_context&lt;/span>&lt;span class="p">)&lt;/span> }
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">}&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>DAGSTER_CURRENT_IMAGE&lt;/code> is the pipeline&amp;rsquo;s ECR image URI. &lt;code>DAGSTER_CONTAINER_CONTEXT&lt;/code> tells Dagster which ECS task definition and container name to use for runs. Note that &lt;code>container_name&lt;/code> has to match the &lt;code>name&lt;/code> field of the container in the run task definition exactly. If it doesn&amp;rsquo;t, the run task launches and Dagster can&amp;rsquo;t find the container it&amp;rsquo;s supposed to be watching. Both of these go on the &lt;strong>usercode&lt;/strong> container only, not the run container. Building &lt;code>DAGSTER_CONTAINER_CONTEXT&lt;/code> from &lt;code>aws_ecs_task_definition.run.arn&lt;/code> also creates the dependency that forces Terraform to build the run task definition first.&lt;/p>
&lt;hr>
&lt;h2 id="registering-with-cloud-map">Registering With Cloud Map&lt;/h2>
&lt;p>The platform created the private DNS namespace, and each pipeline creates its own service record inside of that DNS namespace:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-hcl" data-lang="hcl">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># dagster-pipeline/cloud_map.tf
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Look up the namespace created by the platform module
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="k">data&lt;/span> &lt;span class="s2">&amp;#34;aws_service_discovery_dns_namespace&amp;#34; &amp;#34;dagster&amp;#34;&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> type&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;DNS_PRIVATE&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> name&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;pipelines-${var.env}.usercode&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">}&lt;span class="c1">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Each pipeline gets its own service record in the shared namespace
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="k">resource&lt;/span> &lt;span class="s2">&amp;#34;aws_service_discovery_service&amp;#34; &amp;#34;usercode&amp;#34;&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> name&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;${var.pipeline_name}-pipeline-${var.env}&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">dns_config&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> namespace_id&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">data&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">aws_service_discovery_dns_namespace&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">dagster&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">id&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">dns_records&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> ttl&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="m">300&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> type&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;A&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> }
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> routing_policy&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;MULTIVALUE&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> }
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">}&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The ECS service ties itself to that record with a &lt;code>service_registries&lt;/code> block, and from then on ECS registers the Fargate task&amp;rsquo;s private IP with Cloud Map automatically:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-hcl" data-lang="hcl">&lt;span class="line">&lt;span class="cl">&lt;span class="k">resource&lt;/span> &lt;span class="s2">&amp;#34;aws_ecs_service&amp;#34; &amp;#34;usercode&amp;#34;&lt;/span> {&lt;span class="c1">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"> # ...
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="k">service_registries&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> registry_arn&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">aws_service_discovery_service&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">usercode&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">arn&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> }
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">}&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The DNS name becomes &lt;code>&amp;lt;pipeline-name&amp;gt;-pipeline-&amp;lt;env&amp;gt;.pipelines-&amp;lt;env&amp;gt;.usercode&lt;/code>, which goes into &lt;code>workspace.yaml&lt;/code>:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="c"># workspace.yaml (stored in SSM)&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">load_from&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">grpc_server&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">host&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">ingest-pipeline-prod.pipelines-prod.usercode&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">port&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">4000&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">location_name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;ingest&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">grpc_server&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">host&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">transforms-pipeline-prod.pipelines-prod.usercode&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">port&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">4000&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">location_name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;transforms&amp;#34;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&amp;ldquo;ingest&amp;rdquo; and &amp;ldquo;transforms&amp;rdquo; are what you see in the Dagster webserver when looking at each of your pipeline deployments.&lt;/p>
&lt;h3 id="ttl-and-task-replacement">TTL and Task Replacement&lt;/h3>
&lt;p>The &lt;code>ttl = 300&lt;/code> in that &lt;code>dns_records&lt;/code> is important here. Fargate tasks get a new private IP every time they&amp;rsquo;re replaced, and a code server is replaced on every deploy. Cloud Map updates the A record immediately, but the Daemon and Webserver are resolving that name through the VPC resolver, which respects the TTL. For a few minutes after a deploy, they can be holding the IP of a task that no longer exists, and in practice, that means that a code location goes red (a.k.a looks like a failed deployment in the UI) in the UI right after a deploy and then fixes itself a few minutes later. Decreasing the TTL to 15 or 30 seconds makes deploys settle much faster, at the cost of more DNS queries, which for a handful of code servers is probably acceptable.&lt;/p>
&lt;p>&lt;code>routing_policy = &amp;quot;MULTIVALUE&amp;quot;&lt;/code> returns every healthy instance registered under the name. With &lt;code>desired_count = 1&lt;/code> there&amp;rsquo;s only ever one, but it&amp;rsquo;s the right policy if you later run more than one replica of a code server. Similarly, when you add a new pipeline, you need to update &lt;code>workspace.yaml&lt;/code> in SSM and restart the Daemon and Webserver services so they pick up the new entry. I&amp;rsquo;m currently running this manually, but it&amp;rsquo;s worth automating in the future (but not a deal-breaker, just not something I&amp;rsquo;d consider &amp;ldquo;complete&amp;rdquo;).&lt;/p>
&lt;hr>
&lt;h2 id="four-roles-per-pipeline">Four Roles Per Pipeline&lt;/h2>
&lt;p>Each pipeline module creates four IAM roles: execution and task roles for both the &lt;strong>usercode&lt;/strong> service (always-on code server) and the &lt;strong>run&lt;/strong> task (ephemeral, launched per job execution).&lt;/p>
&lt;p>Just like I mentioned in
&lt;a href="https://kristianeschenburg.netlify.app/post/deploying-dagster-to-ecs-1-platform/">Part One&lt;/a>, if you&amp;rsquo;re handling AWS secrets, if the value is injected by the ECS &lt;code>secrets&lt;/code> block in the task definition, the &lt;strong>execution&lt;/strong> role needs to read it. If your application code calls &lt;code>boto3&lt;/code> to fetch it, the &lt;strong>task&lt;/strong> role needs to read it.&lt;/p>
&lt;p>The roles are structurally identical, but the tags differ. The tags are what the platform&amp;rsquo;s &lt;code>iam:PassRole&lt;/code> condition checks against:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-hcl" data-lang="hcl">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># dagster-pipeline/iam.tf
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">locals&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> usercode_role_tags&lt;/span> &lt;span class="o">=&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> &amp;#34;dagster:component&amp;#34;&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;usercode&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> &amp;#34;dagster:pipeline&amp;#34;&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">var&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">pipeline_name&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> &amp;#34;dagster:managed-by&amp;#34;&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;terraform&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> }
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> run_role_tags&lt;/span> &lt;span class="o">=&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> &amp;#34;dagster:component&amp;#34;&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;run&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> &amp;#34;dagster:pipeline&amp;#34;&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">var&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">pipeline_name&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> &amp;#34;dagster:managed-by&amp;#34;&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;terraform&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> }
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">}&lt;span class="c1">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Run execution role, used by ECS to start the ephemeral run container
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="k">resource&lt;/span> &lt;span class="s2">&amp;#34;aws_iam_role&amp;#34; &amp;#34;run_exec&amp;#34;&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> name&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;${var.name_prefix}-${var.pipeline_name}-run-exec-role-${var.pipeline_env}&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> assume_role_policy&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">data&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">aws_iam_policy_document&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">ecs_task_assume_role&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">json&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> tags&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">merge&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="k">local&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">run_role_tags&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="k">var&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">tags&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">}
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">resource&lt;/span> &lt;span class="s2">&amp;#34;aws_iam_role_policy_attachment&amp;#34; &amp;#34;run_exec_attach&amp;#34;&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> role&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">aws_iam_role&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">run_exec&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">name&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> policy_arn&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">}&lt;span class="c1">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Run task role, used by the running job container to call AWS APIs
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="k">resource&lt;/span> &lt;span class="s2">&amp;#34;aws_iam_role&amp;#34; &amp;#34;run_task&amp;#34;&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> name&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;${var.name_prefix}-${var.pipeline_name}-run-task-role-${var.pipeline_env}&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> assume_role_policy&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">data&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">aws_iam_policy_document&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">ecs_task_assume_role&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">json&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> tags&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">merge&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="k">local&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">run_role_tags&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="k">var&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">tags&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">}&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="the-empty-resource-list-trap">The Empty Resource List Trap&lt;/h3>
&lt;p>IAM rejects a policy statement with an empty &lt;code>Resource&lt;/code> list, so a pipeline that connects to no databases can&amp;rsquo;t just get a policy document with zero ARNs in it. The document has to not exist at all:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-hcl" data-lang="hcl">&lt;span class="line">&lt;span class="cl">&lt;span class="k">data&lt;/span> &lt;span class="s2">&amp;#34;aws_iam_policy_document&amp;#34; &amp;#34;db_secrets_read&amp;#34;&lt;/span> {&lt;span class="c1">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"> # A pipeline that reads no databases must not get this policy at all.
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="n"> count&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">length&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="k">var&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">db_secret_ids&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="err">&amp;gt;&lt;/span> &lt;span class="m">0&lt;/span> &lt;span class="err">?&lt;/span> &lt;span class="m">1&lt;/span> &lt;span class="err">:&lt;/span> &lt;span class="m">0&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">statement&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> sid&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;ReadDatabaseSecrets&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> effect&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;Allow&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> actions&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;secretsmanager:GetSecretValue&amp;#34;, &amp;#34;secretsmanager:DescribeSecret&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> resources&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="k">for&lt;/span> &lt;span class="k">s&lt;/span> &lt;span class="k">in&lt;/span> &lt;span class="k">data&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">aws_secretsmanager_secret&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">db_secrets&lt;/span> &lt;span class="err">:&lt;/span> &lt;span class="k">s&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">arn&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> }
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">}
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">resource&lt;/span> &lt;span class="s2">&amp;#34;aws_iam_role_policy&amp;#34; &amp;#34;run_db_secrets&amp;#34;&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> count&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">length&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="k">var&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">db_secret_ids&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="err">&amp;gt;&lt;/span> &lt;span class="m">0&lt;/span> &lt;span class="err">?&lt;/span> &lt;span class="m">1&lt;/span> &lt;span class="err">:&lt;/span> &lt;span class="m">0&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> name&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;${var.name_prefix}-${var.pipeline_name}-run-db-secrets-${var.pipeline_env}&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> role&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">aws_iam_role&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">run_task&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">id&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> policy&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">data&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">aws_iam_policy_document&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">db_secrets_read&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="m">0&lt;/span>&lt;span class="p">].&lt;/span>&lt;span class="k">json&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">}&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;hr>
&lt;h2 id="secrets-management">Secrets Management&lt;/h2>
&lt;p>I&amp;rsquo;m using two different categories of secrets. &lt;strong>Dagster&amp;rsquo;s own Postgres credentials&lt;/strong> are stored as a single Secrets Manager secret with JSON keys. These get injected as individual environment variables using the JSON key syntax in the ECS task definition &lt;code>secrets&lt;/code> block:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-hcl" data-lang="hcl">&lt;span class="line">&lt;span class="cl">&lt;span class="k">locals&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> dagster_container_secrets&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> name&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;DAGSTER_POSTGRES_HOSTNAME&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> valueFrom&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;${data.aws_secretsmanager_secret.dagster_postgres.arn}:hostname::&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> }&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> name&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;DAGSTER_POSTGRES_USER&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> valueFrom&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;${data.aws_secretsmanager_secret.dagster_postgres.arn}:username::&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> }&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> name&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;DAGSTER_POSTGRES_PASSWORD&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> valueFrom&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;${data.aws_secretsmanager_secret.dagster_postgres.arn}:password::&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> }&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> name&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;DAGSTER_POSTGRES_DB&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> valueFrom&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;${data.aws_secretsmanager_secret.dagster_postgres.arn}:name::&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> }
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">}&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The &lt;code>:key::&lt;/code> suffix trailing fields are the version stage and version ID. When those are empty, you get the most recent / current version. Since these are injected by ECS, the &lt;strong>execution&lt;/strong> role is what needs &lt;code>GetSecretValue&lt;/code> on the secret.&lt;/p>
&lt;p>&lt;strong>Source database credentials&lt;/strong> (for pipelines that read from other databases) are not injected at all anymore. My first version of the Terraform module wrote a &lt;code>.pgpass&lt;/code> file at container startup: each secret ID came in as &lt;code>PGPASS_SECRET_JSON_1&lt;/code>, &lt;code>PGPASS_SECRET_JSON_2&lt;/code> and so on, plus a &lt;code>PGPASS_SECRET_JSON_VARS&lt;/code> variable listing which env vars to look at, and the entrypoint fetched each one and assembled the file. It worked, but it was messy and became really annoying to manage since I was dragging around another script for building the .pgpass file. It also meant credentials sat in the container environment and on disk for the life of the task, the entrypoint had to grow a chunk of logic that had nothing to do with running jobs, so I replaced it with the following&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-hcl" data-lang="hcl">&lt;span class="line">&lt;span class="cl">&lt;span class="k">variable&lt;/span> &lt;span class="s2">&amp;#34;db_secret_ids&amp;#34;&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> type&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">list&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="k">string&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> description&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;Secrets Manager secrets for the databases this pipeline connects to. The task role is granted GetSecretValue on them; the application reads them at runtime.&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> default&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">}&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="kn">import&lt;/span> &lt;span class="nn">json&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">import&lt;/span> &lt;span class="nn">boto3&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">def&lt;/span> &lt;span class="nf">get_connection_details&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">secret_id&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">str&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">-&amp;gt;&lt;/span> &lt;span class="nb">dict&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">client&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">boto3&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">client&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;secretsmanager&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">secret&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">client&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">get_secret_value&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">SecretId&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">secret_id&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="n">json&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">loads&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">secret&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;SecretString&amp;#34;&lt;/span>&lt;span class="p">])&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The credentials are never in the environment and never on disk in the task, and adding a database is one more entry in a list variable.&lt;/p>
&lt;hr>
&lt;h2 id="what-id-do-and-still-might-do-differently">What I&amp;rsquo;d Do (and still might do&amp;hellip;) Differently&lt;/h2>
&lt;p>&lt;strong>Automate workspace.yaml updates.&lt;/strong> Right now, adding a pipeline requires a manual SSM update and service restart. A Lambda triggered by ECS task state changes, or even a simple CI step, could handle this.&lt;/p>
&lt;p>&lt;strong>Use Cognito + proper OIDC federation for access control.&lt;/strong> The dual-webserver approach works, but it&amp;rsquo;s not pretty. If you control your IdP, set up Cognito federation from the start. I tried setting up a proxy server to redirect traffic based on users belonging to one Cognito user-group or another, but this ended up being a huge headache.&lt;/p>
&lt;p>&lt;strong>Consider ECS Exec for debugging.&lt;/strong> I didn&amp;rsquo;t enable it initially and spent a lot of time reading logs trying to diagnose container startup issues. &lt;code>aws ecs execute-command&lt;/code> is worth the extra IAM policy on the task role.&lt;/p>
&lt;p>&lt;strong>Don&amp;rsquo;t put dagster.yaml in the platform images.&lt;/strong> Storing it in SSM and injecting at runtime was the right call for the daemon and webserver. Rebuilding (EVERY) image every time you want to change a config is not the move. For pipeline images, where the code changes anyway, baking it in is fine and simpler.&lt;/p>
&lt;p>&lt;strong>Fetch application secrets at runtime instead of injecting them.&lt;/strong> See above. This is the change I&amp;rsquo;m happiest about.&lt;/p>
&lt;hr>
&lt;h2 id="actually-one-more-thing">Actually, One More Thing&lt;/h2>
&lt;p>As I was writing up this post, I went back to the IAM code to check that I&amp;rsquo;d described the tag conditions correctly, and found a bug.&lt;/p>
&lt;p>Here&amp;rsquo;s the setup again. The platform&amp;rsquo;s &lt;code>iam:PassRole&lt;/code> policy only allows the Daemon to pass roles tagged &lt;code>dagster:component = &amp;quot;pipeline&amp;quot;&lt;/code>. The pipeline module&amp;rsquo;s local says something else:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-hcl" data-lang="hcl">&lt;span class="line">&lt;span class="cl">&lt;span class="n">run_role_tags&lt;/span> &lt;span class="o">=&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> &amp;#34;dagster:component&amp;#34;&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;run&amp;#34; # not &amp;#34;pipeline&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> &amp;#34;dagster:pipeline&amp;#34;&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">var&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">pipeline_name&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> &amp;#34;dagster:managed-by&amp;#34;&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;terraform&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">}&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>&amp;quot;run&amp;quot;&lt;/code>, not &lt;code>&amp;quot;pipeline&amp;quot;&lt;/code>. By that reading the Daemon should never be able to pass these roles, and every run should fail. But runs work fine in both environments, so I had never noticed. The &lt;em>correct&lt;/em> approach is in the IaC merge command&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-hcl" data-lang="hcl">&lt;span class="line">&lt;span class="cl">&lt;span class="n">tags&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">merge&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="k">local&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">run_role_tags&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="k">var&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">tags&lt;/span>&lt;span class="p">)&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>merge&lt;/code> lets later arguments win, so &lt;code>var.tags&lt;/code> overrides the module&amp;rsquo;s own local. And every caller passes a tag block that happens to include exactly the key in question:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-hcl" data-lang="hcl">&lt;span class="line">&lt;span class="cl">&lt;span class="n">tags&lt;/span> &lt;span class="o">=&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> &amp;#34;env&amp;#34;&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">var&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">pipeline_env&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> &amp;#34;owner&amp;#34;&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;data-platform&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> &amp;#34;application&amp;#34;&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;dagster&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> &amp;#34;dagster:pipeline&amp;#34;&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">var&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">pipeline_name&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> &amp;#34;dagster:component&amp;#34;&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;pipeline&amp;#34;&lt;/span>&lt;span class="c1"> # this is what makes PassRole work
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="n"> &amp;#34;dagster:managed-by&amp;#34;&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;terraform&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">}&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>So the roles do come out tagged with &lt;code>pipeline&lt;/code>, and the security control does work, but only because of the argument order in a &lt;code>merge&lt;/code> and a tag the caller happens to set. A new pipeline that passes a &lt;code>tags&lt;/code> map without &lt;code>dagster:component&lt;/code> would plan clean, apply clean, start its code server, show up in the UI, and then fail the first time someone launched a run, with an AccessDenied on &lt;code>PassRole&lt;/code> and nothing in the Terraform to suggest why.&lt;/p>
&lt;p>The fix is to stop letting callers override the tag the policy depends on:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-hcl" data-lang="hcl">&lt;span class="line">&lt;span class="cl">&lt;span class="n">tags&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">merge&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="k">var&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">tags&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="k">local&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">run_role_tags&lt;/span>&lt;span class="p">)&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="and-change-the-platform-condition-to-match-run-which-is-the-value-the-module-actually-controls">and change the platform condition to match &lt;code>&amp;quot;run&amp;quot;&lt;/code>, which is the value the module actually controls.&lt;/h2>
&lt;h2 id="wrapping-up">Wrapping Up&lt;/h2>
&lt;p>Getting Dagster onto ECS was genuinely hard for me. The documentation covers the &amp;ldquo;in a perfect world&amp;rdquo; scenario, but not the real-world complexity of integrating with existing cloud infrastructure that you don&amp;rsquo;t partially (or entirely) control. There are plenty of &amp;ldquo;in a perfect world with a clean slate, here&amp;rsquo;s how you do it&amp;rdquo; walkthroughs, but very little in the way of justification or documentation of architecture choices. The platform/pipeline split in Terraform has been a huge win for me. I&amp;rsquo;ve templated our Dagster pipeline development platform and put together templated Dockerfiles and &lt;code>docker-compose.yaml&lt;/code> files. Deploying a new pipeline is now a &lt;code>terraform apply&lt;/code> with a handful of variables, no platform infrastructure touched, no service restarts required (except for the workspace.yaml update, which I&amp;rsquo;m working on).&lt;/p></description></item><item><title>Deploying Dagster to AWS ECS, Part 1: The Platform</title><link>https://kristianeschenburg.netlify.app/post/deploying-dagster-to-ecs-1-platform/</link><pubDate>Tue, 04 Nov 2025 09:00:00 -0800</pubDate><guid>https://kristianeschenburg.netlify.app/post/deploying-dagster-to-ecs-1-platform/</guid><description>&lt;p>Here at Just-Evotec Biologics, the Data Platform team uses Dagster as our orchestration platform of choice. Many of our services are deployed to AWS ECS as Fargate or EC2 tasks. Getting Dagster onto ECS took me an embarrassing number of hours, and most of those issues stemmed from a lack of documentation around getting Dagster pipelines up and running in the cloud while NOT using Dagster+ (their premium service offering). The official docs cover the happy, perfect world path, but beyond that, you&amp;rsquo;re left stitching together forum threads, GitHub issues, and a lot of trial and error. I figured I&amp;rsquo;d put together some notes on what I built, and how I built it.&lt;/p>
&lt;p>The components are of this system are two-fold &amp;ndash; the actual Python-based pipeline code, and the Terraform code I used to deploy that code to ECS. The Terraform part also comes in two pieces, because I&amp;rsquo;ve split the deployment down the middle. In this post, I&amp;rsquo;ll detail the platform half: the long-lived infrastructure everything else plugs into, such as the webserver and the daemon.
&lt;a href="https://kristianeschenburg.netlify.app/post/deploying-dagster-to-ecs-2-pipelines/">Part two&lt;/a> covers the pipeline module, what you&amp;rsquo;d instantiate per pipeline and then redeploy repeatedly as you make updates to the pipeline code.&lt;/p>
&lt;p>If you came here because something in your deployment cannot reach something else, it might be helpful for you to skip straight to the networking section, covering security groups, ports, service discovery. But if not, read on&amp;hellip;&lt;/p>
&lt;hr>
&lt;h2 id="setup">Setup&lt;/h2>
&lt;p>Our team already had a multi-environment AWS setup, with VPCs, subnets, security groups, tunneling, and load balancers in place. The goal was to plug Dagster into that existing infrastructure as cleanly as possible, with proper separation of concerns. Again, as I mentioned in some other posts, I obviously can&amp;rsquo;t share our company code, so I&amp;rsquo;ll just describe some of the approaches I took.&lt;/p>
&lt;p>I split the Terraform code into two distinct modules:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>&lt;code>dagster-platform&lt;/code>&lt;/strong>: the long-lived infrastructure. Daemon, Webserver(s), shared IAM roles, ALB listener rules, SSM parameters, Cloud Map namespace, and S3/RDS backends.&lt;/li>
&lt;li>&lt;strong>&lt;code>dagster-pipeline&lt;/code>&lt;/strong>: one instantiation per pipeline. Each pipeline gets its own ECS task definitions, service, Cloud Map entry, IAM roles, and log groups. You call this module once per pipeline and it slots right into the platform.&lt;/li>
&lt;/ul>
&lt;p>This split makes sense from a frequency-of-interaction standpoint. The platform piece deploys infrequently, and doesn&amp;rsquo;t need to be modified much. Pipelines deploy constantly, sometimes multiple times a day, and I don&amp;rsquo;t want to touch platform infrastructure every time I push a new job.&lt;/p>
&lt;hr>
&lt;h2 id="architecture-overview">Architecture overview&lt;/h2>
&lt;p>Here&amp;rsquo;s the high-level picture:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="cl">ALB (existing)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> ├── /pipelines/admin/* → Webserver (read-write) [Cognito-authenticated]
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> └── /pipelines/* → Webserver (read-only) [Cognito-authenticated]
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">Supporting Services
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> ├── RDS PostgreSQL (run storage, event log, schedule storage)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> ├── S3 (compute logs)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> ├── AWS Secrets Manager (DB credentials, application secrets)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> ├── SSM Parameter Store (dagster.yaml, workspace.yaml)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> └── Cloud Map (service discovery for usercode servers)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">ECS Cluster (existing)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> ├── dagster-daemon (always-on Fargate service)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> ├── dagster-webserver-rw (always-on Fargate service)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> ├── dagster-webserver-ro (always-on Fargate service)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> └── pipeline-&amp;lt;name&amp;gt; (always-on usercode service, one per pipeline)&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Everything above the &lt;code>pipeline-&amp;lt;name&amp;gt;&lt;/code> line is the platform &amp;ndash; that&amp;rsquo;s what this current post is about. The Daemon and Webserver containers share the same &lt;code>dagster.yaml&lt;/code> and &lt;code>workspace.yaml&lt;/code>, loaded at runtime from SSM. Pipelines register themselves via Cloud Map so the Daemon can find them.&lt;/p>
&lt;hr>
&lt;h2 id="the-read-only-webserver-problem">The read-only webserver problem&lt;/h2>
&lt;p>Dagster&amp;rsquo;s &lt;code>dagster-webserver&lt;/code> supports a &lt;code>--read-only&lt;/code> flag, but there&amp;rsquo;s no built-in authentication layer that lets you say &amp;ldquo;scientists get read-only access, data engineers get admin access.&amp;rdquo; Dagster does have a subscription tier called Dagster+ that &lt;em>does&lt;/em> offer this functionality, but we do not pay for that. So, we&amp;rsquo;re lefting putting our own solution in place. I&amp;rsquo;m not sure if there is a &amp;ldquo;right&amp;rdquo; answer here. One solution, given our constraints, is to combine Cognito with our IdP federation, but since I don&amp;rsquo;t control our company&amp;rsquo;s IdP tenant, I couldn&amp;rsquo;t get that working. Instead, I deployed &lt;strong>two separate ECS services&lt;/strong>, one read-write and one read-only, each behind its own ALB target group and listener rule.&lt;/p>
&lt;p>&lt;strong>Read-write webserver command:&lt;/strong>&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">dagster-webserver -h 0.0.0.0 -p &lt;span class="m">3000&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -w /opt/dagster/dagster_home/workspace.yaml &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -l /pipelines/admin&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>Read-only webserver command:&lt;/strong>&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">dagster-webserver -h 0.0.0.0 -p &lt;span class="m">3000&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -w /opt/dagster/dagster_home/workspace.yaml &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -l /pipelines &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --read-only&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The &lt;code>-l&lt;/code> flag sets the URL prefix. The ALB rules do the routing:&lt;/p>
&lt;ul>
&lt;li>&lt;code>/pipelines/admin*&lt;/code> → read-write target group (higher priority)&lt;/li>
&lt;li>&lt;code>/pipelines*&lt;/code> → read-only target group (lower priority)&lt;/li>
&lt;/ul>
&lt;p>Both rules are Cognito-authenticated, so anyone hitting either URL has to log in. Scientists get the read-only URL. Engineers get the admin URL. Simple, but it works.&lt;/p>
&lt;h3 id="two-actions-per-listener-rule">Two actions per listener rule&lt;/h3>
&lt;p>An authenticated listener rule is two actions in order. The &lt;code>authenticate-cognito&lt;/code> action runs first, then the &lt;code>forward&lt;/code> action sends the request to the target group. If you only write the forward action, the rule works and nobody has to log in, which is the kind of mistake you don&amp;rsquo;t notice until someone tells you they never saw a login screen.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-hcl" data-lang="hcl">&lt;span class="line">&lt;span class="cl">&lt;span class="k">resource&lt;/span> &lt;span class="s2">&amp;#34;aws_lb_listener_rule&amp;#34; &amp;#34;pipelines_admin_rw&amp;#34;&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> listener_arn&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">data&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">aws_alb_listener&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">webserver&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">arn&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> priority&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">local&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">admin_listener_priority&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">action&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> type&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;authenticate-cognito&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">authenticate_cognito&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> user_pool_arn&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">data&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">aws_cognito_user_pool&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">dagster&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">arn&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> user_pool_client_id&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">data&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">aws_cognito_user_pool_client&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">dagster&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">id&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> user_pool_domain&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">data&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">aws_cognito_user_pool&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">dagster&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">domain&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> on_unauthenticated_request&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;authenticate&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> scope&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;openid&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> session_timeout&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="m">3600&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> }
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> }
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">action&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> type&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;forward&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> target_group_arn&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">aws_lb_target_group&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">webserver_rw&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">arn&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> }
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">condition&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">path_pattern&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> values&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;/${var.alb_rule_listener_rule_path}/admin*&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> }
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> }
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">}&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The read-only rule is identical except for the path pattern and target group, plus a &lt;code>depends_on&lt;/code> pointing at the admin rule so the two are never created in the wrong order.&lt;/p>
&lt;h3 id="finding-an-open-listener-priority">Finding an open listener priority&lt;/h3>
&lt;p>ALB listener rule priorities have to be unique across the whole listener, and they need to be &lt;em>specified&lt;/em>. Neither Terraform nor AWS will &amp;ldquo;infer&amp;rdquo; the next open value. Our ALB is shared with other applications, so hardcoding numbers means constantly checking what&amp;rsquo;s available in the console or through the CLI. I ended up writing a small bash script that the &lt;code>external&lt;/code> data source calls at &lt;code>terraform plan&lt;/code> time. It lists the existing rules and returns the first &lt;code>N&lt;/code> unused priority slots:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-hcl" data-lang="hcl">&lt;span class="line">&lt;span class="cl">&lt;span class="k">data&lt;/span> &lt;span class="s2">&amp;#34;external&amp;#34; &amp;#34;listener_priorities&amp;#34;&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> program&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;bash&amp;#34;, &amp;#34;${path.module}/external/next_listener_priorities.sh&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> query&lt;/span> &lt;span class="o">=&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> listener_arn&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">data&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">aws_alb_listener&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">webserver&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">arn&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> region&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">data&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">aws_region&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">current&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">id&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> profile&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">var&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">aws_profile&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> count&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="m">2&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> }
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">}
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">locals&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> admin_listener_priority&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">tonumber&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="k">data&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">external&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">listener_priorities&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">result&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">priority_1&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> ro_listener_priority&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">tonumber&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="k">data&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">external&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">listener_priorities&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">result&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">priority_2&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">}&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>And the script itself, which is a &lt;code>jq&lt;/code> expression over &lt;code>describe-rules&lt;/code>:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">&lt;span class="cp">#!/usr/bin/env bash
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="cp">&lt;/span>&lt;span class="nb">set&lt;/span> -euo pipefail
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">INPUT_JSON&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="k">$(&lt;/span>cat&lt;span class="k">)&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">LISTENER_ARN&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="k">$(&lt;/span>jq -r &lt;span class="s1">&amp;#39;.listener_arn&amp;#39;&lt;/span> &lt;span class="o">&amp;lt;&amp;lt;&amp;lt;&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="nv">$INPUT_JSON&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="k">)&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">REGION&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="k">$(&lt;/span>jq -r &lt;span class="s1">&amp;#39;.region&amp;#39;&lt;/span> &lt;span class="o">&amp;lt;&amp;lt;&amp;lt;&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="nv">$INPUT_JSON&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="k">)&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">COUNT&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="k">$(&lt;/span>jq -r &lt;span class="s1">&amp;#39;.count // 2&amp;#39;&lt;/span> &lt;span class="o">&amp;lt;&amp;lt;&amp;lt;&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="nv">$INPUT_JSON&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="k">)&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">RULES_JSON&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="k">$(&lt;/span>aws --region &lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="nv">$REGION&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span> elbv2 describe-rules &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --listener-arn &lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="nv">$LISTENER_ARN&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span> --output json&lt;span class="k">)&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Find the first COUNT missing priorities in [1..50000]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">jq -c --argjson count &lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="nv">$COUNT&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span> &lt;span class="s1">&amp;#39;
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> [ .Rules[].Priority | select(. != &amp;#34;default&amp;#34;) | tonumber ] | unique as $used
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> | reduce range(1; 50001) as $i
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> ({out: []};
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> if (.out | length) &amp;gt;= $count then .
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> elif ($used | index($i)) == null then .out += [$i]
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> else . end
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> )
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> | .out as $m
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> | reduce range(0; ($m | length)) as $idx
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> ({}; . + {(&amp;#34;priority_&amp;#34; + (($idx + 1) | tostring)): ($m[$idx] | tostring)})
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1">&amp;#39;&lt;/span> &lt;span class="o">&amp;lt;&amp;lt;&amp;lt;&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="nv">$RULES_JSON&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The &lt;code>external&lt;/code> data source runs on every plan, so the machine running Terraform needs the AWS CLI and &lt;code>jq&lt;/code> installed and credentials that can call &lt;code>describe-rules&lt;/code> (this could be your local machine, your CI/CD runner, an EC2 instance, etc.) The priorities are read at plan time, so if someone else claims a slot between your plan and your apply, the apply fails, though that&amp;rsquo;s rare enough that I&amp;rsquo;ve been willing to live with it. Basically, hardcoding priorities was not an option I wanted to entertain.&lt;/p>
&lt;h3 id="health-checks-and-alb-path-prefixes">Health checks and ALB path prefixes&lt;/h3>
&lt;p>The ALB health checks for the webservers need to use the correct path prefix. If your webserver is mounted at &lt;code>/pipelines&lt;/code> and your health check pings &lt;code>/&lt;/code>, it&amp;rsquo;ll get a redirect (or a 404) and the target will never go healthy.&lt;/p>
&lt;p>Read-only health check path: &lt;code>/&amp;lt;your-prefix&amp;gt;&lt;/code>
Read-write health check path: &lt;code>/&amp;lt;your-prefix&amp;gt;/admin&lt;/code>&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-hcl" data-lang="hcl">&lt;span class="line">&lt;span class="cl">&lt;span class="k">health_check&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> protocol&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;HTTP&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> path&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;/${var.alb_rule_listener_rule_path}/admin&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> matcher&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;200-399&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> healthy_threshold&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="m">2&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> unhealthy_threshold&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="m">3&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> interval&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="m">30&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> timeout&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="m">25&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">}&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Set the matcher to &lt;code>200-399&lt;/code> to handle redirects during startup. The Dagster webserver takes 15 to 30 seconds to come up on a cold start, so &lt;code>unhealthy_threshold&lt;/code> and &lt;code>interval&lt;/code> need to be generous enough that a slow container start doesn&amp;rsquo;t put the service into a restart loop.&lt;/p>
&lt;hr>
&lt;h2 id="dagsteryaml-the-compute-and-storage-specs">dagster.yaml: the compute and storage specs&lt;/h2>
&lt;p>The &lt;code>dagster.yaml&lt;/code> configuration basically let&amp;rsquo;s you define job queing, which system will run your jobs (here, ECS), where logs and compute history will be stored, etc. Here&amp;rsquo;s what our final config looks like (pretty similar to what&amp;rsquo;s already out there):&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">run_coordinator&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">module&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">dagster.core.run_coordinator&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">class&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">QueuedRunCoordinator&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">config&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">max_concurrent_runs&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">15&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">scheduler&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">module&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">dagster.core.scheduler&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">class&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">DagsterDaemonScheduler&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">run_launcher&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">module&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">dagster_aws.ecs&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">class&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">EcsRunLauncher&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">config&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">use_current_ecs_task_config&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">include_sidecars&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">run_task_kwargs&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">cluster&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;your-ecs-cluster-name&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">compute_logs&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">module&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">dagster_aws.s3.compute_log_manager&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">class&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">S3ComputeLogManager&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">config&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">bucket&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">env&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">DAGSTER_LOG_BUCKET&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">prefix&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;compute_logs&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">region&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;your-aws-region&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">run_storage&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">module&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">dagster_postgres.run_storage&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">class&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">PostgresRunStorage&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">config&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">postgres_db&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">hostname&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">env&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">DAGSTER_POSTGRES_HOSTNAME }&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">username&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">env&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">DAGSTER_POSTGRES_USER }&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">password&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">env&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">DAGSTER_POSTGRES_PASSWORD }&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">db_name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">env&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">DAGSTER_POSTGRES_DB }&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">port&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">5432&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">schedule_storage&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">module&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">dagster_postgres.schedule_storage&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">class&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">PostgresScheduleStorage&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">config&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">postgres_db&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">hostname&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">env&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">DAGSTER_POSTGRES_HOSTNAME }&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">username&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">env&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">DAGSTER_POSTGRES_USER }&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">password&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">env&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">DAGSTER_POSTGRES_PASSWORD }&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">db_name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">env&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">DAGSTER_POSTGRES_DB }&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">port&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">5432&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">event_log_storage&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">module&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">dagster_postgres.event_log&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">class&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">PostgresEventLogStorage&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">config&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">postgres_db&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">hostname&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">env&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">DAGSTER_POSTGRES_HOSTNAME }&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">username&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">env&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">DAGSTER_POSTGRES_USER }&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">password&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">env&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">DAGSTER_POSTGRES_PASSWORD }&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">db_name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">env&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">DAGSTER_POSTGRES_DB }&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">port&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">5432&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">retention&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">schedule&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">purge_after_days&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">90&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">sensor&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">purge_after_days&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">skipped&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">7&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">failure&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">30&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">success&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>-&lt;span class="m">1&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The ECS task configuration was confusing to me initially. &lt;strong>&lt;code>use_current_ecs_task_config: true&lt;/code>&lt;/strong> tells the EcsRunLauncher to take the network configuration (subnets, security groups, cluster) from the task that requested the run rather than making you restate all of it in &lt;code>run_task_kwargs&lt;/code>. Combined with a per-pipeline run task definition, which is covered in part two, it means each pipeline&amp;rsquo;s runs launch into the same networking as that pipeline&amp;rsquo;s code server, with that pipeline&amp;rsquo;s own IAM roles. You don&amp;rsquo;t need to define a shared task definition across pipelines, which keeps the principle of least privilege in play.&lt;/p>
&lt;p>Every credential in that file is an &lt;code>env:&lt;/code> reference, and nothing secret is in the file itself.&lt;/p>
&lt;hr>
&lt;h2 id="where-the-platform-config-lives">Where the platform config lives&lt;/h2>
&lt;p>There are two config files: &lt;code>dagster.yaml&lt;/code> (instance config, above) and &lt;code>workspace.yaml&lt;/code> (the list of code locations the Daemon and Webserver load over gRPC). Baking config into the Docker image means rebuilding and redeploying the image every time you tweak a config value. For the Daemon and Webserver that&amp;rsquo;s a bad idea, because those images almost never change, but the config does. So Terraform writes both files into SSM parameters:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-hcl" data-lang="hcl">&lt;span class="line">&lt;span class="cl">&lt;span class="k">resource&lt;/span> &lt;span class="s2">&amp;#34;aws_ssm_parameter&amp;#34; &amp;#34;dagster_yaml&amp;#34;&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> name&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;${var.ssm_prefix}/dagster.yaml&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> type&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;String&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> value&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">var&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">dagster_yaml&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">}
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">resource&lt;/span> &lt;span class="s2">&amp;#34;aws_ssm_parameter&amp;#34; &amp;#34;workspace_yaml&amp;#34;&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> name&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;${var.ssm_prefix}/workspace-${var.platform_env}.yaml&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> type&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;String&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> value&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">var&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">workspace_yaml&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">}&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>and the task definitions inject them as container secrets:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-hcl" data-lang="hcl">&lt;span class="line">&lt;span class="cl">&lt;span class="k">locals&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> dagster_config_ssm_secrets&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> { name&lt;/span> &lt;span class="o">=&lt;/span>&lt;span class="n"> &amp;#34;DAGSTER_YAML&amp;#34;, valueFrom&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">aws_ssm_parameter&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">dagster_yaml&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">arn&lt;/span> }&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> { name&lt;/span> &lt;span class="o">=&lt;/span>&lt;span class="n"> &amp;#34;WORKSPACE_YAML&amp;#34;, valueFrom&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">aws_ssm_parameter&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">workspace_yaml&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">arn&lt;/span> }&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> common_platform_env&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> { name&lt;/span> &lt;span class="o">=&lt;/span>&lt;span class="n"> &amp;#34;DAGSTER_CONFIG_FROM_ENV&amp;#34;, value&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;1&amp;#34;&lt;/span> }&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> { name&lt;/span> &lt;span class="o">=&lt;/span>&lt;span class="n"> &amp;#34;DAGSTER_DEPLOYMENT_ENV&amp;#34;, value&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">var&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">platform_env&lt;/span> }&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> { name&lt;/span> &lt;span class="o">=&lt;/span>&lt;span class="n"> &amp;#34;DAGSTER_HOME&amp;#34;, value&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">var&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">dagster_home&lt;/span> }&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> { name&lt;/span> &lt;span class="o">=&lt;/span>&lt;span class="n"> &amp;#34;DAGSTER_LOG_BUCKET&amp;#34;, value&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;${var.dagster_log_bucket}-${var.platform_env}&amp;#34;&lt;/span> }&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">}&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>DAGSTER_CONFIG_FROM_ENV&lt;/code> is a flag the entrypoint script checks. When it&amp;rsquo;s set, the script writes &lt;code>$DAGSTER_YAML&lt;/code> and &lt;code>$WORKSPACE_YAML&lt;/code> out to &lt;code>$DAGSTER_HOME/dagster.yaml&lt;/code> and &lt;code>$DAGSTER_HOME/workspace.yaml&lt;/code> before starting the process, and leaves whatever is in the image alone otherwise. Changing a config value is then an SSM update and a service restart, not a full image rebuild and redeployment.&lt;/p>
&lt;p>The ECS &lt;code>secrets&lt;/code> block works with plain SSM &lt;code>String&lt;/code> parameters, not just &lt;code>SecureString&lt;/code> and Secrets Manager, and standard SSM parameters are capped at 4KB of value, so a &lt;code>workspace.yaml&lt;/code> with a lot of code locations will eventually need an advanced parameter (8KB) or a different mechanism. Pipeline containers do the opposite and ship their config in the image. That&amp;rsquo;s covered in
&lt;a href="https://kristianeschenburg.netlify.app/post/deploying-dagster-to-ecs-2-pipelines/">Part Two&lt;/a>. I also changed my mind about it after writing these posts, so I add some thoughts about that as well.&lt;/p>
&lt;hr>
&lt;h2 id="networking-the-part-that-took-me-the-longest">Networking: the part that took me the longest&lt;/h2>
&lt;p>I found the networking stuff to be the most confusing. Multiple features all have to be correct before anything communicates to anything else.&lt;/p>
&lt;h3 id="what-talks-to-what">What talks to what&lt;/h3>
&lt;p>Before any of the Terraform, it was worthwhile to me to write the traffic down explicitly, because the number of distinct flows is small and each flow has to be allowed somewhere:&lt;/p>
&lt;pre>&lt;code class="language-mermaid">graph LR
Browser(Browser)
ALB{ALB}
RW[webserver-rw]
RO[webserver-ro]
Daemon[daemon]
Uc[usercode]
Run[run task]
CM(Cloud Map)
ECSAPI(ECS API)
PG(Postgres)
S3(S3)
Browser --&amp;gt;|443| ALB
ALB --&amp;gt;|&amp;#34;/admin*&amp;#34;| RW
ALB --&amp;gt;|&amp;#34;/*&amp;#34;| RO
RW --&amp;gt;|4000| Uc
RO --&amp;gt;|4000| Uc
Daemon --&amp;gt;|4000| Uc
Daemon --&amp;gt;|RunTask| ECSAPI
ECSAPI --&amp;gt;|launches| Run
Uc -.-&amp;gt;|register| CM
CM -.-&amp;gt;|resolve| Daemon
CM -.-&amp;gt;|resolve| RW
RW --&amp;gt;|5432| PG
Daemon --&amp;gt;|5432| PG
Uc --&amp;gt;|5432| PG
Run --&amp;gt;|5432| PG
Run --&amp;gt;|443| S3
classDef platform fill:#e8f0fe,stroke:#4285f4
classDef pipeline fill:#e6f4ea,stroke:#34a853
classDef ephemeral fill:#fef7e0,stroke:#f9ab00
classDef backend fill:#f1f3f4,stroke:#9aa0a6
class RW,RO,Daemon platform
class Uc pipeline
class Run ephemeral
class CM,ECSAPI,PG,S3 backend&lt;/code>&lt;/pre>&lt;p>Blue is the platform module, green is a pipeline, amber is the ephemeral run task, grey is something AWS runs for you. Solid arrows are request traffic, labelled with the port. Dotted arrows are service discovery, which carries no application data. Here is the same thing as a table:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>From&lt;/th>
&lt;th>To&lt;/th>
&lt;th>Port&lt;/th>
&lt;th>Why&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>ALB&lt;/td>
&lt;td>Webserver&lt;/td>
&lt;td>3000 (&lt;code>webserver_port&lt;/code>)&lt;/td>
&lt;td>Serving the UI&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Webserver&lt;/td>
&lt;td>Usercode server&lt;/td>
&lt;td>4000&lt;/td>
&lt;td>Loading code locations over gRPC&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Daemon&lt;/td>
&lt;td>Usercode server&lt;/td>
&lt;td>4000&lt;/td>
&lt;td>Polling schedules and sensors&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Daemon&lt;/td>
&lt;td>ECS API&lt;/td>
&lt;td>443&lt;/td>
&lt;td>&lt;code>RunTask&lt;/code> to launch runs&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Daemon&lt;/td>
&lt;td>EC2 API&lt;/td>
&lt;td>443&lt;/td>
&lt;td>&lt;code>DescribeNetworkInterfaces&lt;/code> to resolve task IPs&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Daemon, Webserver, Usercode, Run&lt;/td>
&lt;td>RDS Postgres&lt;/td>
&lt;td>5432&lt;/td>
&lt;td>Run storage, event log, schedule storage&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Daemon, Webserver, Usercode, Run&lt;/td>
&lt;td>S3&lt;/td>
&lt;td>443&lt;/td>
&lt;td>Compute logs&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>All tasks (at startup)&lt;/td>
&lt;td>ECR, Secrets Manager, SSM, CloudWatch Logs&lt;/td>
&lt;td>443&lt;/td>
&lt;td>Pulling the image, injecting secrets and config&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The Daemon never receives inbound traffic from anything, so its security group needs no ingress rules at all.&lt;/p>
&lt;h3 id="three-security-groups">Three security groups&lt;/h3>
&lt;p>The platform module creates all three security groups, including the one used by pipelines. The usercode security group uses security group references, not CIDR blocks, to restrict inbound port 4000 to &lt;em>only&lt;/em> the Daemon and Webserver, so nothing else in the VPC can reach a code server. While a CIDR rule like &lt;code>10.0.0.0/16&lt;/code> would work too, doing so means anything that gets a private IP in that range can open a gRPC connection to your code servers, and you don&amp;rsquo;t want that. A code server could tell any caller what jobs exist and let them be launched. Referencing the daemon and webserver security groups instead means the rule stays correct as subnets change and there&amp;rsquo;s no CIDR math to get wrong.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-hcl" data-lang="hcl">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># dagster-platform/vpc.tf
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Daemon: outbound-only. It talks to ECS APIs and usercode servers, never receives inbound.
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="k">resource&lt;/span> &lt;span class="s2">&amp;#34;aws_security_group&amp;#34; &amp;#34;daemon&amp;#34;&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> name&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;${var.name_prefix}-daemon-sg-${var.env}&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> description&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;Dagster Daemon. Outbound only.&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> vpc_id&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">var&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">vpc_id&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">egress&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> description&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;Allow all outbound&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> from_port&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="m">0&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> to_port&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="m">0&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> protocol&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;-1&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> cidr_blocks&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;0.0.0.0/0&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> }
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">}&lt;span class="c1">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Webserver: inbound from ALB only, on the webserver port.
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="k">resource&lt;/span> &lt;span class="s2">&amp;#34;aws_security_group&amp;#34; &amp;#34;webserver&amp;#34;&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> name&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;${var.name_prefix}-webserver-sg-${var.env}&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> description&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;Dagster webservers (RO+RW). Inbound from ALB only.&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> vpc_id&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">var&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">vpc_id&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">ingress&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> description&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;Inbound from ALB security group&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> from_port&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">var&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">webserver_port&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> to_port&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">var&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">webserver_port&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> protocol&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;tcp&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> security_groups&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="k">var&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">alb_sg_id&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="c1"> # reference your existing ALB SG here
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span> }
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">egress&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> description&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;Allow all outbound&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> from_port&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="m">0&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> to_port&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="m">0&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> protocol&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;-1&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> cidr_blocks&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;0.0.0.0/0&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> }
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">}&lt;span class="c1">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Usercode servers: inbound TCP 4000 from daemon and webserver SGs only.
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># This SG is shared across all pipeline usercode services.
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="k">resource&lt;/span> &lt;span class="s2">&amp;#34;aws_security_group&amp;#34; &amp;#34;usercode&amp;#34;&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> name&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;${var.name_prefix}-usercode-sg-${var.env}&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> description&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;Dagster usercode. Allow TCP 4000 from daemon and webserver only.&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> vpc_id&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">var&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">vpc_id&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">ingress&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> description&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;gRPC from daemon and webserver&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> from_port&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="m">4000&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> to_port&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="m">4000&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> protocol&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;tcp&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> security_groups&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">aws_security_group&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">daemon&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">id&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">aws_security_group&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">webserver&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">id&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> }
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">egress&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> description&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;Allow all outbound&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> from_port&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="m">0&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> to_port&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="m">0&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> protocol&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;-1&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> cidr_blocks&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;0.0.0.0/0&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> }
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">}&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The usercode security group is defined here and looked up by name in each pipeline module, so the platform owns the definition and pipelines just join it.&lt;/p>
&lt;h3 id="how-do-tasks-get-their-networking">How do tasks get their networking?&lt;/h3>
&lt;p>The Dagster documentation unfortunately does not describe this clearly. There is no security group anywhere in the Terraform for run tasks. So how do they run? As I mentioned above, the answer is &lt;code>use_current_ecs_task_config: true&lt;/code> in &lt;code>dagster.yaml&lt;/code>. When the EcsRunLauncher launches a run, it reads the network configuration of the task making the request (the usercode code server) and reuses it. Run tasks land in the same subnets and the same usercode security group as the code server that spawned them automatically. Meaning:&lt;/p>
&lt;ul>
&lt;li>A run task inherits inbound 4000 from the daemon and webserver&lt;/li>
&lt;li>A run task needs the same &lt;strong>egress&lt;/strong> as the code server: Postgres to write events, S3 for compute logs, and everything in the startup list below. Egress is where run tasks actually fail, never ingress.&lt;/li>
&lt;li>If you lock the usercode security group&amp;rsquo;s egress down, you&amp;rsquo;re locking down runs too, and the failure will look like a job that starts and then hangs rather than a network error.&lt;/li>
&lt;/ul>
&lt;p>Each pipeline&amp;rsquo;s runs get that pipeline&amp;rsquo;s networking and that pipeline&amp;rsquo;s IAM roles, with nothing shared and nothing restated. The pipeline&amp;rsquo;s tasks are inheriting the networking rules defined for that whole pipeline.&lt;/p>
&lt;h3 id="private-subnets-need-a-path-to-aws-apis">Private Subnets Need a Path to AWS APIs&lt;/h3>
&lt;p>All of these tasks run in private subnets with &lt;code>assign_public_ip = false&lt;/code>. A Fargate task in a private subnet still has to reach several AWS endpoints over the AWS API surface before that container code runs at all: ECR to pull the image (both the API and the layer storage in S3), Secrets Manager and SSM to inject secrets and config, and CloudWatch Logs to write those outputs. That traffic needs either a NAT gateway in the route table or VPC interface endpoints for each of those services, plus an S3 gateway endpoint. Our VPC already had a NAT gateway, so I leveraged that. If that path is missing, the task doesn&amp;rsquo;t fail in a way that mentions networking. It sits in &lt;code>PROVISIONING&lt;/code> or &lt;code>PENDING&lt;/code>, then stops with a &lt;code>ResourceInitializationError&lt;/code> about being unable to pull the image or retrieve a secret. Nothing in the container logs, because the container never started, and nothing in the Dagster UI, because Dagster never got far enough to know about it.&lt;/p>
&lt;h3 id="what-each-failure-actually-looks-like">What Each Failure Actually Looks Like&lt;/h3>
&lt;p>This table contains rough debugging notes I wish I&amp;rsquo;d had:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Symptom&lt;/th>
&lt;th>Usual cause&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Task stuck in &lt;code>PROVISIONING&lt;/code>, then &lt;code>ResourceInitializationError&lt;/code>&lt;/td>
&lt;td>No egress path from the private subnet. Missing NAT route or VPC endpoints&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Code location shows as failed in the UI, gRPC deadline exceeded&lt;/td>
&lt;td>Usercode SG isn&amp;rsquo;t allowing 4000 from the webserver SG&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Schedules and sensors never tick, but the UI loads fine&lt;/td>
&lt;td>Same rule, but the daemon SG. The webserver and daemon are separate sources&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Code location resolves intermittently after a deploy&lt;/td>
&lt;td>Cloud Map DNS TTL still serving the old task&amp;rsquo;s IP. See part two&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>ALB target never goes healthy&lt;/td>
&lt;td>Health check path missing the URL prefix, or the matcher rejecting a redirect&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Runs launch and immediately fail with no logs&lt;/td>
&lt;td>IAM, not networking. Usually the missing &lt;code>ecs:TagResource&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Run starts, then hangs without writing events&lt;/td>
&lt;td>Egress from the usercode SG. Run tasks inherit it&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Generally, if you&amp;rsquo;re seeing an error, I&amp;rsquo;d start with examining the IAM roles first.&lt;/p>
&lt;hr>
&lt;h2 id="service-discovery-the-namespace">Service Discovery: The Namespace&lt;/h2>
&lt;p>The Daemon needs to find each pipeline&amp;rsquo;s code server. On ECS Fargate, the standard approach is AWS Cloud Map (private DNS). The platform creates the namespace, and each pipeline creates its own service record within it.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-hcl" data-lang="hcl">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># dagster-platform/cloud_map.tf
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">resource&lt;/span> &lt;span class="s2">&amp;#34;aws_service_discovery_private_dns_namespace&amp;#34; &amp;#34;dagster&amp;#34;&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> name&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;pipelines-${var.env}.usercode&amp;#34; # e.g. &amp;#34;pipelines-prod.usercode&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> description&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;Private DNS namespace for Dagster usercode servers&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> vpc&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">var&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">vpc_id&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">}&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Two requirements come with a private DNS namespace. It&amp;rsquo;s bound to exactly one VPC, so a multi-VPC setup needs a namespace per VPC (which is part of why the name carries the environment). And the VPC needs both &lt;code>enableDnsSupport&lt;/code> and &lt;code>enableDnsHostnames&lt;/code> turned on, or the records resolve from nothing and every code location fails to load with what looks like a connection error rather than a DNS one.&lt;/p>
&lt;hr>
&lt;h2 id="iam-probably-the-crux-of-everything">IAM: Probably the crux of everything&lt;/h2>
&lt;p>There are two IAM roles per service: an &lt;strong>execution role&lt;/strong> (used by ECS to pull images, write logs, and fetch secrets before the container starts) and a &lt;strong>task role&lt;/strong> (used by the running container to call AWS APIs at runtime). Regarding AWS secrets: if the value is injected by the ECS &lt;code>secrets&lt;/code> block in the task definition, the &lt;strong>execution&lt;/strong> role needs to read it, because &lt;em>something&lt;/em> needs to inject it into the task definition. If your application code calls &lt;code>boto3&lt;/code> to fetch it, the &lt;strong>task&lt;/strong> role needs to read it. Yes, I was confused initially too.&lt;/p>
&lt;h3 id="the-shared-trust-policy">The Shared Trust Policy&lt;/h3>
&lt;p>All ECS task roles share the same trust policy:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-hcl" data-lang="hcl">&lt;span class="line">&lt;span class="cl">&lt;span class="k">data&lt;/span> &lt;span class="s2">&amp;#34;aws_iam_policy_document&amp;#34; &amp;#34;ecs_task_assume_role&amp;#34;&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">statement&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> actions&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;sts:AssumeRole&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">principals&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> type&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;Service&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> identifiers&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;ecs-tasks.amazonaws.com&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> }
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> }
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">}&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="execution-roles-webserver--daemon">Execution Roles (Webserver + Daemon)&lt;/h3>
&lt;p>Execution roles need the standard ECS managed policy, plus access to Secrets Manager and SSM so ECS can inject those values before the container starts:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-hcl" data-lang="hcl">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># dagster-platform/iam.tf
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">resource&lt;/span> &lt;span class="s2">&amp;#34;aws_iam_role&amp;#34; &amp;#34;daemon_execution&amp;#34;&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> name&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;${var.name_prefix}-daemon-exec-role-${var.env}&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> assume_role_policy&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">data&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">aws_iam_policy_document&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">ecs_task_assume_role&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">json&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">}
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">resource&lt;/span> &lt;span class="s2">&amp;#34;aws_iam_role_policy_attachment&amp;#34; &amp;#34;daemon_exec_attach&amp;#34;&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> role&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">aws_iam_role&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">daemon_execution&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">name&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> policy_arn&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">}&lt;span class="c1">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Execution role needs Secrets Manager access so ECS can inject secrets into the container
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="k">resource&lt;/span> &lt;span class="s2">&amp;#34;aws_iam_role_policy&amp;#34; &amp;#34;daemon_exec_db_secret&amp;#34;&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> name&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;${var.name_prefix}-daemon-exec-db-secret-${var.env}&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> role&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">aws_iam_role&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">daemon_execution&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">id&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> policy&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">data&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">aws_iam_policy_document&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">dagster_db_secret_read&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">json&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">}&lt;span class="c1">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Execution role needs SSM access to inject dagster.yaml and workspace.yaml as env vars
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="k">resource&lt;/span> &lt;span class="s2">&amp;#34;aws_iam_role_policy&amp;#34; &amp;#34;daemon_exec_ssm&amp;#34;&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> name&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;${var.name_prefix}-daemon-exec-ssm-${var.env}&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> role&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">aws_iam_role&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">daemon_execution&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">id&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> policy&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">data&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">aws_iam_policy_document&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">dagster_ssm_read&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">json&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">}&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The webserver execution role is identical in structure.&lt;/p>
&lt;h3 id="daemon-task-role">Daemon Task Role&lt;/h3>
&lt;p>The Daemon&amp;rsquo;s task role needs to launch, describe, stop, and tag ECS run tasks. Dagster tags every run task it spawns with run metadata, and if the permission is absent, runs will launch and then immediately fail.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-hcl" data-lang="hcl">&lt;span class="line">&lt;span class="cl">&lt;span class="k">resource&lt;/span> &lt;span class="s2">&amp;#34;aws_iam_role&amp;#34; &amp;#34;daemon_task&amp;#34;&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> name&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;${var.name_prefix}-daemon-task-role-${var.env}&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> assume_role_policy&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">data&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">aws_iam_policy_document&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">ecs_task_assume_role&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">json&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">}&lt;span class="c1">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Allow launching run tasks for any task def matching &amp;#34;pipeline-*-run-*&amp;#34;
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Scoped to the specific cluster so the daemon can&amp;#39;t escape to other clusters
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="k">data&lt;/span> &lt;span class="s2">&amp;#34;aws_iam_policy_document&amp;#34; &amp;#34;ecs_run_tasks&amp;#34;&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">statement&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> sid&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;RunDescribeStopRunTasks&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> effect&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;Allow&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> actions&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;ecs:RunTask&amp;#34;, &amp;#34;ecs:DescribeTasks&amp;#34;, &amp;#34;ecs:StopTask&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> resources&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;arn:aws:ecs:${var.region}:${data.aws_caller_identity.current.account_id}:task-definition/pipeline-*-run-*:*&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;arn:aws:ecs:${var.region}:${data.aws_caller_identity.current.account_id}:task/${var.cluster_name}/*&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">condition&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> test&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;ArnEquals&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> variable&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;ecs:cluster&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> values&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="k">data&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">aws_ecs_cluster&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">cluster&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">arn&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> }
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> }&lt;span class="c1">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"> # DescribeTaskDefinition has no resource-level support, must be &amp;#34;*&amp;#34;
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="k">statement&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> sid&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;DescribeTaskDefinitions&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> effect&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;Allow&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> actions&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;ecs:DescribeTaskDefinition&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> resources&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;*&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> }
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">}
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">resource&lt;/span> &lt;span class="s2">&amp;#34;aws_iam_role_policy&amp;#34; &amp;#34;daemon_ecs_run&amp;#34;&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> name&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;${var.name_prefix}-daemon-ecs-run-${var.env}&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> role&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">aws_iam_role&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">daemon_task&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">id&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> policy&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">data&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">aws_iam_policy_document&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">ecs_run_tasks&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">json&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">}&lt;span class="c1">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Allow tagging ECS tasks. Without this, Dagster run tasks fail silently at launch.
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="k">data&lt;/span> &lt;span class="s2">&amp;#34;aws_iam_policy_document&amp;#34; &amp;#34;ecs_tag_resource&amp;#34;&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">statement&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> sid&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;AllowTagEcsTasks&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> effect&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;Allow&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> actions&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;ecs:TagResource&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> resources&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;arn:aws:ecs:${var.region}:${data.aws_caller_identity.current.account_id}:task/${var.cluster_name}/*&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> }
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">}
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">resource&lt;/span> &lt;span class="s2">&amp;#34;aws_iam_role_policy&amp;#34; &amp;#34;daemon_ecs_tag&amp;#34;&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> name&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;${var.name_prefix}-daemon-ecs-tag-${var.env}&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> role&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">aws_iam_role&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">daemon_task&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">id&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> policy&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">data&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">aws_iam_policy_document&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">ecs_tag_resource&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">json&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">}&lt;span class="c1">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># The daemon also needs ec2:DescribeNetworkInterfaces to resolve Fargate task IPs
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="k">resource&lt;/span> &lt;span class="s2">&amp;#34;aws_iam_role_policy&amp;#34; &amp;#34;daemon_describe_enis&amp;#34;&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> name&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;${var.name_prefix}-daemon-describe-enis-${var.env}&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> role&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">aws_iam_role&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">daemon_task&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">id&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> policy&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">data&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">aws_iam_policy_document&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">ec2_describe_enis&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">json&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">}&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="passrole-contract">PassRole Contract&lt;/h3>
&lt;p>The last piece of the daemon&amp;rsquo;s task role is the one that ties the two modules together. To launch a run task, the daemon has to pass that task&amp;rsquo;s execution and task roles to ECS, and THOSE roles live in the pipeline module, not here. The naive version grants &lt;code>iam:PassRole&lt;/code> on &lt;code>*&lt;/code>, which hands the daemon the ability to pass any role in the account to ECS, but that doesn&amp;rsquo;t follow the principle of least priviledge. Instead, I scoped it with tag conditions:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-hcl" data-lang="hcl">&lt;span class="line">&lt;span class="cl">&lt;span class="k">data&lt;/span> &lt;span class="s2">&amp;#34;aws_iam_policy_document&amp;#34; &amp;#34;ecs_pass_dagster_roles&amp;#34;&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">statement&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> sid&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;PassOnlyDagsterRunRoles&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> effect&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;Allow&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> actions&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;iam:PassRole&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> resources&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;*&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="c1"> # resource-level not supported for PassRole; use tag conditions instead
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">condition&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> test&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;StringEquals&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> variable&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;iam:PassedToService&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> values&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;ecs-tasks.amazonaws.com&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> }&lt;span class="c1">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"> # Only allow passing roles tagged as dagster pipeline components
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="k">condition&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> test&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;StringEquals&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> variable&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;iam:ResourceTag/dagster:component&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> values&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;pipeline&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> }
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">condition&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> test&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;StringEquals&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> variable&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;iam:ResourceTag/dagster:managed-by&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> values&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;terraform&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> }
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> }
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">}
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">resource&lt;/span> &lt;span class="s2">&amp;#34;aws_iam_role_policy&amp;#34; &amp;#34;daemon_pass_roles&amp;#34;&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> name&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;${var.name_prefix}-daemon-pass-run-roles-${var.env}&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> role&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">aws_iam_role&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">daemon_task&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">id&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> policy&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">data&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">aws_iam_policy_document&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">ecs_pass_dagster_roles&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">json&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">}&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>iam:PassRole&lt;/code> doesn&amp;rsquo;t support resource-level scoping the way you&amp;rsquo;d want, so &lt;code>&amp;quot;*&amp;quot;&lt;/code> plus a strict tag condition is the practical answer. That means that you can add new pipelines without ever touching the platform&amp;rsquo;s IAM policies. As long as a new pipeline&amp;rsquo;s run roles carry the right tags, the daemon can pass them.&lt;/p>
&lt;hr>
&lt;h2 id="what-the-platform-expects-from-a-pipeline">What the Platform Expects From a Pipeline&lt;/h2>
&lt;p>In actuality, the tag condition is really a contract. To plug into this Dagster platform, a pipeline has to:&lt;/p>
&lt;ol>
&lt;li>Register a service in the Cloud Map namespace, so the Daemon and Webserver can resolve it by DNS on port 4000.&lt;/li>
&lt;li>Attach its usercode service to the platform&amp;rsquo;s usercode security group, so that traffic is actually allowed.&lt;/li>
&lt;li>Tag its run roles with &lt;code>dagster:component&lt;/code> and &lt;code>dagster:managed-by&lt;/code>, so the Daemon is permitted to pass them to ECS.&lt;/li>
&lt;li>Get its code location into &lt;code>workspace.yaml&lt;/code> in SSM, so the Webserver and Daemon know it exists.&lt;/li>
&lt;/ol>
&lt;p>
&lt;a href="https://kristianeschenburg.netlify.app/post/deploying-dagster-to-ecs-2-pipelines/">Part two&lt;/a> covers the module that does all four, plus the two-task-definition pattern, secrets, and how deploys actually roll a new image onto ECS.&lt;/p></description></item></channel></rss>