<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Data Platform |</title><link>https://kristianeschenburg.netlify.app/category/data-platform/</link><atom:link href="https://kristianeschenburg.netlify.app/category/data-platform/index.xml" rel="self" type="application/rss+xml"/><description>Data Platform</description><generator>Source Themes Academic (https://sourcethemes.com/academic/)</generator><language>en-us</language><lastBuildDate>Tue, 27 Jan 2026 09:00:00 -0800</lastBuildDate><image><url>https://kristianeschenburg.netlify.app/img/Bayes.jpg</url><title>Data Platform</title><link>https://kristianeschenburg.netlify.app/category/data-platform/</link></image><item><title>Correlation IDs and Request Lineage Across Services</title><link>https://kristianeschenburg.netlify.app/post/request-correlation-middleware/</link><pubDate>Tue, 27 Jan 2026 09:00:00 -0800</pubDate><guid>https://kristianeschenburg.netlify.app/post/request-correlation-middleware/</guid><description>&lt;p>We are a pretty small team, yet we still have a pretty large and expansive suite of services, dashboards, and backend data systems. When the number of services was small, auditting and logging was quite easy, but over the last few years, it&amp;rsquo;s become quite a chore. Imagine the following scenario:&lt;/p>
&lt;p>A scientist tells you the page on the cell culture analysis dashboard was slow this morning.&lt;/p>
&lt;p>If you can&amp;rsquo;t answer at least one of the following, you might find this post helpful:&lt;/p>
&lt;ol>
&lt;li>Which page?&lt;/li>
&lt;li>What do they mean &amp;ldquo;slow&amp;rdquo;?&lt;/li>
&lt;li>Which user?&lt;/li>
&lt;li>When?&lt;/li>
&lt;li>What endpoint and what parameters?&lt;/li>
&lt;/ol>
&lt;p>The dashboard they&amp;rsquo;re referring to called some backend API, that API called two more, and one of those queried the LIMS system directly. That&amp;rsquo;s multiple services and log streams, and I&amp;rsquo;m willing to bet that not one line in any of them tells you which entries belong to that user&amp;rsquo;s click.&lt;/p>
&lt;p>We face this issue in a variety of places in our data platform. I don&amp;rsquo;t mean logging in the general sense of &lt;code>import logging&lt;/code>, but the narrower scope of how a request identifies itself as it moves between all of our services, so that afterwards we can put the pieces back in order and say which step was slow, or which one returned the 403 error.&lt;/p>
&lt;p>I built something that I&amp;rsquo;ve been slowly deploying and incorporating into most of our services and dashboards. Three pieces do the work: a request-scoped context, middleware that fills it in, and a handful of headers forwarded on every outbound call. It pairs with
&lt;a href="https://kristianeschenburg.netlify.app/post/service-to-service-auth-cognito/">the previous post&lt;/a> on service-to-service auth, since knowing &lt;em>who&lt;/em> called and when is half the battle.&lt;/p>
&lt;hr>
&lt;h2 id="what-i-wanted-every-line-to-carry">What I wanted every line to carry&lt;/h2>
&lt;p>My goal for this tooling was that any single &amp;ldquo;typical&amp;rdquo; log line, anywhere in the system, can answer which request, which user, which service, which endpoint, how long, and what happened. This ends up being a fixed set of fields stamped onto every record key:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Field&lt;/th>
&lt;th>Example&lt;/th>
&lt;th>Why&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>request_id&lt;/code>&lt;/td>
&lt;td>&lt;code>req-474c2493-...&lt;/code>&lt;/td>
&lt;td>Ties every line from one request together, across services&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>session_id&lt;/code>&lt;/td>
&lt;td>&lt;code>ses-a55813da-...&lt;/code>&lt;/td>
&lt;td>Ties multiple requests from one browser session together&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>user_id&lt;/code>&lt;/td>
&lt;td>&lt;code>user@example.com&lt;/code>&lt;/td>
&lt;td>Who was actually doing this&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>service&lt;/code>&lt;/td>
&lt;td>&lt;code>lims-adapter&lt;/code>&lt;/td>
&lt;td>Which service emitted the line&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>requesting_service&lt;/code>&lt;/td>
&lt;td>&lt;code>dashboard&lt;/code>&lt;/td>
&lt;td>Which service called &lt;em>this&lt;/em> one&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>method&lt;/code> / &lt;code>path&lt;/code>&lt;/td>
&lt;td>&lt;code>GET&lt;/code> &lt;code>/samples/S-123/molecule&lt;/code>&lt;/td>
&lt;td>Which endpoint&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>client&lt;/code>&lt;/td>
&lt;td>&lt;code>10.0.5.233&lt;/code>&lt;/td>
&lt;td>Source IP&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>status_code&lt;/code>&lt;/td>
&lt;td>&lt;code>403&lt;/code>&lt;/td>
&lt;td>What happened&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>duration_s&lt;/code>&lt;/td>
&lt;td>&lt;code>1.284&lt;/code>&lt;/td>
&lt;td>How long it took&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>event&lt;/code>&lt;/td>
&lt;td>&lt;code>request_end&lt;/code>&lt;/td>
&lt;td>Machine-readable label for the kind of line&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>From my perspective, &lt;code>request_id&lt;/code> and &lt;code>session_id&lt;/code> are the most helpful. A request ID covers one call to a service and everything it fans out into. A session ID covers everything one person did in one sitting. When someone says &amp;ldquo;it was slow this morning&amp;rdquo;, you find their session, then look at which requests inside it were slow.&lt;/p>
&lt;hr>
&lt;h2 id="how-the-context-travels">How the Context Travels&lt;/h2>
&lt;p>There are two mechanisms for how context travels within and between requests, one inside a process and one between processes.&lt;/p>
&lt;pre>&lt;code class="language-mermaid">graph LR
Browser(Browser)
Dash[Dashboard]
ApiA[Service A]
ApiB[Service B]
Lims[LIMS]
Logs[Log store]
Browser --&amp;gt;|&amp;#34;click&amp;#34;| Dash
Dash --&amp;gt;|&amp;#34;X-Request-ID, X-Session-ID, X-User-ID, X-Requesting-Service: dashboard&amp;#34;| ApiA
ApiA --&amp;gt;|&amp;#34;same IDs, X-Requesting-Service: service-a&amp;#34;| ApiB
ApiA --&amp;gt;|&amp;#34;same IDs&amp;#34;| Lims
Dash -.-&amp;gt;|&amp;#34;req-abc&amp;#34;| Logs
ApiA -.-&amp;gt;|&amp;#34;req-abc&amp;#34;| Logs
ApiB -.-&amp;gt;|&amp;#34;req-abc&amp;#34;| Logs
classDef store fill:#f3e8fd,stroke:#a142f4
class Logs store&lt;/code>&lt;/pre>&lt;p>Inside a process, the fields live in a
&lt;a href="https://docs.python.org/3/library/contextvars.html" target="_blank" rel="noopener">&lt;code>ContextVar&lt;/code>&lt;/a>, so they follow the request through &lt;code>await&lt;/code> chains and across threads without being passed as arguments. Between processes, they&amp;rsquo;re plain HTTP headers that outbound calls forward. &lt;code>X-Requesting-Service&lt;/code> is the one that changes at each hop, while the other three carry through unchanged. That is what lets one &lt;code>request_id&lt;/code> span the entire tree.&lt;/p>
&lt;hr>
&lt;h2 id="why-use-a-filter-not-a-loggeradapter">Why use a filter, not a LoggerAdapter&lt;/h2>
&lt;p>One way to attach context to log records is by using Python&amp;rsquo;s &lt;code>logging.LoggerAdapter&lt;/code>, but I think it&amp;rsquo;s the wrong tool for what I&amp;rsquo;m trying to do. An
&lt;a href="https://docs.python.org/3/library/logging.html#loggeradapter-objects" target="_blank" rel="noopener">adapter&lt;/a> applies only to log calls made &lt;em>through it&lt;/em>. Your own code gets the context, but the third-party library that raises the interesting exception does not, because it holds its own plain &lt;code>logging.getLogger(__name__)&lt;/code>.&lt;/p>
&lt;p>A &lt;code>logging.Filter&lt;/code> attached to the root handler sees every record in the process, irrespective of where it came from:&lt;/p>
&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="n">_log_context&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">ContextVar&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="nb">dict&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">ContextVar&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;log_context&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &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;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">class&lt;/span> &lt;span class="nc">ContextFilter&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">logging&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">Filter&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;&amp;#34;&amp;#34;Inject current context fields onto every LogRecord.&amp;#34;&amp;#34;&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">def&lt;/span> &lt;span class="nf">filter&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="bp">self&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">record&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">logging&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">LogRecord&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">-&amp;gt;&lt;/span> &lt;span class="nb">bool&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">for&lt;/span> &lt;span class="n">key&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">value&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="o">**&lt;/span>&lt;span class="n">_global_fields&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="o">**&lt;/span>&lt;span class="n">_log_context&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">get&lt;/span>&lt;span class="p">()}&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">items&lt;/span>&lt;span class="p">():&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="ow">not&lt;/span> &lt;span class="nb">hasattr&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">record&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">key&lt;/span>&lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nb">setattr&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">record&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">key&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">value&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="kc">True&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The &lt;code>hasattr&lt;/code> check means a field passed explicitly at the call site wins over the passive default context, so a caller can override &lt;code>user_id&lt;/code> for one line without unbinding it. And &lt;code>_global_fields&lt;/code> is a plain dict rather than a ContextVar, because ContextVar values aren&amp;rsquo;t inherited by new threads: static configuration like the service name has to live outside the context or it vanishes in a thread pool.&lt;/p>
&lt;p>Binding is a context manager that nests:&lt;/p>
&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="nd">@contextmanager&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">def&lt;/span> &lt;span class="nf">log_context&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="o">**&lt;/span>&lt;span class="n">fields&lt;/span>&lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">current&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">_log_context&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">get&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">token&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">_log_context&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">set&lt;/span>&lt;span class="p">({&lt;/span>&lt;span class="o">**&lt;/span>&lt;span class="n">current&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="o">**&lt;/span>&lt;span class="n">fields&lt;/span>&lt;span class="p">})&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">try&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">yield&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">finally&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">_log_context&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">reset&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">token&lt;/span>&lt;span class="p">)&lt;/span>&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="k">with&lt;/span> &lt;span class="n">log_context&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">request_id&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">rid&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">session_id&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">sid&lt;/span>&lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">log&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">info&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;request received&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="c1"># two fields&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">with&lt;/span> &lt;span class="n">log_context&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">step&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;validate&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">log&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">info&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;validating&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="c1"># three&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">log&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">info&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;continuing&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="c1"># back to two&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The &lt;code>token&lt;/code> mechanism restores the previous context exactly, including when the block exits by exception, which a naive save-and-restore gets wrong.&lt;/p>
&lt;p>There&amp;rsquo;s also a version without a scope boundary, for values that resolve partway through a request. Identity is the usual case: you don&amp;rsquo;t know the user until auth has run, but you want every line after that point to carry it.&lt;/p>
&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="k">async&lt;/span> &lt;span class="k">def&lt;/span> &lt;span class="nf">get_current_user&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">token&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="n">User&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">user&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">decode_token&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">token&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">update_log_context&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">user_id&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">user&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">email&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="c1"># every later line carries it&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">user&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;hr>
&lt;h2 id="middleware">Middleware&lt;/h2>
&lt;p>The middleware operates once per request: accept or generate the IDs, resolve the user, bind everything, time the request, and echo the IDs back.&lt;/p>
&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="n">request_id&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">request&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">headers&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">get&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;x-request-id&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="ow">or&lt;/span> &lt;span class="sa">f&lt;/span>&lt;span class="s2">&amp;#34;req-&lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">uuid&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">uuid4&lt;/span>&lt;span class="p">()&lt;/span>&lt;span class="si">}&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="n">session_id&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">request&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">headers&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">get&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;x-session-id&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="ow">or&lt;/span> &lt;span class="sa">f&lt;/span>&lt;span class="s2">&amp;#34;ses-&lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">uuid&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">uuid4&lt;/span>&lt;span class="p">()&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Accept-or-generate is what enables cross-service correlation. The first service to see a request mints the ID, and every service after that inherits the minted ID from the header. Echoing the request and session IDs back in the response headers means a client, a browser console, or a support ticket can name the exact request, which turns &amp;ldquo;it was slow this morning&amp;rdquo; into a string I can now search for in CloudWatch. Identity resolves from whichever source is available, in priority order:&lt;/p>
&lt;ol>
&lt;li>&lt;code>x-amzn-oidc-data&lt;/code>, the ALB-injected Cognito JWT, decoded for the &lt;code>email&lt;/code> claim. This is a direct browser hit.&lt;/li>
&lt;li>&lt;code>X-User-ID&lt;/code>, forwarded by an upstream service that already decoded its own JWT.&lt;/li>
&lt;li>A dev stub, when &lt;code>APP_ENV&lt;/code> is &lt;code>dev&lt;/code>, &lt;code>local&lt;/code>, or unset.&lt;/li>
&lt;/ol>
&lt;p>The second tier carries a user&amp;rsquo;s identity into services that the user never talks to directly. Service B has no OIDC header, because its caller was service A, not a browser. It can still log which person&amp;rsquo;s click ultimately caused the call.&lt;/p>
&lt;h3 id="grading-by-status">Grading by status&lt;/h3>
&lt;p>&lt;code>request_end&lt;/code> is logged at &lt;code>ERROR&lt;/code> for 5xx, &lt;code>WARNING&lt;/code> for 4xx, and &lt;code>INFO&lt;/code> otherwise:&lt;/p>
&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="n">log&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">log&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">logging&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">ERROR&lt;/span> &lt;span class="k">if&lt;/span> &lt;span class="n">status&lt;/span> &lt;span class="o">&amp;gt;=&lt;/span> &lt;span class="mi">500&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">else&lt;/span> &lt;span class="n">logging&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">WARNING&lt;/span> &lt;span class="k">if&lt;/span> &lt;span class="n">status&lt;/span> &lt;span class="o">&amp;gt;=&lt;/span> &lt;span class="mi">400&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">else&lt;/span> &lt;span class="n">logging&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">INFO&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;request completed&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">extra&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;event&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;request_end&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;status_code&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">status&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;duration_s&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">duration_s&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;/code>&lt;/pre>&lt;/div>&lt;hr>
&lt;h2 id="403s-that-explain-nothing">403s that explain nothing&lt;/h2>
&lt;p>A route raises &lt;code>HTTPException(403, detail=&amp;quot;missing scope: service-b/write&amp;quot;)&lt;/code>. The client gets a useful error. The log gets a bare &lt;code>403&lt;/code> and nothing else. The reason is ordering: FastAPI&amp;rsquo;s &lt;code>ExceptionMiddleware&lt;/code> sits &lt;em>inside&lt;/em> the middleware, so by the time the middleware sees anything, the exception has already been converted into a response. The detail explaining the rejection went to the client and nowhere else. At that point, debugging a 403 means reproducing it, which is annoying and what this whole codebase is meant to fix.&lt;/p>
&lt;p>The fix is an exception handler that records the reason, registered alongside the middleware:&lt;/p>
&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="k">async&lt;/span> &lt;span class="k">def&lt;/span> &lt;span class="nf">http_exception_log_handler&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">request&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">exc&lt;/span>&lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">log&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">log&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">logging&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">ERROR&lt;/span> &lt;span class="k">if&lt;/span> &lt;span class="n">exc&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">status_code&lt;/span> &lt;span class="o">&amp;gt;=&lt;/span> &lt;span class="mi">500&lt;/span> &lt;span class="k">else&lt;/span> &lt;span class="n">logging&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">WARNING&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;request rejected&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">extra&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;event&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;request_rejected&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;status_code&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">exc&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">status_code&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;error_detail&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">_detail_text&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">exc&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">detail&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="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="k">await&lt;/span> &lt;span class="n">_default_http_exception_handler&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">request&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">exc&lt;/span>&lt;span class="p">)&lt;/span>&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="n">app&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">add_exception_handler&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">StarletteHTTPException&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">http_exception_log_handler&lt;/span>&lt;span class="p">)&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>It runs inside the middleware&amp;rsquo;s context, so the request, session, and user fields are already bound. It delegates to Starlette&amp;rsquo;s default handler, so the response the client receives is unchanged. And because &lt;code>detail&lt;/code> is often a dict for structured errors, it gets JSON-serialized rather than &lt;code>str()&lt;/code>-ed, so it stays greppable instead of turning into Python repr with single quotes.&lt;/p>
&lt;hr>
&lt;h2 id="health-probe-overload">Health probe overload&lt;/h2>
&lt;p>A load balancer probes &lt;code>/health&lt;/code> every few seconds from every node, which means that those log lines can dominate log volume while reporting only that nothing happened, especially if they&amp;rsquo;re occuring frequently. I chose to suppress them, while still retaining useful information in the case that they fail:&lt;/p>
&lt;ol>
&lt;li>Skip the &lt;code>request_start&lt;/code>/&lt;code>request_end&lt;/code> pair in the middleware for &lt;em>passing&lt;/em> probes.&lt;/li>
&lt;li>Drop uvicorn&amp;rsquo;s own access-log line for the same requests, via a filter on the root handler.&lt;/li>
&lt;/ol>
&lt;p>Uvicorn logs independently of your middleware, so leaving that half in place means you&amp;rsquo;ve halved the noise and kept the volume. A probe returning 4xx or 5xx is always logged &amp;ndash; that&amp;rsquo;s the whole point of building this logging functionality. The correlation headers are still echoed on suppressed requests too, so if a probe starts failing and something retries, the retry is traceable.&lt;/p>
&lt;hr>
&lt;h2 id="auto-capturing-tracebacks">Auto-capturing tracebacks&lt;/h2>
&lt;p>A log call made from inside an &lt;code>except&lt;/code> block captures the active exception automatically, even when the caller writes &lt;code>log.warning(&amp;quot;call failed&amp;quot;)&lt;/code> with no &lt;code>exc_info=True&lt;/code>. I typically write &lt;code>logger.warning&lt;/code> in error handlers constantly, and almost never remember &lt;code>exc_info&lt;/code>. This results in a log full of &amp;ldquo;call failed&amp;rdquo; with no stacktrace. Capturing it by default means the traceback is there whether or not the author thought about it.&lt;/p>
&lt;p>The record then carries both locations, which are usually different:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-json" data-lang="json">&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="nt">&amp;#34;timestamp&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;2026-05-26T19:09:37.015695Z&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="nt">&amp;#34;level&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;WARNING&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="nt">&amp;#34;service&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;lims-adapter&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="nt">&amp;#34;message&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;LIMS call failed: SQL query failed (upstream_status=405)&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="nt">&amp;#34;func&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;_lims_error&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="nt">&amp;#34;filename&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;errors.py&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="nt">&amp;#34;lineno&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">47&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;request_id&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;req-474c2493-18ea-42a0-be0f-5ce4d096b14c&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="nt">&amp;#34;session_id&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;ses-a55813da-3ab1-4471-9761-59d2ef988179&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="nt">&amp;#34;user_id&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;user@example.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="nt">&amp;#34;path&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;/samples/S-20260129-269/molecule&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="nt">&amp;#34;exception&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;Traceback (most recent call last):\n File \&amp;#34;.../molecule.py\&amp;#34;, line 10, in get_molecule\n ...\nLimsError: SQL query failed&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;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>filename&lt;/code> and &lt;code>lineno&lt;/code> point at the error-handling code: the place someone decided to log. The traceback points somewhere else entirely, at the origin of the raise. That is where the bug lives. You want both, and they are rarely in the same file.&lt;/p>
&lt;hr>
&lt;h2 id="field-names-and-markers">Field names and markers&lt;/h2>
&lt;p>&lt;strong>Constants instead of string literals.&lt;/strong> A typo in &lt;code>fields.REQUEST_ID&lt;/code> is a &lt;code>NameError&lt;/code> at import. A typo in &lt;code>&amp;quot;request_id&amp;quot;&lt;/code> is a silent schema divergence that nobody notices until a dashboard query quietly returns fewer rows than it should.&lt;/p>
&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">from&lt;/span> &lt;span class="nn">logging_lib&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">fields&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">with&lt;/span> &lt;span class="n">log_context&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="o">**&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="n">fields&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">REQUEST_ID&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">rid&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">fields&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">USER_ID&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">uid&lt;/span>&lt;span class="p">}):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">log&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">info&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;handling request&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">extra&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="n">fields&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">EVENT&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;request_start&amp;#34;&lt;/span>&lt;span class="p">})&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>Markers for categories.&lt;/strong> A top-level &lt;code>marker&lt;/code> field tags a record for dashboards and alerting: &lt;code>AUDIT&lt;/code> for user-initiated state changes, &lt;code>PERF&lt;/code> for timings, &lt;code>SECURITY&lt;/code> for auth and access control, &lt;code>SYSTEM&lt;/code> for lifecycle, &lt;code>PIPELINE&lt;/code> for job execution. It&amp;rsquo;s a small closed vocabulary, which is the point, and it makes queries trivial in whatever you&amp;rsquo;re storing logs in:&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"># CloudWatch Logs Insights
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">filter marker = &amp;#34;AUDIT&amp;#34;
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"># Loki
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">{service=&amp;#34;lims-adapter&amp;#34;} | json | marker = &amp;#34;AUDIT&amp;#34;&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;hr>
&lt;h2 id="what-i-now-get">What I now get&lt;/h2>
&lt;p>With the IDs in place, three questions become queries rather than investigations.&lt;/p>
&lt;p>&lt;strong>Sequencing.&lt;/strong> Filter on one &lt;code>request_id&lt;/code>, sort by timestamp, and I have the ordered list of everything every service did for that one call, including the services the user never touched directly.&lt;/p>
&lt;p>&lt;strong>Lineage.&lt;/strong> &lt;code>requesting_service&lt;/code> on each hop gives the call graph as it actually ran&lt;/p>
&lt;p>&lt;strong>Latency attribution.&lt;/strong> Every service records &lt;code>duration_s&lt;/code> for the same &lt;code>request_id&lt;/code>. The dashboard reports 3.1 seconds, service A reports 2.9, service B reports 2.7, and the LIMS query reports 2.6. The slow thing is the LIMS query, and it&amp;rsquo;s established without adding a single timer.&lt;/p></description></item><item><title>Service-to-Service Auth with Cognito Scopes</title><link>https://kristianeschenburg.netlify.app/post/service-to-service-auth-cognito/</link><pubDate>Tue, 09 Dec 2025 09:00:00 -0800</pubDate><guid>https://kristianeschenburg.netlify.app/post/service-to-service-auth-cognito/</guid><description>&lt;p>I&amp;rsquo;m building tools that help scientists run their day-to-day work. I work in a heavily regulated environment, subject to both U.S. and European digital policy requirements. The scenario we face regularly is: two services talk to each other. Then three. A dashboard calls a backend API, that API calls another one, a nightly job calls all of them. Somewhow, we have to answer how one service proves to another that it is allowed to make the call.&lt;/p>
&lt;p>The first way I addressed this was to use Internal Bearer Tokens. Basically, you generate a long random string, store it in the AWS Secrets Manager, inject it into every service as an environment variable, have each service check inbound requests for it.&lt;/p>
&lt;p>I replaced that solution with Cognito&amp;rsquo;s OAuth2 client-credentials flow. Here each service carries its own identity and each endpoint declares the scope it requires. We didn&amp;rsquo;t need to define any new infrastructure for this, since Cognito was already authenticating browser users through the ALB, so the service-to-service (S2S) path ended up reusing the same user pool, the same JWKS endpoint, and the same validation code that was already there.&lt;/p>
&lt;hr>
&lt;h2 id="why-a-shared-bearer-token-was-not-viable">Why a shared bearer token was not viable&lt;/h2>
&lt;p>The bearer token approach was really easy, but it wasn&amp;rsquo;t a viable approach in the long run, for a few reasons:&lt;/p>
&lt;p>&lt;strong>No expiry.&lt;/strong> A static token has no TTL. If it leaks through a log line, a memory dump, or a compromised container, it stays valid until you manually rotate it and run a redeploy of every service holding it. There is no safe window during rotation where both the old and new values work, unless you write that special-casing yourself.&lt;/p>
&lt;p>&lt;strong>No identity.&lt;/strong> When service B receives the token, it can verify the value matches what it was given, but it can&amp;rsquo;t tell &lt;em>who&lt;/em> sent it. Every caller is indistinguishable, so you can only observe that the token was used.&lt;/p>
&lt;p>&lt;strong>No scope.&lt;/strong> The receiving service has no way to enforce what the caller may do. Either it accepts the token and grants full access, or it rejects it. There&amp;rsquo;s no way to express &amp;ldquo;service A may read but not write&amp;rdquo;.&lt;/p>
&lt;p>&lt;strong>Shared blast radius.&lt;/strong> The same secret works everywhere. One compromised service exposes the key to every other service, so a single breach leaves a huge hole.&lt;/p>
&lt;p>Comparing these two solutions against each other, we have:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Property&lt;/th>
&lt;th>Static bearer token&lt;/th>
&lt;th>Cognito client credentials&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Expiry&lt;/td>
&lt;td>Never&lt;/td>
&lt;td>~1 hour, auto-refreshed&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Identity&lt;/td>
&lt;td>None (opaque string)&lt;/td>
&lt;td>Signed JWT with a &lt;code>client_id&lt;/code> claim&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Scope enforcement&lt;/td>
&lt;td>Impossible&lt;/td>
&lt;td>Per-endpoint, per-caller&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Compromise blast radius&lt;/td>
&lt;td>All services, indefinitely&lt;/td>
&lt;td>One service, for TTL&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Rotation&lt;/td>
&lt;td>Manual, coordinated redeploy&lt;/td>
&lt;td>Automatic&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Auditability&lt;/td>
&lt;td>&amp;ldquo;the token was used&amp;rdquo;&lt;/td>
&lt;td>&amp;ldquo;service A called endpoint X&amp;rdquo;&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Being able to attribute traffic to a caller enhances the utility of logs, which is most of the subject of
&lt;a href="https://kristianeschenburg.netlify.app/post/request-correlation-middleware/">my next post&lt;/a>.&lt;/p>
&lt;hr>
&lt;h2 id="two-gates">Two gates&lt;/h2>
&lt;p>There are two authentication and authorization gates in this architecture:&lt;/p>
&lt;p>&lt;strong>Gate 1 is the ALB.&lt;/strong> Everything in our system is either accesible from outside our VPC via the ALB, or has ingress/egress restricted to within the VPC. The ALB decides only &lt;em>how a request gets in&lt;/em>: either this is an authenticated browser user who must complete an interactive Cognito login, or it&amp;rsquo;s a caller to hand straight to the service. The ALB knows nothing about your application&amp;rsquo;s permissions.&lt;/p>
&lt;p>&lt;strong>Gate 2 is the service.&lt;/strong> It validates &lt;em>identity&lt;/em> and enforces &lt;em>scope&lt;/em>. This is the real authorization check for S2S communication. It behaves identically no matter what language or host the caller runs on.&lt;/p>
&lt;p>An incoming request has to clear both. A browser user who hasn&amp;rsquo;t been authenticated gets a 302 status to the Cognito login page via Gate 1 and never reaches the dashboard or API. A service with a valid token but the wrong scope passes Gate 1 but receives a 403 at Gate 2.&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">caller ──▶ [ Gate 1: ALB ] ──▶ [ Gate 2: service ] ──▶ your handler
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> browser or machine? who are you, and what may you do?
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> │ browser, │ no or invalid credential
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> │ not logged in │ valid token, wrong scope
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> ▼ ▼
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> 302 → Cognito login 401 / 403&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;hr>
&lt;h2 id="what-services-can-call-what">What services can call what&lt;/h2>
&lt;p>Below is a diagram of allowed communication. Solid arrows are requests, and each one is labeled with the scope it must present. Dashed arrows are token issuance.&lt;/p>
&lt;pre>&lt;code class="language-mermaid">graph LR
Human(Browser user)
Robot(Laptop / CI job)
ALB{ALB}
Dash[Dashboard]
SvcA[Service A]
SvcB[Service B]
SvcC[Service C]
Cognito(Cognito)
Human --&amp;gt;|&amp;#34;session cookie, no scope&amp;#34;| ALB
Robot --&amp;gt;|&amp;#34;Authorization: Bearer&amp;#34;| ALB
ALB --&amp;gt;|&amp;#34;x-amzn-oidc-identity&amp;#34;| Dash
ALB --&amp;gt;|&amp;#34;Bearer passthrough&amp;#34;| SvcA
Dash --&amp;gt;|&amp;#34;service-a/read&amp;#34;| SvcA
Dash --&amp;gt;|&amp;#34;service-b/read&amp;#34;| SvcB
SvcA --&amp;gt;|&amp;#34;service-b/read&amp;#34;| SvcB
SvcA --&amp;gt;|&amp;#34;service-c/write&amp;#34;| SvcC
Cognito -.-&amp;gt;|&amp;#34;token: service-a/read, service-b/read&amp;#34;| Dash
Cognito -.-&amp;gt;|&amp;#34;token: service-b/read, service-c/write&amp;#34;| SvcA
classDef human fill:#e8f0fe,stroke:#4285f4
classDef machine fill:#e6f4ea,stroke:#34a853
class Human human
class Robot machine&lt;/code>&lt;/pre>&lt;p>A browser user reaches the dashboard with no scope at all, because the ALB already authenticated them interactively and the dashboard is the thing they&amp;rsquo;re allowed to look at. The dashboard&amp;rsquo;s own token is &lt;em>narrower&lt;/em> than service A&amp;rsquo;s: it can read service A and service B, but nothing grants it write access to service C. If the dashboard is compromised, the write path isn&amp;rsquo;t reachable from there.&lt;/p>
&lt;p>The permissions are just a table, so adding a consumer means adding a row, not changing any service&amp;rsquo;s code:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>App client (the caller)&lt;/th>
&lt;th>Allowed scopes&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>dashboard&lt;/code>&lt;/td>
&lt;td>&lt;code>service-a/read&lt;/code>, &lt;code>service-b/read&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>service-a&lt;/code>&lt;/td>
&lt;td>&lt;code>service-b/read&lt;/code>, &lt;code>service-c/write&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>ci-runner&lt;/code>&lt;/td>
&lt;td>&lt;code>service-a/read&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>developer&lt;/code>&lt;/td>
&lt;td>&lt;code>service-a/read&lt;/code>, &lt;code>service-b/read&lt;/code>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;hr>
&lt;h2 id="cognito-resource-servers-custom-scopes-and-app-clients">Cognito resource servers, custom scopes, and app clients&lt;/h2>
&lt;h3 id="the-resource-server-defines-capabilities">The resource server defines capabilities&lt;/h3>
&lt;p>A &lt;strong>resource server&lt;/strong> represents a service that exposes capabilities. It has two
name-like fields that do very different jobs:&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"># terraform code
&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_cognito_resource_server&amp;#34; &amp;#34;service_b&amp;#34;&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> user_pool_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">cognito_user_pool_id&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"> # The scope prefix. Opaque string, conventionally a URL. Immutable in practice.
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="n"> identifier&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;https://api.example.com/service-b&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"> # Console display name only. Nothing references it.
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="n"> name&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;service-b&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">scope&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> scope_name&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;read&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> scope_description&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;Read access to service-b&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="k">scope&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> scope_name&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;write&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> scope_description&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;Write access to service-b&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="k">scope&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> scope_name&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;admin&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> scope_description&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;Administrative access to service-b&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 &lt;code>identifier&lt;/code> field becomes the prefix of every scope the
resource server defines, so the three scopes above are really:&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">https://api.example.com/service-b/read
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">https://api.example.com/service-b/write
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">https://api.example.com/service-b/admin&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>That full string is what a client requests, what appears in the JWT&amp;rsquo;s &lt;code>scope&lt;/code>
claim, and what a service compares against. &lt;code>name&lt;/code> is a display label in the
console and is referenced by nothing.&lt;/p>
&lt;h3 id="only-custom-scopes-work-for-machine-to-machine">Only custom scopes work for machine-to-machine&lt;/h3>
&lt;p>The client-credentials flow works &lt;strong>only&lt;/strong> with custom scopes from a resource server. I defined these scope manually in my FastAPI applications, and decorated the relevant endpoints with their respective scopes. The built-in OpenID scopes (&lt;code>openid&lt;/code>, &lt;code>email&lt;/code>, &lt;code>profile&lt;/code>, &lt;code>aws.cognito.signin.user.admin&lt;/code>)
are for user-facing flows, and asking for one with &lt;code>grant_type=client_credentials&lt;/code>
fails. Every machine caller needs at least one resource server to exist,
even if the service it calls has exactly one capability.&lt;/p>
&lt;p>The app client also needs a secret and the flow explicitly enabled. A client
without &lt;code>generate_secret&lt;/code> cannot do client credentials at all, and the user pool
needs a domain configured, since the token endpoint lives at
&lt;code>https://&amp;lt;domain&amp;gt;.auth.&amp;lt;region&amp;gt;.amazoncognito.com/oauth2/token&lt;/code>.&lt;/p>
&lt;h3 id="the-app-client-selects-a-subset">The app client selects a subset&lt;/h3>
&lt;p>An &lt;strong>app client&lt;/strong> represents a caller of a service. One per calling service, with
&lt;code>allowed_oauth_scopes&lt;/code> listing every downstream capability it may request:&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_cognito_user_pool_client&amp;#34; &amp;#34;service_a_m2m&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;service-a-m2m&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> user_pool_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">cognito_user_pool_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="n"> generate_secret&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="kt">true&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> allowed_oauth_flows_user_pool_client&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="kt">true&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> allowed_oauth_flows&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;client_credentials&amp;#34;&lt;/span>&lt;span class="p">]&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"> # Full scope strings, exactly as composed above.
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="n"> allowed_oauth_scopes&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;https://api.example.com/service-b/read&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;https://api.example.com/service-c/write&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 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"> # Cognito validates that each scope exists, so the resource servers
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"> # must be created first. Terraform will not infer this ordering.
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="n"> depends_on&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_cognito_resource_server&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">service_b&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_cognito_resource_server&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">service_c&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>Service A is granted &lt;code>read&lt;/code> on service B, and &lt;code>write&lt;/code> on service C. It cannot write to service B even though that scope exists, because its app client never lists it as a viable permissions. Cognito rejects an app client referencing a scope that doesn&amp;rsquo;t exist yet, and Terraform sees no dependency between the two resources because the scope is a hand-written string rather than a reference to the resource server&amp;rsquo;s attributes.&lt;/p>
&lt;p>&lt;strong>Scopes model capabilities, not consumers.&lt;/strong> Service B declares that reading,
writing, and administering are things that can be done to it (this is defined both in the FastAPI code via decorated endpoints, and in the Cognito resource server). But Service B doesn&amp;rsquo;t say anything about who can take those actions. Consumers get assigned action permissions through their app client&amp;rsquo;s allowed-scopes list.&lt;/p>
&lt;p>That separation lets you onboard a caller without touching the
service being called. A new consumer is one app client and one secret. Service B
does not redeploy, does not learn the new caller&amp;rsquo;s name, and does not grow a
config entry. Its code already says &lt;code>require_scope(&amp;quot;read&amp;quot;)&lt;/code> and will keep saying
that no matter how many services eventually call it.&lt;/p>
&lt;h3 id="two-possible-gotchas">Two possible gotchas&lt;/h3>
&lt;p>&lt;strong>An omitted scope parameter is not the same as no scopes.&lt;/strong> If a token request
leaves &lt;code>scope&lt;/code> off entirely, Cognito issues a token carrying &lt;em>every&lt;/em> scope the app
client is allowed. This means that every downstream call then presents maximum privilege. Always request scopes explicitly, one per downstream call. This is also why caching tokens per scope rather than per client matters.&lt;/p>
&lt;p>&lt;strong>Removing a scope is harder than adding one.&lt;/strong> Cognito will not let you delete a
scope from a resource server while an app client still lists it in
&lt;code>allowed_oauth_scopes&lt;/code>. The order is: remove it from every app client, apply, then
remove it from the resource server. In Terraform that&amp;rsquo;s two applies, and doing it
in one produces an error that names the resource server rather than the app client that&amp;rsquo;s actually the problem.&lt;/p>
&lt;h2 id="validating-a-token">Validating a token&lt;/h2>
&lt;p>The receiving side has to check four things:&lt;/p>
&lt;ol>
&lt;li>The &lt;strong>signature&lt;/strong>, against Cognito&amp;rsquo;s public JWKS for the pool.&lt;/li>
&lt;li>The &lt;strong>issuer&lt;/strong>, so a token from some other pool isn&amp;rsquo;t accepted.&lt;/li>
&lt;li>&lt;code>token_use == &amp;quot;access&amp;quot;&lt;/code>, so an ID token can&amp;rsquo;t be substituted for an access token.&lt;/li>
&lt;li>The &lt;strong>scope claim&lt;/strong> contains the specific scope the endpoint requires.&lt;/li>
&lt;/ol>
&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="k">def&lt;/span> &lt;span class="nf">_decode_token&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="bp">self&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">token&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">signing_key&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="bp">self&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">_jwk_client&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">get_signing_key_from_jwt&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">token&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">claims&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">jwt&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">decode&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">token&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">signing_key&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">key&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">algorithms&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;RS256&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">issuer&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="bp">self&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">issuer&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="c1"># Client-credentials tokens carry `client_id` instead of `aud`,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="c1"># so skip audience verification and check token_use instead.&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">options&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;verify_aud&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="kc">False&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="k">if&lt;/span> &lt;span class="n">claims&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">get&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;token_use&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">!=&lt;/span> &lt;span class="s2">&amp;#34;access&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">raise&lt;/span> &lt;span class="n">HTTPException&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">401&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;wrong token type&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">return&lt;/span> &lt;span class="n">claims&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Client-credentials tokens have no &lt;code>aud&lt;/code> claim. Instead, they carry &lt;code>client_id&lt;/code> , so leaving audience verification on rejects every valid S2S token with a confusing error. Turning it off here is valid, but only because &lt;code>token_use&lt;/code> and the issuer are being checked instead.&lt;/p>
&lt;p>Scope enforcement then becomes a dependency on the router:&lt;/p>
&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="n">full_scope&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="sa">f&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="bp">self&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">resource_server_id&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s2">/&lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="bp">self&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">_scope&lt;/span>&lt;span class="si">}&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="k">if&lt;/span> &lt;span class="n">full_scope&lt;/span> &lt;span class="ow">not&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">claims&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">get&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;scope&amp;#34;&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="o">.&lt;/span>&lt;span class="n">split&lt;/span>&lt;span class="p">():&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">raise&lt;/span> &lt;span class="n">HTTPException&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">403&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="sa">f&lt;/span>&lt;span class="s2">&amp;#34;token missing required scope: &lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">full_scope&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>&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="n">app&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">FastAPI&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">dependencies&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">Depends&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">auth&lt;/span>&lt;span class="p">)])&lt;/span> &lt;span class="c1"># authenticated everywhere&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">router&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">APIRouter&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">dependencies&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">Depends&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">auth&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">require_scope&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;read&amp;#34;&lt;/span>&lt;span class="p">))])&lt;/span> &lt;span class="c1"># plus a scope&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>There is also a design decision buried here. Browser requests arriving with ALB OIDC headers pass the scope check without a scope, since the ALB already authenticated the human interactively and browser sessions have no scopes to check.&lt;/p>
&lt;hr>
&lt;h2 id="fetching-a-token">Fetching a token&lt;/h2>
&lt;p>The outbound half fetches tokens from Cognito and caches them until they expire.&lt;/p>
&lt;p>&lt;strong>Cache per scope, not per client.&lt;/strong> A service calling three downstream APIs holds three tokens, each carrying only the access it needs for that call.&lt;/p>
&lt;p>&lt;strong>Guard the refresh with a lock, and re-check inside it.&lt;/strong> Under concurrent load, several coroutines will notice the expired token at the same moment and all stampede Cognito&amp;rsquo;s token endpoint:&lt;/p>
&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="k">async&lt;/span> &lt;span class="k">def&lt;/span> &lt;span class="nf">get_token&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="bp">self&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">scope&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">str&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">cached&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="bp">self&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">_tokens&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">get&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">scope&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="n">cached&lt;/span> &lt;span class="ow">and&lt;/span> &lt;span class="n">time&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">time&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="o">&amp;lt;&lt;/span> &lt;span class="n">cached&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">expires_at&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">cached&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">access_token&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">if&lt;/span> &lt;span class="n">scope&lt;/span> &lt;span class="ow">not&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="bp">self&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">_locks&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="bp">self&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">_locks&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">scope&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">asyncio&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">Lock&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">async&lt;/span> &lt;span class="k">with&lt;/span> &lt;span class="bp">self&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">_locks&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">scope&lt;/span>&lt;span class="p">]:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="c1"># Re-check inside the lock: another coroutine may have fetched already.&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">cached&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="bp">self&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">_tokens&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">get&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">scope&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="n">cached&lt;/span> &lt;span class="ow">and&lt;/span> &lt;span class="n">time&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">time&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="o">&amp;lt;&lt;/span> &lt;span class="n">cached&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">expires_at&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">cached&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">access_token&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="bp">self&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">_tokens&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">scope&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">await&lt;/span> &lt;span class="bp">self&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">_fetch_new_token&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">scope&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="bp">self&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">_tokens&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">scope&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">access_token&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Expiry is stored with a 60 second skew subtracted, so a token is treated as expired slightly before Cognito thinks so. Without that, a token that passes the check and then spends 400ms in flight can arrive already invalid.&lt;/p>
&lt;hr>
&lt;h2 id="required-alb-rules">Required ALB rules&lt;/h2>
&lt;p>If your ALB has a Cognito authentication action on its listener rule, it applies to &lt;em>every&lt;/em> request on that rule. A service presenting a valid Bearer token gets a 302 redirect to the Cognito login page. It never reaches your service, so all that careful token validation never runs.&lt;/p>
&lt;p>The listener needs to rules, defined in order:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Higher priority:&lt;/strong> match requests carrying an &lt;code>Authorization: Bearer&lt;/code> header and &lt;code>forward&lt;/code> them straight to the EC2 target group, with no authenticate action.&lt;/li>
&lt;li>&lt;strong>Lower priority:&lt;/strong> everything else gets &lt;code>authenticate-cognito&lt;/code> and the normal browser login.&lt;/li>
&lt;/ol>
&lt;h3 id="gotcha-source-ip-bypass-breaks-browser-login">Gotcha! Source-IP bypass breaks browser login&lt;/h3>
&lt;p>With respect to Gate 1, you could also consider matching on &lt;strong>source IP&lt;/strong> instead, forwarding anything from an allowlisted CIDR. This fixes Gate 1, and Gate 2 is unchanged either way.&lt;/p>
&lt;p>However, a source-IP matching rule forwards &lt;em>without&lt;/em> running the authenticate action. So once you allowlist the office or VPN range, a person opening a dashboard in a browser from that network skips the login entirely and arrives with no session and no token. This is DEFINITELY NOT what you want, because we want all requests to be authenticated. This is specific to browser users, and S2S communication still works.&lt;/p>
&lt;p>Also, allowlisting the &lt;strong>VPC&amp;rsquo;s own CIDR&lt;/strong> admits in-VPC services only. A public ALB sees a laptop&amp;rsquo;s corporate or VPN &lt;em>egress&lt;/em> IP. That address is nowhere near the VPC range. So a VPC-CIDR rule never admits laptops, even on VPN. If in-VPC calls work and laptops get redirected to a login page, this is why.&lt;/p>
&lt;h3 id="gotcha-header-bypass-can-worsen-an-authentication-bug">Gotcha! Header bypass can worsen an authentication bug&lt;/h3>
&lt;p>We trust browser requests based on the presence of the &lt;code>x-amzn-oidc-identity&lt;/code> header, and that the browser path skips scope checks. That&amp;rsquo;s normally safe, because the ALB&amp;rsquo;s Cognito action &lt;em>overwrites&lt;/em> that header on every request it processes, so a client can&amp;rsquo;t forge it.&lt;/p>
&lt;p>A bypassed request doesn&amp;rsquo;t run that action, so with a header-match rule, anyone on the internet can send:&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">Authorization: Bearer anything
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">x-amzn-oidc-identity: someone@example.com&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The first header triggers the bypass. The second makes your service treat the request as an authenticated browser user, which skips the scope check entirely. The &lt;code>Bearer&lt;/code> value is never validated, because the ALB-header path returns before the token path is reached.&lt;/p>
&lt;p>The mitigations, either of which closes it:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Strip inbound &lt;code>x-amzn-oidc-*&lt;/code> headers on the bypass path&lt;/strong>, so only the ALB can ever set them.&lt;/li>
&lt;li>&lt;strong>Dont trusting identity by presence of the header alone.&lt;/strong> Verify the signed &lt;code>x-amzn-oidc-data&lt;/code> JWT rather than reading &lt;code>x-amzn-oidc-identity&lt;/code> as a plain string.&lt;/li>
&lt;/ul>
&lt;p>The second is the better fix because it removes a whole class of bugs instance of one instance of it.&lt;/p>
&lt;hr>
&lt;h2 id="calling-it-from-anywhere">Calling it from anywhere&lt;/h2>
&lt;p>Something that confused me initially was that &lt;code>curl&lt;/code> requests from the CLI would fail. But this was because we weren&amp;rsquo;t passing in any form of authentication into the request, which made local testing or scenarios like calling a service directly from an EC2 instance, fail. I enabled this functionality with two HTTP steps that are pretty much identical in Python, Java, or a shell script:&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="nv">TOKEN&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="k">$(&lt;/span>curl -s -X POST &lt;span class="s2">&amp;#34;https://&amp;lt;domain&amp;gt;.auth.&amp;lt;region&amp;gt;.amazoncognito.com/oauth2/token&amp;#34;&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> -u &lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="nv">$CLIENT_ID&lt;/span>&lt;span class="s2">:&lt;/span>&lt;span class="nv">$CLIENT_SECRET&lt;/span>&lt;span class="s2">&amp;#34;&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> -d &lt;span class="nv">grant_type&lt;/span>&lt;span class="o">=&lt;/span>client_credentials &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -d &lt;span class="s1">&amp;#39;scope=https://api.example.com/service-a/read&amp;#39;&lt;/span> &lt;span class="p">|&lt;/span> jq -r .access_token&lt;span class="k">)&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">curl &lt;span class="s2">&amp;#34;https://api.example.com/service-a/things&amp;#34;&lt;/span> -H &lt;span class="s2">&amp;#34;Authorization: Bearer &lt;/span>&lt;span class="nv">$TOKEN&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>A Python client is a convenience wrapper around these steps &amp;ndash; it&amp;rsquo;s not the ONLY way to run this authorization flow. Anything that can make an HTTPS request can participate. But we might want to alleviate this by building in a seperate app client for developers and for automation purposes.&lt;/p>
&lt;hr>
&lt;h2 id="wrapping-up">Wrapping up&lt;/h2>
&lt;p>This was a pretty approachable problem, especially since we already had the infrastructure in place. We were already using Cognito to authenticate browser users through the ALB, and the client-credentials flow runs against the same user pool, the same JWKS endpoint, and the same validation code. We just needed one more OAuth flow on the system already in place.&lt;/p></description></item><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><item><title>Building a Schema Registry from Scratch for a Scientific Data Platform</title><link>https://kristianeschenburg.netlify.app/post/schema-registry/</link><pubDate>Tue, 24 Jun 2025 09:00:00 -0700</pubDate><guid>https://kristianeschenburg.netlify.app/post/schema-registry/</guid><description>&lt;p>When I joined Just-Evotec Biologics (first as a data scientist, now as a data platform engineer), I inherited a data ecosystem that probably looked pretty familiar to what lots of others have dealth with: a dozen scientific instruments each outputting data in proprietary formats, a LIMS system with its own schema, upstream and downstream experimental systems, Excel workbooks scientists had been maintaining for years but hidden away from any production storage system, and a swathe of applications that had been developed previously but had very little in the way of data governance. We didn&amp;rsquo;t have a central schema definitions, very little in the way of contracts between producers (e.g. instrumentation) and consumers (e.g. scientists, applications, clients, more instruments), and no way to ask or answer &amp;ldquo;what&amp;rsquo;s being pulled down here?&amp;rdquo;.&lt;/p>
&lt;p>I&amp;rsquo;ve been slowly building up our data platform, including implementing a formal schema registry. Here, I&amp;rsquo;ll cover some core design decisions I made and patterns that worked for me for the registry. The implementation is domain (and in our case, functional group and stakeholder) agnostic.&lt;/p>
&lt;hr>
&lt;h2 id="why-not-just-use-an-existing-registry-tool">Why not just use an existing registry tool?&lt;/h2>
&lt;p>Some alternative solutions to implementing / using a schema registry include Confluent&amp;rsquo;s Schema Registry, AWS Glue, Great Expectations, etc. Confluent&amp;rsquo;s Schema Registry is excellent for Kafka-based event streams, AWS Glue has a data catalog, and Great Expectations handles data quality well (just like Pandera). But none of those really addressed all or most of my needs, or fit in with my environment: a mix of validated DataFrames, LIMS API outputs, Parquet files in S3, and a team of scientists, some of whom wrote Python but weren&amp;rsquo;t data engineers. I wanted the following:&lt;/p>
&lt;ol>
&lt;li>Define schemas as Python classes with static type annotations&lt;/li>
&lt;li>Automatically serialize those classes to human-readable YAML&lt;/li>
&lt;li>Query schemas by metadata (tier, data lineage stage, source system)&lt;/li>
&lt;li>Reconstruct a live Pandera model from a stored YAML definition at runtime&lt;/li>
&lt;/ol>
&lt;p>We&amp;rsquo;re primarily working with data tables (as opposed to events), so I wanted a coupling between a type agnostic representation like YAML and Pandera. However, I&amp;rsquo;ve since extended a lot of this functionality for event-based schemas. At some point, I might want to revisit Glue again so I don&amp;rsquo;t reinvent the wheel.&lt;/p>
&lt;hr>
&lt;h2 id="core-architecture">Core architecture&lt;/h2>
&lt;p>The general schema development flow is: &lt;strong>write Python → export YAML → publish to S3 → consume via the registry client at runtime&lt;/strong>.&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">author a Pandera contract (contracts/domains/**/schema.py)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> → export-models-to-yaml # Python model to canonical YAML
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> → upload-yaml-to-s3 # publish, through the canonical-dtype gate
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> S3 (what consumers actually read)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> → REGISTRY.get_pandera_model(name) # at runtime, in a pipeline
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> → a live Pandera DataFrameModel&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The important property of this diagram is the direction of the arrow into S3. Python is how schemas are &lt;em>authored&lt;/em>, but S3 is what consumers &lt;em>read&lt;/em>. A pipeline never imports a contract class. It fetches YAML and builds a Pandera model from it at runtime, which means a schema change propagates by re-publishing to S3, with no rebuild or redeploy of any consumer. I initially didn&amp;rsquo;t build this in, and found myself needed to re-install the registry every time I authored a new contract. Not ideal.&lt;/p>
&lt;p>The repository is a monorepo of three independently versioned pieces:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Piece&lt;/th>
&lt;th>What it is&lt;/th>
&lt;th>Who uses it&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>the service&lt;/td>
&lt;td>Contract definitions, templates, source-system specs, the FastAPI API, and the export/publish tooling&lt;/td>
&lt;td>Deployed as a Lambda. Not installed as a library.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>the client&lt;/td>
&lt;td>Fetch schemas from S3 and deserialize them to Pandera models, plus the canonical dtype vocabulary&lt;/td>
&lt;td>Pipelines, and anything validating data against a schema (e.g. dashboards)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>the data IO package&lt;/td>
&lt;td>Connectors for the LIMS and for the parquet/delta lakehouse&lt;/td>
&lt;td>Pipelines&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>I started with one package and split it once pipelines started depending on it. A pipeline that just wants to validate a DataFrame should not be installing FastAPI, &lt;code>jsonschema&lt;/code>, and the whole publish toolchain to do it. The client should be thin, and the dependency direction one-directional a.k.a. the service depends on the client.&lt;/p>
&lt;h3 id="repository-layout">Repository layout&lt;/h3>
&lt;p>The code tree looks like this. Names are genericized so as to hide our proprietary namespaces and functionality, but the shape is exactly what we run:&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">schema-registry/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">├── registry/ # the service
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│ ├── contracts/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│ │ ├── templates/ # JSON Schema per schema_type
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│ │ │ ├── dataframe/v1_0/schema.yaml
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│ │ │ ├── event/v1_0/schema.yaml
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│ │ │ └── api/v1_0/schema.yaml
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│ │ ├── domains/ # the contracts themselves
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│ │ │ ├── lab/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│ │ │ │ └── analytics/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│ │ │ │ └── cell_count/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│ │ │ │ ├── v1_0/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│ │ │ │ │ ├── schema.py # authored
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│ │ │ │ │ └── schema.yaml # generated, committed
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│ │ │ │ └── v2_0/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│ │ │ │ ├── schema.py
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│ │ │ │ └── schema.yaml
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│ │ │ ├── operations/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│ │ │ └── enterprise/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│ │ └── utilities/dataframe/export.py # Python model → YAML
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│ ├── integrations/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│ │ └── source_specs/ # contract → physical source table
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│ │ └── lab/analytics/cell_count/v1_0/spec.yaml
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│ ├── api/ # FastAPI app (deployed as a Lambda)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│ │ ├── app.py
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│ │ ├── auth.py
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│ │ └── routers/{domains,schemas,specs}.py
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│ ├── publish/sync_schemas.py # validation gates + S3 upload
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│ └── scripts/ # the two CLI entry points
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">└── packages/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> ├── schema-registry-client/ # what pipelines + dashboards install
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> │ └── schema_registry_client/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> │ ├── contracts/utilities/dataframe/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> │ │ ├── dtypes.py # the canonical vocabulary
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> │ │ ├── mixins.py # SchemaInfo, SchemaMeta, ContractMixin (e.g. constants + fields applied to all schemas)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> │ │ ├── registration.py # the decorator + path checks
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> │ │ └── base.py # base DataFrameModel configs
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> │ └── io/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> │ ├── registry.py # the consumer-facing Registry
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> │ ├── backends/s3.py # index-backed S3 reads
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> │ └── adapters/ # YAML → live Pandera model
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> └── data-io/ # source-system and lakehouse connectors&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>I wanted to be able to seperate the client from the registry and contracts, so I want to emphasize which side of the &lt;code>packages/&lt;/code> line each concern lives on. Everything a &lt;em>consumer&lt;/em> needs, the dtype vocabulary, the identity dataclasses, the deserializer, sits in the client. Everything about &lt;em>producing&lt;/em> the registry, discovery, export, validation gates, upload, and the API, sits in the service. The decorator lives in the client rather than the service, which looks odd at first, but contracts are authored against it and the deserializer needs the same &lt;code>SchemaInfo&lt;/code> shape when it rebuilds a model, so it belongs on the shared side.&lt;/p>
&lt;p>The generated &lt;code>schema.yaml&lt;/code> sitting next to the authored &lt;code>schema.py&lt;/code> and getting committed is intentional. A schema change shows up in code review as a readable diff against the previous commit of the actual YAML contract, not just as a Python class that someone would have to mentally compile.&lt;/p>
&lt;h3 id="adding-a-schema-end-to-end">Adding a schema, end to end&lt;/h3>
&lt;p>The whole process for a contributor is only five steps:&lt;/p>
&lt;ol>
&lt;li>Create &lt;code>contracts/domains/&amp;lt;domain&amp;gt;/&amp;lt;subdomains&amp;gt;/&amp;lt;entity&amp;gt;/v1_0/schema.py&lt;/code> and write the Pandera model, decorated with &lt;code>@register_dataframe_schema&lt;/code>.&lt;/li>
&lt;li>If it maps to a physical source table, add the matching spec under &lt;code>integrations/source_specs/&lt;/code>.&lt;/li>
&lt;li>Run the export command. It walks the tree, checks that the declared name and version match the directory path, and writes &lt;code>schema.yaml&lt;/code> next to each &lt;code>schema.py&lt;/code>.&lt;/li>
&lt;li>Commit both files. CI re-runs the export and fails the build if anything is out of date.&lt;/li>
&lt;li>Run the upload command. It validates every document against its template, runs the canonical dtype gate, uploads to S3, and rewrites the index.&lt;/li>
&lt;/ol>
&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">poetry run export-models-to-yaml &lt;span class="c1"># Python models → canonical schema.yaml&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">poetry run upload-yaml-to-s3 &lt;span class="c1"># validate, publish, reindex&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Consumers of the registry pick the changes up on their next run, without any intermediate rebuilds or redeploys. So the rest of my post is mostly about what happens inside steps 3 and 5.&lt;/p>
&lt;hr>
&lt;h2 id="schema-identity-the-domainsubdomainentity-pattern">Schema identity: the domain/subdomain/entity pattern&lt;/h2>
&lt;p>I was very opinionated and rigid about the &lt;em>structure&lt;/em> of the registry, and not just about how to define the contracts themselves. Every schema gets a unique dotted name composed of domain, zero or more subdomains, and an entity name:&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"> * lab.analytics.cell_count
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> * lab.upstream.bioreactor_run
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> * operations.equipment.instrument
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> * enterprise.finance.purchase_order&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>This naming scheme is enforced structurally. Schemas live at file paths that mirror their names:&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">registry/contracts/domains/lab/analytics/cell_count/v1_0/schema.py&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The &lt;code>@register_dataframe_schema&lt;/code> decorator captures the identity at class definition time:&lt;/p>
&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="nd">@register_dataframe_schema&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">domain&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;lab&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">subdomains&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;analytics&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">entity&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;cell_count&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">version&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;1.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 class="n">description&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;LIMS cell count assay table.&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">meta&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">SchemaMeta&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">tier&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;bronze&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">alignment&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;source&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">source_system&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;lims&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 class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">class&lt;/span> &lt;span class="nc">LIMSCellCountSchema&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">ContractMixin&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">LIMSBaseSchema&lt;/span>&lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">run_row_id&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">str&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">pa&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">Field&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">coerce&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="kc">True&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">sample_id&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">str&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">pa&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">Field&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">vcd&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">Optional&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="nb">float&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">pa&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">Field&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">nullable&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="kc">True&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">coerce&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="kc">True&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">viability&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">Optional&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="nb">float&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">pa&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">Field&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">nullable&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="kc">True&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">coerce&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="kc">True&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">run_date&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">Timestamp&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">pa&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">Field&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">nullable&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="kc">True&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">coerce&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="kc">True&lt;/span>&lt;span class="p">)&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The decorator is thin and only builds a frozen &lt;code>SchemaInfo&lt;/code>, derives the dotted name from its parts, and stamps both onto the class:&lt;/p>
&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="k">def&lt;/span> &lt;span class="nf">_register&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="bp">cls&lt;/span>&lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">info&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">SchemaInfo&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">domain&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">domain&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">subdomains&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">subdomains&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">entity&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">entity&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">version&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">version&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">owner&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">owner&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="n">description&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="bp">cls&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">__schema_info__&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">info&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="bp">cls&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">__schema_meta__&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">meta&lt;/span> &lt;span class="ow">or&lt;/span> &lt;span class="n">SchemaMeta&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="bp">cls&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>SchemaInfo.__post_init__&lt;/code> does a quick n&amp;rsquo; dirty validation by rejecting any domain, subdomain, or entity containing &lt;code>.&lt;/code>, &lt;code>/&lt;/code>, or &lt;code>-&lt;/code>, since those would corrupt the dotted name or the path it maps to, and then joins the parts into &lt;code>name&lt;/code>. The more expensive checks, that the folder version matches the declared version (&lt;code>v1_0&lt;/code> → &lt;code>1.0&lt;/code>) and that the directory path matches the dotted name, run at &lt;strong>export time&lt;/strong>, not at import time:&lt;/p>
&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="k">if&lt;/span> &lt;span class="n">RegistryConfig&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">enforce_domain_name&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">confirm_schema_name_matches_module&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="bp">cls&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="bp">cls&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">name&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">info&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">name&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">if&lt;/span> &lt;span class="n">RegistryConfig&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">enforce_path_version&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">confirm_schema_version_matches_module_version&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="bp">cls&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="bp">cls&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">version&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">info&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">version&lt;/span>&lt;span class="p">)&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Enforcing contract path structure at import time makes the contract classes impossible to define anywhere else, which is a problem the first time a scientist wants to draft a schema in a notebook to see what it looks like. Doing it at export means the rule is enforced on everything that gets published, while drafting stays cheap. There&amp;rsquo;s a context manager for the notebook usecase too:&lt;/p>
&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="k">with&lt;/span> &lt;span class="n">relaxed_registry_checks&lt;/span>&lt;span class="p">():&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nd">@register_dataframe_schema&lt;/span>&lt;span class="p">(&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">class&lt;/span> &lt;span class="nc">DraftSchema&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">ContractMixin&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">BaseDataFrameSchema&lt;/span>&lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="o">...&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>But there is a tradeoff, such a badly-placed schema fails later than it could. In practice &amp;ldquo;later&amp;rdquo; is the export step, which runs in CI on every PR, so nothing badly-placed reaches S3 regardless. Contracts inherit from a base model that sets the validation posture in one place:&lt;/p>
&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="k">class&lt;/span> &lt;span class="nc">BaseDataFrameSchema&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">pa&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">DataFrameModel&lt;/span>&lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">class&lt;/span> &lt;span class="nc">Config&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">strict&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s1">&amp;#39;filter&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">coerce&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="kc">True&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>strict='filter'&lt;/code> drops columns not in the schema rather than raising, which for source-system data is almost always what you want. The LIMS base adds &lt;code>drop_invalid_rows = True&lt;/code> and the row identity columns every LIMS table carries.&lt;/p>
&lt;hr>
&lt;h2 id="versioning-semantic-path-encoded-and-strict">Versioning: semantic, path-encoded, and strict&lt;/h2>
&lt;p>Schema versions follow semantic versioning (&lt;code>1.0&lt;/code>, &lt;code>2.1&lt;/code>, &lt;code>1.0.1&lt;/code>) and are encoded into the directory structure as Python-safe package names:&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">v1_0 → 1.0
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">v2_1 → 2.1
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">v1_0_1 → 1.0.1&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The conversion is bidirectional and validated in both directions. When I need to add a new version of a schema, I create a new directory alongside the old one. Both continue to exist and be served by the registry.&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">cell_count/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> v1_0/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> schema.py
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> schema.yaml ← auto-generated
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> v2_0/ ← new version; v1_0 still works
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> schema.py
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> schema.yaml&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Version resolution supports three modes: a specific version (&lt;code>&amp;quot;1.0&amp;quot;&lt;/code>), &lt;code>&amp;quot;latest&amp;quot;&lt;/code> (resolved by semantic comparison at read time), and &lt;code>None&lt;/code> (all versions). This last mode is useful when you need to understand the full version history of a schema. Resolving &lt;code>latest&lt;/code> uses &lt;code>packaging.version.Version&lt;/code> for comparison rather than string sorting, so &lt;code>10.0&lt;/code> sorts above &lt;code>9.0&lt;/code>.&lt;/p>
&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="k">for&lt;/span> &lt;span class="n">strver&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">vers&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">semver&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">Version&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">strver&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="c1"># don&amp;#39;t allow pre-releases, or dev-releases&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="n">semver&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">pre&lt;/span> &lt;span class="ow">or&lt;/span> &lt;span class="n">semver&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">dev&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">continue&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">parsed&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">append&lt;/span>&lt;span class="p">((&lt;/span>&lt;span class="n">semver&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">strver&lt;/span>&lt;span class="p">))&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>So I can publish &lt;code>2.0.0rc1&lt;/code>, point a pipeline at it explicitly to test, and know that nothing pinned to &lt;code>latest&lt;/code> will pick it up by accident.&lt;/p>
&lt;hr>
&lt;h2 id="export-pipeline-python--yaml">Export pipeline: python → YAML&lt;/h2>
&lt;p>Rather than maintaining YAML files by hand, the build step automatically discovers and converts decorated Pandera models. Discovery works by walking the file tree looking for &lt;code>schema.py&lt;/code> files, dynamically importing them as namespace packages (which avoid collisions between modules at the same relative path), and inspecting each imported module for classes that subclass both &lt;code>pa.DataFrameModel&lt;/code> and &lt;code>ContractMixin&lt;/code> and have a populated &lt;code>__schema_info__&lt;/code>. The code builds a unique dotted module name from the full path:&lt;/p>
&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="k">def&lt;/span> &lt;span class="nf">_path_to_module_name&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">filepath&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">Path&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">package_root&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">Path&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">-&amp;gt;&lt;/span> &lt;span class="nb">str&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">rel&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">filepath&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">resolve&lt;/span>&lt;span class="p">()&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">relative_to&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">package_root&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">resolve&lt;/span>&lt;span class="p">())&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">parts&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">rel&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">with_suffix&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="o">.&lt;/span>&lt;span class="n">parts&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="s1">&amp;#39;.&amp;#39;&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">join&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">parts&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="c1"># → &amp;#34;contracts.domains.lab.analytics.cell_count.v1_0.schema&amp;#34;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Each import gets a unique name in &lt;code>sys.modules&lt;/code> to prevent collisions (ideally we wouldn&amp;rsquo;t be naming schemas the same name, but this approach allows for it). Setting &lt;code>mod.__package__&lt;/code> to the parent of that dotted name keeps relative imports inside the schema file working.&lt;/p>
&lt;p>Pandera fields are serialized into a YAML-friendly dictionary, and checks that can be represented cleanly (e.g. comparisons and membership tests) are serialized. Anything else is skipped with a warning, since they&amp;rsquo;re harder to represent. Not ideal if you &lt;em>do&lt;/em> want some more complicated validation logic, but works for now &amp;ndash; though I think Great Expectations is probably better for these sorts of checks than Pandera is.&lt;/p>
&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="c1"># pa.Check.greater_than(0) → {&amp;#34;gt&amp;#34;: 0}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># pa.Check.isin([&amp;#34;A&amp;#34;, &amp;#34;B&amp;#34;]) → {&amp;#34;isin&amp;#34;: [&amp;#34;A&amp;#34;, &amp;#34;B&amp;#34;]}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># lambda df: df[&amp;#34;x&amp;#34;] &amp;gt; df[&amp;#34;y&amp;#34;] → warning, skipped&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The serializer returns a &lt;em>list&lt;/em> per check rather than a single dict, because some Pandera checks are compound. &lt;code>in_range&lt;/code> has to expand into a &lt;code>ge&lt;/code> entry and an &lt;code>le&lt;/code> entry, since the YAML vocabulary has no range primitive. The resulting YAML is human-readable and versionable:&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">schema&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">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">lab.analytics.cell_count&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">domain&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">lab&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">entity&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">cell_count&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">version&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;1.0&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">owner&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">data-platform&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">schema_type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">dataframe&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">description&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">LIMS cell count assay table.&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">meta&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">tier&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">bronze&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">alignment&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">source&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">source_system&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">LIMS&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">columns&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">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">run_row_id&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">dtype&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">str&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">nullable&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">false&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">coerce&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">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">viability&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">dtype&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">float64&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">nullable&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">coerce&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">checks&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">ge&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">0.0&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">le&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">1.0&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;hr>
&lt;h2 id="canonical-dtype-vocabulary">Canonical dtype vocabulary&lt;/h2>
&lt;p>My initial version of a Python-to-YAML-to-Python round trip wrote out whatever dtype spelling the contract author (a.k.a. me) used. Pandera and pandas and &lt;code>typing&lt;/code> will happily provide &lt;code>float&lt;/code>, &lt;code>float64&lt;/code>, &lt;code>double&lt;/code>, &lt;code>np.float64&lt;/code>, &lt;code>typing.List[float]&lt;/code>, and &lt;code>Timestamp&lt;/code> for what are actually three types. I didn&amp;rsquo;t want consuming services to have to deal with this ambiguity. The registry now defines a closed dtype vocabulary:&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">scalars: str, int32, int64, float32, float64, bool, datetime64[ns]
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">lists: List[&amp;lt;scalar&amp;gt;] (element one of int/float/str/bool)&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Author-side spellings are normalized to that vocabulary in exactly one function, &lt;code>canonicalize_dtype&lt;/code>, called at export time:&lt;/p>
&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="n">_SCALAR_ALIASES&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">Dict&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="nb">str&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">=&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;int&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;int64&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;integer&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;int64&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;float&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;float64&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;double&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;float64&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;str&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;str&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;string&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;str&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;bool&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;bool&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;boolean&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;bool&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;datetime&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;datetime64[ns]&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;timestamp&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;datetime64[ns]&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;date&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;datetime64[ns]&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"># ... plus the canonical spellings mapping to themselves&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;/code>&lt;/pre>&lt;/div>&lt;hr>
&lt;h2 id="publishing-contracts">Publishing contracts&lt;/h2>
&lt;p>The dtype vocabulary is only a contract if something enforces it, so publishing to S3 runs every document through a hard gate first:&lt;/p>
&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="k">def&lt;/span> &lt;span class="nf">assert_canonical_dtypes&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">doc&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">source&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;&amp;lt;doc&amp;gt;&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">-&amp;gt;&lt;/span> &lt;span class="kc">None&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">schema&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="n">doc&lt;/span> &lt;span class="ow">or&lt;/span> &lt;span class="p">{})&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">get&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;schema&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="p">{})&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="n">schema&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">get&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;schema_type&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">!=&lt;/span> &lt;span class="s2">&amp;#34;dataframe&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">return&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">problems&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">for&lt;/span> &lt;span class="n">col&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="n">doc&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">get&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;columns&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="ow">or&lt;/span> &lt;span class="p">[]):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">dtype&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">col&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">get&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;dtype&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">if&lt;/span> &lt;span class="n">dtype&lt;/span> &lt;span class="ow">is&lt;/span> &lt;span class="kc">None&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">continue&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">try&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">canonical&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">canonicalize_dtype&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">dtype&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">except&lt;/span> &lt;span class="n">UnknownDtypeError&lt;/span> &lt;span class="k">as&lt;/span> &lt;span class="n">e&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">problems&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">append&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="sa">f&lt;/span>&lt;span class="s2">&amp;#34;column &lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">col&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">get&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;name&amp;#39;&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="si">!r}&lt;/span>&lt;span class="s2">: &lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">e&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s2">&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">continue&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="ow">not&lt;/span> &lt;span class="n">is_canonical&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">dtype&lt;/span>&lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">problems&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">append&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="sa">f&lt;/span>&lt;span class="s2">&amp;#34;column &lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">col&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">get&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;name&amp;#39;&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="si">!r}&lt;/span>&lt;span class="s2">: dtype &lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">dtype&lt;/span>&lt;span class="si">!r}&lt;/span>&lt;span class="s2"> is not canonical &amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="sa">f&lt;/span>&lt;span class="s2">&amp;#34;(expected &lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">canonical&lt;/span>&lt;span class="si">!r}&lt;/span>&lt;span class="s2">), re-export this schema.&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="k">if&lt;/span> &lt;span class="n">problems&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">raise&lt;/span> &lt;span class="ne">ValueError&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="sa">f&lt;/span>&lt;span class="s2">&amp;#34;Refusing to publish &lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">schema&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">get&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;name&amp;#39;&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="si">!r}&lt;/span>&lt;span class="s2">:&lt;/span>&lt;span class="se">\n&lt;/span>&lt;span class="s2"> - &amp;#34;&lt;/span> &lt;span class="o">+&lt;/span> &lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="se">\n&lt;/span>&lt;span class="s2"> - &amp;#34;&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">join&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">problems&lt;/span>&lt;span class="p">))&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The two rejection cases are doing different jobs. Rejecting an unknown dtype catches a type consumers can&amp;rsquo;t deserialize. Rejecting a dtype that is merely &lt;em>non-canonical&lt;/em> (one that &lt;code>canonicalize_dtype&lt;/code> could fix) catches something else entirely: it means this YAML was not produced by the current exporter. Someone hand-edited it, or it was generated before the normalization step existed. Silently canonicalizing it at publish time would hide that. Refusing forces a re-export, which keeps the YAML in S3 in sync with the Python that claims to define it. The check acts as sort of a staleness detector.&lt;/p>
&lt;hr>
&lt;h2 id="schema-templates-validating-the-schemas-themselves">Schema templates: validating the schemas themselves&lt;/h2>
&lt;p>Separately from dtypes, each schema document is validated against a JSON Schema template selected by its &lt;code>schema_type&lt;/code>:&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">contracts/templates/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> dataframe/v1_0/schema.yaml
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> event/v1_0/schema.yaml
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> api/v1_0/schema.yaml&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>At publish time the templates are themselves checked as valid JSON Schemas with &lt;code>Draft202012Validator.check_schema&lt;/code>, compiled once, and then each contract is validated against the template matching its declared type. A schema with no &lt;code>schema_type&lt;/code>, or one naming a type with no template, is a hard failure, and wont upload.&lt;/p>
&lt;p>This is the layer that lets the registry hold more than DataFrames. &lt;code>dataframe&lt;/code> contracts carry &lt;code>columns&lt;/code>. &lt;code>api&lt;/code> contracts carry &lt;code>request&lt;/code>, &lt;code>response&lt;/code>, and &lt;code>callback&lt;/code> sections. &lt;code>event&lt;/code> contracts carry their own shape. They all live in the same tree, share the same identity and versioning rules, and are all published through the same gate, but each is structurally validated against its own template.&lt;/p>
&lt;hr>
&lt;h2 id="schema-metadata-queryable-semantic-tags">Schema metadata: queryable semantic tags&lt;/h2>
&lt;p>Every schema can carry structured metadata in a &lt;code>SchemaMeta&lt;/code> dataclass:&lt;/p>
&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="nd">@dataclass&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">frozen&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="kc">True&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">class&lt;/span> &lt;span class="nc">SchemaMeta&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">tier&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">Optional&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">Literal&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;bronze&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;silver&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;gold&amp;#34;&lt;/span>&lt;span class="p">]]&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="kc">None&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">alignment&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">Optional&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">Literal&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;source&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;canonical&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;denormalized&amp;#34;&lt;/span>&lt;span class="p">]]&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="kc">None&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">contract_type&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">Optional&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">Literal&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;ingress&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;internal&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;egress&amp;#34;&lt;/span>&lt;span class="p">]]&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="kc">None&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">source_system&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">Optional&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">Union&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="nb">str&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">List&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">=&lt;/span> &lt;span class="kc">None&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">stage&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">Optional&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">=&lt;/span> &lt;span class="kc">None&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>These fields encode the data&amp;rsquo;s position in a medallion-style architecture. Bronze/silver/gold indicate the refinement level. Alignment captures whether data is still in the shape of its source system, has been canonicalized, or has been denormalized for consumption. Contract type captures directionality in the platform: ingress for uploads and instrument files, internal for the middle of pipelines, egress for published dashboards and APIs. This metadata is queryable:&lt;/p>
&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="n">registry&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">Registry&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">s3_bucket&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;my-data-lakehouse&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"># Find all bronze schemas sourced from the LIMS&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">lims_bronze&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">registry&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">find_by_meta&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">tier&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;bronze&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">source_system&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;lims&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"># Find all schemas used as egress contracts&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">egress_schemas&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">registry&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">find_by_meta&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">contract_type&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;egress&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"># Get all tiers currently in use&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">registry&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">get_tiers&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="c1"># → [&amp;#34;bronze&amp;#34;, &amp;#34;gold&amp;#34;, &amp;#34;silver&amp;#34;]&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>This became invaluable for impact analysis. When a source system changed its output, I could immediately identify which schemas were &lt;code>alignment=&amp;quot;source&amp;quot;&lt;/code> and &lt;code>source_system=&amp;quot;lims&amp;quot;&lt;/code> and therefore potentially affected.&lt;/p>
&lt;p>The same identity travels with the data, not just the registry. &lt;code>ContractMixin&lt;/code> can flatten a contract into a string key/value header for stamping onto parquet metadata:&lt;/p>
&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="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;contract.domain&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;lab&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;contract.entity&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;cell_count&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;contract.version&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;1.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 class="o">...&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;/code>&lt;/pre>&lt;/div>&lt;p>So a file on disk can answer which contract and which version produced it, without anyone having to consult a pipeline log.&lt;/p>
&lt;hr>
&lt;h2 id="runtime-registry">Runtime registry&lt;/h2>
&lt;p>The &lt;code>Registry&lt;/code> class wraps an S3 backend and provides the consumer-facing API. It&amp;rsquo;s thin by design. Most logic lives in the export pipeline and the contract definitions themselves.&lt;/p>
&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">from&lt;/span> &lt;span class="nn">schema_registry_client&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">REGISTRY&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"># Load a schema as a live Pandera model&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">Model&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">REGISTRY&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">get_pandera_model&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;lab.analytics.cell_count&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">version&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;latest&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"># Validate a DataFrame against it&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">validated&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">Model&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">validate&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">df&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"># Or get the raw YAML doc&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">doc&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">REGISTRY&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">get_schema&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;lab.analytics.cell_count&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">version&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;1.0&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>get_pandera_model&lt;/code> builds a class dynamically: each YAML column becomes a Pandera &lt;code>Field&lt;/code> plus a type annotation, the check dicts become &lt;code>Field&lt;/code> kwargs, and the schema&amp;rsquo;s config overrides are applied on top of the base model&amp;rsquo;s config. Because the dtype strings are guaranteed canonical, the annotation lookup is a single flat dict with seven entries and no fallbacks.&lt;/p>
&lt;p>The backend reads an index CSV written at upload time rather than doing live &lt;code>list_objects&lt;/code> calls:&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">contracts/index/schema_index.csv # name, key, version, date, schema_type
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">integrations/index/spec_index.csv&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Listing names, resolving versions, and enumerating domains are all filters over that dataframe. It loads once and stays cached on the backend instance. This keeps latency predictable and avoids a per-request S3 API call every time something enumerates the registry. The cost is that the index is a build artifact like everything else: it&amp;rsquo;s rewritten on every publish, so it&amp;rsquo;s accurate as of the last upload and no fresher.&lt;/p>
&lt;p>Alongside the contracts, the registry stores integration specs that map a contract to its physical source table: schema and table name, a &lt;code>field_to_source&lt;/code> column mapping, and the created/modified columns used for date partitioning. That&amp;rsquo;s what lets a pipeline say &amp;ldquo;load this contract for this date&amp;rdquo; without knowing anything about the source system&amp;rsquo;s table layout. It&amp;rsquo;s also the seam where a source system renaming a column becomes a spec change rather than a pipeline change.&lt;/p>
&lt;hr>
&lt;h2 id="api">API&lt;/h2>
&lt;p>The registry is also served over HTTP by a FastAPI app deployed as an AWS Lambda function, with Cognito authentication applied as a global dependency and a small set of public paths for health and docs.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Method&lt;/th>
&lt;th>Path&lt;/th>
&lt;th>Description&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>GET&lt;/code>&lt;/td>
&lt;td>&lt;code>/domains/&lt;/code>&lt;/td>
&lt;td>List all domains&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>GET&lt;/code>&lt;/td>
&lt;td>&lt;code>/schemas/&lt;/code>&lt;/td>
&lt;td>List schema names (&lt;code>?domain=&lt;/code>, &lt;code>?version=&lt;/code>)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>GET&lt;/code>&lt;/td>
&lt;td>&lt;code>/schemas/{name}&lt;/code>&lt;/td>
&lt;td>A schema document (&lt;code>?version=latest&lt;/code>)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>GET&lt;/code>&lt;/td>
&lt;td>&lt;code>/schemas/{name}/versions&lt;/code>&lt;/td>
&lt;td>List versions for a schema&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>GET&lt;/code>&lt;/td>
&lt;td>&lt;code>/schemas/{name}/diff&lt;/code>&lt;/td>
&lt;td>Diff two versions (&lt;code>?from=&amp;amp;to=&lt;/code>)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>POST&lt;/code>&lt;/td>
&lt;td>&lt;code>/schemas/{name}/validate&lt;/code>&lt;/td>
&lt;td>Validate a payload against a schema section&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The &lt;code>diff&lt;/code> endpoint answers &amp;ldquo;what actually changed between 1.0 and 2.0&amp;rdquo;, which is helpful if something starts breaking after publishing schemas. The &lt;code>validate&lt;/code> endpoint lets a service check a payload against an &lt;code>api&lt;/code>-type contract over HTTP, without installing the client or writing Python at all. It refuses if you point it at a &lt;code>dataframe&lt;/code> schema, since there&amp;rsquo;s no meaningful section to validate against.&lt;/p>
&lt;p>Mostly, though, the API exists so that the registry is browsable by people who aren&amp;rsquo;t going to &lt;code>pip install&lt;/code> anything. Being able to send a scientist a URL that shows exactly what a table contains has done more for adoption than any amount of documentation. I ended up building a nice dashboard that displayed datatable-specific contracts as tables, and let&amp;rsquo;s users browse by domain, subdomain(s), entity, and version. It&amp;rsquo;s much easier to look at than a YAML file or Pandera class.&lt;/p>
&lt;hr>
&lt;h2 id="closing-toughts">Closing toughts&lt;/h2>
&lt;p>A schema registry is about making implicit contracts explicit. Before building this, the &amp;ldquo;contract&amp;rdquo; between a pipeline and its consumer (e.g. dashboards, APIs) was whatever the pipeline happened to output on any given run. Afterwards, it was a versioned, machine-readable document that both sides could validate against independently. None of this is domain-specific, and these same patterns would work for any environment where you need versioned, queryable, code-first schema definitions that can be serialized and distributed. I&amp;rsquo;ve incorporated contracts for everything from scientific instruments, to finance, to ERP domains in our registry.&lt;/p>
&lt;p>I underestimated how much work the &lt;em>enforcement&lt;/em> would be, as opposed to the contract definitions themselves. Defining schemas as Python classes is easy, and scientists who are somewhat Python-saavy can do this easily as well. Making it impossible to publish a schema that consumers can&amp;rsquo;t read, or to end up with two spellings of the same type, or to quietly delete something a pipeline still depends on, took a lot longer, and is what makes the system robust. For our small team in a domain with complex, heterogeneous data, implementing this registry and schema enforcement has been a huge boon.&lt;/p>
&lt;hr>
&lt;p>&lt;em>I work on data platform infrastructure at a biologics company. The patterns in this post are generalized from production code, with domain-specific details abstracted away. The core framework concepts are shareable.&lt;/em>&lt;/p></description></item></channel></rss>