<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: AWS Community Builders </title>
    <description>The latest articles on DEV Community by AWS Community Builders  (aws-builders).</description>
    <link>https://dev.to/aws-builders</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Forganization%2Fprofile_image%2F2794%2F88da75b6-aadd-4ea1-8083-ae2dfca8be94.png</url>
      <title>DEV Community: AWS Community Builders </title>
      <link>https://dev.to/aws-builders</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/aws-builders"/>
    <language>en</language>
    <item>
      <title>Workflow en Strands: un DAG de tareas ejecutado como herramienta única</title>
      <dc:creator>Laura Bolaños</dc:creator>
      <pubDate>Wed, 19 Aug 2026 21:31:31 +0000</pubDate>
      <link>https://dev.to/aws-builders/workflow-en-strands-un-dag-de-tareas-ejecutado-como-herramienta-unica-2a43</link>
      <guid>https://dev.to/aws-builders/workflow-en-strands-un-dag-de-tareas-ejecutado-como-herramienta-unica-2a43</guid>
      <description>&lt;p&gt;En estas notas te explico cómo orquestar agentes con la arquitectura Workflow de Strands Agents utilizando paralelismo y join points, ejemplificado con la construcción de un entregable para preparar y gestionar reuniones como Solutions Architect. Testado de dos maneras: con Gemma4 + Ollama (local) y con la API de Gemini.&lt;/p&gt;

&lt;h2&gt;
  
  
  Introducción
&lt;/h2&gt;

&lt;p&gt;La idea principal es experimentar con el patrón de orquestación workflow sin gastar en suscripciones pagas de tokens, dejando el deploy en AWS para otra instancia. Durante el desarrollo y testing me encontré con algunos inconvenientes de features no implementadas y descubrí proveedores de modelos soportados al día de hoy. &lt;/p&gt;

&lt;p&gt;🗒 &lt;strong&gt;El caso de uso&lt;/strong&gt; que elegí para representar el patrón de orquestación &lt;em&gt;&lt;strong&gt;Workflow&lt;/strong&gt;&lt;/em&gt; es un copiloto para Solutions Architects que prepara y cierra discovery meetings con clientes: investiga la empresa, mapea a los stakeholders, genera preguntas de discovery fundamentadas en ese contexto, resume la reunión una vez ocurrida, y sintetiza todo en un entregable de seguimiento (email + próximos pasos + gaps a resolver).&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;La idea del diseño se sacó del &lt;a href="https://partyrock.aws/u/jfescott/CkWsGSARh/Prepare-and-Manage-meeting-as-a-Solution-Architect" rel="noopener noreferrer"&gt;playground de PartyRock&lt;/a&gt; de Jeff Escott&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;En las secciones siguientes cubro los conceptos claves del patrón, el diseño e implementación del caso de uso, los hallazgos reales de ejecución con ambos modelos, y las evaluaciones con &lt;code&gt;strands-agents-evals&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conceptos Claves
&lt;/h2&gt;

&lt;h3&gt;
  
  
  ➡ Workflow de Agentes es un patrón de orquestación coordinado en una secuencia definida.
&lt;/h3&gt;

&lt;p&gt;Cada Agente realiza una serie de tareas definidas. El desarrollador puede descomponer tareas complejas en componentes manejables y distribuirlos entre agentes especializados. Cada workflow tiene un control explícito del &lt;strong&gt;orden de ejecución de las tareas&lt;/strong&gt;, &lt;strong&gt;las dependencias&lt;/strong&gt; y  &lt;strong&gt;el flujo de información entre tareas&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Workflow es apropiado:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Cuando se tiene un proceso complejo pero repetible que se desea encapsular en una herramienta única, confiable y reutilizable, un &lt;code&gt;Workflow&lt;/code&gt;es un grafo de tareas definido por el desarrollador que un agente puede ejecutar como una acción única y potente.&lt;/li&gt;
&lt;li&gt;Si tenemos complejidad en los procesos con múltiples pasos, resolución de tareas con distintas etapas secuenciales.&lt;/li&gt;
&lt;li&gt;Procesos que requieren subagentes especializados en cada etapa o un seguimiento detallado en cada paso&lt;/li&gt;
&lt;li&gt;Ejecutar tareas independientes en paralelo, mientras hay gestión de independencias. De esta manera optimizamos recursos.&lt;/li&gt;
&lt;li&gt;Si tenemos que &lt;strong&gt;reintentar pasos por error&lt;/strong&gt; tareas especificas sin reiniciar todo el flujo.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Para clarificar las diferencias entre patrones de orquestación, te muestro una comparativa entre workflow, graph y swarm. La mayor diferencia a considerar es &lt;strong&gt;cómo se determina la ruta de ejecución&lt;/strong&gt; :&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Workflow&lt;/th&gt;
&lt;th&gt;Graph&lt;/th&gt;
&lt;th&gt;Swarm&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Concepto fundamental&lt;/td&gt;
&lt;td&gt;Un grafo de tareas predefinido (DAG) ejecutado como una herramienta única y no conversacional.&lt;/td&gt;
&lt;td&gt;Un flowchart estructurado y definido por el desarrollador, donde un agente decide qué camino tomar.&lt;/td&gt;
&lt;td&gt;Un equipo dinámico y colaborativo de agentes que se pasan las tareas de forma autónoma.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Estructura&lt;/td&gt;
&lt;td&gt;Un desarrollador define todas las tareas y sus dependencias en el código.&lt;/td&gt;
&lt;td&gt;Un desarrollador define todos los nodos (agentes) y aristas (transiciones) con antelación.&lt;/td&gt;
&lt;td&gt;Un desarrollador proporciona un pool de agentes. Los propios agentes deciden el camino.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Flujo de ejecución&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Determinista y paralelo&lt;/strong&gt;. El flujo está determinado por el grafo de dependencias. Las tareas independientes se ejecutan en paralelo.&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Controlado pero dinámico&lt;/strong&gt;. El flujo sigue los bordes del grafo, pero la decisión de un LLM en cada nodo determina el camino (path).&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Secuencial y autónomo&lt;/strong&gt;. Un agente ejecuta una tarea y luego cede el control a otro agente adecuado en el enjambre.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;¿Permite ciclo?&lt;/td&gt;
&lt;td&gt;NO.&lt;/td&gt;
&lt;td&gt;SÍ.&lt;/td&gt;
&lt;td&gt;SÍ.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mecanismos de compartir estados&lt;/td&gt;
&lt;td&gt;La herramienta captura automáticamente los outputs de las tareas y los pasa como inputs a las tareas dependientes.&lt;/td&gt;
&lt;td&gt;Se pasa un objeto de estado compartido a todos los agentes, quienes pueden leerlo y modificarlo libremente.&lt;/td&gt;
&lt;td&gt;Todos los agentes disponen de un contexto compartido o memoria de trabajo, que contiene la solicitud original, el historial de tareas y el conocimiento de los agentes anteriores.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Historial de conversaciones&lt;/td&gt;
&lt;td&gt;Contexto específico de la tarea. Una tarea recibe un resumen curado de los resultados relevantes de sus dependencias, no el historial completo.&lt;/td&gt;
&lt;td&gt;Transcripción completa. Todo el historial de diálogo forma parte del estado compartido, lo que proporciona a cada agente un contexto completo y abierto.&lt;/td&gt;
&lt;td&gt;Transcripción compartida. El contexto compartido proporciona un historial completo de los handoffs de agentes y el conocimiento aportado por agentes anteriores, disponible para el agente actual.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Control del comportamiento&lt;/td&gt;
&lt;td&gt;La solicitud del usuario puede activar un workflow predefinido, pero no puede alterar su estructura interna.&lt;/td&gt;
&lt;td&gt;La información que introduce el usuario en cada paso puede influir directamente en la ruta que seguirá el grafo a continuación.&lt;/td&gt;
&lt;td&gt;La indicación inicial del usuario define el objetivo, pero a partir de ahí el Swarm corre de forma autónoma.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Escalabilidad&lt;/td&gt;
&lt;td&gt;Escala bien en operaciones repetibles y complejas.&lt;/td&gt;
&lt;td&gt;Escala bien en procesos complejos(muchas ramificaciones, condiciones).&lt;/td&gt;
&lt;td&gt;Escala en función del número de agentes especializados en el equipo y de la complejidad de la tarea colaborativa.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Manejo de errores&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Sistémico&lt;/strong&gt;. Un fallo en una tarea detendrá todas las tareas dependientes posteriores. Es probable que todo el workflow entre en estado &lt;code&gt;failed&lt;/code&gt;.&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Controlable&lt;/strong&gt;. Un desarrollador puede definir aristas de "error" explícitas para dirigir el flow a un nodo de error-handling si falla un paso.&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Agent-driven&lt;/strong&gt;. Un agente puede decidir transferir la tarea a un especialista en manejo de errores. El sistema se basa en tiempos de espera y límites de transferencia para evitar bucles infinitos.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  ➡ Conceptos genéricos de Workflow agénticos
&lt;/h3&gt;

&lt;p&gt;Para la implementación del caso de uso, solo usé la &lt;code&gt;workflow tool&lt;/code&gt;, una de las ~45 tools nativas disponibles en &lt;code&gt;strands-agents-tools&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Componentes claves en la arquitectura:&lt;/p&gt;

&lt;p&gt;⭐&lt;strong&gt;Definición y distribución de tareas.&lt;/strong&gt; Cada tarea necesita una descripción especifica de qué debe lograr el agente, una asignación que haga  matching con el agente adecuado según sus capacidades, y un nivel de prioridad que determine qué se ejecuta primero cuando hay margen de elección.&lt;/p&gt;

&lt;p&gt;⭐&lt;strong&gt;Gestión de dependencias.&lt;/strong&gt; Algunas tareas deben ejecutarse en un orden específico (dependencias secuenciales), mientras que las tareas independientes pueden correr en simultáneo (ejecución paralela). En la ejecución paralela tener en cuenta que el modelo de IA que se utilice debe permitirlo.&lt;br&gt;
Los &lt;em&gt;join points&lt;/em&gt; son los puntos donde varios caminos paralelos convergen antes de continuar. En el caso de uso elegido, los dos join points son &lt;code&gt;discovery_questions&lt;/code&gt; (espera a &lt;code&gt;company_research&lt;/code&gt; + &lt;code&gt;stakeholder_mapping&lt;/code&gt;) y &lt;code&gt;follow_up_actions&lt;/code&gt; (espera a &lt;code&gt;discovery_questions&lt;/code&gt; + &lt;code&gt;meeting_summary&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;⭐&lt;strong&gt;Flujo de información.&lt;/strong&gt; El mapeo de input/output conecta la salida de un agente con la entrada de otro, la preservación de contexto mantiene la información relevante a lo largo de todo el workflow, y la gestión de estado permite trackear el progreso general del workflow.&lt;/p&gt;

&lt;p&gt;Parámetros básicos del workflow:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;action&lt;/code&gt; → create, start, status (indica si hubo error o no), list, delete.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;workflow_id&lt;/code&gt; → identificador único del workflow.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;tasks&lt;/code&gt; → lista de tareas con sus correspondientes propiedades&lt;/p&gt;

&lt;p&gt;Algunas propiedades dentro de cada task:&lt;br&gt;
&lt;code&gt;task_id&lt;/code&gt; : Obligatorio. Identificador o nombre de la tarea.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;description&lt;/code&gt;: Obligatorio. se completa con un resumen de lo que hace la tarea, es un pequeño prompt.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;dependencies&lt;/code&gt;: Opcional. Encadenas una tarea con la otra para que no se ejecute si las dependencias aún no culminaron.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;system_prompt&lt;/code&gt;:  Obligatorio.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;priority&lt;/code&gt;: Propiedad numérica que indica prioridad en la ejecución por el modelo.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;tools&lt;/code&gt;: Opcional. Pero si no lo especificás puede que en la inferencia el modelo decida usar cualquier tool nativa y entre en un loop infinito. &lt;br&gt;
Si no querés que la tarea ejecute ninguna tool se puede especificar cómo &lt;code&gt;"tools": NO_TOOLS&lt;/code&gt;, esto no es un valor oficial del SDK, es un workaround verificado empíricamente: se pasa un nombre de tool que no existe en el registry, forzando al sub-agente a correr sin tools. Pasar &lt;code&gt;"tools": []&lt;/code&gt; no funciona porque es falsy en Python y el workflow lo ignora heredando todo el toolset del agente padre.&lt;/p&gt;
&lt;h3&gt;
  
  
  ➡ Topologías de Secuencias y Workflow
&lt;/h3&gt;

&lt;p&gt;En Strands Agents, "Workflow" puede referirse a dos cosas distintas: una arquitectura secuencial que se implementa manualmente encadenando agentes en código Python, o la &lt;em&gt;Workflow Tool&lt;/em&gt; (&lt;code&gt;strands-agents-tools&lt;/code&gt;), que gestiona dependencias, paralelismo y pasaje de contexto automáticamente. &lt;/p&gt;
&lt;h4&gt;
  
  
  Workflow Secuencial
&lt;/h4&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyrsp016dpxtgsavpi0au.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyrsp016dpxtgsavpi0au.jpg" alt="Workflow-secuencial" width="800" height="162"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;En la topología secuencial, cada agente es un objeto Python independiente y el desarrollador conecta las salidas manualmente. Es la forma más simple de encadenar agentes, pero no hay gestión automática de dependencias ni paralelismo.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;strands&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Agent&lt;/span&gt;
&lt;span class="c1"&gt;# Create specialized agents
&lt;/span&gt;&lt;span class="n"&gt;agent1&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Agent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;system_prompt&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;You are Agent 1. Perform the first step.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;callback_handler&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;agent2&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Agent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;system_prompt&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;You are Agent 2. Perform the second step.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;callback_handler&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;agent3&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Agent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;system_prompt&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;You are Agent 3. Perform the final step.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# Sequential workflow processing
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;process_workflow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;input_data&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;step1_result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;agent1&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Process: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;input_data&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;step2_result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;agent2&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Process: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;step1_result&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;final_result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;agent3&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Process: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;step2_result&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;final_result&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h4&gt;
  
  
  Workflow Tool
&lt;/h4&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpmzes5l1g4p3qcra6i1s.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpmzes5l1g4p3qcra6i1s.jpg" alt="Workflow-tool" width="800" height="385"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;La &lt;em&gt;Workflow Tool&lt;/em&gt; gestiona dependencias, paralelismo y pasaje de contexto automáticamente. El desarrollador define la estructura (tasks + dependencies) y la tool se encarga del resto: qué corre en paralelo, qué espera, y qué contexto inyecta a cada tarea dependiente.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;strands&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Agent&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;strands_tools&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;workflow&lt;/span&gt;

&lt;span class="c1"&gt;# Create an agent with workflow capability
&lt;/span&gt;&lt;span class="n"&gt;agent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Agent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tools&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;workflow&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;

&lt;span class="c1"&gt;# Create a multi-agent workflow
&lt;/span&gt;&lt;span class="n"&gt;agent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;workflow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;create&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;workflow_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;generic_workflow&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;tasks&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;task_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;task1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;description&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Description of the first task&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;system_prompt&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;You perform the first task.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;priority&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;task_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;task2&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;description&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Description of the second task&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;dependencies&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;task1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;system_prompt&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;You perform the second task.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;priority&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;task_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;task3&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;description&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Description of the third task&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;dependencies&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;task2&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;system_prompt&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;You perform the third task.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;priority&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# Execute workflow (parallel processing where possible)
&lt;/span&gt;&lt;span class="n"&gt;agent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;workflow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;start&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;workflow_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;generic_workflow&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;# Check results
&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;agent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;workflow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;workflow_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;generic_workflow&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  ➡ Evaluaciones (Evals)
&lt;/h3&gt;

&lt;p&gt;Strands cuenta con una &lt;a href="https://strandsagents.com/docs/user-guide/evals-sdk/quickstart/" rel="noopener noreferrer"&gt;marco oficial&lt;/a&gt; para realizar evaluaciones de resultados simples hasta el análisis de interacciones de multiagentes complejos. &lt;/p&gt;

&lt;p&gt;Para evaluar el workflow usé &lt;code&gt;strands-agents-evals&lt;/code&gt; con el mismo provider configurado en &lt;code&gt;.env&lt;/code&gt; (Gemini u Ollama) como juez. En producción la práctica estándar es usar un modelo más capaz como judge (ej. el workflow corre con &lt;code&gt;gemini-2.0-flash&lt;/code&gt; y el judge usa &lt;code&gt;gemini-2.5-pro&lt;/code&gt;), pero para este proyecto educativo se unifica por simplicidad.&lt;/p&gt;

&lt;p&gt;Combiné dos tipos de evaluación:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;LLM-as-a-judge&lt;/strong&gt; — el modelo evalúa la calidad del output final:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Evaluador&lt;/th&gt;
&lt;th&gt;¿Qué evalúa?&lt;/th&gt;
&lt;th&gt;Escala&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;OutputEvaluator&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Verifica que &lt;code&gt;follow_up_actions&lt;/code&gt; tenga las 3 secciones obligatorias (email, next steps, gaps) y que el contenido esté fundamentado en los datos reales de la reunión.&lt;/td&gt;
&lt;td&gt;0 / 0.5 / 1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;HelpfulnessEvaluator&lt;/code&gt;(1)&lt;/td&gt;
&lt;td&gt;Evalúa si el entregable final es útil para un Solutions Architect — no solo si tiene el formato correcto. (Es una instancia de &lt;em&gt;OutputEvaluator&lt;/em&gt; con rubric propio, no una clase separada del SDK!)&lt;/td&gt;
&lt;td&gt;0 / 0.5 / 1&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;(1)&lt;code&gt;HelpfulnessEvaluator&lt;/code&gt; del SDK requiere trazas OpenTelemetry que la Workflow Tool no provee. Se reemplazó por un segundo &lt;code&gt;OutputEvaluator&lt;/code&gt; con rubric de helpfulness.&lt;br&gt;
Cuando usé &lt;code&gt;HelpfulnessEvaluator&lt;/code&gt; del SDK, el score devolvió 0.0 con el error: "Trace parsing requires actual_trajectory to be a Session object, got NoneType". El evaluador espera un objeto Session de Strands (que debe contener trazas OpenTelemetry de la ejecución del agente), no solo el texto del output.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Métricas deterministas&lt;/strong&gt; — evalúan propiedades estructurales del patrón, sin llamadas a LLM:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Métrica&lt;/th&gt;
&lt;th&gt;¿Qué mide?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;task_completion_rate&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Fracción de tasks que completaron con &lt;code&gt;status="completed"&lt;/code&gt; en el JSON persistido.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;join_point_coverage&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Verifica que ambos join points produjeron output no vacío, confirma que la inyección de contexto funcionó.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;required_sections&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Check determinista de las 3 secciones del entregable (email, next steps, gaps).&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;stakeholder_coverage&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Verifica que cada stakeholder del input aparece en &lt;code&gt;discovery_questions&lt;/code&gt;, confirma que el contexto de &lt;code&gt;stakeholder_mapping&lt;/code&gt; llegó al join point #1.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;keyword_presence&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Presencia de keywords de dominio (con soporte de sinónimos via `\&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;
&lt;h2&gt;
  
  
  Descripción de la solución
&lt;/h2&gt;

&lt;p&gt;📦 &lt;strong&gt;Repositorio GitHub:&lt;/strong&gt;  &lt;a href="https://github.com/reinalau/strands-workflow" rel="noopener noreferrer"&gt;github.com/reinalau/strands-workflow&lt;/a&gt; &lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmb9dsyquvbfspszs1fat.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmb9dsyquvbfspszs1fat.jpg" alt="Workflow copiloto para Solutions Architects" width="800" height="254"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Para la prueba de concepto utilicé dos opciones locales de ejecución que permitió analizar sus resultados, pero se puede utilizar solo una:&lt;br&gt;
a. Ollama + el pequeño modelo &lt;strong&gt;gemma4:e2b-it-qat&lt;/strong&gt; (que pesa poco mas de 4gb) ejecutando en Docker. &lt;br&gt;
En caso que quieras ejecutar realmente las task que son independientes en paralelo, hay que indicarselo a ollama (sino el comportamiento por default es secuencial) :&lt;br&gt;
{% raw %}&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Start the Ollama server with a persistent volume&lt;/span&gt;
docker run &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;--name&lt;/span&gt; ollama &lt;span class="nt"&gt;-p&lt;/span&gt; 11434:11434 &lt;span class="nt"&gt;-v&lt;/span&gt; ollama_data:/root/.ollama ollama/ollama

&lt;span class="c"&gt;# Start the Ollama server en parallel&lt;/span&gt;
&lt;span class="c"&gt;# docker run -d --name ollama -p 11434:11434 -v ollama_data:/root/.ollama -e OLLAMA_NUM_PARALLEL=3  ollama/ollama&lt;/span&gt;

&lt;span class="c"&gt;# Download the model&lt;/span&gt;
docker &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-it&lt;/span&gt; ollama ollama pull gemma4:e2b-it-qat

&lt;span class="c"&gt;# Test that the model responds&lt;/span&gt;
&lt;span class="c"&gt;# If the container has already been created and &lt;/span&gt;
&lt;span class="c"&gt;# is currently stopped, simply use: docker start ollama&lt;/span&gt;
docker &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-it&lt;/span&gt; ollama ollama run gemma4:e2b-it-qat

&lt;span class="c"&gt;# Verify that the model is running&lt;/span&gt;
docker &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-it&lt;/span&gt; ollama ollama ps
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;b. Api de gemini. La api key se puede generar de manera gratuita desde &lt;a href="https://aistudio.google.com/" rel="noopener noreferrer"&gt;aquí&lt;/a&gt; y utilizar algunos de estos modelos (experimentar con los que te permita):&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;gemini-2.5-flash-lite
gemini-2.5-flash
gemini-3.5-flash
gemini-3.5-flash-lite
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;
&lt;p&gt;&lt;strong&gt;El código está en Python&lt;/strong&gt; y la estructura del proyecto se diseñó de manera que sea más explicativo su lógica, todo el detalle lo encontrás en el &lt;code&gt;Readme.md&lt;/code&gt; del proyecto:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;strands-workflow/
├── README.md
├── requirements.txt                  # strands-agents[litellm,ollama], strands-agents-tools, pydantic, pytest
├── .env.example                      # MODEL_PROVIDER, GEMINI_API_KEY, OLLAMA_MODEL_ID
├── .gitignore
├── pytest.ini                        # pythonpath=. so tests/ and evals/ resolve src./config imports
│
├── src/
│   ├── __init__.py
│   ├── config.py                     # get_model_config(): single global provider (Gemini or Ollama) via MODEL_PROVIDER env var
│   ├── main.py                       # Entry point: builds tasks, creates+starts the workflow, assembles the final .md
│   │
│   ├── agents/
│   │   ├── __init__.py
│   │   └── prompts.py                # System prompts per task_id (company_research, stakeholder_mapping, etc.)
│   │
│   ├── workflow/
│   │   ├── __init__.py
│   │   ├── builder.py                # build_tasks(): builds the task list — task_id, dependencies, priority
│   │   ├── deliverable.py            # Reads the persisted workflow JSON and assembles the final .md
│   │   └── instrumentation.py        # Timing hook: logs START/END + duration per task
│   │
│   ├── models/
│   │   └── schemas.py                # Pydantic: DiscoveryMeetingInput
│   │
│   └── utils/
│       ├── logging_config.py         # Tees stdout+stderr to logs/, optional DEBUG level
│       └── report_export.py          # Writes the final .md to outputs/
│
├── tests/
│   ├── __init__.py
│   ├── test_config.py                # get_model_config(): provider resolution + error if API key is missing
│   ├── test_workflow_structure.py    # Validates the workflow is well-formed: no cycles, existing dependencies, correct join points
│   ├── test_schemas.py               # Pydantic validation of DiscoveryMeetingInput
│   ├── test_report_export.py         # Generates the .md from a mocked workflow JSON
│   └── test_instrumentation.py       # Verifies install_task_timing_hooks() logs START/END with duration, without calling a real LLM
│
├── examples/
│   └── sample_input.json             # Sample input to run the workflow quickly
│
├── evals/
│   ├── __init__.py
│   ├── evaluate_workflow.py          # strands-evals runner: OutputEvaluator + HelpfulnessEvaluator + deterministic metrics
│   ├── metrics.py                    # Domain metrics: task completion rate, join point coverage, required sections, stakeholder coverage, keyword presence
│   └── test_cases/
│       └── workflow_eval_cases.json  # Input + expected properties per eval case
│
├── logs/
│   └── .gitkeep                      # logs/run_&amp;lt;timestamp&amp;gt;.log, gitignored otherwise
│
└── outputs/
    └── .gitkeep                      # .md generated per run, gitignored otherwise

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h3&gt;
  
  
  Ejecución Local
&lt;/h3&gt;

&lt;p&gt;Una vez que tengas el repositorio del código fuente clonado y el docker de ollama con el modelo gemma 4 o la api key de gemini generada, pasamos armar el ambiente.&lt;/p&gt;

&lt;p&gt;Los requerimientos que vas a encontrar en  &lt;code&gt;requirements.txt&lt;/code&gt; :&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;strands-agents[litellm,ollama]==1.52.0
strands-agents-tools==0.8.6
ollama==0.5.1
pydantic==2.13.4
pytest==8.3.4
strands-agents-evals&amp;gt;=1.0.0
python-dotenv==1.0.1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; requirements.txt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;En &lt;code&gt;.env&lt;/code&gt; se necesitan estos valores. Notar que depende el modelo que elegimos podemos intercambiar el &lt;code&gt;MODEL_PROVIDER&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# Select ONE provider: "ollama" or "gemini"
MODEL_PROVIDER=ollama

# --- Gemini (only required if MODEL_PROVIDER=gemini) ---
# Free tier key: https://aistudio.google.com/apikey
GEMINI_API_KEY=YourApiKey
GEMINI_MODEL_ID=gemini-3.5-flash-lite

# --- Ollama (only used if MODEL_PROVIDER=ollama) ---
# Must have the Ollama daemon running locally: https://ollama.com
# Pull the model first: ollama pull gemma4:e2b-it-qat
OLLAMA_MODEL_ID=gemma4:e2b-it-qat
OLLAMA_HOST=http://localhost:11434

# --- Workflow persistence ---
# Redirects strands_tools' internal workflow state JSON into logs/ instead of
# the default ~/.strands/workflows/ — keeps everything from one run together.
STRANDS_WORKFLOW_DIR=logs/workflows
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cp&lt;/span&gt; .env.example .env
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;En &lt;code&gt;src/config.py&lt;/code&gt;&lt;/strong&gt; está la configuración de ejecución, en este caso de uso utilicé parámetros como la &lt;code&gt;temperature&lt;/code&gt; y  &lt;code&gt;max_tokens&lt;/code&gt; (limita los tokens que pueden gastar los agentes). &lt;br&gt;
→ Si optas  por ejecutar con el provider &lt;em&gt;ollama&lt;/em&gt; hay que tener en cuenta el valor de la ventana de contexto: &lt;code&gt;num_ctx&lt;/code&gt;.&lt;br&gt;
→ Si optas por ejecutar con la apikey de Gemini, en el SDK de Strands, el dispatcher &lt;code&gt;create_model&lt;/code&gt; que usa &lt;code&gt;workflow&lt;/code&gt;internamente soporta estos modelos solo vía &lt;strong&gt;litellm&lt;/strong&gt; (&lt;code&gt;"model_provider": "litellm"&lt;/code&gt;)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Las pruebas&lt;/strong&gt; están organizadas en &lt;code&gt;tests/&lt;/code&gt; en &lt;strong&gt;un solo nivel: ejecución rápida y determinista&lt;/strong&gt;. Validan la estructura del workflow (sin ciclos, dependencias existentes, join points correctos), el schema del input con Pydantic, la exportación del &lt;code&gt;.md&lt;/code&gt; desde un JSON mockeado, y los hooks de timing (sin llamadas al LLM).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;python -m pytest
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Ejecución del Workflow principal.&lt;/strong&gt; En &lt;code&gt;examples/sample_input.txt&lt;/code&gt;está el input de ejemplo en un formato json:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="nl"&gt;"company_name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="nl"&gt;"known_challenges"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="nl"&gt;"stakeholders"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="nl"&gt;"raw_meeting_notes"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"..."&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Se puede ejecutar contra el modelo ollama local o vía api de gemini comentado más arriba.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python &lt;span class="nt"&gt;-m&lt;/span&gt; src.main
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;🎯 &lt;strong&gt;Un bug real&lt;/strong&gt;: durante las pruebas descubrí que usando Gemini con paralelismo real puede hacer crashear el proceso de forma intermitente (y no es un problema de Windows, ocurre también en Linux). La causa es un bug conocido y abierto en LiteLLM: el cliente HTTP que usa para el streaming de Gemini es un singleton global que no distingue entre los distintos &lt;em&gt;event loops&lt;/em&gt; que crea cada tarea del workflow al correr en paralelo. &lt;br&gt;
&lt;strong&gt;Solución aplicada:&lt;/strong&gt; limitar la ejecución a un hilo por vez cuando el proveedor es Gemini, vía la env var &lt;code&gt;STRANDS_WORKFLOW_MAX_THREADS=1&lt;/code&gt; de Strands. &lt;/p&gt;

&lt;p&gt;&lt;strong&gt;El log&lt;/strong&gt; de las llamadas del workflow está en &lt;code&gt;logs/&lt;/code&gt;. Contiene la data cruda que permite verificar si hubo paralelismo, cuanto tiempo tarda cada task, etc.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;El resultado final&lt;/strong&gt; &lt;br&gt;
Es un  documento markdown con diferentes secciones como:&lt;br&gt;
Company Research - Stakeholder Mapping - Meeting Summary - Discovery Questions - Follow-up Deliverable and Gap&lt;br&gt;
 Se guarda en &lt;code&gt;outputs/discovery_meeting_yyyymmdd_hhmmss.md&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Por último,  hice una evaluación de pruebas sobre el workflow multiagente real con el concepto de &lt;em&gt;"Evals".&lt;/em&gt;&lt;/strong&gt; Ejecuta el workflow completo contra el modelo configurado, corre los evaluadores LLM-as-judge y las métricas deterministas, muestra los resultados en consola y guarda el reporte completo en &lt;code&gt;outputs/eval_report_&amp;lt;timestamp&amp;gt;.json&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python  &lt;span class="nt"&gt;-m&lt;/span&gt;  evals.evaluate_workflow
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;📝 &lt;strong&gt;Nota:&lt;/strong&gt; La explicación del código fuente y paso a paso de la ejecución se encuentra detallado en el &lt;code&gt;README.md&lt;/code&gt; en  &lt;a href="https://github.com/reinalau/strands-workflow" rel="noopener noreferrer"&gt;github&lt;/a&gt; .&lt;/p&gt;




&lt;h2&gt;
  
  
  Conclusiones y lecciones aprendidas
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;El patrón Workflow en Strands resuelve en lineas generales lo que indica su documentación.&lt;/strong&gt; Dependency resolution, paralelismo real y context passing automático entre join points funcionaron correctamente y sin código extra de orquestación. El DAG se ejecuta en el orden correcto y los join points reciben el contexto inyectado de sus dependencias sin intervención manual.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;La Workflow Tool de Strands está en maduración activa.&lt;/strong&gt; &lt;code&gt;pause&lt;/code&gt;/&lt;code&gt;resume&lt;/code&gt; están documentados como &lt;em&gt;Advanced Features&lt;/em&gt; pero no implementados — al ejecutarlos devuelven &lt;code&gt;"🚧 Action '...' is not yet implemented"&lt;/code&gt;, verificado contra el código fuente instalado. El workflow puede quedar colgado indefinidamente si una task termina en &lt;code&gt;error&lt;/code&gt; (el loop interno gira para siempre esperando dependientes que nunca se vuelven "ready"). El resultado de cada task no se expone via API... hay que leer el JSON persistido directamente. &lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Algunos comportamientos requieren workarounds no oficiales.&lt;/strong&gt; La herencia de tools en sub-agentes no está documentada. Por defecto cada sub-agente hereda todo el toolset del padre, incluida la &lt;code&gt;workflow&lt;/code&gt; tool, lo que con modelos agénticos lleva a llamadas recursivas reales. &lt;code&gt;"tools": []&lt;/code&gt; (lista vacía) no funciona como restricción porque es falsy en Python — la solución es &lt;code&gt;NO_TOOLS&lt;/code&gt; (&lt;code&gt;["__no_tools__"]&lt;/code&gt;), un placeholder que fuerza &lt;code&gt;filtered_tools&lt;/code&gt; a quedar vacío. No encontré hooks públicos para observar timing por task, se requiere monkeypatch sobre &lt;code&gt;WorkflowManager.execute_task&lt;/code&gt;.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Los hooks de Strands tienen un mecanismo &lt;code&gt;LimitToolCounts&lt;/code&gt;&lt;/strong&gt; (via &lt;code&gt;BeforeToolCallEvent&lt;/code&gt;) que permite cancelar una tool cuando se invoca más veces de lo permitido, útil como guardrail contra loops. Sin embargo, no aplica al bug de herencia de la Workflow Tool, ese mecanismo actúa sobre el agente padre, pero los sub-agentes creados internamente por el framework son instancias separadas sobre las que no hay control para registrar hooks desde afuera. Por eso &lt;code&gt;"tools": NO_TOOLS&lt;/code&gt; sigue siendo la solución correcta.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Concurrencia del workflow tool con Gemini.&lt;/strong&gt; Cuando hay tasks en paralelo + LiteLLM/aiohttp, se produce un &lt;code&gt;RuntimeError: loop is not the running loop&lt;/code&gt; intermitente. Cada task corre en su propio thread con su propio &lt;code&gt;asyncio.run()&lt;/code&gt;, y el cierre del connector de aiohttp cae en el loop equivocado. Se solucionó colocando una variables de entorno de Strands &lt;code&gt;STRANDS_WORKFLOW_MAX_THREADS=1&lt;/code&gt;, limitando la concurrencia. (esto no sucede con Ollama-Gemma4 via Docker).&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Recursos
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Curso Fundamentos (hacelos!)&lt;/strong&gt;&lt;br&gt;
&lt;a href="https://skillbuilder.aws/learn/CFBFZNS11J/building-ai-agent-harnesses-with-strands-agents/D6YG8S6N7M" rel="noopener noreferrer"&gt;Building AI Agent Harnesses with Strands Agents&lt;/a&gt;&lt;br&gt;
&lt;a href="https://builder.aws.com/content/3GYPrAplMhl2IAl7jJW0yeKzXrR/building-ai-agent-harnesses-with-strands-agents-a-free-14-video-course/" rel="noopener noreferrer"&gt;Building AI Agent Harnesses – Video Course&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Documentación Strands Agents&lt;/strong&gt;&lt;br&gt;
&lt;a href="https://strandsagents.com/docs/user-guide/concepts/multi-agent/workflow/" rel="noopener noreferrer"&gt;Agent Workflows&lt;/a&gt;&lt;br&gt;
&lt;a href="https://strandsagents.com/docs/user-guide/evals-sdk/evaluators/" rel="noopener noreferrer"&gt;Evals&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Otros&lt;/strong&gt;&lt;br&gt;
&lt;a href="https://builder.aws.com/content/3Ecodd9aFqsG1SAkQKmqZ2KxPiP/5-multi-agent-patterns-in-strands-agents-which-one-and-when" rel="noopener noreferrer"&gt;5 Multi-Agent Patterns in Strands&lt;/a&gt;&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ai</category>
      <category>strandagents</category>
      <category>python</category>
      <category>agents</category>
    </item>
    <item>
      <title>SnapStart for Python Lambda</title>
      <dc:creator>Sabarish Sathasivan</dc:creator>
      <pubDate>Wed, 19 Aug 2026 18:17:49 +0000</pubDate>
      <link>https://dev.to/aws-builders/snapstart-for-python-lambda-ogo</link>
      <guid>https://dev.to/aws-builders/snapstart-for-python-lambda-ogo</guid>
      <description>&lt;p&gt;Recently we built an analytical API on Lambda using Python 3.12, FastAPI and DuckDB to query Parquet files in S3. The functionality worked fine, but we ran into cold start issues, mainly from initializing DuckDB and FastAPI at startup. This article explains how we used Lambda SnapStart to reduce that cold start, and what we learned from the experience.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why DuckDB?
&lt;/h2&gt;

&lt;p&gt;DuckDB is an open-source, in-process analytical (OLAP) database engine. The simplest mental model is: it is SQLite, but optimized for analytics.&lt;/p&gt;

&lt;p&gt;Because it runs in-process inside the Lambda, there is no database server or cluster to manage, and no database credentials to handle, as S3 is read through the Lambda execution role. It also queries Parquet directly, including Parquet objects in S3:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;
    &lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;request_count&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;median&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;response_time_ms&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;median_ms&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;quantile_cont&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;response_time_ms&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;95&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;p95_ms&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;read_parquet&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s1"&gt;'s3://example-bucket/website-requests/*.parquet'&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What is Lambda SnapStart?
&lt;/h2&gt;

&lt;p&gt;Every time Lambda spins up a new environment, it runs your initialization code again. SnapStart changes when that happens: the init runs when you publish a version. Lambda takes a snapshot of the environment after it is initialized, encrypts it, and caches it. From then on, Lambda restores new environments from that snapshot instead of starting from scratch.&lt;/p&gt;

&lt;p&gt;Here is what that looks like on a cold request:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Without SnapStart
  start the runtime
  -&amp;gt; run the initialization code
  -&amp;gt; handle the request

With SnapStart
  restore the snapshot
  -&amp;gt; handle the request
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;SnapStart is currently available on three runtimes, but it is not free everywhere:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Runtime&lt;/th&gt;
&lt;th&gt;Extra SnapStart charge&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Java 11 and later&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Python 3.12 and later&lt;/td&gt;
&lt;td&gt;Snapshot caching and restoration&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;.NET 8 and later&lt;/td&gt;
&lt;td&gt;Snapshot caching and restoration&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Sample project
&lt;/h2&gt;

&lt;p&gt;Let's try to explain SnapStart with a website-performance API. This API queries the performance of a website, with the data stored in S3. Built with FastAPI, it takes a date range and an optional page, then uses DuckDB to scan the Parquet files for request count, median and p95 response time, error count and error rate, overall and per page.&lt;/p&gt;

&lt;p&gt;The complete source is available in the &lt;a href="https://github.com/ssathasivan/python_lambda_with_snapshot_support" rel="noopener noreferrer"&gt;python_lambda_with_snapshot_support&lt;/a&gt; repository.&lt;/p&gt;

&lt;p&gt;The CDK project in the repository deploys the following resources:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1x20l1ntp1c3a6q0g2wm.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1x20l1ntp1c3a6q0g2wm.png" alt="Architecture of the two-function SnapStart comparison" width="800" height="299"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Implementation
&lt;/h2&gt;

&lt;p&gt;The CDK stack deploys two Lambda functions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;web-perf-without-snapstart&lt;/code&gt; runs &lt;code&gt;app.lambda_handler&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;web-perf-with-snapstart&lt;/code&gt; runs &lt;code&gt;app_snapstart.lambda_handler&lt;/code&gt; and has SnapStart enabled&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Everything else is identical: Python 3.12, x86_64, 1024 MB, a 30 second timeout, and the same ZIP.&lt;/p&gt;

&lt;p&gt;The shared code sits in &lt;code&gt;src/app.py&lt;/code&gt;, which holds the DuckDB query class, the FastAPI app and the Mangum handler. &lt;code&gt;src/app_snapstart.py&lt;/code&gt; is a thin wrapper that imports it, registers the after-restore hook and re-exports the handler.&lt;/p&gt;

&lt;p&gt;The sections below cover the code changes SnapStart needed.&lt;/p&gt;

&lt;h3&gt;
  
  
  Keep expensive initialization at module level
&lt;/h3&gt;

&lt;p&gt;The DuckDB connection, the FastAPI app and the route registration all happen at module level in &lt;code&gt;src/app.py&lt;/code&gt;, so &lt;code&gt;web-perf-with-snapstart&lt;/code&gt; captures them in its snapshot.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;analytics&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;DuckDBWebsiteAnalytics&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;dataset_uri&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getenv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;WEBSITE_DATASET_URI&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;data/website-requests/*.parquet&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;aws_region&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getenv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;AWS_REGION&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getenv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;AWS_DEFAULT_REGION&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;FastAPI&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Website performance analytics API&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;version&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2.0.0&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;docs_url&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;redoc_url&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Refreshing S3 Connection
&lt;/h3&gt;

&lt;p&gt;DuckDB creates its S3 secret during init, so whatever credentials it resolved are part of the snapshot. One snapshot can then be restored many times over several hours.&lt;/p&gt;

&lt;p&gt;So &lt;code&gt;src/app_snapstart.py&lt;/code&gt; recreates that secret after every restore:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;SnapStart entry point.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;

&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;snapshot_restore_py&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;register_after_restore&lt;/span&gt;

&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;app&lt;/span&gt;


&lt;span class="nd"&gt;@register_after_restore&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;refresh_credentials_after_restore&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;analytics&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;refresh_s3_credentials&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;


&lt;span class="n"&gt;lambda_handler&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;lambda_handler&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Most functions don't need a hook. When SnapStart is on, the runtime switches to container credentials rather than the access-key environment variables, specifically so they don't expire before a restore, and SDK connections usually resume on their own. We rebuild the secret anyway: it's one cheap call, and AWS advises refreshing ephemeral data like temporary credentials in the handler even without SnapStart.&lt;/p&gt;

&lt;p&gt;Keep hooks quick. Runtime load and all after-restore hooks share a 10-second budget before &lt;code&gt;SnapStartTimeoutException&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Wiring it up in CDK
&lt;/h3&gt;

&lt;p&gt;SnapStart is a function-level setting, and it applies to every version published from it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;with_snapstart&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;lambda_&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Function&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="c1"&gt;# identical configuration omitted
&lt;/span&gt;    &lt;span class="n"&gt;snap_start&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;lambda_&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SnapStartConf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ON_PUBLISHED_VERSIONS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;with_snapstart_alias&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;lambda_&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Alias&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;WithSnapStartLiveAlias&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;alias_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;live&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;version&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;with_snapstart&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;current_version&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Ensure that API Gateway points to the alias. Pointing it at the function's unqualified ARN invokes &lt;code&gt;$LATEST&lt;/code&gt;, which cannot use SnapStart, so cold starts stay slow.&lt;/p&gt;

&lt;h2&gt;
  
  
  Performance
&lt;/h2&gt;

&lt;p&gt;Both functions ran with the same configuration:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Memory: 1024 MB&lt;/li&gt;
&lt;li&gt;Region: us-east-1&lt;/li&gt;
&lt;li&gt;Architecture: x86_64&lt;/li&gt;
&lt;li&gt;Package type: ZIP&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Test strategy: we built a harness that forces cold starts by publishing a fresh version, then firing 30 concurrent requests at each API. That gives 30 cold starts per function per endpoint, confirmed by &lt;code&gt;Init Duration&lt;/code&gt; in the &lt;code&gt;REPORT&lt;/code&gt; lines of the function without SnapStart, and &lt;code&gt;Restore Duration&lt;/code&gt; in the &lt;code&gt;REPORT&lt;/code&gt; lines of the SnapStart function. A SnapStart function's &lt;code&gt;REPORT&lt;/code&gt; has no &lt;code&gt;Init Duration&lt;/code&gt; field at all, because it initialized at publish time; that value lands in a separate &lt;code&gt;INIT_REPORT&lt;/code&gt; record.&lt;/p&gt;

&lt;p&gt;Client-side through API Gateway:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Endpoint&lt;/th&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Without SnapStart&lt;/th&gt;
&lt;th&gt;With SnapStart&lt;/th&gt;
&lt;th&gt;Improvement&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/health&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;median&lt;/td&gt;
&lt;td&gt;5478.9 ms&lt;/td&gt;
&lt;td&gt;1189.0 ms&lt;/td&gt;
&lt;td&gt;78.3%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/health&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;p95&lt;/td&gt;
&lt;td&gt;6136.9 ms&lt;/td&gt;
&lt;td&gt;1349.6 ms&lt;/td&gt;
&lt;td&gt;78.0%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/analytics/website&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;median&lt;/td&gt;
&lt;td&gt;7589.8 ms&lt;/td&gt;
&lt;td&gt;4057.7 ms&lt;/td&gt;
&lt;td&gt;46.5%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/analytics/website&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;p95&lt;/td&gt;
&lt;td&gt;8381.3 ms&lt;/td&gt;
&lt;td&gt;4217.9 ms&lt;/td&gt;
&lt;td&gt;49.7%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Improvement is &lt;code&gt;(without - with) / without * 100&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Approximate monthly cost
&lt;/h2&gt;

&lt;p&gt;The following cost is calculated for a SnapStart-enabled Lambda running a single published version in us-east-1 at 1024 MB. Lambda bills per GB-second, so 1024 MB appears as 1 GB in the calculations.&lt;/p&gt;

&lt;p&gt;Assumptions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;100,000 requests a month&lt;/li&gt;
&lt;li&gt;500 ms average billed duration&lt;/li&gt;
&lt;li&gt;the version is active all month&lt;/li&gt;
&lt;li&gt;1% of invocations restore a snapshot&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Component&lt;/th&gt;
&lt;th&gt;Calculation&lt;/th&gt;
&lt;th&gt;Monthly cost&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Requests&lt;/td&gt;
&lt;td&gt;100,000 ÷ 1,000,000 × $0.20&lt;/td&gt;
&lt;td&gt;$0.02&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Duration&lt;/td&gt;
&lt;td&gt;100,000 × 0.5 s × 1 GB × $0.0000166667&lt;/td&gt;
&lt;td&gt;$0.83&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SnapStart cache&lt;/td&gt;
&lt;td&gt;2,592,000 s (30 days) × 1 GB × $0.0000015046&lt;/td&gt;
&lt;td&gt;$3.90&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SnapStart restores&lt;/td&gt;
&lt;td&gt;1,000 × 1 GB × $0.0001397998&lt;/td&gt;
&lt;td&gt;$0.14&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$4.89&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The cache is the largest item, and it is billed on memory and uptime rather than traffic. Restores scale with how often Lambda creates environments, which invocation count alone cannot tell you:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Restore rate&lt;/th&gt;
&lt;th&gt;Restores&lt;/th&gt;
&lt;th&gt;Restore cost&lt;/th&gt;
&lt;th&gt;Total&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1%&lt;/td&gt;
&lt;td&gt;1,000&lt;/td&gt;
&lt;td&gt;$0.14&lt;/td&gt;
&lt;td&gt;$4.89&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;10%&lt;/td&gt;
&lt;td&gt;10,000&lt;/td&gt;
&lt;td&gt;$1.40&lt;/td&gt;
&lt;td&gt;$6.15&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;td&gt;100,000&lt;/td&gt;
&lt;td&gt;$13.98&lt;/td&gt;
&lt;td&gt;$18.73&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Gotchas
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Every published version keeps its own cached snapshot, billed for at least three hours and for as long as the version exists. Delete the ones you don't need.&lt;/li&gt;
&lt;li&gt;Lambda re-runs your init code when it patches snapshots, and bills you for it, so "runs once at publish" is not quite true.&lt;/li&gt;
&lt;li&gt;Enabling SnapStart isn't enough. If API Gateway hits the unqualified ARN it gets &lt;code&gt;$LATEST&lt;/code&gt; and no SnapStart.&lt;/li&gt;
&lt;li&gt;Don't trust a connection opened during init.&lt;/li&gt;
&lt;li&gt;One snapshot seeds many environments, so anything unique from init gets duplicated. Generate per-request values in the request.&lt;/li&gt;
&lt;li&gt;Publishing is slower. The version sits in &lt;code&gt;Pending&lt;/code&gt; until the snapshot is built, so wait for &lt;code&gt;Active&lt;/code&gt; before moving the alias.&lt;/li&gt;
&lt;li&gt;SnapStart can't be combined with provisioned concurrency. They solve the same problem in different ways, so pick one.&lt;/li&gt;
&lt;li&gt;No Amazon EFS, no Amazon S3 Files, and no ephemeral storage above 512 MB. The last one matters for DuckDB, since large joins and sorts spill to &lt;code&gt;/tmp&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;SnapStart brought our initialization down from seconds to under a second, which is a real gain on a cold request. It does nothing for query time, and the snapshot cache costs money whether the function is called or not.&lt;/p&gt;

&lt;p&gt;Worth trying, but measure it on your own workload before you commit to it.&lt;/p&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/lambda/latest/dg/snapstart.html" rel="noopener noreferrer"&gt;AWS Lambda SnapStart&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://aws.amazon.com/blogs/aws/aws-lambda-snapstart-for-python-and-net-functions-is-now-generally-available/" rel="noopener noreferrer"&gt;Lambda SnapStart for Python and .NET is now generally available&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/lambda/latest/dg/snapstart-activate.html" rel="noopener noreferrer"&gt;Activating and managing SnapStart&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/lambda/latest/dg/snapstart-best-practices.html" rel="noopener noreferrer"&gt;Maximizing SnapStart performance&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/lambda/latest/dg/snapstart-runtime-hooks-python.html" rel="noopener noreferrer"&gt;Python SnapStart runtime hooks&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/lambda/latest/dg/snapstart-monitoring.html" rel="noopener noreferrer"&gt;Monitoring for Lambda SnapStart&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://aws.amazon.com/lambda/pricing/" rel="noopener noreferrer"&gt;AWS Lambda pricing&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://duckdb.org/why_duckdb" rel="noopener noreferrer"&gt;Why DuckDB&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://duckdb.org/docs/current/data/parquet/overview" rel="noopener noreferrer"&gt;DuckDB Parquet overview&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://duckdb.org/docs/current/core_extensions/httpfs/s3api" rel="noopener noreferrer"&gt;DuckDB S3 API support&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>aws</category>
      <category>lambda</category>
      <category>python</category>
      <category>serverless</category>
    </item>
    <item>
      <title>Zero-Downtime Releases with Docker Compose on One EC2 Instance</title>
      <dc:creator>Yusuf Adeyemo</dc:creator>
      <pubDate>Wed, 19 Aug 2026 17:11:13 +0000</pubDate>
      <link>https://dev.to/aws-builders/zero-downtime-releases-with-docker-compose-on-one-ec2-instance-1i69</link>
      <guid>https://dev.to/aws-builders/zero-downtime-releases-with-docker-compose-on-one-ec2-instance-1i69</guid>
      <description>&lt;p&gt;Imagine this scenario. You have one EC2 instance. It runs seven containers: a web application, two background workers, a database, a cache, a RADIUS server, and a reverse proxy. You push a change. Your pipeline connects to the instance and runs this command:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;--build&lt;/span&gt; backend

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Docker builds the image. Docker then stops the old container and starts the new one. The build takes 90 seconds. The application takes 20 more seconds to open its database connections. For almost two minutes, your users get a 502 error.&lt;/p&gt;

&lt;p&gt;For Our &lt;a href="https://wifipadi.com" rel="noopener noreferrer"&gt;WiFi Hotsopt platform&lt;/a&gt;, those two minutes have a direct cost. A customer at a hotel front desk pays for a data plan on a phone. If the API is down at that moment, the payment provider sends a webhook to a server that cannot answer it. The customer pays, and the customer gets no internet access.&lt;/p&gt;

&lt;p&gt;The usual advice is to move to Amazon ECS or Amazon EKS. That advice is correct at a certain size. It was not correct for me. This article shows the method I use instead: a blue/green release on one EC2 instance, with Docker Compose, a container health check, and an atomic proxy reload.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I did not move to ECS
&lt;/h2&gt;

&lt;p&gt;I want to be honest about this decision, because the cost is not only money.&lt;/p&gt;

&lt;p&gt;A move to ECS adds an Application Load Balancer, an ECR repository, task definitions, and a service definition. It also adds a new failure surface that I must learn before my first outage, not during it. My platform serves one region and a small number of tenants. One &lt;code&gt;t3&lt;/code&gt; instance holds the full stack, and it has spare capacity.&lt;/p&gt;

&lt;p&gt;There is also a technical constraint. My gateways dial into a WireGuard hub. That hub must run on the host, because it needs kernel and network interface access. A FreeRADIUS container answers UDP on the same box, over that tunnel. A move to Fargate would divide this stack between two networks with no advantage.&lt;/p&gt;

&lt;p&gt;So the question became simple. Can one EC2 instance do a zero-downtime release? It can. The pattern is older than the container orchestrators, and the orchestrators did not invent it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The four parts of the design
&lt;/h2&gt;

&lt;p&gt;A blue/green release needs four things. Each one maps to a small piece of code.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Two identical slots.&lt;/strong&gt; Only one slot takes traffic at a time.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;A health check.&lt;/strong&gt; The new slot must prove that it works.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;A switch.&lt;/strong&gt; One action moves all traffic from the old slot to the new slot.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;A record of the active slot.&lt;/strong&gt; The next release must know where to go.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Here is the flow:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  release starts
       │
       ├─ build the image, tag it as the idle slot (green)
       ├─ run the database migration one time
       ├─ start green, and wait for its health check
       │ └─ green is unhealthy? stop green, keep blue live, exit
       ├─ tell the proxy to send traffic to green ← the switch
       ├─ write "green" to the active-slot file
       ├─ wait 30 seconds for in-flight requests
       └─ stop blue

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note the order. The proxy switches only after the health check passes. Every failure before the switch leaves the old version live.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two slots with Compose profiles
&lt;/h2&gt;

&lt;p&gt;Docker Compose supports YAML anchors and profiles. An anchor removes the duplication between the two slots. A profile keeps the idle slot out of a normal &lt;code&gt;docker compose up&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;x-backend-common&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nl"&gt;&amp;amp;backend-common&lt;/span&gt;
  &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
  &lt;span class="na"&gt;env_file&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;.env.prod&lt;/span&gt;
  &lt;span class="na"&gt;stop_grace_period&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;45s&lt;/span&gt;
  &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;postgres&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;condition&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;service_healthy&lt;/span&gt;
  &lt;span class="na"&gt;healthcheck&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;test&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CMD"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;python"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-c"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt;
      &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;import&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;urllib.request;&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;urllib.request.urlopen('http://127.0.0.1:8000/internal/health/ready',&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;timeout=2)"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;3s&lt;/span&gt;
    &lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;3s&lt;/span&gt;
    &lt;span class="na"&gt;retries&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;20&lt;/span&gt;
    &lt;span class="na"&gt;start_period&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;60s&lt;/span&gt;

&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;backend_blue&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;*backend-common&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;app-backend-blue&lt;/span&gt;
    &lt;span class="na"&gt;profiles&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;deployment"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;

  &lt;span class="na"&gt;backend_green&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;*backend-common&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;app-backend-green&lt;/span&gt;
    &lt;span class="na"&gt;profiles&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;deployment"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two settings in this block prevent common release errors.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;start_period: 60s&lt;/code&gt; gives the container time to start before Docker counts a failed check. Without it, a slow start looks like a broken release.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;stop_grace_period: 45s&lt;/code&gt; sets the time between the graceful stop signal and the forced stop signal. My web server has a 30-second graceful timeout. The grace period must be longer than that timeout. If it is shorter, Docker stops a process by force during a request.&lt;/p&gt;

&lt;h2&gt;
  
  
  The health check must query the database
&lt;/h2&gt;

&lt;p&gt;A health check that only proves that the process is alive is not sufficient. My readiness endpoint makes one query:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nd"&gt;@require_GET&lt;/span&gt;
&lt;span class="nd"&gt;@never_cache&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;ready&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;The process is ready for traffic only when its database is reachable.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;connection&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cursor&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;cursor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;cursor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SELECT 1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;cursor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetchone&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;Exception&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;JsonResponse&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;unavailable&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;503&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;JsonResponse&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ok&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This endpoint answers the only question the release script asks: can this container serve a real request? A liveness probe cannot answer that question. I keep both endpoints, and I use only the readiness endpoint for the cutover.&lt;/p&gt;

&lt;h2&gt;
  
  
  Migrations are a release action, not a start command
&lt;/h2&gt;

&lt;p&gt;Many teams put the database migration in the container start command. Do not do this in a blue/green release.&lt;/p&gt;

&lt;p&gt;During the release, the old version and the new version are both live. If the new container changes the schema as it starts, the old container can fail immediately. The migration must run one time, as its own step:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose run &lt;span class="nt"&gt;--rm&lt;/span&gt; &lt;span class="nt"&gt;--no-deps&lt;/span&gt; backend_green python manage.py migrate &lt;span class="nt"&gt;--noinput&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This rule has a consequence. Every schema change must use the expand/contract method. First you add the new column, and you keep the old column. You release the code that writes to both. Only in a later release do you remove the old column. The old version must continue to operate against the new schema for the length of the cutover.&lt;/p&gt;

&lt;h2&gt;
  
  
  The switch
&lt;/h2&gt;

&lt;p&gt;My reverse proxy is Caddy, in a container. The configuration file names the upstream by its Compose service name. A template holds the name &lt;code&gt;backend:8000&lt;/code&gt;, and one command writes the file for the correct slot:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sed&lt;/span&gt; &lt;span class="s2"&gt;"s/backend:8000/&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;upstream&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:8000/g"&lt;/span&gt; caddy/Caddyfile.template &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; caddy/Caddyfile.next

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The next step is the part I recommend most. The script does not send the new file to the live proxy. It first validates the file in a disposable container:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose run &lt;span class="nt"&gt;--rm&lt;/span&gt; &lt;span class="nt"&gt;--no-deps&lt;/span&gt; caddy &lt;span class="se"&gt;\&lt;/span&gt;
  caddy adapt &lt;span class="nt"&gt;--config&lt;/span&gt; /etc/caddy/Caddyfile.next &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--adapter&lt;/span&gt; caddyfile &lt;span class="nt"&gt;--pretty&lt;/span&gt; &lt;span class="nt"&gt;--validate&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; caddy/Caddyfile.next.json

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A bad configuration file now fails in a container that serves no traffic. Only a valid file becomes the live file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;mv&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; caddy/Caddyfile.next caddy/Caddyfile
docker compose &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-T&lt;/span&gt; caddy caddy reload &lt;span class="nt"&gt;--config&lt;/span&gt; /etc/caddy/Caddyfile.json &lt;span class="nt"&gt;--force&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;mv&lt;/code&gt; on the same filesystem is atomic. No process can read a file that is half written. The proxy reload is also atomic: the proxy loads the new configuration into memory, and it applies the change in one action. If the load fails, the previous configuration continues to serve traffic. The script then puts the previous file back on disk, so a later restart cannot start the rejected configuration.&lt;/p&gt;

&lt;h2&gt;
  
  
  The failure paths
&lt;/h2&gt;

&lt;p&gt;A release with no error is the easy part. These are the failures that the script must also handle.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The new slot never becomes healthy.&lt;/strong&gt; An error trap stops the new slot and prints its last 200 log lines. The old slot never stopped, so there is no outage.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The proxy rejects the new configuration.&lt;/strong&gt; The reload fails, the old configuration stays live, and the script exits. The trap then stops the new slot.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The disk write of the active-slot file fails.&lt;/strong&gt; This one is easy to miss. At this point the proxy already sends traffic to the new slot. If the script exits here, the trap stops the slot that now serves all traffic. So the script switches the proxy back to the old slot first:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'%s\n'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$target&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; .backend-active.next &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;mv&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; .backend-active.next .backend-active&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt;
  ./scripts/reload-caddy.sh &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$active&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true
  exit &lt;/span&gt;1
&lt;span class="k"&gt;fi&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Requests arrive during the switch.&lt;/strong&gt; The proxy accepts a request one microsecond before the reload. The old container must answer it. This is why the script waits 30 seconds before it stops the old slot.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this method does not give you
&lt;/h2&gt;

&lt;p&gt;I must be clear about the limits of this method. Do not use it in a system that has different requirements.&lt;/p&gt;

&lt;p&gt;This method gives you zero-downtime releases. It does not give you high availability. The instance is one point of failure. A hardware event, a full disk, or a kernel panic stops everything.&lt;/p&gt;

&lt;p&gt;It does not give you horizontal scale. When one instance is not sufficient, you need a load balancer and more than one host. At that point the orchestrator is worth its complexity, and ECS is the shorter path.&lt;/p&gt;

&lt;p&gt;This method also keeps state on the instance. My database runs in a container with a volume, so my backup procedure, not my instance, is my recovery plan.&lt;/p&gt;

&lt;p&gt;Use this method when one instance holds your load, and your releases still cause downtime. Move to ECS when one instance is no longer sufficient. Do not move earlier only because the release procedure is the problem. The release procedure has a smaller answer.&lt;/p&gt;

</description>
      <category>aws</category>
      <category>containers</category>
      <category>docker</category>
      <category>ec2</category>
    </item>
    <item>
      <title>From Panic to Peace: Mastering Zero-Downtime ECS Deployments</title>
      <dc:creator>N Chandra Prakash Reddy</dc:creator>
      <pubDate>Wed, 19 Aug 2026 16:28:37 +0000</pubDate>
      <link>https://dev.to/aws-builders/from-panic-to-peace-mastering-zero-downtime-ecs-deployments-1ddb</link>
      <guid>https://dev.to/aws-builders/from-panic-to-peace-mastering-zero-downtime-ecs-deployments-1ddb</guid>
      <description>&lt;p&gt;Friday afternoon. The code is merged, the pipeline is green and the release is ready. But instead of pressing the deploy button and heading out for the weekend, your team is haggling who needs to stick around "just in case" everything catches fire.&lt;/p&gt;

&lt;p&gt;Let’s be real. Deploying new code to production is like placing a bet on the roulette table.&lt;/p&gt;

&lt;p&gt;You start the app, watch the server logs like a hawk, and hope the customer service channel is silent. If an alert is triggered, there is panic. You hustle to determine what broke, patch it live, or execute a nasty manual rollback while users are down.&lt;/p&gt;

&lt;p&gt;Does this sound familiar? This is a stressful routine and is very typical in software engineering. But it doesn’t have to be this way. Modern traffic-shifting tactics on Amazon Elastic Container Service (ECS) can turn stressful releases into repetitive, automated processes.&lt;/p&gt;

&lt;p&gt;Now you can take control of your deployments with Blue-Green and Canary tactics.&lt;/p&gt;

&lt;h1&gt;
  
  
  &lt;strong&gt;The Vicious Cycle of Deployment Dread&lt;/strong&gt;
&lt;/h1&gt;

&lt;p&gt;Here is the point … when delivering software is hard, teams naturally want to do it less often.&lt;/p&gt;

&lt;p&gt;If releasing code takes downtime, late night coordination and significant risk, you start batching your changes. You don't deploy a single minor feature on a Tuesday, you combine three weeks of updates into one enormous weekend release.&lt;/p&gt;

&lt;p&gt;And that’s a huge problem. Larger batches of code have more variables, more possible conflicts and a much greater danger of breaking something vital. The pain when that huge discharge inevitably bombs strengthens your fear of deploying.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyohefrvvwv7sqwrvj2bc.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyohefrvvwv7sqwrvj2bc.png" alt=" " width="799" height="379"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;To break this loop you need to be able to test in a true production environment without impacting the users and you also need to be able to roll back changes immediately if something goes wrong.&lt;/p&gt;

&lt;h1&gt;
  
  
  &lt;strong&gt;The Two-House Strategy: Blue-Green Deployments&lt;/strong&gt;
&lt;/h1&gt;

&lt;p&gt;Imagine that you are moving into a new residence. You simply build an exact replica of your house next door instead of loading all your stuff into a truck, selling your old house and praying the new one has functional plumbing.&lt;/p&gt;

&lt;p&gt;You bring your furniture in, check the water pressure, sleep on the bed for a night, and make sure it’s perfect. Then when you are happy you just move your mailing address to the new house. If the roof starts leaking the next day, you just change your address back and move next door to your previous, perfectly functional home.&lt;/p&gt;

&lt;p&gt;This is called a Blue-Green deployment in the cloud world.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;How the Dual Environment Works&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;You have the same infrastructural settings. We can call the existing live environment “Blue” and the freshly updated environment “Green”.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Step 1: The Shadow Launch.&lt;/strong&gt; Your ECS pipeline distributes the new container image to the Green environment. At this stage 100% of your live client traffic is still going to the Blue environment.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Step 2: Private Validation.&lt;/strong&gt; Your team does testing against the green environment. It is a totally accurate testing ground because it is connected to the same production databases and employs the same networking rules.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Step 3: The Swap.&lt;/strong&gt; When the Green environment has passed all of the checks, you adjust your Application Load Balancer (ALB) to send traffic to the Green environment.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Step 4: The Safety Net.&lt;/strong&gt; The old Blue environment is still running for a pre-determined cool down period.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You’re probably wondering… what if a little bug snuck through our testing? You just flip the load balancer back to Blue. The rollback is seconds, not hours.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Defining Blue-Green Infrastructure&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;To get this working within AWS using Terraform you need to create two target groups and let ECS manage the traffic moving.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# The target group for our current live traffic
resource "aws_lb_target_group" "primary_tg" {
  name        = "app-primary-tg"
  port        = 80
  protocol    = "HTTP"
  vpc_id      = aws_vpc.main_network.id
  target_type = "ip"

  health_check {
    path                = "/api/health"
    interval            = 15
    healthy_threshold   = 2
  }
}

# The target group for our incoming new releases
resource "aws_lb_target_group" "secondary_tg" {
  name        = "app-secondary-tg"
  port        = 80
  protocol    = "HTTP"
  vpc_id      = aws_vpc.main_network.id
  target_type = "ip"

  health_check {
    path                = "/api/health"
    interval            = 15
    healthy_threshold   = 2
  }
}

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You set up your ECS service to use the &lt;code&gt;CODE_DEPLOY&lt;/code&gt; controller (or native ECS deployment tools) to handle the transition between the two target groups.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;resource "aws_ecs_service" "web_api" {
  name            = "core-web-api"
  cluster         = aws_ecs_cluster.production.id
  task_definition = aws_ecs_task_definition.api_def.arn
  desired_count   = 4

  deployment_controller {
    type = "ECS"
  }

  # Instructing ECS to keep the old tasks around for a safety buffer
  blue_green_deployment_config {
    deployment_ready_wait_time_in_minutes = 10
    terminate_blue_tasks_on_deployment_success {
      enabled               = true
      termination_wait_time = 30
    }
  }
}

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h1&gt;
  
  
  &lt;strong&gt;The Taste Test: Canary Releases&lt;/strong&gt;
&lt;/h1&gt;

&lt;p&gt;While Blue-Green provides a good safety net, it still requires all your users to move to the new version at the same time.&lt;/p&gt;

&lt;p&gt;And here’s where it gets interesting...what if you only exposed a very small fraction of your consumers to the new code?&lt;/p&gt;

&lt;p&gt;Think of it as making a big pot of soup for a banquet. You taste one spoonful, then serve it to 500 visitors. You put sugar instead of salt in the soup; by accident, you only spoiled one spoonful, not the whole dinner party.&lt;/p&gt;

&lt;p&gt;Canary deployments slowly transfer traffic to the new version in small increments.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;The Phased Rollout Process&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;Instead of a hard swap, ECS’s canary deployment uses the weighted routing features of an AWS Application Load Balancer.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Phase 1: The Initial Dip.&lt;/strong&gt; You push the new version out, but only send 5% of your live traffic to it. The other 95% continue with the stable version.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Phase 2: Metric Monitoring.&lt;/strong&gt; You keep a careful eye on your dashboards. Is that 5% seeing error rate spikes? Seeing database slowness growing up?&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Phase 3: The Ramp-Up.&lt;/strong&gt; If the system seems healthy after a period of time, you bump the weight up to 20%, then 50% and finally 100%.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If the new version faults out at any stage, you immediately set the ALB weights for the new version back to 0%. The bug’s blast radius is tightly controlled to a small subset of users.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Configuring Weighted Traffic&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;To do this, you create a listener rule on your AWS load balancer to route traffic based on the supplied weights.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;resource "aws_lb_listener_rule" "canary_traffic_split" {
  listener_arn = aws_lb_listener.https_listener.arn
  priority     = 50

  action {
    type = "forward"
    forward {
      target_group {
        arn    = aws_lb_target_group.stable_version.arn
        weight = 90
      }
      target_group {
        arn    = aws_lb_target_group.new_release.arn
        weight = 10
      }
      # Critical: Ensure users don't bounce between versions
      stickiness {
        enabled  = true
        duration = 3600 
      }
    }
  }

  condition {
    path_pattern {
      values = ["/api/*"]
    }
  }
}

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;See the stickiness configuration in the codeblock. This ensures that a user who lands on the updated version of your app stays on that version for the length of their session. Randomly bouncing a user between two separate codebases every click will be a poor user experience.&lt;/p&gt;

&lt;h1&gt;
  
  
  &lt;strong&gt;Choosing Your Deployment Weapon&lt;/strong&gt;
&lt;/h1&gt;

&lt;p&gt;Fair enough... neither is always "better" than the other. They encounter quite different operating challenges.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Opt for Blue-Green when:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;You are installing internal tools or APIs where a harsh cutover is tolerable.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;You want to be able to conduct intensive integration tests on the exact production infrastructure before any real traffic actually hits the servers.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;You want your deployment pipeline to be simple and speedy.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Opt for Canary when:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;You are deploying really critical functionality (like payment gateway) where even 1 minute outage is fatal.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;You need to validate business metrics (e.g. conversion rates or user engagement) and technical data before a full launch.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;If the traffic volumes are high, then you will have enough data even with a small 2% slice of traffic to identify anomalies.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h1&gt;
  
  
  &lt;strong&gt;Rules for Safe Deployments&lt;/strong&gt;
&lt;/h1&gt;

&lt;p&gt;Whichever approach you pick, dynamically rerouting traffic adds layers of complication. You can't just lay down in these infrastructure models and expect miracles if you disregard the rest of the ecosystem.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;1. The Database Schema Trap&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;Simply said, your database is not rollback-able, but your code is.&lt;/p&gt;

&lt;p&gt;If you change a database column name in your new deployment and direct traffic to the new version, the previous version will immediately crash because it is expecting the old column name. If you ever have to roll back, your application is now broken forever.&lt;/p&gt;

&lt;p&gt;If you want zero-downtime deployments then database modifications must be fully backward-compatible. You have to follow a pattern of “expand and contract”:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Deploy 1:&lt;/strong&gt; Add the new database column (both code versions still function).&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Deploy 2:&lt;/strong&gt; Update the application code to read/write to the new column.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Deploy 3:&lt;/strong&gt; Remove the old column days later, long after the rollback window has closed.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;2. You Must Have Excellent Metrics&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;Driving blindfolded is like moving traffic without visibility.&lt;/p&gt;

&lt;p&gt;How do you know a Canary release is working when you route 10% of your traffic to it? You can’t depend on users to file support tickets. You want automatic dashboards on HTTP 500s, response times, and CPU utilization.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;3. Automate the Rollback&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;Humans panic. Systems do not.&lt;/p&gt;

&lt;p&gt;Don’t have a stressed engineer manually modify load balancer weights when anything goes sideways. Use automatic lifecycle hooks. You can use AWS to trigger Lambda functions on ECS deploys. Your Lambda can automatically abort the deployment and roll back traffic if it finds that the new target group is failing health checks.&lt;/p&gt;

&lt;h1&gt;
  
  
  &lt;strong&gt;Key Takeaways&lt;/strong&gt;
&lt;/h1&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Blue-Green deployments enable an easy escape route by keeping two similar settings that offer a suitable testing ground before going live.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Canary releases minimize the damage of uncovered bugs by first exposing new code to a small subset of users and growing up gradually.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;It must be able to work with databases backward. If your database updates break your previous code, you don't have your safety net of rollback anymore.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Observability is a must. If you don't have analytics to see how your application is functioning in real-time, you can't securely move traffic.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h1&gt;
  
  
  &lt;strong&gt;Conclusion&lt;/strong&gt;
&lt;/h1&gt;

&lt;p&gt;Software deployments should be dull, at the end of the day.&lt;/p&gt;

&lt;p&gt;By using Blue-Green cutovers for a speedy escape route, or Canary rollouts to reduce the blast radius of issues, you safeguard your users from your mistakes. When engineers are no longer afraid to break the system, they code with more confidence. They merge pull more quickly. They provide little, digestible updates rather than terrifyingly large monoliths.&lt;/p&gt;

&lt;p&gt;Long story short...investing time in your ECS deployment architecture is more than simply focusing on server health. It’s about maintaining the mental health of your engineering staff, keeping your weekends intact, and delivering uninterrupted value to your users.&lt;/p&gt;

&lt;h1&gt;
  
  
  &lt;strong&gt;About the Author&lt;/strong&gt;
&lt;/h1&gt;

&lt;p&gt;As an &lt;strong&gt;AWS Community Builder&lt;/strong&gt;, I enjoy sharing the things I've learned through my own experiences and events, and I like to help others on their path. If you found this helpful or have any questions, don't hesitate to get in touch! 🚀&lt;/p&gt;

&lt;p&gt;🔗 Connect with me on &lt;a href="https://www.linkedin.com/in/chandra-prakash-reddy/" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt;&lt;/p&gt;

&lt;h1&gt;
  
  
  &lt;strong&gt;Also Published On&lt;/strong&gt;
&lt;/h1&gt;

&lt;p&gt;&lt;a href="https://builder.aws.com/content/3I8oK16gm577L2VBa3KdYxR1XLx/from-panic-to-peace-mastering-zero-downtime-ecs-deployments" rel="noopener noreferrer"&gt;AWS Builder Center&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://devopstour.hashnode.dev/from-panic-to-peace-mastering-zero-downtime-ecs-deployments" rel="noopener noreferrer"&gt;Hashnode&lt;/a&gt;&lt;/p&gt;

</description>
      <category>aws</category>
      <category>ecs</category>
      <category>automation</category>
      <category>database</category>
    </item>
    <item>
      <title>AWS Transit Gateway Policy-Based Routing : le routage intelligent arrive !</title>
      <dc:creator>sylvainbruas</dc:creator>
      <pubDate>Wed, 19 Aug 2026 08:59:39 +0000</pubDate>
      <link>https://dev.to/aws-builders/aws-transit-gateway-policy-based-routing-le-routage-intelligent-arrive--1p1p</link>
      <guid>https://dev.to/aws-builders/aws-transit-gateway-policy-based-routing-le-routage-intelligent-arrive--1p1p</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fk0xf3c2s5e2pd2u7ik0c.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fk0xf3c2s5e2pd2u7ik0c.webp" alt="AWS Transit Gateway Policy-Based Routing" width="512" height="512"&gt;&lt;/a&gt;&lt;/p&gt;



&lt;p&gt;Le routage réseau sur AWS a longtemps été contraint par une logique simple : le trafic est acheminé en fonction de l'adresse IP de destination. Point final. Pour les architectes réseau habitués aux fonctionnalités avancées des équipements on-premise (Cisco, Juniper, Palo Alto), cette limitation était frustrante. AWS vient de combler ce gap avec l'annonce de la disponibilité générale du &lt;strong&gt;Policy-Based Routing (PBR)&lt;/strong&gt; sur &lt;a href="https://docs.aws.amazon.com/vpc/latest/tgw/what-is-transit-gateway.html" rel="noopener noreferrer"&gt;AWS Transit Gateway&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Cette fonctionnalité, annoncée le 30 juillet 2026, permet désormais de prendre des décisions de routage basées sur une combinaison d'attributs : adresse IP source, adresse IP destination, ports, et protocole. C'est un changement fondamental dans la manière dont nous concevons les architectures réseau sur AWS.&lt;/p&gt;
&lt;h2&gt;
  
  
  Qu'est-ce que le Policy-Based Routing ?
&lt;/h2&gt;

&lt;p&gt;Le Policy-Based Routing est un mécanisme qui permet de diriger le trafic réseau en fonction de politiques définies par l'administrateur, plutôt que de s'appuyer uniquement sur la table de routage classique basée sur la destination. En d'autres termes, vous pouvez désormais dire : « tout le trafic HTTPS provenant du subnet applicatif à destination du port 443 doit passer par mon appliance d'inspection, tandis que le trafic SSH de l'équipe opérations peut prendre un chemin direct ».&lt;/p&gt;

&lt;p&gt;Sur AWS Transit Gateway, le PBR fonctionne avec un concept de &lt;strong&gt;policy table&lt;/strong&gt; que vous associez à un attachement Transit Gateway. Dans cette table, vous définissez un ensemble ordonné de règles. Chaque règle classifie le trafic selon les critères suivants :&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Adresse IP source&lt;/strong&gt; : d'où vient le trafic&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Adresse IP destination&lt;/strong&gt; : où va le trafic&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Port source et destination&lt;/strong&gt; : quel service est utilisé&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Protocole&lt;/strong&gt; : TCP, UDP, ICMP, etc.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Les règles sont évaluées dans l'ordre avec une logique &lt;strong&gt;first-match-wins&lt;/strong&gt; : la première règle qui correspond au trafic détermine vers quelle route table le paquet sera dirigé. &lt;strong&gt;Si aucune règle ne correspond, le trafic est silencieusement supprimé (implicit deny)&lt;/strong&gt;. Ce comportement, similaire à celui des Security Groups, impose de toujours prévoir une règle catch-all si vous souhaitez éviter les drops involontaires.&lt;/p&gt;
&lt;h2&gt;
  
  
  Pourquoi c'est un game-changer ?
&lt;/h2&gt;
&lt;h3&gt;
  
  
  Avant le PBR : la complexité architecturale
&lt;/h3&gt;

&lt;p&gt;Avant cette annonce, les architectes réseau qui avaient besoin de diriger le trafic de manière granulaire devaient construire des architectures multi-VPC complexes avec des sauts de routage supplémentaires. Concrètement, cela signifiait :&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Multiplier les VPC d'inspection&lt;/strong&gt; : un VPC dédié pour chaque type de trafic à inspecter différemment&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ajouter des Gateway Load Balancers&lt;/strong&gt; : pour distribuer le trafic vers des appliances tierces&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Créer des chaînes de routage&lt;/strong&gt; : avec plusieurs Transit Gateway route tables interconnectées&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Gérer des coûts croissants&lt;/strong&gt; : chaque saut supplémentaire génère des frais de traitement de données&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Cette complexité se traduisait par une surcharge opérationnelle importante, des latences accrues, et un risque d'erreur de configuration élevé.&lt;/p&gt;
&lt;h3&gt;
  
  
  Avec le PBR : la simplicité retrouvée
&lt;/h3&gt;

&lt;p&gt;Le PBR élimine une grande partie de cette complexité en étendant les capacités natives de routage du Transit Gateway. Plus besoin d'ajouter des couches intermédiaires : vous classifiez et dirigez le trafic directement au niveau du Transit Gateway, en une seule étape.&lt;/p&gt;
&lt;h2&gt;
  
  
  Cas d'usage concrets
&lt;/h2&gt;

&lt;p&gt;Le PBR ouvre la porte à de nombreux scénarios que les équipes réseau et sécurité attendaient depuis longtemps.&lt;/p&gt;
&lt;h3&gt;
  
  
  Inspection sélective du trafic
&lt;/h3&gt;

&lt;p&gt;C'est probablement le cas d'usage le plus demandé. Plutôt que de faire passer tout le trafic par &lt;a href="https://docs.aws.amazon.com/network-firewall/latest/developerguide/what-is-aws-network-firewall.html" rel="noopener noreferrer"&gt;AWS Network Firewall&lt;/a&gt; ou une appliance tierce (ce qui génère des coûts et de la latence), vous pouvez désormais ne router que le trafic sensible vers l'inspection. Par exemple :&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Le trafic à destination des bases de données (port 3306, 5432) passe par l'inspection&lt;/li&gt;
&lt;li&gt;Le trafic interne entre microservices sur des ports applicatifs connus peut contourner l'inspection&lt;/li&gt;
&lt;li&gt;Le trafic vers des destinations externes spécifiques est systématiquement inspecté&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;
  
  
  Routage basé sur la source pour la connectivité hybride
&lt;/h3&gt;

&lt;p&gt;Pour les entreprises utilisant &lt;a href="https://docs.aws.amazon.com/directconnect/latest/UserGuide/Welcome.html" rel="noopener noreferrer"&gt;AWS Direct Connect&lt;/a&gt; et &lt;a href="https://docs.aws.amazon.com/vpn/latest/s2svpn/VPC_VPN.html" rel="noopener noreferrer"&gt;AWS VPN&lt;/a&gt; en parallèle, le PBR permet de diriger le trafic vers le bon chemin en fonction de sa source ou de son protocole :&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Le trafic critique (bases de données, ERP) emprunte le Direct Connect pour bénéficier de la latence faible et de la bande passante garantie&lt;/li&gt;
&lt;li&gt;Le trafic moins sensible (mises à jour, synchronisation) peut utiliser le VPN comme chemin secondaire&lt;/li&gt;
&lt;li&gt;Le basculement peut être géré de manière plus fine en fonction des applications&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;
  
  
  Isolation des environnements
&lt;/h3&gt;

&lt;p&gt;Le PBR permet d'isoler les environnements de production et de développement dans des domaines de routage séparés, limitant ainsi les mouvements latéraux. Même si deux environnements partagent des plages IP similaires ou des attachements communs, les règles PBR garantissent que le trafic de développement ne peut jamais atteindre les ressources de production, et inversement.&lt;/p&gt;
&lt;h2&gt;
  
  
  Configuration et mise en place
&lt;/h2&gt;
&lt;h3&gt;
  
  
  Contrainte fondamentale : policy table OU route table, jamais les deux
&lt;/h3&gt;

&lt;p&gt;C'est le point le plus important à comprendre avant de se lancer : &lt;strong&gt;un attachement Transit Gateway ne peut être associé qu'à une policy table OU à une route table, jamais aux deux simultanément&lt;/strong&gt;. Il n'existe aucun mode hybride. Concrètement :&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Si votre attachement VPC est actuellement associé à une route table classique, vous &lt;strong&gt;devez d'abord dissocier&lt;/strong&gt; cette route table avant de pouvoir associer une policy table&lt;/li&gt;
&lt;li&gt;Pendant la fenêtre de dissociation/association, &lt;strong&gt;le trafic de cet attachement sera interrompu&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Une policy table vide (sans aucune règle) entraîne le &lt;strong&gt;drop de tout le trafic entrant&lt;/strong&gt; sur cet attachement — c'est l'implicit deny&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Cette exclusivité signifie que le PBR remplace complètement la logique de routage classique pour un attachement donné. C'est un choix architectural binaire par attachement.&lt;/p&gt;
&lt;h3&gt;
  
  
  Les étapes de mise en place
&lt;/h3&gt;

&lt;p&gt;La configuration du PBR se fait en quatre étapes :&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Créer les route tables cibles&lt;/strong&gt; : chaque règle PBR pointe vers une route table TGW qui contient les routes effectives&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Créer une policy table&lt;/strong&gt; : vous définissez une table de politiques associée à votre Transit Gateway&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Définir les règles&lt;/strong&gt; : vous créez un ensemble ordonné de règles (numérotées de 1 à 50 000) avec les critères de classification et la route table cible&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Dissocier la route table existante puis associer la policy table à l'attachement&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Le PBR est configurable via la &lt;a href="https://console.aws.amazon.com/" rel="noopener noreferrer"&gt;Console AWS&lt;/a&gt;, l'&lt;a href="https://docs.aws.amazon.com/cli/latest/reference/ec2/" rel="noopener noreferrer"&gt;AWS CLI&lt;/a&gt;, et les &lt;a href="https://docs.aws.amazon.com/sdkref/latest/guide/overview.html" rel="noopener noreferrer"&gt;SDK AWS&lt;/a&gt;. Les commandes CLI dédiées (&lt;code&gt;create-transit-gateway-policy-table&lt;/code&gt;, &lt;code&gt;create-transit-gateway-policy-table-entry&lt;/code&gt;, &lt;code&gt;associate-transit-gateway-policy-table&lt;/code&gt;) sont disponibles dès maintenant.&lt;/p&gt;
&lt;h3&gt;
  
  
  Exemple concret : filtrage HTTP/HTTPS et SSH
&lt;/h3&gt;

&lt;p&gt;Voici un exemple complet de mise en place via AWS CLI, qui implémente le scénario décrit en introduction : le trafic HTTPS est inspecté, le trafic SSH prend un chemin direct.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 1. Créer la policy table&lt;/span&gt;
aws ec2 create-transit-gateway-policy-table &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--transit-gateway-id&lt;/span&gt; tgw-0bc994abffEXAMPLE &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--tag-specifications&lt;/span&gt; &lt;span class="s1"&gt;'ResourceType=transit-gateway-policy-table,Tags=[{Key=Name,Value=app-security-policy}]'&lt;/span&gt;
&lt;span class="c"&gt;# Retourne : tgw-ptb-0ca78a549EXAMPLE&lt;/span&gt;

&lt;span class="c"&gt;# 2. Règle 100 : trafic HTTPS (port 443) du subnet applicatif → inspection firewall&lt;/span&gt;
aws ec2 create-transit-gateway-policy-table-entry &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--transit-gateway-policy-table-id&lt;/span&gt; tgw-ptb-0ca78a549EXAMPLE &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--policy-rule-number&lt;/span&gt; 100 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--policy-rule&lt;/span&gt; &lt;span class="s1"&gt;'{"SourceCidrBlock":"10.1.10.0/24","Protocol":"6","DestinationPortRange":"443"}'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--target-route-table-id&lt;/span&gt; tgw-rtb-0a823edbdeEXAMPLE

&lt;span class="c"&gt;# 3. Règle 200 : trafic HTTP (port 80) → même inspection&lt;/span&gt;
aws ec2 create-transit-gateway-policy-table-entry &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--transit-gateway-policy-table-id&lt;/span&gt; tgw-ptb-0ca78a549EXAMPLE &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--policy-rule-number&lt;/span&gt; 200 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--policy-rule&lt;/span&gt; &lt;span class="s1"&gt;'{"SourceCidrBlock":"10.1.10.0/24","Protocol":"6","DestinationPortRange":"80"}'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--target-route-table-id&lt;/span&gt; tgw-rtb-0a823edbdeEXAMPLE

&lt;span class="c"&gt;# 4. Règle 300 : trafic SSH (port 22) de l'équipe ops → routage direct (pas d'inspection)&lt;/span&gt;
&lt;span class="c"&gt;#    Ne matche que si ce trafic entre bien par l'attachement associé à la policy table&lt;/span&gt;
aws ec2 create-transit-gateway-policy-table-entry &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--transit-gateway-policy-table-id&lt;/span&gt; tgw-ptb-0ca78a549EXAMPLE &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--policy-rule-number&lt;/span&gt; 300 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--policy-rule&lt;/span&gt; &lt;span class="s1"&gt;'{"SourceCidrBlock":"10.1.50.0/24","Protocol":"6","DestinationPortRange":"22"}'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--target-route-table-id&lt;/span&gt; tgw-rtb-0b91f2c3d4EXAMPLE

&lt;span class="c"&gt;# 5. Règle 40000 : catch-all → routage par défaut (évite l'implicit deny)&lt;/span&gt;
&lt;span class="c"&gt;#    Un policy-rule vide matche tout : toutes les conditions valent * par défaut&lt;/span&gt;
aws ec2 create-transit-gateway-policy-table-entry &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--transit-gateway-policy-table-id&lt;/span&gt; tgw-ptb-0ca78a549EXAMPLE &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--policy-rule-number&lt;/span&gt; 40000 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--policy-rule&lt;/span&gt; &lt;span class="s1"&gt;'{}'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--target-route-table-id&lt;/span&gt; tgw-rtb-0f5e4d3c2bEXAMPLE

&lt;span class="c"&gt;# 6. Vérifier les entries avant de basculer l'attachement&lt;/span&gt;
aws ec2 get-transit-gateway-policy-table-entries &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--transit-gateway-policy-table-id&lt;/span&gt; tgw-ptb-0ca78a549EXAMPLE

&lt;span class="c"&gt;# 7. Dissocier la route table existante de l'attachement source&lt;/span&gt;
&lt;span class="c"&gt;#    Un attachement ne peut être associé qu'à une route table OU une policy table&lt;/span&gt;
aws ec2 disassociate-transit-gateway-route-table &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--transit-gateway-attachment-id&lt;/span&gt; tgw-attach-0def6EXAMPLE &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--transit-gateway-route-table-id&lt;/span&gt; tgw-rtb-0c7d8e9f01EXAMPLE

&lt;span class="c"&gt;# 8. Attendre la fin de la dissociation : associer trop tôt fait échouer la requête&lt;/span&gt;
&lt;span class="k"&gt;while&lt;/span&gt; :&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;&lt;span class="nv"&gt;STATE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;aws ec2 get-transit-gateway-route-table-associations &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--transit-gateway-route-table-id&lt;/span&gt; tgw-rtb-0c7d8e9f01EXAMPLE &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--filters&lt;/span&gt; &lt;span class="s2"&gt;"Name=transit-gateway-attachment-id,Values=tgw-attach-0def6EXAMPLE"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s1"&gt;'Associations[0].State'&lt;/span&gt; &lt;span class="nt"&gt;--output&lt;/span&gt; text&lt;span class="si"&gt;)&lt;/span&gt;
  &lt;span class="c"&gt;# "None" : l'association a disparu de la liste&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$STATE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"None"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$STATE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"disassociated"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    &lt;/span&gt;&lt;span class="nb"&gt;break
  &lt;/span&gt;&lt;span class="k"&gt;fi
  &lt;/span&gt;&lt;span class="nb"&gt;sleep &lt;/span&gt;5
&lt;span class="k"&gt;done&lt;/span&gt;

&lt;span class="c"&gt;# 9. Associer la policy table à l'attachement&lt;/span&gt;
aws ec2 associate-transit-gateway-policy-table &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--transit-gateway-policy-table-id&lt;/span&gt; tgw-ptb-0ca78a549EXAMPLE &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--transit-gateway-attachment-id&lt;/span&gt; tgw-attach-0def6EXAMPLE

&lt;span class="c"&gt;# 10. Confirmer l'association&lt;/span&gt;
aws ec2 get-transit-gateway-policy-table-associations &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--transit-gateway-policy-table-id&lt;/span&gt; tgw-ptb-0ca78a549EXAMPLE
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Avec cette configuration, le trafic est traité comme suit :&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Paquet TCP:443 depuis &lt;code&gt;10.1.10.x&lt;/code&gt; → &lt;strong&gt;inspecté par le firewall&lt;/strong&gt; (règle 100)&lt;/li&gt;
&lt;li&gt;Paquet TCP:80 depuis &lt;code&gt;10.1.10.x&lt;/code&gt; → &lt;strong&gt;inspecté par le firewall&lt;/strong&gt; (règle 200)&lt;/li&gt;
&lt;li&gt;Paquet TCP:22 depuis &lt;code&gt;10.1.50.x&lt;/code&gt; → &lt;strong&gt;routage direct&lt;/strong&gt; sans inspection (règle 300)&lt;/li&gt;
&lt;li&gt;Tout autre trafic → &lt;strong&gt;routage par défaut&lt;/strong&gt; (règle 40000 catch-all)&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Considérations et bonnes pratiques
&lt;/h2&gt;

&lt;p&gt;Comme toute fonctionnalité réseau avancée, le PBR nécessite une approche structurée :&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Commencez simple&lt;/strong&gt; : déployez d'abord une ou deux règles sur un attachement non-critique pour valider le comportement&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Documentez vos politiques&lt;/strong&gt; : la logique first-match-wins peut devenir complexe avec de nombreuses règles. Maintenez une documentation claire de l'ordre et de l'intention de chaque règle&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Surveillez les métriques&lt;/strong&gt; : utilisez &lt;a href="https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/WhatIsCloudWatch.html" rel="noopener noreferrer"&gt;Amazon CloudWatch&lt;/a&gt; pour monitorer les hits sur chaque règle et identifier les règles inutilisées. La métrique &lt;code&gt;PacketDropCountNoPolicy&lt;/code&gt; vous alerte quand du trafic est droppé faute de règle correspondante&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prévoyez toujours une règle catch-all&lt;/strong&gt; : sauf si vous souhaitez explicitement un comportement Zero Trust (deny-all), ajoutez une règle à numéro élevé (ex: 40000) qui capture le trafic restant&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Laissez des gaps entre les numéros de règle&lt;/strong&gt; : utilisez 100, 200, 300… pour pouvoir insérer de nouvelles règles sans tout renuméroter&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Testez les scénarios de basculement&lt;/strong&gt; : validez que vos règles PBR se comportent correctement en cas de panne d'un attachement cible&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Attention au BGP&lt;/strong&gt; : associer une policy table à un attachement VPN (Site-to-Site) ou Connect &lt;strong&gt;stoppe les advertisements BGP&lt;/strong&gt; vers le peer. Direct Connect n'est pas affecté (utilise la liste allowed-prefixes)&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Impact sur la latence réseau
&lt;/h2&gt;

&lt;p&gt;Une question légitime : le PBR ajoute-t-il de la latence au transit du trafic ?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;La réponse est non.&lt;/strong&gt; L'évaluation des règles PBR s'effectue au niveau du plan de données du Transit Gateway, au même point de décision que la table de routage classique. Il n'y a pas de saut réseau supplémentaire, pas de composant intermédiaire ajouté dans le chemin. La décision est prise « en ligne » lors du transit du paquet.&lt;/p&gt;

&lt;p&gt;En réalité, le PBR peut même &lt;strong&gt;réduire la latence globale&lt;/strong&gt; de votre architecture :&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Avant le PBR&lt;/strong&gt; : pour inspecter sélectivement le trafic, il fallait tout envoyer vers un VPC d'inspection (un saut supplémentaire) puis filtrer au niveau de l'appliance. Même le trafic « propre » subissait la traversée.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Avec le PBR&lt;/strong&gt; : seul le trafic qui nécessite effectivement une inspection est dirigé vers l'appliance. Le reste prend le chemin direct, économisant un ou plusieurs sauts réseau.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Les quotas de bande passante ne sont pas impactés non plus : chaque attachement VPC conserve sa capacité de 100 Gbps par Availability Zone dans chaque direction.&lt;/p&gt;

&lt;h2&gt;
  
  
  Limites et quotas
&lt;/h2&gt;

&lt;p&gt;Comme tout service AWS, le PBR a ses limites qu'il faut connaître avant de concevoir votre architecture :&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Ressource&lt;/th&gt;
&lt;th&gt;Limite&lt;/th&gt;
&lt;th&gt;Ajustable&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Policy tables par Transit Gateway&lt;/td&gt;
&lt;td&gt;20&lt;/td&gt;
&lt;td&gt;Non&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Policy entries (customer-managed) par TGW, toutes tables confondues&lt;/td&gt;
&lt;td&gt;200&lt;/td&gt;
&lt;td&gt;Oui&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Numéros de règle&lt;/td&gt;
&lt;td&gt;1 à 50 000&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Types d'attachement supportés&lt;/td&gt;
&lt;td&gt;VPC, VPN, Direct Connect, Connect, Peering (sauf Cloud WAN)&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Autres contraintes importantes :&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Exclusivité absolue&lt;/strong&gt; : un attachement est associé à une policy table OU une route table, jamais les deux&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pas de PBR sur les peering Cloud WAN&lt;/strong&gt; : seules les entries system-managed (gérées par Cloud WAN) s'appliquent sur ces attachements&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ports limités à TCP/UDP&lt;/strong&gt; : le filtrage par port ne fonctionne que pour les protocoles TCP (6) et UDP (17). Pour ICMP, GRE ou « Any », les ports sont automatiquement à &lt;code&gt;*&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Policy table vide = blackhole&lt;/strong&gt; : associer une policy table sans aucune règle à un attachement provoque le drop silencieux de tout le trafic&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Route table référencée non supprimable&lt;/strong&gt; : une route table utilisée comme cible par une policy entry ne peut pas être supprimée tant que la référence existe&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Migration : passer d'une route table au PBR sans coupure
&lt;/h2&gt;

&lt;p&gt;La migration vers le PBR est la question qui préoccupe le plus les équipes réseau en production. Soyons clairs : &lt;strong&gt;il existe une micro-interruption inévitable&lt;/strong&gt; lors du basculement, car la dissociation de la route table et l'association de la policy table ne sont pas atomiques.&lt;/p&gt;

&lt;p&gt;Voici la stratégie recommandée pour minimiser l'impact :&lt;/p&gt;

&lt;h3&gt;
  
  
  Approche progressive (recommandée)
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Préparez tout en amont&lt;/strong&gt; : créez la policy table, définissez toutes les règles, et créez les route tables cibles avec les routes nécessaires — tout cela sans aucun impact sur le trafic existant&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Commencez par un attachement non-critique&lt;/strong&gt; : choisissez un VPC de développement ou de test pour valider le comportement&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Incluez une règle catch-all qui réplique le comportement actuel&lt;/strong&gt; : votre règle à numéro élevé doit pointer vers une route table qui reproduit exactement le routage actuel. Ainsi, même si vos règles spécifiques ont un bug, le trafic continue de transiter&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Planifiez une fenêtre de maintenance&lt;/strong&gt; : la dissociation de la route table suivie de l'association de la policy table prend généralement quelques secondes, mais prévoyez 30 à 60 secondes de micro-coupure potentielle&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Basculez&lt;/strong&gt; : dissociez la route table, puis associez immédiatement la policy table&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Validez&lt;/strong&gt; : vérifiez les flux avec &lt;code&gt;GetTransitGatewayPolicyTableEntries&lt;/code&gt; et surveillez la métrique &lt;code&gt;PacketDropCountNoPolicy&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Itérez&lt;/strong&gt; : une fois validé, répétez sur les attachements de production&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Ce qui se passe pendant la bascule
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[Route table associée] ──dissociation──▶ [Aucune association] ──association──▶ [Policy table associée]
                                              │
                                    Trafic droppé pendant
                                    cette fenêtre (secondes)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pendant la fenêtre où l'attachement n'a ni route table ni policy table, &lt;strong&gt;le trafic entrant est droppé&lt;/strong&gt;. Les connexions TCP établies seront coupées et devront être ré-établies. Pour les protocoles stateless (UDP, ICMP), la reprise est transparente dès que la policy table est active.&lt;/p&gt;

&lt;h3&gt;
  
  
  Stratégies de mitigation
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Applications tolérantes&lt;/strong&gt; : la plupart des applications avec des mécanismes de retry (HTTP, gRPC) survivent à une interruption de quelques secondes sans impact utilisateur visible&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Trafic critique&lt;/strong&gt; : pour les flux qui ne tolèrent aucune perte, envisagez un schéma de migration par duplication d'attachement — créez un second attachement VPC (dans un subnet dédié), migrez-le vers PBR, puis basculez le trafic applicatif vers ce nouveau subnet&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DNS failover&lt;/strong&gt; : si vous avez un mécanisme de failover DNS (Route 53 health checks), vous pouvez basculer le trafic temporairement vers un chemin alternatif le temps de la migration&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Coordination multi-AZ&lt;/strong&gt; : migrez un AZ à la fois si votre architecture le permet (attachements distincts par AZ)&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Rollback
&lt;/h3&gt;

&lt;p&gt;En cas de problème, le rollback est simple : dissociez la policy table et réassociez l'ancienne route table. La même micro-interruption s'applique dans l'autre sens. C'est pourquoi il est essentiel de &lt;strong&gt;ne pas supprimer l'ancienne route table&lt;/strong&gt; tant que la migration n'est pas validée en production.&lt;/p&gt;

&lt;h2&gt;
  
  
  Disponibilité et tarification
&lt;/h2&gt;

&lt;p&gt;Le Policy-Based Routing pour AWS Transit Gateway est disponible dans toutes les régions commerciales AWS où Transit Gateway est disponible. Point important : &lt;strong&gt;le PBR n'engendre aucun coût supplémentaire&lt;/strong&gt; au-delà des frais standard de Transit Gateway (frais par attachement + frais de traitement de données). Il n'y a pas de tarification par règle ni par évaluation. C'est une excellente nouvelle car cela signifie que l'adoption ne sera pas freinée par des considérations budgétaires.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Pour les architectes réseau et les équipes sécurité, c'est une fonctionnalité qui va simplifier considérablement les architectures existantes tout en ouvrant de nouvelles possibilités.&lt;/p&gt;

&lt;p&gt;Mon conseil : si vous avez aujourd'hui des architectures multi-VPC complexes uniquement pour gérer du routage conditionnel, c'est le moment de revoir votre design. Le PBR peut potentiellement vous permettre de réduire le nombre de composants intermédiaires, diminuer la latence, et simplifier vos opérations quotidiennes.&lt;/p&gt;

&lt;p&gt;Commencez par identifier vos flux de trafic les plus critiques qui nécessitent un traitement différencié, puis testez le PBR sur un périmètre restreint avant de l'étendre à l'ensemble de votre organisation.&lt;/p&gt;

</description>
      <category>aws</category>
      <category>reseau</category>
      <category>architecture</category>
    </item>
    <item>
      <title>How I Run ML Inference at $0 Idle Compute with SageMaker Async Inference (CDK Included)</title>
      <dc:creator>Matia Rašetina</dc:creator>
      <pubDate>Wed, 19 Aug 2026 08:55:44 +0000</pubDate>
      <link>https://dev.to/aws-builders/how-i-run-ml-inference-at-0-idle-compute-with-sagemaker-async-inference-cdk-included-533l</link>
      <guid>https://dev.to/aws-builders/how-i-run-ml-inference-at-0-idle-compute-with-sagemaker-async-inference-cdk-included-533l</guid>
      <description>&lt;p&gt;A few months ago, I wrote an article “&lt;em&gt;Stop Using Lambda for ML at This Scale&lt;/em&gt;”, where I’ve taken a deeper dive into packing a ML model into a Lambda and tested out at what scale does that approach cost more than using a more conventional approach, like using Sagemaker. And that post is great if you are approaching it as a startup which needs to save as much money as possible.&lt;/p&gt;

&lt;p&gt;However, in this post, we are going to take a closer look at the architecture which I’ve actually shipped in one of my startups called HeartSense. &lt;/p&gt;

&lt;p&gt;HeartSense is a serverless platform which gives a suggestion to the user if they should go and visit a cardiologist, based on a recording of their heartbeats by using their phone’s microphone. The system simply categorizes the sounds by either &lt;code&gt;healthy&lt;/code&gt; or &lt;code&gt;unhealthy&lt;/code&gt; , by using a ResNet18 model in the background. This product is not meant to be a medical one, and any result of the project should not be taken as medical advice.&lt;/p&gt;

&lt;p&gt;In this blog post, I’m giving you my ML pipeline playbook with AWS CDK in Python — every snippet below is from the real-world application. All code, for training the model and CDK infrastructure code, can be found by clicking on the link &lt;a href="https://github.com/mate329/heartsense-online-serverless-pipeline" rel="noopener noreferrer"&gt;here&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The first version: one Lambda doing too much
&lt;/h2&gt;

&lt;p&gt;The first version was a single fat Lambda. It downloaded the WAV, generated a spectrogram, ran the ONNX model, and wrote the result — all in one invocation.&lt;/p&gt;

&lt;p&gt;It worked in a demo. In practice it had three problems:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Coupling.&lt;/strong&gt; Preprocessing (CPU, fast, cheap) and inference (heavy, slow) shared one timeout, one memory setting, and one failure domain. A slow inference would hold the whole invocation hostage.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cold starts.&lt;/strong&gt; Loading the model into the Lambda runtime on every cold start added seconds to user-facing latency.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No back-pressure.&lt;/strong&gt; A burst of uploads meant a burst of concurrent heavy Lambdas, with nothing to smooth the spikes.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The fix to all of these problems is to decouple preprocessing code from inference and put a buffer between them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The architecture
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flasezwsoyawvbs113g2f.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flasezwsoyawvbs113g2f.jpg" alt=" " width="798" height="296"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The flow:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;User requests a presigned URL to upload the WAV file, the API creates an AnalysisRecord in DynamoDB and sets the status to &lt;code&gt;PENDING&lt;/code&gt; , and returns the URL for WAV file upload&lt;/li&gt;
&lt;li&gt;The user uploads a WAV straight to S3 &lt;/li&gt;
&lt;li&gt;S3 creates an &lt;code&gt;ObjectCreated&lt;/code&gt; event under the &lt;code&gt;recordings/&lt;/code&gt; prefix, which lands on an SQS queue&lt;/li&gt;
&lt;li&gt;A preprocessing Lambda (which is configured to use a Docker image, because the scientific Python deps are too big for a zip) pulls the information about the uploaded WAV file, validates the audio, renders a 224×224 spectrogram, uploads it to S3, and calls the async SageMaker endpoint.&lt;/li&gt;
&lt;li&gt;SageMaker runs inference on its own schedule and drops the JSON which contains results into S3 under &lt;code&gt;results/&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;That write triggers a tiny result-processor Lambda that parses the prediction and updates DynamoDB.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Nothing here runs unless there’s work to do. The SageMaker endpoint scales to zero instances when idle. Let’s build it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1 — Buffer uploads with SQS + S3 events
&lt;/h2&gt;

&lt;p&gt;The SQS queue used in this architecture is the shock absorber and gives us batching abilities, partial failure handling and a DLQ — things you don’t get out of the box when using a direct S3-to-Lambda trigger.&lt;/p&gt;

&lt;p&gt;When the user uploads the recording of their heartbeat, a S3 event gets created and goes straight to the queue, instead to the Lambda. The reason is to reduce the number of Lambda invocations and to use batching to lower Lambda provisioning and usage cost. One Lambda can handle multiple S3 events and process everything in one or multiple batches, depending on the load on the system.&lt;/p&gt;

&lt;p&gt;A very important detail is to know how to handle bad and malicious files too, that’s why this architecture has a DLQ set up, so the data doesn’t get lost anywhere in the system. A platform administrator can take a look into the failing data any time.&lt;/p&gt;

&lt;p&gt;The CDK is straightforward — note the dead-letter queue wired in from the start, and the prefix filter so only &lt;code&gt;recordings/&lt;/code&gt; objects trigger the pipeline:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# infra/stacks/queue_stack.py
&lt;/span&gt;
&lt;span class="c1"&gt;# Dead Letter Queue — captures messages that fail 3 times (14-day retention)
&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;dlq&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sqs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Queue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;DeadLetterQueue&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;queue_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;heartsense-dlq-&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;stage&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;retention_period&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;Duration&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;days&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;SQS_RETENTION_DAYS&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# Processing Queue — buffers S3 upload events for the Preprocessing Lambda
&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;processing_queue&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sqs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Queue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ProcessingQueue&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;queue_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;heartsense-processing-queue-&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;stage&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;retention_period&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;Duration&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;days&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;SQS_RETENTION_DAYS&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;visibility_timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;Duration&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;seconds&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;SQS_VISIBILITY_TIMEOUT_SECONDS&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;dead_letter_queue&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;sqs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;DeadLetterQueue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;max_receive_count&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;SQS_MAX_RECEIVE_COUNT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;# 3 attempts, then DLQ
&lt;/span&gt;        &lt;span class="n"&gt;queue&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;dlq&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# S3 → SQS: only ObjectCreated events under recordings/
&lt;/span&gt;&lt;span class="n"&gt;audio_bucket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_event_notification&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;s3&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;EventType&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;OBJECT_CREATED&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;s3n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;SqsDestination&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;processing_queue&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;s3&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;NotificationKeyFilter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prefix&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;recordings/&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two settings matter more than they look:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;visibility_timeout&lt;/code&gt; - AWS recommends setting the SQS visibility timeout to at least six times the value of the Lambda function timeout + the number of seconds of the SQS queue batching window. This configuration give the Lambda enough time to handle any throttling and/or retry attempts which could happen - you can read more about this configuration on official AWS documentation web page - &lt;a href="https://docs.aws.amazon.com/lambda/latest/dg/services-sqs-configure.html" rel="noopener noreferrer"&gt;https://docs.aws.amazon.com/lambda/latest/dg/services-sqs-configure.html&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;max_receive_count&lt;/code&gt; = 3 - after three failed attempts a message lands in the DLQ instead of being retried forever. That DLQ is also where your CloudWatch alarm should point (more on failure handling later).&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;One gotcha worth mentioning:&lt;/strong&gt; adding an S3 notification that targets a resource in &lt;em&gt;another&lt;/em&gt; CDK stack creates a circular dependency. I import the audio bucket &lt;em&gt;by name&lt;/em&gt; inside this stack to break the cycle, rather than passing the L2 bucket construct across stacks.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Step 2 — The preprocessing Lambda (Docker)
&lt;/h2&gt;

&lt;p&gt;For spectrogram generation, the Python code needs libraries like &lt;code&gt;scipy&lt;/code&gt; , &lt;code&gt;Pillow&lt;/code&gt; and others, which inflate the Lambda ZIP way above the limit. That’s why, in this architecture, I’ve made the preprocessing Lambda be a container image Lambda. CDK builds the image from the project root and pushes it to ECR for you:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# infra/stacks/preprocessing_stack.py
&lt;/span&gt;
&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;preprocessing_function&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;_lambda&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;DockerImageFunction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;PreprocessingFunction&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;function_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;heartsense-preprocessing-&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;stage&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;_lambda&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DockerImageCode&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;from_image_asset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;directory&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;project_root&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nb"&gt;file&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;src/services/preprocessing/Dockerfile&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;platform&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;ecr_assets&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Platform&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;LINUX_AMD64&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;exclude&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;infra/cdk.out&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;.git&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;node_modules&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ai/data&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;...&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;Duration&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;seconds&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;PREPROCESSING_LAMBDA_TIMEOUT_SECONDS&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;  &lt;span class="c1"&gt;# 300s
&lt;/span&gt;    &lt;span class="n"&gt;memory_size&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;PREPROCESSING_LAMBDA_MEMORY_MB&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;                      &lt;span class="c1"&gt;# 1024 MB
&lt;/span&gt;    &lt;span class="n"&gt;environment&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;AUDIO_BUCKET_NAME&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;audio_bucket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;bucket_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SPECTROGRAM_BUCKET_NAME&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;spectrogram_bucket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;bucket_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SAGEMAKER_ENDPOINT_NAME&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;sagemaker_endpoint_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;IDEMPOTENCY_TABLE_NAME&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;idempotency_table&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;table_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="c1"&gt;# ...
&lt;/span&gt;    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="n"&gt;tracing&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;_lambda&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Tracing&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ACTIVE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# SQS trigger with partial batch failure reporting
&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;preprocessing_function&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_event_source&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;lambda_event_sources&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;SqsEventSource&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;processing_queue&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;batch_size&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;SQS_BATCH_SIZE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;               &lt;span class="c1"&gt;# 10 as default, but can be adjusted
&lt;/span&gt;        &lt;span class="n"&gt;max_batching_window&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;Duration&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;seconds&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;SQS_BATCH_WINDOW_SECONDS&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="n"&gt;report_batch_item_failures&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;                            &lt;span class="c1"&gt;# &amp;lt;-- important
&lt;/span&gt;    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;report_batch_item_failures=True&lt;/code&gt; is the unsung hero in the configuration. Without it, one bad message in a batch of 10 fails the &lt;em&gt;entire&lt;/em&gt; batch and re-drives all 10. With it, the handler can return just the IDs that failed, and SQS only retries those.&lt;/p&gt;

&lt;p&gt;The intermediate artifacts which the model sees: a 224×224 mel-spectrogram generated from the uploaded WAV file.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftd5u79c7yuhp3npadkhr.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftd5u79c7yuhp3npadkhr.png" alt=" " width="800" height="240"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Inside the handler, the batch loop classifies every error as transient (retry via SQS) or permanent (don’t retry, mark the record failed):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# src/services/preprocessing/handler.py
&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;LambdaContext&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;records&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Records&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[])&lt;/span&gt;
    &lt;span class="n"&gt;batch_item_failures&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;

    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;record&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;records&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;message_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;record&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;messageId&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="nf"&gt;_process_record&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;record&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;TransientError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="c1"&gt;# report as failure so SQS retries just this message
&lt;/span&gt;            &lt;span class="n"&gt;batch_item_failures&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;itemIdentifier&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;message_id&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
        &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;PermanentError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="c1"&gt;# already marked FAILED in DynamoDB; consume the message
&lt;/span&gt;            &lt;span class="k"&gt;pass&lt;/span&gt;
        &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;Exception&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="c1"&gt;# unknown errors are treated as transient (safer to retry)
&lt;/span&gt;            &lt;span class="n"&gt;batch_item_failures&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;itemIdentifier&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;message_id&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;batchItemFailures&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;batch_item_failures&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That transient-vs-permanent distinction is the difference between a self-healing pipeline and one that hammers a dead endpoint forever. &lt;/p&gt;

&lt;p&gt;For example, a throttle or a 5xx is transient, while on the other hand, a corrupt WAV is permanent — even if we try and process the audio file a million times, it will still be corrupted.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffbc8ummo2l9yod1ipwtq.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffbc8ummo2l9yod1ipwtq.png" alt=" " width="799" height="243"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3 — Make it idempotent
&lt;/h2&gt;

&lt;p&gt;S3-to-SQS delivery is at-least-once — which means that some point of time, the same upload event will delivered twice. Hence, we need to protect our system from processing the same information twice, saving processing time and space. &lt;/p&gt;

&lt;p&gt;I use &lt;strong&gt;Lambda Powertools idempotency&lt;/strong&gt;, keyed on the &lt;code&gt;analysis_id&lt;/code&gt;, backed by a small DynamoDB table with TTL:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# src/services/preprocessing/handler.py
&lt;/span&gt;
&lt;span class="n"&gt;persistence_layer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;DynamoDBPersistenceLayer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;table_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;IDEMPOTENCY_TABLE_NAME&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;idempotency_config&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;IdempotencyConfig&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;event_key_jmespath&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;analysis_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;expires_after_seconds&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;3600&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;# 1 hour
&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nd"&gt;@idempotent_function&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;data_keyword_argument&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;data&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;idempotency_config&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;persistence_store&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;persistence_layer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_process_audio_idempotent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Any&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="nf"&gt;_process_audio&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;success&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;analysis_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;analysis_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The idempotency table is defined right next to the function, with &lt;code&gt;PAY_PER_REQUEST&lt;/code&gt; billing and a TTL attribute so old records expire on their own:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# infra/stacks/preprocessing_stack.py
&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;idempotency_table&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;dynamodb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Table&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;IdempotencyTable&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;partition_key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;dynamodb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Attribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;dynamodb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AttributeType&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;STRING&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;billing_mode&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;dynamodb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;BillingMode&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PAY_PER_REQUEST&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;time_to_live_attribute&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;expiration&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In addition, another layer of idempotency was added too: every DynamoDB status update is a conditional write. The record only moves &lt;code&gt;PENDING → PROCESSING → INFERRING&lt;/code&gt; if it’s currently in the expected state. A duplicate invocation simply fails the condition check and no-ops — no exceptions, no double processing.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# src/shared/constants.py — the state machine these conditions enforce
&lt;/span&gt;&lt;span class="n"&gt;VALID_STATUS_TRANSITIONS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;AnalysisStatus&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PENDING&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;AnalysisStatus&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PROCESSING&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;AnalysisStatus&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;FAILED&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="n"&gt;AnalysisStatus&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PROCESSING&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;AnalysisStatus&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;INFERRING&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="n"&gt;AnalysisStatus&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;FAILED&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="n"&gt;AnalysisStatus&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;INFERRING&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;AnalysisStatus&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;COMPLETED&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="n"&gt;AnalysisStatus&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;FAILED&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="n"&gt;AnalysisStatus&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;COMPLETED&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;  &lt;span class="c1"&gt;# terminal
&lt;/span&gt;    &lt;span class="n"&gt;AnalysisStatus&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;FAILED&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;     &lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;  &lt;span class="c1"&gt;# terminal
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here is the state machine diagram which explains this in a more simple way:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5gcleltejrnfvnrrlqmg.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5gcleltejrnfvnrrlqmg.png" alt=" " width="800" height="439"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4 — Invoke SageMaker &lt;em&gt;asynchronously&lt;/em&gt;
&lt;/h2&gt;

&lt;p&gt;This is one of the crucial parts of this ML processing pipeline — instead of calling the Sagemaker endpoint synchronously and waiting for the result, we are using &lt;code&gt;invoke_endpoint_async&lt;/code&gt; to call the endpoint asynchronously, so we don’t have additional Lambda usage for no reason. After sending the processing request to the SageMaker endpoint, Lambda’s job is done, it doesn’t have to wait for the result.&lt;/p&gt;

&lt;p&gt;What I’ve learned here is that the ways of calling the SageMaker endpoint changes, based on the way you are calling it — with a synchronous endpoint, you would usually send the payload / request body to it and wait for the response. On the other hand, with SageMaker Async Inference, the approach to processing data is different:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;you upload the input to S3&lt;/li&gt;
&lt;li&gt;retrieve the S3 file URI and pass it into the &lt;code&gt;invoke_endpoint_async&lt;/code&gt; method as the &lt;code&gt;InputLocation&lt;/code&gt; argument&lt;/li&gt;
&lt;li&gt;let SageMaker write the result back to S3
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# src/services/preprocessing/handler.py
&lt;/span&gt;
&lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sagemaker_runtime_client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;invoke_endpoint_async&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;EndpointName&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;SAGEMAKER_ENDPOINT_NAME&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;InputLocation&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;input_s3_uri&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;            &lt;span class="c1"&gt;# s3://.../spectrograms/&amp;lt;user&amp;gt;/&amp;lt;id&amp;gt;.jpg
&lt;/span&gt;    &lt;span class="n"&gt;ContentType&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;image/jpeg&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;InferenceId&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;analysis_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;Filename&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;analysis_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;.out&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;         &lt;span class="c1"&gt;# forces a deterministic output key
&lt;/span&gt;    &lt;span class="n"&gt;InvocationTimeoutSeconds&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;900&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Hard-won detail:&lt;/strong&gt; set &lt;code&gt;Filename&lt;/code&gt; explicitly. If you don’t, SageMaker writes the async output under a &lt;em&gt;random&lt;/em&gt; UUID that won’t match the &lt;code&gt;InferenceId&lt;/code&gt; you passed. By forcing &lt;code&gt;Filename={analysis_id}.out&lt;/code&gt;, the result lands at a deterministic key (&lt;code&gt;results/{analysis_id}.out&lt;/code&gt;) and the downstream Lambda can map it straight back to the DynamoDB record — no lookup table, no scan.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The invocation is wrapped in manual exponential backoff (1s, 2s) for transient SageMaker errors, and disables boto3’s built-in retries so the two retry mechanisms don’t fight each other.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5 — The scale-to-zero endpoint (the money shot)
&lt;/h2&gt;

&lt;p&gt;Here’s the part that makes idle cost disappear. The async endpoint is configured with an &lt;code&gt;AsyncInferenceConfig&lt;/code&gt; (output + failure S3 paths, one concurrent invocation per instance):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# infra/stacks/inference_stack.py
&lt;/span&gt;
&lt;span class="n"&gt;endpoint_config&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sagemaker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;CfnEndpointConfig&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;InferenceEndpointConfig&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;production_variants&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="n"&gt;sagemaker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CfnEndpointConfig&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;ProductionVariantProperty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;variant_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;AllTraffic&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;model_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;model_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;initial_instance_count&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;instance_type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ml.t3.medium&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="n"&gt;async_inference_config&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;sagemaker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CfnEndpointConfig&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;AsyncInferenceConfigProperty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;output_config&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;sagemaker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CfnEndpointConfig&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;AsyncInferenceOutputConfigProperty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;s3_output_path&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;async_output_s3_uri&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;    &lt;span class="c1"&gt;# results/
&lt;/span&gt;            &lt;span class="n"&gt;s3_failure_path&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;async_failure_s3_uri&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;# failures/
&lt;/span&gt;        &lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="n"&gt;client_config&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;sagemaker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CfnEndpointConfig&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;AsyncInferenceClientConfigProperty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;max_concurrent_invocations_per_instance&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;SAGEMAKER_MAX_CONCURRENT_INVOCATIONS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then Application Auto Scaling with &lt;code&gt;min_capacity=0&lt;/code&gt; lets the endpoint drop to zero instances when there’s nothing in the queue. Async endpoints are the &lt;em&gt;only&lt;/em&gt; SageMaker endpoint type that can do this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_create_autoscaling&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;endpoint_name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;stage&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;endpoint&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;sagemaker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CfnEndpoint&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;aws_cdk&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;aws_applicationautoscaling&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;appscaling&lt;/span&gt;
    &lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;aws_cdk&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;aws_cloudwatch&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;cloudwatch&lt;/span&gt;
    &lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;aws_cdk&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;aws_cloudwatch_actions&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;cw_actions&lt;/span&gt;

    &lt;span class="c1"&gt;# Scale between 0 and 1 instances for this low-cost async endpoint.
&lt;/span&gt;    &lt;span class="n"&gt;scalable_target&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;appscaling&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;ScalableTarget&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;EndpointScalableTarget&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;service_namespace&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;appscaling&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ServiceNamespace&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SAGEMAKER&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;resource_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;endpoint/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;endpoint_name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/variant/AllTraffic&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;scalable_dimension&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sagemaker:variant:DesiredInstanceCount&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;min_capacity&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;max_capacity&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;# Wait until the endpoint exists before attaching autoscaling.
&lt;/span&gt;    &lt;span class="n"&gt;scalable_target&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_dependency&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;endpoint&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;endpoint_dimensions&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;EndpointName&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;endpoint_name&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;# Target tracking should follow queued async work, not invocation rate.
&lt;/span&gt;    &lt;span class="n"&gt;backlog_per_instance_metric&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;cloudwatch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Metric&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;namespace&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;AWS/SageMaker&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;metric_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ApproximateBacklogSizePerInstance&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;dimensions_map&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;endpoint_dimensions&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;statistic&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Average&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;period&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;Duration&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;minutes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;scalable_target&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;scale_to_track_metric&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;BacklogPerInstancePolicy&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;policy_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;heartsense-backlog-per-instance-&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;stage&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;target_value&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;SAGEMAKER_BACKLOG_TARGET_PER_INSTANCE&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="n"&gt;custom_metric&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;backlog_per_instance_metric&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;scale_in_cooldown&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;Duration&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;minutes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;SAGEMAKER_IDLE_TIMEOUT_MINUTES&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="n"&gt;scale_out_cooldown&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;Duration&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;seconds&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;SAGEMAKER_SCALE_OUT_COOLDOWN_SECONDS&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;# Target tracking alone cannot wake a zero-instance endpoint.
&lt;/span&gt;    &lt;span class="c1"&gt;# HasBacklogWithoutCapacity becomes 1 when backlog &amp;gt; 0 and capacity == 0.
&lt;/span&gt;    &lt;span class="n"&gt;scale_from_zero_action&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;appscaling&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;StepScalingAction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ScaleFromZeroAction&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;scaling_target&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;scalable_target&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;policy_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;heartsense-scale-from-zero-&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;stage&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;adjustment_type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;appscaling&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AdjustmentType&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CHANGE_IN_CAPACITY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;metric_aggregation_type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;appscaling&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MetricAggregationType&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MAXIMUM&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;cooldown&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;Duration&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;seconds&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;SAGEMAKER_SCALE_OUT_COOLDOWN_SECONDS&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="c1"&gt;# +1 instance; max_capacity=1 keeps the endpoint at a single instance.
&lt;/span&gt;    &lt;span class="n"&gt;scale_from_zero_action&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_adjustment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;adjustment&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;lower_bound&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;has_backlog_without_capacity_alarm&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;cloudwatch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Alarm&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;HasBacklogWithoutCapacityAlarm&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;alarm_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;heartsense-has-backlog-without-capacity-&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;stage&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;alarm_description&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SageMaker async inference has queued requests but zero instances — &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;triggers the scale-from-zero step scaling policy.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="n"&gt;metric&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;cloudwatch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Metric&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;namespace&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;AWS/SageMaker&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;metric_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;HasBacklogWithoutCapacity&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;dimensions_map&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;endpoint_dimensions&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;statistic&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Maximum&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;period&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;Duration&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;minutes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="n"&gt;threshold&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;comparison_operator&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;cloudwatch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ComparisonOperator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GREATER_THAN_OR_EQUAL_TO_THRESHOLD&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;evaluation_periods&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;SAGEMAKER_SCALE_FROM_ZERO_EVALUATION_PERIODS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;datapoints_to_alarm&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;SAGEMAKER_SCALE_FROM_ZERO_EVALUATION_PERIODS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;treat_missing_data&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;cloudwatch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TreatMissingData&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MISSING&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="c1"&gt;# Alarm fires → step scaling policy → endpoint scales from 0 to 1.
&lt;/span&gt;    &lt;span class="n"&gt;has_backlog_without_capacity_alarm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_alarm_action&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;cw_actions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;ApplicationScalingAction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;scale_from_zero_action&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;scalable_target&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is one very useful metric in CloudWatch for Sagemaker instances for target-tracking called &lt;code&gt;ApproximateBacklogSizePerInstance&lt;/code&gt; , which represents the queued requests relative to the available endpoint capacity, and AWS recommends the usage for this metric for all autoscaling asynchronous endpoints. You can read about it in more detail by clicking on the link here.&lt;/p&gt;

&lt;p&gt;There is one important scale-to-zero edge case when using this configuration. When the endpoint already has zero instances, a small backlog may not be enough for normal target-tracking policy to scale it out right away, and for that reason, I used already available metric &lt;code&gt;HasBacklogWithoutCapacity&lt;/code&gt; which is a second scaling signal. The value of the metric becomes &lt;code&gt;1&lt;/code&gt; when the requests are waiting, but there is no compute instances available to process the information, which triggers a step-scaling policy and it starts the endpoint from zero instances to one.&lt;/p&gt;

&lt;p&gt;It’s important to mention that in this stack, the max number of provisioned SageMaker endpoints is going to be 1 — just to cut down on cost and have a POC ready.&lt;/p&gt;

&lt;p&gt;When an upload arrives after a quiet period, SageMaker spins an instance back up, processes the queued request, and scales back to zero ~15 minutes after the last invocation. The async queue in front of the endpoint means callers never get a “no capacity” error — requests just wait for the instance to warm up.&lt;/p&gt;

&lt;p&gt;The interesting part was not that the model saturated the instance. It did the opposite. During this test, CPU utilization stayed under ~2%, and memory utilization stayed around ~2.3%. It was surprising to me, as the &lt;code&gt;ml.t3.medium&lt;/code&gt; instance is not a very strong instance and it’s only CPU dependent, so I expected a bigger impact on it’s hardware.&lt;/p&gt;

&lt;p&gt;That is exactly why idle cost matters. If the endpoint is lightly used, the waste does not come from a single expensive inference. The waste comes from keeping model-serving compute warm while there are no requests. For this workload, scale-to-zero mattered more than squeezing every last percent out of the instance.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxjc4xskcx4if1ozf8t6l.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxjc4xskcx4if1ozf8t6l.png" alt=" " width="800" height="358"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Another interesting metric which is available to see inside the console is the model latency metric and it looks like this:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7bvpzenjr93zra3jxpk6.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7bvpzenjr93zra3jxpk6.png" alt=" " width="799" height="591"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The actual model execution was not the slow part. SageMaker model latency stayed roughly in the 30–37 ms range during this run.&lt;/p&gt;

&lt;p&gt;That is an important distinction: model latency is not the same as user-visible pipeline latency. The end-to-end path also includes S3 upload, S3 event delivery, SQS batching, preprocessing, async endpoint queueing, result writing, result processing, and frontend polling.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 6 — Close the loop with an S3 event
&lt;/h2&gt;

&lt;p&gt;When SageMaker finishes, it writes the result JSON to &lt;code&gt;results/&lt;/code&gt;. Same as for WAV file upload, that is considered a S3 event, and the information about that event is put inside the SQS queue. When the queue gets some data, it triggers a small, plain-zip Lambda (no Docker needed here) that parses the prediction and finishes the record:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# infra/stacks/inference_stack.py
# Buffer SageMaker result objects so the Lambda can process them in batches.
&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;result_processor_queue&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sqs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Queue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ResultProcessorQueue&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;queue_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;heartsense-result-processor-queue-&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;stage&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;retention_period&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;Duration&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;days&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;SQS_RETENTION_DAYS&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;visibility_timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;Duration&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;seconds&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;RESULT_QUEUE_VISIBILITY_TIMEOUT_SECONDS&lt;/span&gt;
    &lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;dead_letter_queue&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;sqs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;DeadLetterQueue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;max_receive_count&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;SQS_MAX_RECEIVE_COUNT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;queue&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;result_processor_dlq&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# SageMaker writes to results/ → S3 notifies the result-processing SQS queue.
&lt;/span&gt;&lt;span class="n"&gt;local_inference_output_bucket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_event_notification&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;s3&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;EventType&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;OBJECT_CREATED&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;s3n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;SqsDestination&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;result_processor_queue&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;s3&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;NotificationKeyFilter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prefix&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;RESULTS_PREFIX&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# Result Processor Lambda consumes the queue with partial batch failure reporting.
&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;result_processor_function&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_event_source&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;lambda_event_sources&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;SqsEventSource&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;result_processor_queue&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;batch_size&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;SQS_BATCH_SIZE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;max_batching_window&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;Duration&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;seconds&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;SQS_BATCH_WINDOW_SECONDS&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="n"&gt;report_batch_item_failures&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Before the &lt;code&gt;ResultProcessor&lt;/code&gt; Lambda, which has a SQS queue in-front of it - the primary objective of this queue is to absorb the burst of completed SageMaker predictions.&lt;/p&gt;

&lt;p&gt;The result processor validates the payload and writes the final &lt;code&gt;COMPLETED&lt;/code&gt; state — again with a conditional update, so a fast inference that beats preprocessing’s &lt;code&gt;INFERRING&lt;/code&gt; write (or a duplicate S3 event) can never clobber the record:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# src/services/inference/result_processor.py
&lt;/span&gt;
&lt;span class="n"&gt;table&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update_item&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;Key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;analysis_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;analysis_id&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="n"&gt;UpdateExpression&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SET #status = :completed, #prediction = :prediction, &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;#confidence = :confidence, #model_version = :model_version, &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;#completed_at = :completed_at&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;ExpressionAttributeValues&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;:completed&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;AnalysisStatus&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;COMPLETED&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;:prediction&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;prediction&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;:confidence&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Decimal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;confidence&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
        &lt;span class="c1"&gt;# ...
&lt;/span&gt;        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;:inferring&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;AnalysisStatus&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;INFERRING&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;:processing&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;AnalysisStatus&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PROCESSING&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="c1"&gt;# only complete if we're mid-flight — idempotent against duplicates
&lt;/span&gt;    &lt;span class="n"&gt;ConditionExpression&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;#status IN (:inferring, :processing)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here are the invocation statistics of the Results Processor Lambda:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjxn5a5ij4j2y7yjxc6e4.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjxn5a5ij4j2y7yjxc6e4.png" alt=" " width="799" height="240"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This image shows the result processor is tiny:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;145 invocations&lt;/li&gt;
&lt;li&gt;average duration ~257 ms&lt;/li&gt;
&lt;li&gt;max ~586 ms&lt;/li&gt;
&lt;li&gt;min ~44 ms&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This Lambda is intentionally boring — it doesn’t generate audio features, load a model or do any heavy computation. It simply reads the SageMaker output JSON, validates that everything is alright with the payload and updates the DynamoDB table containing the results.&lt;/p&gt;

&lt;p&gt;During multiple test runs, it handled ~140 invocations with an average duration ~260ms and a max duration of just under 600ms. That kind of work for a Lambda is excellent and excels at: short, event-driven glue code around managed services. &lt;/p&gt;

&lt;p&gt;The frontend polls &lt;code&gt;GET /analyses/{id}&lt;/code&gt; and flips from a spinner to the result the moment the record hits &lt;code&gt;COMPLETED&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flfoe4pwdtvu2ulb7sxic.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flfoe4pwdtvu2ulb7sxic.png" alt=" " width="800" height="459"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Failure handling, in one place
&lt;/h2&gt;

&lt;p&gt;Because every stage is decoupled, failures are isolated instead of cascading:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Failure&lt;/th&gt;
&lt;th&gt;What happens&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Corrupt / invalid WAV&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;PermanentError&lt;/code&gt; → record marked &lt;code&gt;FAILED&lt;/code&gt;, message consumed (no retry)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;S3 throttle / 5xx&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;TransientError&lt;/code&gt; → SQS redelivers, up to 3×&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SageMaker throttle&lt;/td&gt;
&lt;td&gt;manual backoff (1s, 2s); if exhausted → &lt;code&gt;FAILED&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Poison message&lt;/td&gt;
&lt;td&gt;after 3 receives → DLQ → CloudWatch alarm&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Duplicate S3 event&lt;/td&gt;
&lt;td&gt;idempotency table + conditional writes → no-op&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  What the metrics showed
&lt;/h2&gt;

&lt;p&gt;After running a small batch through the pipeline, the metrics matched the architecture:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Preprocessing was the heavier Lambda stage, averaging ~8.9s and peaking around ~27.6s.&lt;/li&gt;
&lt;li&gt;Result processing was tiny, averaging ~257ms.&lt;/li&gt;
&lt;li&gt;SageMaker model latency stayed around 30–37ms.&lt;/li&gt;
&lt;li&gt;Endpoint CPU and memory utilization stayed low, which made scale-to-zero more important than raw instance utilization.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That confirmed the main design decision: this was not one workload. It was several smaller workloads with different scaling profiles.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this buys you
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The expensive model-serving compute scales to zero.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Independent tuning.&lt;/strong&gt; Preprocessing is 1 GB / 300s; inference has its own instance type and timeout. Neither blocks the other.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Burst tolerance.&lt;/strong&gt; SQS flattens spikes; the async queue means callers never see capacity errors.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Self-healing.&lt;/strong&gt; Transient errors retry, permanent errors fail fast, duplicates are absorbed, and poison messages park in a DLQ you can replay.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The whole thing is a few hundred lines of CDK. If you’ve been forcing ML into a single Lambda and watching it strain, this is the shape I’d reach for instead.&lt;/p&gt;

</description>
      <category>aws</category>
      <category>programming</category>
      <category>python</category>
      <category>serverless</category>
    </item>
    <item>
      <title>AWS AppSync: The Easiest Way to Build Real-Time Apps</title>
      <dc:creator>Tanseer</dc:creator>
      <pubDate>Wed, 19 Aug 2026 05:30:00 +0000</pubDate>
      <link>https://dev.to/aws-builders/aws-appsync-the-easiest-way-to-build-real-time-apps-5b1c</link>
      <guid>https://dev.to/aws-builders/aws-appsync-the-easiest-way-to-build-real-time-apps-5b1c</guid>
      <description>&lt;p&gt;Build APIs that fetch exactly the data an app needs and push live updates as things change. Stop five in the AWS Hidden Gems series.&lt;/p&gt;

&lt;h2&gt;
  
  
  About this series
&lt;/h2&gt;

&lt;p&gt;Most AWS learning stops after EC2, S3, IAM, and Lambda. But AWS has over two hundred services, and many of the most useful ones rarely appear in tutorials.&lt;/p&gt;

&lt;p&gt;AWS Hidden Gems covers those underrated services you shouldn't ignore. Each article picks one, then explains why it exists, what it does, where it fits, and how to set it up from the console. Know the four basics above and you can follow along. Everything else gets explained as it comes up.&lt;/p&gt;

&lt;h2&gt;
  
  
  Today's service: AWS AppSync
&lt;/h2&gt;

&lt;p&gt;Modern apps often need two things that are awkward to build: letting the client ask for exactly the data it wants in one request, and pushing live updates to the screen the moment data changes. AppSync gives you both. It is a managed service for building GraphQL APIs, with real time updates included.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3n39yhghkxbr1etr852g.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3n39yhghkxbr1etr852g.png" alt=" " width="799" height="221"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why does this service exist?
&lt;/h2&gt;

&lt;p&gt;Two common API headaches led to AppSync. First, with traditional REST APIs, the client often calls several endpoints and gets back more data than it needs, which is slow on mobile. GraphQL fixes this by letting the client ask for precisely the fields it wants in a single request, but running a GraphQL server yourself means managing servers, connecting data sources, and writing a lot of glue.&lt;/p&gt;

&lt;p&gt;Second, live updates. Showing new messages or a changing dashboard in real time usually means managing WebSocket connections, which are long lived two way connections between client and server. Doing that at scale is fiddly.&lt;/p&gt;

&lt;p&gt;AppSync handles both. It runs the GraphQL API for you, connects it to your data with little code, and manages the real time connections so updates just arrive.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is AWS AppSync?
&lt;/h2&gt;

&lt;p&gt;AppSync is a managed GraphQL service. GraphQL is a query language for APIs where the client describes the exact data it wants, and the server returns just that, in one round trip.&lt;/p&gt;

&lt;p&gt;You define a schema, which is the shape of your data and the operations allowed on it. You connect each part of the schema to a data source, which AppSync can talk to directly:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;DynamoDB for fast NoSQL storage&lt;/li&gt;
&lt;li&gt;Lambda for any custom logic&lt;/li&gt;
&lt;li&gt;Relational databases, HTTP endpoints, and others
AppSync supports three kinds of operations: queries to read data, mutations to change data, and subscriptions to receive live updates. When someone changes data through a mutation, AppSync automatically pushes that change to every client subscribed to it. Authentication is built in, with options including API keys and Amazon Cognito, the AWS user sign in service.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  A real world problem
&lt;/h2&gt;

&lt;p&gt;A team is building a shared task board, like a simple Trello. When one person moves a card, everyone else looking at the board should see it move immediately.&lt;/p&gt;

&lt;p&gt;Building this the usual way means a REST API for the data plus a separate WebSocket layer to broadcast changes, with code to track who is connected and who cares about which board. That is a lot of plumbing for a small team.&lt;/p&gt;

&lt;p&gt;With AppSync, they define the board and card types once, back them with DynamoDB, and add a subscription. Now a mutation to move a card is pushed to every open board automatically. The real time behavior comes almost for free.&lt;/p&gt;

&lt;h2&gt;
  
  
  Real world use cases
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Chat and messaging apps deliver new messages instantly to everyone in a conversation&lt;/li&gt;
&lt;li&gt;Collaborative tools like shared boards and documents sync changes across users live&lt;/li&gt;
&lt;li&gt;Live dashboards update metrics on screen as new data arrives&lt;/li&gt;
&lt;li&gt;Multiplayer game features share state like scores and moves between players&lt;/li&gt;
&lt;li&gt;Mobile apps fetch lean, exact payloads to stay fast on slow connections&lt;/li&gt;
&lt;li&gt;Apps that work offline sync their changes when the connection returns
The pattern is flexible data access plus live updates, without building the real time layer yourself.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Where it fits in AWS
&lt;/h2&gt;

&lt;p&gt;The client app talks to a single AppSync endpoint. AppSync routes each request to the right data source, most often DynamoDB for storage or Lambda for custom logic. Cognito handles who is allowed to do what. For live updates, clients hold a subscription connection to AppSync, and AppSync pushes changes to them.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;flowchart LR
    A[Client app] --&amp;gt;|GraphQL query or mutation| B[AppSync API]
    B --&amp;gt; C[DynamoDB]
    B --&amp;gt; D[Lambda]
    B --&amp;gt;|Auth check| E[Cognito]
    B --&amp;gt;|Live updates| A
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;AppSync sits between your app and your data, giving one flexible endpoint and handling the real time push.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the workflow runs
&lt;/h2&gt;

&lt;p&gt;You define a GraphQL schema with your types and operations. You attach data sources and connect each field to an action, such as read this item from DynamoDB. The client sends a query or mutation, and AppSync runs the connected action and returns exactly the requested fields. When a mutation changes data, AppSync notifies every client subscribed to that change over their live connection.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;flowchart TD
    A[Define GraphQL schema] --&amp;gt; B[Connect fields to data sources]
    B --&amp;gt; C[Client sends query or mutation]
    C --&amp;gt; D[AppSync runs it against the data source]
    D --&amp;gt; E[Return exactly the requested fields]
    D --&amp;gt; F[Push updates to subscribed clients]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Setting it up in the AWS Console
&lt;/h2&gt;

&lt;p&gt;You will create a GraphQL API, let AppSync build a DynamoDB backed type, and test it live.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Sign in to the AWS Console, search for AppSync, and open it. Check the region in the top right corner.&lt;/li&gt;
&lt;li&gt;Click Create API, choose GraphQL APIs, and pick the option to design from scratch. Give the API a name and create it.&lt;/li&gt;
&lt;li&gt;Open the Schema page. Define a simple type and the operations for it, for example a Task type with an id and a title, and queries and mutations to create and list tasks.&lt;/li&gt;
&lt;li&gt;Use the Create Resources shortcut on the schema. AppSync can generate a DynamoDB table and the resolvers, which are the small pieces that connect each GraphQL field to a table action, so you do not write them by hand.&lt;/li&gt;
&lt;li&gt;Go to the Queries page, a built in tool for running operations against your API. Run a mutation to create a task, then a query to list tasks, and confirm your data comes back.&lt;/li&gt;
&lt;li&gt;To see real time updates, open a subscription in the Queries page in one browser tab, then run a create mutation in another. The new task appears in the subscription tab immediately.&lt;/li&gt;
&lt;li&gt;For your app to call the API, note the API URL and set an authorization mode under Settings. An API key is fine for testing, while Cognito is the usual choice for real users. Confirm a call works from your code using the URL and key.
Common mistakes: an unauthorized error usually means the request is missing the API key or Cognito token the API expects, so check the authorization mode and that your client sends the right credential. If a field returns null, its resolver is probably not connected to a data source yet.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Using it from code
&lt;/h2&gt;

&lt;p&gt;With AppSync, the code is your GraphQL schema and the operations clients run against it. Here is a small schema and the three operation types.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight graphql"&gt;&lt;code&gt;&lt;span class="k"&gt;type&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;ID&lt;/span&gt;&lt;span class="p"&gt;!&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;!&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="n"&gt;done&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;Boolean&lt;/span&gt;&lt;span class="p"&gt;!&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;span class="k"&gt;type&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Query&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="n"&gt;listTasks&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;span class="k"&gt;type&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Mutation&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="n"&gt;addTask&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;!):&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;span class="k"&gt;type&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Subscription&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="n"&gt;onAddTask&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="err"&gt;@&lt;/span&gt;&lt;span class="n"&gt;aws_subscribe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mutations&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="err"&gt;"&lt;/span&gt;&lt;span class="n"&gt;addTask&lt;/span&gt;&lt;span class="err"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A client adds a task and, thanks to the subscription, every other client is notified without polling:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight graphql"&gt;&lt;code&gt;&lt;span class="k"&gt;mutation&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="n"&gt;addTask&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Write the AppSync post"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="n"&gt;done&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The @aws_subscribe line is what wires the subscription to the mutation, so any addTask pushes the new task to subscribers automatically.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pricing
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Item&lt;/th&gt;
&lt;th&gt;Detail&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Queries and mutations&lt;/td&gt;
&lt;td&gt;Per million operations&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Query and mutation rate&lt;/td&gt;
&lt;td&gt;About $4.00 per million operations (US)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Real time updates&lt;/td&gt;
&lt;td&gt;Per million updates pushed to clients&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Connection time&lt;/td&gt;
&lt;td&gt;Per million minutes clients stay connected&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Free tier&lt;/td&gt;
&lt;td&gt;250,000 operations and 250,000 real time updates per month for 12 months&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  The AWS API and app services family
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;AWS API and App Services
├── AppSync       managed GraphQL with real time updates
├── API Gateway   REST and WebSocket APIs you route yourself
├── Amplify       front end hosting and app building tools
└── Lambda        serverless functions behind your APIs
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The main choice is AppSync versus API Gateway. AppSync is built around GraphQL and real time updates, and connects to data sources with little code. API Gateway is built around REST and WebSocket routing, giving you more manual control. For live, data driven apps, AppSync usually gets you there faster.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrapping up
&lt;/h2&gt;

&lt;p&gt;AppSync gives you a flexible GraphQL API and real time updates without running a server or building a WebSocket layer. Define a schema, point it at your data, and changes flow to every client live. Next time an app needs live, precise data access, you know the service that skips the plumbing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Series progress
&lt;/h2&gt;

&lt;p&gt;You are on stop five of AWS Hidden Gems.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;AWS Elemental MediaConvert&lt;/li&gt;
&lt;li&gt;Amazon IVS&lt;/li&gt;
&lt;li&gt;Amazon Rekognition&lt;/li&gt;
&lt;li&gt;Amazon Personalize&lt;/li&gt;
&lt;li&gt;AWS AppSync (you are here)&lt;/li&gt;
&lt;li&gt;Amazon Timestream&lt;/li&gt;
&lt;li&gt;Amazon Textract&lt;/li&gt;
&lt;li&gt;Amazon Kendra&lt;/li&gt;
&lt;li&gt;AWS DataSync&lt;/li&gt;
&lt;li&gt;AWS IoT Core
Next up is Amazon Timestream, a database built specifically for time series data like sensor readings and metrics.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Let's connect
&lt;/h2&gt;

&lt;p&gt;Questions, corrections, or want to talk through where this fits in your own project? Reach me at &lt;a href="mailto:khantanseer43@gmail.com"&gt;khantanseer43@gmail.com&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>aws</category>
      <category>awsappsync</category>
    </item>
    <item>
      <title>Cómo mantener siempre disponibles las sesiones de tu agente de código remoto (parte 2)</title>
      <dc:creator>Franchesco Romero</dc:creator>
      <pubDate>Wed, 19 Aug 2026 00:53:33 +0000</pubDate>
      <link>https://dev.to/aws-builders/mover-un-agente-de-ia-siempre-encendido-de-un-vps-de-24-a-fargate-spot-pt-2-o4f</link>
      <guid>https://dev.to/aws-builders/mover-un-agente-de-ia-siempre-encendido-de-un-vps-de-24-a-fargate-spot-pt-2-o4f</guid>
      <description>&lt;p&gt;La Parte 1 construyó una caja siempre encendida para una sesión de &lt;code&gt;remote-control&lt;/code&gt; de Claude Code y terminó con una alarma que convierte seis días de silencio en quince minutos. Esa alarma atrapa una caja &lt;em&gt;muerta&lt;/em&gt;. Esta es la secuela, y la caja de aquí nunca estuvo muerta. Estaba &lt;code&gt;active&lt;/code&gt;, su token estaba fresco, y en cada reinicio imprimía:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Take this session with you and pick up right where you left off on any device.
Open the Code tab in the Claude mobile app, or visit claude.ai/code in a browser.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;y la sesión no estaba en &lt;code&gt;claude.ai/code&lt;/code&gt;. Nunca se había registrado. El banner de arriba se imprime al arrancar, &lt;em&gt;antes&lt;/em&gt; de que haya pasado lo que anuncia, así que le mintió al monitoreo, me mintió a mí cuando lo "arreglé", y casi se mintió hasta meterse en este blog post como el arreglo. Este post es sobre la diferencia entre un proceso que dice que está listo y una sesión que de verdad lo está.&lt;/p&gt;

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

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Lo que creía&lt;/th&gt;
&lt;th&gt;Lo que era cierto&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Causa raíz&lt;/td&gt;
&lt;td&gt;el registro se puso viejo a lo largo de ~6 días de uptime&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;nunca se registró&lt;/strong&gt;, el CLI se colgó en un prompt interactivo sin TTY&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;El "arreglo"&lt;/td&gt;
&lt;td&gt;reinicia el servicio; el banner regresa; el heartbeat en verde&lt;/td&gt;
&lt;td&gt;el reinicio imprimió el banner otra vez y aun así no se registró&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;La señal de salud que construí&lt;/td&gt;
&lt;td&gt;grep del journal por &lt;code&gt;claude.ai/code&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;ese string es el &lt;strong&gt;banner de pre-registro&lt;/strong&gt;, verde para un proceso colgado&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;La señal real&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;la &lt;strong&gt;URL registrada&lt;/strong&gt; (&lt;code&gt;claude.ai/code/session_…&lt;/code&gt;), que solo se imprime después del registro&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Placeholders, como en la parte 1: &lt;code&gt;myapp&lt;/code&gt;, &lt;code&gt;appuser&lt;/code&gt;, &lt;code&gt;acme/myapp&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  El síntoma
&lt;/h2&gt;

&lt;p&gt;La sesión simplemente no estaba en la lista de &lt;code&gt;claude.ai/code&lt;/code&gt;, mientras que todo en la caja decía que debería estar:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;systemctl is-active claude-remote-control
&lt;span class="go"&gt;active
&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;stat&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; %y ~/.claude/.credentials.json
&lt;span class="gp"&gt;2026-07-28 02:54:11          #&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;token refrescado hace horas, la auth está bien
&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;aws cloudwatch describe-alarms &lt;span class="nt"&gt;--alarm-names&lt;/span&gt; myapp-agent-box-down &lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="go"&gt;    --query 'MetricAlarms[].StateValue' --output text
OK
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Así que hice lo obvio: reinicié el servicio. Imprimió el banner, el proceso subió, el heartbeat se puso verde, y la sesión seguía sin estar ahí. Ese es el momento en el que vale la pena detenerse. Cada señal que tenía decía sano, y el producto real (una sesión que puedo abrir desde un navegador) no existía.&lt;br&gt;
&lt;strong&gt;Un reinicio que produce un banner no es un reinicio que produce una&lt;br&gt;
sesión.&lt;/strong&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Conseguir una señal que no puede mentir
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;systemctl is-active&lt;/code&gt; reporta que el proceso existe. El banner reporta que el proceso &lt;em&gt;arrancó&lt;/em&gt;. Ninguno reporta que la caja se registró con el punto de encuentro hospedado, lo único que mete la sesión en la lista. Para ver eso, pídele al CLI que escriba su propio log de debug y lee lo que de verdad pasa:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;claude remote-control &lt;span class="nt"&gt;--name&lt;/span&gt; myapp-cloud &lt;span class="nt"&gt;--continue&lt;/span&gt; &lt;span class="nt"&gt;--debug-file&lt;/span&gt; /tmp/rc.log &lt;span class="nt"&gt;--verbose&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Dos fallas cayeron de inmediato, y ninguna era "vieja":&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Take this session with you and pick up right where you left off on any device.
Open the Code tab in the Claude mobile app, or visit claude.ai/code in a browser.
The session keeps running on this machine. ... Press Ctrl+C to stop.

Enable Remote Control? (y/n)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;El proceso imprime el banner y luego &lt;strong&gt;se bloquea en un prompt interactivo&lt;/strong&gt;.&lt;br&gt;
Bajo systemd no hay TTY que lo conteste, así que espera ahí para siempre: &lt;code&gt;active&lt;/code&gt;, banner emitido, registro ni siquiera intentado. Los CLI recientes agregaron esta confirmación; la caja de la parte 1 es anterior a ella.&lt;/p&gt;

&lt;p&gt;Y cuando le di una &lt;code&gt;y&lt;/code&gt;, la segunda falla:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Error: No recent session found in this directory or its worktrees.
Run `claude remote-control` to start a new one.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;--continue&lt;/code&gt;, la bandera de la parte 1 que mantiene el marcador estable reanudando la misma sesión, &lt;strong&gt;da error duro cuando no hay sesión que reanudar&lt;/strong&gt;. En una caja cuyo historial de sesión local se había borrado en una reconstrucción, &lt;code&gt;--continue&lt;/code&gt; no era una optimización; era un arranque que solo podía fallar.&lt;/p&gt;

&lt;h2&gt;
  
  
  El arreglo real: contesta el prompt, y no dependas de que una sesión exista
&lt;/h2&gt;

&lt;p&gt;Las dos fallas viven en una línea, así que el arreglo es una línea, un wrapper en &lt;code&gt;ExecStart&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="py"&gt;ExecStart&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;/bin/bash -c 'cd /home/appuser/myapp; &lt;/span&gt;&lt;span class="se"&gt;\
&lt;/span&gt;  &lt;span class="s"&gt;echo y | /home/appuser/.local/bin/claude remote-control --name myapp-cloud --continue || &lt;/span&gt;&lt;span class="se"&gt;\
&lt;/span&gt;  &lt;span class="s"&gt;echo y | /home/appuser/.local/bin/claude remote-control --name myapp-cloud'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Tres cosas se ganan su lugar:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;echo y&lt;/code&gt;&lt;/strong&gt; contesta el prompt. Importa que el proceso siga corriendo después de que su stdin se cierra (verificado, sí lo hace), así que un &lt;code&gt;echo&lt;/code&gt; de una sola vez basta; sin &lt;code&gt;yes&lt;/code&gt; inundando el pipe, sin &lt;code&gt;sleep infinity&lt;/code&gt; manteniéndolo abierto.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;--continue || &amp;lt;nuevo&amp;gt;&lt;/code&gt;&lt;/strong&gt; trata de reanudar (marcador estable) y cae de vuelta a una sesión nueva cuando no hay nada que reanudar. Como &lt;code&gt;echo y&lt;/code&gt; cierra limpio, un &lt;code&gt;--continue&lt;/code&gt; fallido de verdad &lt;em&gt;regresa&lt;/em&gt; y corre el fallback; un &lt;code&gt;sleep infinity&lt;/code&gt; aquí colgaría el pipeline en el camino de falla y reintroduciría el atoro silencioso exacto.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sin &lt;code&gt;$@&lt;/code&gt; ni &lt;code&gt;$VAR&lt;/code&gt;&lt;/strong&gt; en &lt;code&gt;ExecStart&lt;/code&gt;. systemd expande &lt;code&gt;${}&lt;/code&gt; y &lt;code&gt;$VAR&lt;/code&gt; pelón él mismo, y mutila &lt;code&gt;$@&lt;/code&gt;; la jugada segura es repetir la ruta en lugar de factorizarla en una variable.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Ahora el log de debug muestra lo que el banner solo fingía:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[bridge:init] Resuming session session_01Pp8… on environment env_01Vp…
[bridge:init] Registered, server environmentId=env_01Vp…
[bridge:title] server title: myapp-cloud
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Registered&lt;/code&gt;. Ese es el recibo. El banner era el menú.&lt;/p&gt;

&lt;h2&gt;
  
  
  Defensa en profundidad, todavía vale la pena conservarla
&lt;/h2&gt;

&lt;p&gt;El wrapper arregla el outage. Dos guardas del primer borrador de este trabajo se quedan, porque endurecen la caja contra &lt;em&gt;otras&lt;/em&gt; maneras en que la sesión puede caducar:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reciclado diario.&lt;/strong&gt; &lt;code&gt;RuntimeMaxSec=86400&lt;/code&gt; rebota la unidad cada 24h; &lt;code&gt;Restart=always&lt;/code&gt; la trae de vuelta y &lt;code&gt;--continue&lt;/code&gt; reanuda la misma sesión, así que el marcador aguanta. Si un registro alguna vez se pone viejo en un uptime largo, esto le pone tope al radio de impacto en un día en lugar de confiar en&lt;br&gt;
que viva para siempre. &lt;code&gt;TimeoutStopSec=20&lt;/code&gt; mantiene cada reciclado rápido, ya que el CLI ignora &lt;code&gt;SIGTERM&lt;/code&gt; por ~90s.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Escritura de vuelta de credenciales.&lt;/strong&gt; La parte 1 señaló que el CLI rota su refresh token y la captura del secreto se pudre. Un helper chico empuja el token vivo de vuelta a Secrets Manager en cada corrida del watchdog, protegiendo con fuerza contra escribir un archivo local malformado encima de un secreto bueno, para que una instancia &lt;em&gt;reemplazada&lt;/em&gt; ya no arranque sin autenticar.&lt;/p&gt;
&lt;h2&gt;
  
  
  El health check al que engañó el mismo banner
&lt;/h2&gt;

&lt;p&gt;Aquí está la parte que arde. La primera versión del chequeo de readiness, la que se suponía atrapaba justo este estado de "arriba pero sin registrar", hacía esto:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# MAL: hace match al banner de pre-registro, así que está verde para un proceso colgado&lt;/span&gt;
journalctl &lt;span class="nt"&gt;-u&lt;/span&gt; claude-remote-control &lt;span class="nt"&gt;--since&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;AGE&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;s ago"&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-q&lt;/span&gt; &lt;span class="s2"&gt;"claude.ai/code"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;claude.ai/code&lt;/code&gt; está en el banner que se imprime &lt;em&gt;antes&lt;/em&gt; del prompt en el que el proceso estaba colgado. El chequeo que construí para detectar la falla quedaba satisfecho por la falla. Es el bug original vestido con el disfraz de su propio arreglo.&lt;/p&gt;

&lt;p&gt;La sesión registrada imprime una línea distinta, una URL con un id de sesión o de environment:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Continue coding in the Claude mobile app or https://claude.ai/code/session_01Pp8…
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;El discriminador entre "anunciada" y "registrada" es un solo carácter: lo que sigue a &lt;code&gt;code&lt;/code&gt;. El banner dice &lt;code&gt;code in a browser&lt;/code&gt;; la cosa real dice &lt;code&gt;code/session_…&lt;/code&gt; o &lt;code&gt;code?environment=…&lt;/code&gt;. Así que haz match a eso:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# solo una sesión REGISTRADA imprime una URL con '/' o '?' después de 'code'&lt;/span&gt;
journalctl &lt;span class="nt"&gt;-u&lt;/span&gt; claude-remote-control &lt;span class="nt"&gt;--since&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;AGE&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;s ago"&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-qE&lt;/span&gt; &lt;span class="s1"&gt;'claude\.ai/code[/?]'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Con el chequeo corregido el heartbeat significa lo que dice: active, lo bastante joven para haberse registrado, y una URL de registro en su propio log.&lt;br&gt;
Cualquier otra cosa degrada el latido a &lt;code&gt;DEGRADED&lt;/code&gt; y reinicia, y &lt;code&gt;DEGRADED&lt;/code&gt; no hace match al filtro &lt;code&gt;OK&lt;/code&gt; de la parte 1, así que un estado que no se va a auto-sanar de todos modos le suena a la alarma existente.&lt;/p&gt;

&lt;h2&gt;
  
  
  Callejones sin salida (los dos el mismo error)
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Checar por una conexión de red abierta.&lt;/strong&gt; La sonda de readiness intuitiva:
seguramente una sesión registrada sostiene un socket al punto de encuentro.
Medido en la caja sana: cero sockets establecidos. Una sesión registrada
inactiva no mantiene ninguno. El chequeo reiniciaría una caja &lt;em&gt;funcionando&lt;/em&gt;
en cada tick.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Grep del banner de arranque.&lt;/strong&gt; Cubierto arriba. Verde antes de que el
trabajo esté hecho.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Los dos fallan de la misma manera: afirman sobre algo que es cierto &lt;em&gt;antes&lt;/em&gt; de&lt;br&gt;
que pase la cosa real. Un socket que quizá todavía no esté ahí; un banner que&lt;br&gt;
está ahí demasiado pronto. El arreglo en los dos casos es afirmar sobre el&lt;br&gt;
artefacto que existe &lt;em&gt;solo después del éxito&lt;/em&gt;: la línea &lt;code&gt;Registered&lt;/code&gt;, la URL&lt;br&gt;
con un id.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lecciones
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Un banner es un menú, no un recibo.&lt;/strong&gt; Cualquier mensaje de "ya puedes
hacer X" impreso al arrancar es una intención, no una confirmación. Los
health checks tienen que indexarse en el artefacto que existe solo una vez
que X de verdad pasó.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reiniciar hasta que reaparezca el mensaje feliz no es verificación.&lt;/strong&gt; Vi
el banner, vi el heartbeat ponerse verde, y seguí adelante, dos veces. El
mensaje era el mismo las dos veces porque nunca dependió del éxito.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Los prompts interactivos son minas en cajas sin atender.&lt;/strong&gt; Una sola
compuerta &lt;code&gt;(y/n)&lt;/code&gt; nueva, incontestable sin un TTY, convierte en silencio un
servicio que funciona en uno colgado. Dale la respuesta explícitamente; no
asumas que un servicio hereda la de tu terminal.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Las banderas de conveniencia tienen modos de falla.&lt;/strong&gt; &lt;code&gt;--continue&lt;/code&gt;
mantiene un marcador estable y falla duro cuando no hay nada que continuar.
En una caja que se puede reconstruir desde nada, "reanudar" necesita un "o
arranca de nuevo" al lado.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cuando no hay API de estado, afirma sobre el log de éxito, el
específico.&lt;/strong&gt; No el banner optimista. La línea que solo el camino exitoso
puede producir.&lt;/li&gt;
&lt;/ol&gt;

</description>
      <category>agents</category>
      <category>aws</category>
      <category>devops</category>
      <category>serverless</category>
    </item>
    <item>
      <title>RDS High Availability and credential rotation without downtime</title>
      <dc:creator>Carlos Filho</dc:creator>
      <pubDate>Tue, 18 Aug 2026 18:54:22 +0000</pubDate>
      <link>https://dev.to/aws-builders/rds-high-availability-and-credential-rotation-without-downtime-5fpj</link>
      <guid>https://dev.to/aws-builders/rds-high-availability-and-credential-rotation-without-downtime-5fpj</guid>
      <description>&lt;p&gt;I got an AWS question and implemented it to make sure that the option is correct.&lt;/p&gt;

&lt;p&gt;A critical financial application runs on RDS for PostgreSQL. The requirements are tight: 1-second RPO, 60-second RTO, and database credentials rotated every 30 days without taking the application offline.&lt;/p&gt;

&lt;p&gt;Two independent problems. Two independent solutions.&lt;/p&gt;




&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;p&gt;Check these before running &lt;code&gt;terraform apply&lt;/code&gt;:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;RDS Proxy availability&lt;/strong&gt;&lt;br&gt;
RDS Proxy is not available on all instance types. It requires instances with at least 2 vCPUs. &lt;code&gt;db.t3.micro&lt;/code&gt; is not supported. &lt;code&gt;db.t3.medium&lt;/code&gt; and above work.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Terraform executor permissions&lt;/strong&gt;&lt;br&gt;
The IAM principal running Terraform needs, at minimum:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;rds:CreateDBInstance
rds:CreateDBProxy
rds:CreateDBProxyTargetGroup
rds:RegisterDBProxyTargets
rds:ModifyDBInstance
iam:CreateRole
iam:AttachRolePolicy
iam:PutRolePolicy
iam:PassRole
secretsmanager:CreateSecret
secretsmanager:PutSecretValue
secretsmanager:RotateSecret
lambda:CreateFunction
lambda:AddPermission
ec2:CreateSecurityGroup
ec2:AuthorizeSecurityGroupIngress
ec2:CreateDBSubnetGroup
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;AdministratorAccess&lt;/code&gt; on the account covers all of these. Lock it down after the initial setup.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;VPC requirements&lt;/strong&gt;&lt;br&gt;
RDS Proxy runs inside your VPC. You need at least two private subnets in different Availability Zones. The rotation Lambda also runs inside the VPC so it can reach the RDS instance directly during the credential update step.&lt;/p&gt;


&lt;h2&gt;
  
  
  The problem
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Database failure recovery&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;RPO of 1 second means almost no data loss is acceptable. RTO of 60 seconds means the application must resume within a minute of a failure. A standard single-instance RDS setup fails both requirements: there is no automatic failover, and restoring from a backup takes far longer than 60 seconds.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Credential rotation&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Rotating credentials on a schedule sounds simple until you factor in application downtime. If you update a password and the application still holds connections authenticated with the old one, those connections fail. The rotation mechanism needs to handle the transition without the application noticing.&lt;/p&gt;


&lt;h2&gt;
  
  
  The solutions
&lt;/h2&gt;
&lt;h3&gt;
  
  
  Multi-AZ + RDS Proxy
&lt;/h3&gt;

&lt;p&gt;RDS Multi-AZ keeps a synchronous standby replica in a second Availability Zone. Every write to the primary is committed to the standby before the transaction is acknowledged. This gives you RPO close to zero, typically under 1 second for PostgreSQL.&lt;/p&gt;

&lt;p&gt;When the primary fails, RDS automatically promotes the standby. The DNS record for the cluster endpoint is updated to point to the new primary. Failover typically completes in 60 seconds or less, which meets the RTO requirement.&lt;/p&gt;

&lt;p&gt;The problem is what happens to application connections during that DNS update. Existing database connections are dropped. If your application has a connection pool, it will spend time re-establishing connections and waiting for DNS propagation. Under load, this causes errors.&lt;/p&gt;

&lt;p&gt;RDS Proxy sits between the application and the database. The application connects to the Proxy endpoint, not the RDS endpoint directly. The Proxy maintains a warm pool of connections to the database. During a Multi-AZ failover, the Proxy detects the new primary and reroutes connections internally. The application connection to the Proxy stays alive. From the application's perspective, the failover is nearly invisible.&lt;/p&gt;

&lt;p&gt;Without the Proxy, Multi-AZ alone may technically meet 60-second RTO on paper but cause application-level errors during the DNS transition. With the Proxy, the application keeps working through the failover.&lt;/p&gt;
&lt;h3&gt;
  
  
  Secrets Manager with automatic rotation
&lt;/h3&gt;

&lt;p&gt;Secrets Manager stores the database credentials and handles rotation through a managed Lambda function. AWS provides a built-in rotation Lambda for RDS PostgreSQL that handles the full rotation cycle: generate a new password, update it in RDS, update the secret, verify the new credentials work.&lt;/p&gt;

&lt;p&gt;The rotation Lambda follows a four-step process using staging labels (&lt;code&gt;AWSPENDING&lt;/code&gt;, &lt;code&gt;AWSCURRENT&lt;/code&gt;, &lt;code&gt;AWSPREVIOUS&lt;/code&gt;). The new password is set in the database before it becomes &lt;code&gt;AWSCURRENT&lt;/code&gt;. This means both the old and new passwords are valid during the transition window. Applications retrieving credentials from Secrets Manager at runtime always get the current valid credentials without any downtime.&lt;/p&gt;

&lt;p&gt;RDS Proxy reads credentials from Secrets Manager directly. The application connects to the Proxy endpoint and never touches credentials directly. When Secrets Manager rotates the password, the Proxy picks up the new credentials on its next refresh cycle. The application connection to the Proxy stays alive throughout.&lt;/p&gt;


&lt;h2&gt;
  
  
  Terraform
&lt;/h2&gt;
&lt;h3&gt;
  
  
  File structure
&lt;/h3&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;├── variables.tf
├── networking.tf
├── iam.tf
├── rds.tf
├── secrets.tf
└── outputs.tf
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;variables.tf&lt;/code&gt;
&lt;/h3&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;variable&lt;/span&gt; &lt;span class="s2"&gt;"aws_region"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;description&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"AWS region to deploy resources"&lt;/span&gt;
  &lt;span class="nx"&gt;type&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;string&lt;/span&gt;
  &lt;span class="nx"&gt;default&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"us-east-1"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;variable&lt;/span&gt; &lt;span class="s2"&gt;"vpc_id"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;description&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"ID of the VPC where RDS and RDS Proxy will run"&lt;/span&gt;
  &lt;span class="nx"&gt;type&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;string&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;variable&lt;/span&gt; &lt;span class="s2"&gt;"private_subnet_ids"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;description&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"List of private subnet IDs in at least two AZs"&lt;/span&gt;
  &lt;span class="nx"&gt;type&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;variable&lt;/span&gt; &lt;span class="s2"&gt;"app_security_group_id"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;description&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Security group ID of the application servers that connect to RDS Proxy"&lt;/span&gt;
  &lt;span class="nx"&gt;type&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;string&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;variable&lt;/span&gt; &lt;span class="s2"&gt;"db_password"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;description&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Initial database password (Secrets Manager takes over after first rotation)"&lt;/span&gt;
  &lt;span class="nx"&gt;type&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;string&lt;/span&gt;
  &lt;span class="nx"&gt;sensitive&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;networking.tf&lt;/code&gt;: security groups
&lt;/h3&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Security group for the RDS instance&lt;/span&gt;
&lt;span class="c1"&gt;# Accepts connections from RDS Proxy and the rotation Lambda only&lt;/span&gt;
&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_security_group"&lt;/span&gt; &lt;span class="s2"&gt;"rds"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"financial-app-rds"&lt;/span&gt;
  &lt;span class="nx"&gt;description&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Allow PostgreSQL access from RDS Proxy and rotation Lambda"&lt;/span&gt;
  &lt;span class="nx"&gt;vpc_id&lt;/span&gt;      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;vpc_id&lt;/span&gt;

  &lt;span class="nx"&gt;ingress&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;description&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"PostgreSQL from RDS Proxy"&lt;/span&gt;
    &lt;span class="nx"&gt;from_port&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;5432&lt;/span&gt;
    &lt;span class="nx"&gt;to_port&lt;/span&gt;         &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;5432&lt;/span&gt;
    &lt;span class="nx"&gt;protocol&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"tcp"&lt;/span&gt;
    &lt;span class="nx"&gt;security_groups&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;aws_security_group&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rds_proxy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;aws_security_group&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rotation_lambda&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;egress&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;from_port&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
    &lt;span class="nx"&gt;to_port&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
    &lt;span class="nx"&gt;protocol&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"-1"&lt;/span&gt;
    &lt;span class="nx"&gt;cidr_blocks&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"0.0.0.0/0"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;# Security group for RDS Proxy&lt;/span&gt;
&lt;span class="c1"&gt;# Accepts connections from the application tier&lt;/span&gt;
&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_security_group"&lt;/span&gt; &lt;span class="s2"&gt;"rds_proxy"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"financial-app-rds-proxy"&lt;/span&gt;
  &lt;span class="nx"&gt;description&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Allow PostgreSQL access from application servers"&lt;/span&gt;
  &lt;span class="nx"&gt;vpc_id&lt;/span&gt;      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;vpc_id&lt;/span&gt;

  &lt;span class="nx"&gt;ingress&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;description&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"PostgreSQL from application"&lt;/span&gt;
    &lt;span class="nx"&gt;from_port&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;5432&lt;/span&gt;
    &lt;span class="nx"&gt;to_port&lt;/span&gt;         &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;5432&lt;/span&gt;
    &lt;span class="nx"&gt;protocol&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"tcp"&lt;/span&gt;
    &lt;span class="nx"&gt;security_groups&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;app_security_group_id&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;egress&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;from_port&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
    &lt;span class="nx"&gt;to_port&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
    &lt;span class="nx"&gt;protocol&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"-1"&lt;/span&gt;
    &lt;span class="nx"&gt;cidr_blocks&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"0.0.0.0/0"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;# Security group for the rotation Lambda&lt;/span&gt;
&lt;span class="c1"&gt;# Needs outbound access to RDS and to the Secrets Manager VPC endpoint (or internet)&lt;/span&gt;
&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_security_group"&lt;/span&gt; &lt;span class="s2"&gt;"rotation_lambda"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"financial-app-rotation-lambda"&lt;/span&gt;
  &lt;span class="nx"&gt;description&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Allow rotation Lambda to reach RDS and Secrets Manager"&lt;/span&gt;
  &lt;span class="nx"&gt;vpc_id&lt;/span&gt;      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;vpc_id&lt;/span&gt;

  &lt;span class="nx"&gt;egress&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;from_port&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
    &lt;span class="nx"&gt;to_port&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
    &lt;span class="nx"&gt;protocol&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"-1"&lt;/span&gt;
    &lt;span class="nx"&gt;cidr_blocks&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"0.0.0.0/0"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;# DB subnet group: RDS requires subnets in at least two AZs&lt;/span&gt;
&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_db_subnet_group"&lt;/span&gt; &lt;span class="s2"&gt;"main"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"financial-app-db-subnet-group"&lt;/span&gt;
  &lt;span class="nx"&gt;subnet_ids&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;private_subnet_ids&lt;/span&gt;

  &lt;span class="nx"&gt;tags&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;Name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"financial-app-db-subnet-group"&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;iam.tf&lt;/code&gt;: roles for RDS Proxy and rotation Lambda
&lt;/h3&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Role for RDS Proxy to read credentials from Secrets Manager&lt;/span&gt;
&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_iam_role"&lt;/span&gt; &lt;span class="s2"&gt;"rds_proxy_role"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"RDSProxySecretsManagerRole"&lt;/span&gt;

  &lt;span class="nx"&gt;assume_role_policy&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;jsonencode&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="nx"&gt;Version&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"2012-10-17"&lt;/span&gt;
    &lt;span class="nx"&gt;Statement&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt;
      &lt;span class="nx"&gt;Effect&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Allow"&lt;/span&gt;
      &lt;span class="nx"&gt;Principal&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Service&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"rds.amazonaws.com"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="nx"&gt;Action&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"sts:AssumeRole"&lt;/span&gt;
    &lt;span class="p"&gt;}]&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_iam_role_policy"&lt;/span&gt; &lt;span class="s2"&gt;"rds_proxy_secrets"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"RDSProxySecretsManagerPolicy"&lt;/span&gt;
  &lt;span class="nx"&gt;role&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_iam_role&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rds_proxy_role&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;

  &lt;span class="nx"&gt;policy&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;jsonencode&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="nx"&gt;Version&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"2012-10-17"&lt;/span&gt;
    &lt;span class="nx"&gt;Statement&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt;
      &lt;span class="nx"&gt;Effect&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Allow"&lt;/span&gt;
      &lt;span class="nx"&gt;Action&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="s2"&gt;"secretsmanager:GetSecretValue"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s2"&gt;"secretsmanager:DescribeSecret"&lt;/span&gt;
      &lt;span class="p"&gt;]&lt;/span&gt;
      &lt;span class="nx"&gt;Resource&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_secretsmanager_secret&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;db_credentials&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;arn&lt;/span&gt;
    &lt;span class="p"&gt;}]&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;rds.tf&lt;/code&gt;: RDS instance and Proxy
&lt;/h3&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_db_instance"&lt;/span&gt; &lt;span class="s2"&gt;"primary"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;identifier&lt;/span&gt;              &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"financial-app-db"&lt;/span&gt;
  &lt;span class="nx"&gt;engine&lt;/span&gt;                  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"postgres"&lt;/span&gt;
  &lt;span class="nx"&gt;engine_version&lt;/span&gt;          &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"15.4"&lt;/span&gt;
  &lt;span class="nx"&gt;instance_class&lt;/span&gt;          &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"db.t3.medium"&lt;/span&gt;
  &lt;span class="nx"&gt;allocated_storage&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;
  &lt;span class="nx"&gt;db_name&lt;/span&gt;                 &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"financialdb"&lt;/span&gt;
  &lt;span class="nx"&gt;username&lt;/span&gt;                &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"dbadmin"&lt;/span&gt;
  &lt;span class="nx"&gt;password&lt;/span&gt;                &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;db_password&lt;/span&gt;
  &lt;span class="nx"&gt;multi_az&lt;/span&gt;                &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="nx"&gt;storage_encrypted&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="nx"&gt;backup_retention_period&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;7&lt;/span&gt;
  &lt;span class="nx"&gt;skip_final_snapshot&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;

  &lt;span class="nx"&gt;vpc_security_group_ids&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;aws_security_group&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rds&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="nx"&gt;db_subnet_group_name&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_db_subnet_group&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;main&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;

  &lt;span class="nx"&gt;tags&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;Name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"financial-app-db"&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_db_proxy"&lt;/span&gt; &lt;span class="s2"&gt;"main"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;                   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"financial-app-proxy"&lt;/span&gt;
  &lt;span class="nx"&gt;debug_logging&lt;/span&gt;          &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
  &lt;span class="nx"&gt;engine_family&lt;/span&gt;          &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"POSTGRESQL"&lt;/span&gt;
  &lt;span class="nx"&gt;idle_client_timeout&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1800&lt;/span&gt;
  &lt;span class="nx"&gt;require_tls&lt;/span&gt;            &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="nx"&gt;role_arn&lt;/span&gt;               &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_iam_role&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rds_proxy_role&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;arn&lt;/span&gt;
  &lt;span class="nx"&gt;vpc_security_group_ids&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;aws_security_group&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rds_proxy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="nx"&gt;vpc_subnet_ids&lt;/span&gt;         &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;private_subnet_ids&lt;/span&gt;

  &lt;span class="nx"&gt;auth&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;auth_scheme&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"SECRETS"&lt;/span&gt;
    &lt;span class="nx"&gt;iam_auth&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"DISABLED"&lt;/span&gt;
    &lt;span class="nx"&gt;secret_arn&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_secretsmanager_secret&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;db_credentials&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;arn&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;tags&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;Name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"financial-app-proxy"&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_db_proxy_default_target_group"&lt;/span&gt; &lt;span class="s2"&gt;"main"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;db_proxy_name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_db_proxy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;main&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;

  &lt;span class="nx"&gt;connection_pool_config&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;connection_borrow_timeout&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;120&lt;/span&gt;
    &lt;span class="nx"&gt;max_connections_percent&lt;/span&gt;      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;
    &lt;span class="nx"&gt;max_idle_connections_percent&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_db_proxy_target"&lt;/span&gt; &lt;span class="s2"&gt;"main"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;db_instance_identifier&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_db_instance&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;primary&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;identifier&lt;/span&gt;
  &lt;span class="nx"&gt;db_proxy_name&lt;/span&gt;          &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_db_proxy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;main&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;
  &lt;span class="nx"&gt;target_group_name&lt;/span&gt;      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_db_proxy_default_target_group&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;main&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;The application connects to &lt;code&gt;aws_db_proxy.main.endpoint&lt;/code&gt;, not to the RDS instance endpoint directly.&lt;/p&gt;
&lt;h3&gt;
  
  
  &lt;code&gt;secrets.tf&lt;/code&gt;: Secrets Manager and rotation
&lt;/h3&gt;

&lt;p&gt;The rotation Lambda comes from the AWS Serverless Application Repository. AWS maintains it. You do not write or host any rotation code.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_secretsmanager_secret"&lt;/span&gt; &lt;span class="s2"&gt;"db_credentials"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;                    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"financial-app/db-credentials"&lt;/span&gt;
  &lt;span class="nx"&gt;recovery_window_in_days&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;7&lt;/span&gt;

  &lt;span class="nx"&gt;tags&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;Name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"financial-app-db-credentials"&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_secretsmanager_secret_version"&lt;/span&gt; &lt;span class="s2"&gt;"db_credentials"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;secret_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_secretsmanager_secret&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;db_credentials&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;

  &lt;span class="nx"&gt;secret_string&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;jsonencode&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="nx"&gt;username&lt;/span&gt;             &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"dbadmin"&lt;/span&gt;
    &lt;span class="nx"&gt;password&lt;/span&gt;             &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;db_password&lt;/span&gt;
    &lt;span class="nx"&gt;engine&lt;/span&gt;               &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"postgres"&lt;/span&gt;
    &lt;span class="nx"&gt;host&lt;/span&gt;                 &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_db_instance&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;primary&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;address&lt;/span&gt;
    &lt;span class="nx"&gt;port&lt;/span&gt;                 &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;5432&lt;/span&gt;
    &lt;span class="nx"&gt;dbname&lt;/span&gt;               &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"financialdb"&lt;/span&gt;
    &lt;span class="nx"&gt;dbInstanceIdentifier&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_db_instance&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;primary&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;identifier&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;# Deploy the AWS-managed rotation Lambda from the Serverless Application Repository&lt;/span&gt;
&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_serverlessapplicationrepository_cloudformation_stack"&lt;/span&gt; &lt;span class="s2"&gt;"rotation_lambda"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;             &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"SecretsManagerRDSPostgreSQLRotationSingleUser"&lt;/span&gt;
  &lt;span class="nx"&gt;application_id&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"arn:aws:serverlessrepo:us-east-1:297356227824:applications/SecretsManagerRDSPostgreSQLRotationSingleUser"&lt;/span&gt;
  &lt;span class="nx"&gt;semantic_version&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"1.1.387"&lt;/span&gt;

  &lt;span class="nx"&gt;capabilities&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="s2"&gt;"CAPABILITY_IAM"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s2"&gt;"CAPABILITY_RESOURCE_POLICY"&lt;/span&gt;
  &lt;span class="p"&gt;]&lt;/span&gt;

  &lt;span class="nx"&gt;parameters&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;endpoint&lt;/span&gt;            &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"https://secretsmanager.${var.aws_region}.amazonaws.com"&lt;/span&gt;
    &lt;span class="nx"&gt;functionName&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"SecretsManagerRDSPostgreSQLRotation"&lt;/span&gt;
    &lt;span class="nx"&gt;vpcSubnetIds&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;","&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;private_subnet_ids&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nx"&gt;vpcSecurityGroupIds&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_security_group&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rotation_lambda&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="s2"&gt;"aws_lambda_function"&lt;/span&gt; &lt;span class="s2"&gt;"rotation"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;function_name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"SecretsManagerRDSPostgreSQLRotation"&lt;/span&gt;
  &lt;span class="nx"&gt;depends_on&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;aws_serverlessapplicationrepository_cloudformation_stack&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rotation_lambda&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_lambda_permission"&lt;/span&gt; &lt;span class="s2"&gt;"secrets_manager"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;statement_id&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"AllowSecretsManagerInvocation"&lt;/span&gt;
  &lt;span class="nx"&gt;action&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"lambda:InvokeFunction"&lt;/span&gt;
  &lt;span class="nx"&gt;function_name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;aws_lambda_function&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rotation&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;function_name&lt;/span&gt;
  &lt;span class="nx"&gt;principal&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"secretsmanager.amazonaws.com"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_secretsmanager_secret_rotation"&lt;/span&gt; &lt;span class="s2"&gt;"db_credentials"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;secret_id&lt;/span&gt;           &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_secretsmanager_secret&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;db_credentials&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;rotation_lambda_arn&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;aws_lambda_function&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rotation&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;arn&lt;/span&gt;

  &lt;span class="nx"&gt;rotation_rules&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;automatically_after_days&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;depends_on&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;aws_lambda_permission&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;secrets_manager&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  &lt;code&gt;outputs.tf&lt;/code&gt;
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;output&lt;/span&gt; &lt;span class="s2"&gt;"proxy_endpoint"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;description&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Connect the application to this endpoint, not to the RDS instance directly"&lt;/span&gt;
  &lt;span class="nx"&gt;value&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_db_proxy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;main&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;endpoint&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;output&lt;/span&gt; &lt;span class="s2"&gt;"secret_arn"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;description&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"ARN of the Secrets Manager secret storing database credentials"&lt;/span&gt;
  &lt;span class="nx"&gt;value&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_secretsmanager_secret&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;db_credentials&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;arn&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;output&lt;/span&gt; &lt;span class="s2"&gt;"rds_instance_id"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;description&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"RDS instance identifier"&lt;/span&gt;
  &lt;span class="nx"&gt;value&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_db_instance&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;primary&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;identifier&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Validating the deployment
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Check 1: Multi-AZ is active&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws rds describe-db-instances &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--db-instance-identifier&lt;/span&gt; financial-app-db &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s1"&gt;'DBInstances[0].MultiAZ'&lt;/span&gt;
&lt;span class="c"&gt;# Expected: true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Check 2: RDS Proxy is available and connected to the instance&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws rds describe-db-proxies &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--db-proxy-name&lt;/span&gt; financial-app-proxy &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s1"&gt;'DBProxies[0].Status'&lt;/span&gt;
&lt;span class="c"&gt;# Expected: "available"&lt;/span&gt;

aws rds describe-db-proxy-targets &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--db-proxy-name&lt;/span&gt; financial-app-proxy &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s1"&gt;'Targets[*].{Endpoint:Endpoint,State:TargetHealth.State}'&lt;/span&gt;
&lt;span class="c"&gt;# Expected: State "AVAILABLE"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Check 3: rotation is configured and working&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws secretsmanager describe-secret &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--secret-id&lt;/span&gt; financial-app/db-credentials &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s1"&gt;'{RotationEnabled:RotationEnabled,LastRotatedDate:LastRotatedDate,NextRotationDate:NextRotationDate}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To trigger a manual rotation immediately and confirm the Lambda executes without errors:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws secretsmanager rotate-secret &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--secret-id&lt;/span&gt; financial-app/db-credentials
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then check the rotation status:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws secretsmanager describe-secret &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--secret-id&lt;/span&gt; financial-app/db-credentials &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s1"&gt;'RotationRules'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Check 4: simulate a failover&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;RDS provides a reboot-with-failover option that forces the standby to become primary. This lets you observe the actual failover behavior before a real incident:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws rds reboot-db-instance &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--db-instance-identifier&lt;/span&gt; financial-app-db &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--force-failover&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Monitor the event log during the failover:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws rds describe-events &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--source-identifier&lt;/span&gt; financial-app-db &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--source-type&lt;/span&gt; db-instance &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--duration&lt;/span&gt; 10 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s1"&gt;'Events[*].{Time:Date,Message:Message}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The application should continue serving requests through the Proxy during this window. Check your application logs for connection errors. If the Proxy is working correctly, you should see none.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why the other options do not work?
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Option: automated backups with PITR&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;PITR restores to a point in time by replaying transaction logs onto a snapshot. This creates a new RDS instance. Provisioning a new instance, restoring data, and updating connection strings takes 15 to 30 minutes at minimum. The 60-second RTO is not achievable with this approach.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Option: Parameter Store with EventBridge rotation&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Parameter Store does not have native RDS credential rotation. You write the rotation logic yourself in Lambda, manage the staging window yourself, and handle the transition between old and new credentials yourself. This is exactly what Secrets Manager already does, with managed Lambda functions and staging label support built in. Option D reimplements a subset of Secrets Manager without the reliability guarantees.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Option: read replica with manual promotion&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A read replica provides an asynchronous copy of the data. Promotion requires manual steps: stopping replication, promoting the replica, updating the application connection string. The process takes several minutes and requires human intervention. Neither the RTO nor the automation requirement is met.&lt;/p&gt;




&lt;h2&gt;
  
  
  What the architecture looks like end to end
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Application
    |
    v
RDS Proxy endpoint
    |
    |-- Normal operation --&amp;gt; RDS Primary (AZ-a)
    |                             |
    |                         Synchronous replication
    |                             |
    |                        RDS Standby (AZ-b)
    |
    |-- During failover --&amp;gt; Proxy detects new primary
                            Proxy reroutes internally
                            Application connection stays alive

Secrets Manager (30-day rotation schedule)
    |
    v
Rotation Lambda (inside VPC)
    |-- Updates password in RDS
    |-- Updates AWSCURRENT in Secrets Manager
    |
RDS Proxy reads new credentials on next refresh
Application never sees the credential change
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Cost to keep in mind
&lt;/h2&gt;

&lt;p&gt;Multi-AZ doubles the instance cost because the standby runs continuously. RDS Proxy charges per vCPU of the underlying RDS instance per hour. Secrets Manager charges $0.40 per secret per month plus $0.05 per 10,000 API calls.&lt;/p&gt;

&lt;p&gt;For a financial application with a 1-second RPO and 60-second RTO requirement, the alternative to this setup is manual failover, potential data loss, and credential management code you have to maintain and test yourself.&lt;/p&gt;

</description>
      <category>database</category>
      <category>aws</category>
      <category>terraform</category>
      <category>cloudoperations</category>
    </item>
    <item>
      <title>DynamoDB Now Does Vector Search</title>
      <dc:creator>Marcelo Acosta Cavalero</dc:creator>
      <pubDate>Tue, 18 Aug 2026 16:57:51 +0000</pubDate>
      <link>https://dev.to/aws-builders/dynamodb-now-does-vector-search-4hld</link>
      <guid>https://dev.to/aws-builders/dynamodb-now-does-vector-search-4hld</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://buildwithaws.substack.com" rel="noopener noreferrer"&gt;Build With AWS&lt;/a&gt;. Subscribe for weekly AWS builds.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;a href="https://substackcdn.com/image/fetch/$s_!I5Ad!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F9d30ebe2-5191-492f-84d7-7c059baa9b4b_1536x1024.png" rel="noopener noreferrer"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzdwket5rugsy7gba4nj8.png" width="800" height="534"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;On August 5, 2026, AWS made vector search &lt;a href="https://aws.amazon.com/blogs/aws/amazon-dynamodb-now-supports-real-time-vector-search-at-any-scale/" rel="noopener noreferrer"&gt;generally available&lt;/a&gt; in Amazon DynamoDB.&lt;/p&gt;

&lt;p&gt;The headline promises single-digit millisecond searches, 99%+ recall, and a design that reaches trillions of vectors.&lt;/p&gt;

&lt;p&gt;The reaction across engineering forums split in two within hours.&lt;/p&gt;

&lt;p&gt;One group read it as the end of a product category, with variations on “so I don’t need S3 Vector buckets anymore?” and “MongoDB is finished.”&lt;/p&gt;

&lt;p&gt;Another group opened the service quotas page and came back with a much narrower reading, pointing out that the documented way to hold latency down and scale search throughput as your index grows is to keep each query scoped to a manageable partition.&lt;/p&gt;

&lt;p&gt;Both readings are defensible from the announcement alone.&lt;/p&gt;

&lt;p&gt;The documentation settles it, and it also contains three things the announcement never mentions: a hard requirement that rules out many existing tables, a security warning that matters enormously for multi-tenant applications, and a pricing example where vector writes cost far more than searches.&lt;/p&gt;

&lt;p&gt;Before any of that makes sense, we need to talk about what a vector actually is, because the rest follows from it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a Vector Actually Is
&lt;/h2&gt;

&lt;p&gt;Imagine you run a bookstore and you want to arrange books so that similar ones sit near each other.&lt;/p&gt;

&lt;p&gt;You could sort alphabetically, but then a cookbook lands beside a book on cosmology. Instead, suppose you give every book a set of coordinates, the way a city gives every building a street address.&lt;/p&gt;

&lt;p&gt;One coordinate might loosely capture “how technical is this,” another “how much does this concern food,” another “how narrative is this.”&lt;/p&gt;

&lt;p&gt;Books about pasta end up clustered in one corner of the room. Books about black holes end up in a different corner.&lt;/p&gt;

&lt;p&gt;An &lt;strong&gt;embedding&lt;/strong&gt; is exactly that: a list of numbers that acts as an address describing the meaning of a piece of text.&lt;/p&gt;

&lt;p&gt;A machine learning model reads your text and produces the address.&lt;/p&gt;

&lt;p&gt;Two pieces of text that mean similar things get addresses that sit close together.&lt;/p&gt;

&lt;p&gt;That closeness is the whole trick, and it is why the technique works for search: you convert the shopper’s phrase “lightweight running shoes for summer” into an address, then look for products whose addresses are nearby.&lt;/p&gt;

&lt;p&gt;You never match keywords. You match meaning.&lt;/p&gt;

&lt;p&gt;That is why people call it &lt;strong&gt;semantic search&lt;/strong&gt;, and it is why a search for “footwear for hot weather” can surface a product whose description never uses either word.&lt;/p&gt;

&lt;p&gt;The number of coordinates is called the number of &lt;strong&gt;dimensions&lt;/strong&gt;. Our bookstore used three. Real models use hundreds or thousands, because meaning is complicated and three numbers cannot capture it. DynamoDB accepts up to 4,096 dimensions.&lt;/p&gt;

&lt;p&gt;Because there are many ways to measure “near,” you have to pick one, called the &lt;strong&gt;distance function&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Picture each address as an arrow drawn from the middle of the room out to that point. You now have two arrows, one for the shopper’s query and one for a product, and three sensible ways to compare them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cosine&lt;/strong&gt; distance only cares about the angle between the arrows, ignoring their length. It asks “are these pointing the same way,” which for text means “are these about the same thing,” while ignoring how emphatically each one says it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Euclidean&lt;/strong&gt; distance is the tape-measure answer: how far apart are the two arrowheads in a straight line. Length matters here, so a long arrow and a short arrow pointing identically still count as far apart.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Dot product&lt;/strong&gt; is the one worth slowing down for, because everyone repeats the phrase “it considers direction and magnitude” without saying what it does.&lt;/p&gt;

&lt;p&gt;Think of two people pushing a shopping cart. If both push in the same direction, the work they get done depends on the direction &lt;em&gt;and&lt;/em&gt; on how hard each pushes: two people shoving hard in agreement moves the cart much further than two people nudging it gently.&lt;/p&gt;

&lt;p&gt;If one pushes sideways, their effort barely contributes. If one pushes backwards, they actively subtract. The dot product is that number, agreement scaled by effort, which is precisely why dot product scores can come out &lt;strong&gt;negative&lt;/strong&gt; when the two arrows point opposite ways.&lt;/p&gt;

&lt;p&gt;AWS calls Cosine the safe default when you are unsure, and otherwise recommends matching whatever measure your embedding model’s own documentation specifies.&lt;/p&gt;

&lt;p&gt;Dot product then comes with a choice worth understanding rather than following blindly.&lt;/p&gt;

&lt;p&gt;AWS recommends &lt;strong&gt;normalizing&lt;/strong&gt; your embeddings to unit length, which means rescaling every arrow to exactly the same length so only direction can differ. Do that and dot product ranks results identically to Cosine, because you have removed the “how hard each person pushes” part and left only “do they agree.”&lt;/p&gt;

&lt;p&gt;Skip normalization only when you &lt;em&gt;want&lt;/em&gt; length to carry meaning. The documentation’s example is a recommendation system that stretches each product’s arrow in proportion to its popularity score, so popular products push harder and rank higher.&lt;/p&gt;

&lt;p&gt;One trap here has caught people in every vector database that offers a choice.&lt;/p&gt;

&lt;p&gt;For Cosine and Euclidean, a &lt;strong&gt;lower&lt;/strong&gt; score means a &lt;strong&gt;closer&lt;/strong&gt; match, with zero meaning identical. For Dot product, higher means closer. The comparison flips depending on a setting you chose weeks earlier, and nothing stops you from sorting the wrong way.&lt;/p&gt;

&lt;p&gt;Two related details are easy to get wrong: Cosine distance here runs from 0 for identical direction up to 2 for opposite directions, not 0 to 1, and Dot product scores can be &lt;strong&gt;negative&lt;/strong&gt; for vectors pointing the opposite way.&lt;/p&gt;

&lt;p&gt;If you write a relevance threshold assuming scores never go below zero, it will behave strangely on exactly the results you meant to exclude.&lt;/p&gt;

&lt;p&gt;What DynamoDB shipped is not a new data type for any of this.&lt;/p&gt;

&lt;p&gt;You store the embedding in the ordinary List type, where each element is a Number holding one coordinate, and you write it with a normal PutItem or UpdateItem call. No new data type and no change to your item schema is required, though as the next section covers, you may still need to change one setting on the table itself.&lt;/p&gt;

&lt;p&gt;What is new is a new kind of &lt;strong&gt;index&lt;/strong&gt;. An index is a second, reorganized copy of your data that DynamoDB maintains for you, kept in a shape that makes one particular question fast, the way a library keeps a card catalog sorted by author alongside shelves sorted by subject.&lt;/p&gt;

&lt;p&gt;You declare a vector index on the attribute holding your embeddings, then query it through a new SearchVectors call that takes a query address, a count of how many neighbors you want (up to 100, called &lt;strong&gt;top-K&lt;/strong&gt;), and optional filters.&lt;/p&gt;

&lt;p&gt;Two words from that setup will recur, so here they are up front.&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;SearchSchema&lt;/strong&gt; is simply the bundle of settings you declare when creating the index, holding the grouping attribute and the attributes you want to filter on. It is the form you fill in when you order the card catalog.&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;projection&lt;/strong&gt; is your choice of which of the item’s other attributes get copied onto the index entry alongside the vector, the way a catalog card carries the title and author but not the book’s full text.&lt;/p&gt;

&lt;p&gt;Copy little and the card is cheap but you have to go fetch the book to read anything else.&lt;/p&gt;

&lt;p&gt;Copy everything and the card is self-sufficient but expensive, in ways the pricing section gets specific about.&lt;/p&gt;

&lt;p&gt;Almost every design choice about that index is fixed the moment you create it.&lt;/p&gt;

&lt;p&gt;You cannot change the number of dimensions or the distance function.&lt;/p&gt;

&lt;p&gt;The SearchSchema is fixed at creation, and the documentation is explicit that you cannot add, remove, or change the partition key afterward.&lt;/p&gt;

&lt;p&gt;Projection choices are fixed too: with an INCLUDE projection you cannot later change which attributes are included.&lt;/p&gt;

&lt;p&gt;Changing any of these means creating a second index and migrating to it, which AWS documents as a four-step dance: create the new index under a different name, wait for it to finish &lt;strong&gt;backfilling&lt;/strong&gt;, cut your application over, then delete the old one to stop paying for its storage.&lt;/p&gt;

&lt;p&gt;Backfilling is what happens when you point an index at a table that already holds data: DynamoDB walks every existing item and builds the index entry for it, because an index created today knows nothing about items written last year.&lt;/p&gt;

&lt;p&gt;It is the clerk who has to sit down and type up a catalog card for every book already on the shelves before the catalog is any use to anyone.&lt;/p&gt;

&lt;p&gt;Your base table is never affected, because DynamoDB re-derives the index from your items. But it does mean the design work happens up front, and this is the reason the rest of this post exists.&lt;/p&gt;

&lt;p&gt;Finally, the 99%+ recall figure in the announcement is the most informative number in it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Recall&lt;/strong&gt; means: of the genuinely closest neighbors, what fraction did the search actually find? A figure below 100% tells you this is &lt;strong&gt;approximate nearest neighbor&lt;/strong&gt; search, and AWS documents it as such.&lt;/p&gt;

&lt;p&gt;Rather than comparing your query against every stored vector, which would be like reading every book in the building, the system uses a structure that walks it quickly to the right neighborhood and looks around there.&lt;/p&gt;

&lt;p&gt;It usually finds the true nearest neighbors and occasionally misses one.&lt;/p&gt;

&lt;p&gt;At large scale, production vector systems generally work this way, because the exact version does not survive contact with a billion items.&lt;/p&gt;

&lt;p&gt;What matters practically is that AWS states a recall figure and gives you no knob to tune it. You get 99%+ or you use something else.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two Requirements That Decide Whether You Can Use This At All
&lt;/h2&gt;

&lt;p&gt;Before evaluating anything else, check two facts about your table, because either one can end the conversation.&lt;/p&gt;

&lt;p&gt;The first is not mentioned in the announcement at all.&lt;/p&gt;

&lt;p&gt;DynamoDB bills capacity in two modes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Provisioned&lt;/strong&gt; mode means you tell AWS in advance how much traffic to reserve, like booking a fixed number of restaurant tables for the evening.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;On-demand&lt;/strong&gt; mode means you pay per request and AWS handles the scaling, like walking in and being seated.&lt;/p&gt;

&lt;p&gt;Vector indexes are supported only on tables using on-demand capacity mode, and are not supported on provisioned capacity tables.&lt;/p&gt;

&lt;p&gt;If your production table runs provisioned capacity, which many cost-optimized tables do precisely because it is cheaper for steady predictable traffic, you must switch it to on-demand before you can add a vector index.&lt;/p&gt;

&lt;p&gt;That is a real change to your cost profile, and it needs to be part of the evaluation rather than a surprise during implementation.&lt;/p&gt;

&lt;p&gt;The second is a self-service threshold rather than a true ceiling.&lt;/p&gt;

&lt;p&gt;You can create a vector index on a base table up to 600 GB without asking anyone. Above that, the quota is adjustable, but you have to go through AWS Support to raise it.&lt;/p&gt;

&lt;p&gt;AWS does not publish a reason for the threshold, so I will not invent one.&lt;/p&gt;

&lt;p&gt;The practical consequence is simply that the largest existing DynamoDB tables cannot self-serve their way into this feature and need a conversation first.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Partition Key Is the One Decision You Cannot Change Your Mind About
&lt;/h2&gt;

&lt;p&gt;DynamoDB’s default quotas are &lt;strong&gt;1 GBps of vector search and 10 MBps of vector writes, per partition key value&lt;/strong&gt;. Both are adjustable through AWS Support, so treat them as the shape of the system rather than a wall.&lt;/p&gt;

&lt;p&gt;A &lt;strong&gt;partition key&lt;/strong&gt; is the label DynamoDB uses to decide which group your data belongs to. In our bookstore, it is which branch of the shop a book lives in.&lt;/p&gt;

&lt;p&gt;Every SearchVectors call must name exactly one partition key value, so every search happens inside one branch. You cannot search two branches in one call.&lt;/p&gt;

&lt;p&gt;That constraint is what makes the whole thing fast.&lt;/p&gt;

&lt;p&gt;A partition key restricts each search to the portion of the index belonging to a single value, and the call does not search the entire index.&lt;/p&gt;

&lt;p&gt;Searching less data lowers cost, can improve latency and recall, and scales throughput sideways as you add more values.&lt;/p&gt;

&lt;p&gt;Fifty branches give you roughly fifty times the total search and write capacity of one, because each branch gets its own 1 GBps allowance.&lt;/p&gt;

&lt;p&gt;This is why the trillion-vector claim and the per-partition quota are both true at once, and it is worth being precise rather than dramatic about it.&lt;/p&gt;

&lt;p&gt;AWS documents no storage limit for vector indexes, so the system as a whole genuinely has no size ceiling. Throughput, meanwhile, stays governed by those per-partition-key quotas.&lt;/p&gt;

&lt;p&gt;Horizontal partitioning is the primary way to scale out, adding branches rather than enlarging one, and the per-partition quotas can also be raised through Support if a single branch genuinely needs more.&lt;/p&gt;

&lt;p&gt;AWS’s own worked example makes the tradeoff concrete: a 768-dimension embedding plus 1 KB of other item data comes to roughly 4 KB per item, which works out to about 250,000 vectors examined per second and about 2,500 vector writes per second, per partition key value. As the number of vectors in a partition grows, each search examines more data and you approach the limit sooner.&lt;/p&gt;

&lt;p&gt;So the partition key is your central design decision, and the guidance is more subtle than “pick something with lots of distinct values.” AWS says to avoid extremes in &lt;strong&gt;both&lt;/strong&gt; directions.&lt;/p&gt;

&lt;p&gt;The word for this is &lt;strong&gt;cardinality&lt;/strong&gt;, which just means how many different values an attribute actually takes across your data. A yes-or-no flag has a cardinality of two.&lt;/p&gt;

&lt;p&gt;A country field has around two hundred. An order ID has as many values as you have orders, one apiece. Both ends of that scale hurt you here, for opposite reasons.&lt;/p&gt;

&lt;p&gt;Too few distinct values fails the obvious way. A boolean gives you two branches, most items land in one of them, and you get almost no throughput scaling and little of the latency or cost benefit.&lt;/p&gt;

&lt;p&gt;Too many distinct values fails in a way that is easy to miss, and it is the more interesting failure. If you partition by something nearly unique, like an item ID, each branch ends up holding a single item. A nearest-neighbor search inside a branch with one occupant has no neighbors to compare against, so &lt;strong&gt;recall gets worse, not better&lt;/strong&gt;. You have not made search faster, you have made it meaningless.&lt;/p&gt;

&lt;p&gt;Splitting a bookstore into one building per book does not help anyone find a similar book.&lt;/p&gt;

&lt;p&gt;The documentation’s example sits in the middle: location data partitioned by US state gives you about fifty values, each holding a meaningful population of vectors for good recall, and about fifty times the horizontal throughput. Good candidates have low-to-medium cardinality relative to the size and distribution of your dataset, hold enough vectors per value that nearest-neighbor search still has a meaningful population to search, and line up with a scope your application naturally searches on its own.&lt;/p&gt;

&lt;p&gt;Tenant, workspace, geography, product category, or document collection can all work, but only when each value contains a real population. User ID is excellent if each user accumulates thousands of memory items and poor if each user has five. Nothing is automatically a good partition key.&lt;/p&gt;

&lt;p&gt;The constraint also runs the other direction, which forces genuine design work. If your application truly needs to search across all tenants or all collections at once, you have two choices.&lt;/p&gt;

&lt;p&gt;You can omit the partition key and accept one shared search space, where every query competes for a single allowance and examines more data as your indexed collection grows.&lt;/p&gt;

&lt;p&gt;Or you can issue N separate SearchVectors calls and merge the ranked results yourself.&lt;/p&gt;

&lt;p&gt;Merging is easy when the distance function is consistent across calls, but you pay for the aggregate bytes examined and returned across all N calls, and you inherit both the extra code and a worse &lt;strong&gt;tail latency&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Tail latency is the slow end of your response-time spread: not how long a typical request takes, but how long the unlucky few take. Fan-out makes it worse for a specific reason.&lt;/p&gt;

&lt;p&gt;A fan-out query cannot finish until its slowest branch answers, and the more branches you query in parallel, the higher the chance that at least one of them lands in its own slow tail.&lt;/p&gt;

&lt;p&gt;Choose carefully, because this is the decision the title of this post is about.&lt;/p&gt;

&lt;p&gt;To filter more narrowly inside a partition, use inline filters instead, which the next sections cover.&lt;/p&gt;

&lt;h2&gt;
  
  
  Partition Keys Are Not Walls
&lt;/h2&gt;

&lt;p&gt;This deserves its own section because it is the single most consequential thing in the documentation that the announcement does not mention, and because the natural way to use partition keys walks straight into it.&lt;/p&gt;

&lt;p&gt;If you partition by tenant ID, it is tempting to conclude you have isolated your tenants. You have not.&lt;/p&gt;

&lt;p&gt;The documentation is worth reading slowly: partition key scoping is a data-locality and performance optimization, not an access-control mechanism.&lt;/p&gt;

&lt;p&gt;Any &lt;strong&gt;principal&lt;/strong&gt; holding dynamodb:SearchVectors permission on the index can search &lt;strong&gt;any&lt;/strong&gt; partition key value.&lt;/p&gt;

&lt;p&gt;Principal is AWS’s word for whoever is making the call, whether that is a human user, an application, or another AWS service acting on your behalf.&lt;/p&gt;

&lt;p&gt;The reason is a gap in the permission system.&lt;/p&gt;

&lt;p&gt;Permissions in AWS are written as &lt;strong&gt;IAM&lt;/strong&gt; policies, IAM being Identity and Access Management, the service that decides who is allowed to do what.&lt;/p&gt;

&lt;p&gt;DynamoDB normally offers &lt;strong&gt;fine-grained access control&lt;/strong&gt;, which lets an IAM policy say “this credential may only touch rows whose key starts with this tenant’s ID,” via a condition called dynamodb:LeadingKeys.&lt;/p&gt;

&lt;p&gt;Think of it as a keycard that opens only your own floor of the building. That condition does not apply to SearchVectors.&lt;/p&gt;

&lt;p&gt;Fine-grained access control is not supported for the SearchVectors API. The keycard reader is not installed on this door.&lt;/p&gt;

&lt;p&gt;The practical consequence: a partition key is a signpost telling the search where to look, not a wall preventing it from looking elsewhere.&lt;/p&gt;

&lt;p&gt;Any bug, and any code path where the caller gets to influence which partition key value your service searches on their behalf, becomes a cross-tenant data leak, and no IAM policy will stop it.&lt;/p&gt;

&lt;p&gt;The shape to watch for is a tenant identifier that arrives in the request instead of being derived from the authenticated session, because at that point the caller is choosing which tenant’s data your trusted credentials go read.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;If your workload requires strict tenant isolation at the data layer, AWS’s own recommendation is to use separate tables or separate indexes with distinct IAM grants per tenant, and to accept the operational cost that comes with that.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  A Written Byte Costs 260 Times More Than a Searched Byte
&lt;/h2&gt;

&lt;p&gt;Vector search bills on three dimensions, all charged on top of the normal costs of the underlying table:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Vector writes:&lt;/strong&gt; $0.52 per GB written into the index, covering the vector plus any attributes copied along with it&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Vector search:&lt;/strong&gt; $0.002 per GB of data processed to build the response, including data returned&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Storage:&lt;/strong&gt; $0.25 per GB-month, the same rate as ordinary table storage&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Look at the first two rates together.&lt;/p&gt;

&lt;p&gt;Per gigabyte, writing costs &lt;strong&gt;260 times more than searching&lt;/strong&gt;. That ratio is the most surprising thing on the pricing page, and it points the opposite way from where most people expect the money to go.&lt;/p&gt;

&lt;p&gt;AWS’s own worked example shows it landing. Take a knowledge base of one million items with 768-dimension vectors and 1 KB of projected attributes, running ten writes and ten searches per second for thirty days.&lt;/p&gt;

&lt;p&gt;The searches, all 25.92 million of them, process 1,586 GB and cost &lt;strong&gt;$3.17&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The writes, also 25.92 million of them, cost &lt;strong&gt;$51.42&lt;/strong&gt;. Storage adds $0.95. With equal numbers of reads and writes, the writes dominate by roughly sixteen times.&lt;/p&gt;

&lt;p&gt;Be careful about what that does and does not prove. It is not a law that writes always dominate.&lt;/p&gt;

&lt;p&gt;Your own balance depends on how often your content changes, how many searches you serve, how big each partition is, how many dimensions you chose, how much you project, and therefore how much data each search examines.&lt;/p&gt;

&lt;p&gt;A workload serving millions of searches against a rarely-updated set of documents can easily spend more on searching than on writing.&lt;/p&gt;

&lt;p&gt;What the example does establish is that the write side deserves attention it usually does not get, and that backfilling embeddings for an existing table is a real one-time cost worth estimating before you start.&lt;/p&gt;

&lt;p&gt;One detail from the monitoring documentation turns into a genuine optimization. You are charged vector write capacity each time you write, update, or delete an item in a way that &lt;strong&gt;changes an attribute the vector index holds&lt;/strong&gt;, and writes that touch nothing in the index cost nothing on it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The condition matters more than it first appears.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If a frequently-updated field such as a view counter is not projected into the index and is not part of its SearchSchema, updating it incurs no vector write charge.&lt;/p&gt;

&lt;p&gt;But with ProjectionType: ALL, that counter &lt;em&gt;is&lt;/em&gt; in the index, so every increment is a vector write you pay for.&lt;/p&gt;

&lt;p&gt;This is one of the places where a lazy projection choice quietly becomes a recurring bill, which leads directly into the next point.&lt;/p&gt;

&lt;p&gt;On the search side, resist the temptation to reverse-engineer per-query cost from index size.&lt;/p&gt;

&lt;p&gt;AWS does not publish what fraction of an index a query examines, and the honest answer is that you cannot derive it from the documentation.&lt;/p&gt;

&lt;p&gt;What you can do is measure it, because DynamoDB reports it directly.&lt;/p&gt;

&lt;p&gt;Set ReturnConsumedCapacity on your requests and you get back VectorSearchRequestBytes for searches and VectorWriteRequestBytes for writes, both also published to CloudWatch, AWS’s monitoring service for metrics from AWS services and applications.&lt;/p&gt;

&lt;p&gt;Those two metrics, measured against your own data and your own query mix, will tell you more than any published estimate.&lt;/p&gt;

&lt;p&gt;Two more levers matter for cost, both about restraint.&lt;/p&gt;

&lt;p&gt;Projection controls which table attributes get copied into the index, and a broader projection increases both storage and write cost. Project only what you read directly from search results and fetch the rest with a follow-up GetItem.&lt;/p&gt;

&lt;p&gt;Separately, returning the vector attribute itself in your results is expensive, because the response then carries the full list of coordinates. You almost never need it.&lt;/p&gt;

&lt;p&gt;Finally, dimensions cost money twice. A 1,536-dimension vector uses roughly four times the vector storage of a 384-dimension one, and higher dimensions raise both search and write charges because every vector carries more data.&lt;/p&gt;

&lt;p&gt;Embeddings are stored in the index at 32-bit floating point precision, about four bytes per dimension, so the vector portion of a 1,536-dimension entry runs roughly 6 KB before you add projected attributes.&lt;/p&gt;

&lt;p&gt;Higher-precision values are accepted but lose precision on the way into the index. Choose the smallest number of dimensions that meets your relevance needs, and remember you cannot change it later.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the API Will Not Do For You
&lt;/h2&gt;

&lt;p&gt;Several limitations are documented clearly and are easy to miss when you are reading a launch post.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Search results are eventually consistent.&lt;/strong&gt; The documentation states that there may be a brief delay between writing or updating a vector and its appearing in search results.&lt;/p&gt;

&lt;p&gt;“Real-time” in the launch title describes how fast a search returns, not how fast a new vector becomes findable.&lt;/p&gt;

&lt;p&gt;For many product-catalog workloads a brief propagation delay is acceptable, though not all of them: newly added items, price changes, and stock status can all be cases where it is not.&lt;/p&gt;

&lt;p&gt;For &lt;strong&gt;agent memory&lt;/strong&gt;, where an AI assistant stores what it learned during one turn of a conversation so it can look the fact up on the very next turn, it becomes a correctness question.&lt;/p&gt;

&lt;p&gt;Either way, measure the actual lag against your own write pattern before you design around it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Filters are exact-match only.&lt;/strong&gt; Inline filters let you narrow results on non-vector attributes, up to 18 per index, but only on exact values.&lt;/p&gt;

&lt;p&gt;No BETWEEN, no BEGINS_WITH, no ranges of any kind.&lt;/p&gt;

&lt;p&gt;That excludes a lot of what people actually want: price ranges, date windows, numeric thresholds, prefix matching.&lt;/p&gt;

&lt;p&gt;The workaround is bucketing, encoding a price as a tier or a timestamp as a day key and matching exactly against the bucket.&lt;/p&gt;

&lt;p&gt;It works, at the cost of granularity and a schema decision you must get right before building the index.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;There is no keyword search.&lt;/strong&gt; DynamoDB offers no lexical or BM25 component, meaning no scoring based on literal word matches.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Hybrid search&lt;/strong&gt;, which blends literal matching with semantic matching and often beats either alone for document retrieval, is therefore not a single call here. You either run a second system for the lexical half and combine results in your application, or you accept semantic-only retrieval.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Responses cap at 16 MB with no pagination.&lt;/strong&gt; You cannot ask for the next page. The documentation warns about the specific combination that hits this: projecting all attributes, with large items, at a high top-K.&lt;/p&gt;

&lt;p&gt;If your items are big, narrow the projection or lower top-K.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;You cannot use Query, Scan, or PartiQL against a vector index.&lt;/strong&gt; Those are DynamoDB’s three normal ways of reading data, PartiQL being an SQL-like query language AWS offers as an alternative to the native API for people who would rather write something resembling SELECT.&lt;/p&gt;

&lt;p&gt;None of them work here. SearchVectors is the only way to read from a vector index.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;DynamoDB will not regenerate your embeddings.&lt;/strong&gt; This one causes silent, hard-to-debug quality decay. If you edit a product description and do not recompute its embedding, the index keeps answering from the old address.&lt;/p&gt;

&lt;p&gt;The text and its meaning-coordinates have drifted apart, and nothing errors out. Search quality just quietly gets worse.&lt;/p&gt;

&lt;p&gt;AWS suggests catching content changes with &lt;strong&gt;DynamoDB Streams&lt;/strong&gt;, a feed that reports every change made to your table as it happens, like a conveyor belt carrying a receipt for each edit past you, and running a downstream process that reads those receipts, regenerates the affected embeddings, and writes them back.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Build that on day one, not after someone complains about relevance.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;An item can vanish from search results without any error.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If you remove the partition key attribute from an item, or forget to include it when writing, DynamoDB does not complain.&lt;/p&gt;

&lt;p&gt;The item is silently de-indexed and stops appearing in searches, even though the base table row and its embedding are both still sitting there perfectly intact.&lt;/p&gt;

&lt;p&gt;The book is still in the building; you just tore the branch label off its spine, and now nobody can find it.&lt;/p&gt;

&lt;p&gt;If an item mysteriously disappears from results, check whether its partition key attribute is still present.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;You cannot search an index while it is still filling up.&lt;/strong&gt; After you create a vector index, DynamoDB backfills it from your existing items.&lt;/p&gt;

&lt;p&gt;Searching before that finishes returns an error. Poll DescribeTable until IndexStatus is ACTIVE and Backfilling is false.&lt;/p&gt;

&lt;p&gt;The same applies after restoring a table from a backup or from point-in-time recovery, DynamoDB’s continuous backup feature that lets you rewind a table to any second within a retention window, because in both cases DynamoDB rebuilds the index from the restored data rather than copying the old index wholesale.&lt;/p&gt;

&lt;p&gt;The clerk has to retype every card.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;DAX does not support SearchVectors.&lt;/strong&gt; DAX is DynamoDB Accelerator, a cache that sits in front of your table and keeps recently-read items in memory so repeat reads skip the database entirely, the way a shop clerk keeps the ten most-requested titles on the counter instead of walking to the shelves each time.&lt;/p&gt;

&lt;p&gt;Vector searches cannot use it. If your application reads through DAX today, vector searches have to bypass the counter and go to DynamoDB directly, which means they do not get the cache’s latency benefit.&lt;/p&gt;

&lt;p&gt;Your ordinary cached reads carry on unaffected.&lt;/p&gt;

&lt;p&gt;One small piece of good news among all this: only items that actually contain a valid vector attribute get replicated into the index.&lt;/p&gt;

&lt;p&gt;Items without an embedding consume no vector index storage, so you can mix indexed and non-indexed items in the same table without paying for the latter.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Global tables&lt;/strong&gt; are DynamoDB’s way of keeping copies of the same table in several AWS Regions at once, so users in Frankfurt and Sydney each read from a nearby copy instead of reaching across an ocean.&lt;/p&gt;

&lt;p&gt;Each copy is a &lt;strong&gt;replica&lt;/strong&gt;. If you run global tables, DynamoDB creates the vector index on every replica automatically from the definition you gave it once, so you do not set it up Region by Region.&lt;/p&gt;

&lt;p&gt;Two behaviors are worth knowing.&lt;/p&gt;

&lt;p&gt;Copying a new vector to the other Regions and indexing it there happen &lt;strong&gt;asynchronously&lt;/strong&gt;, meaning the write finishes and confirms before the other copies have caught up, so a vector written in Frankfurt may not be findable in Sydney for a moment.&lt;/p&gt;

&lt;p&gt;This holds even on tables using multi-Region strong consistency, the stricter mode where a strongly consistent read against any replica is guaranteed to see the latest successful write. That guarantee covers strongly consistent reads, and vector search sits outside it.&lt;/p&gt;

&lt;p&gt;And because the search is approximate, the same query against identical data in two Regions can legitimately return slightly different results or ordering. That is not a bug to chase.&lt;/p&gt;

&lt;p&gt;The state of local development support is worth confirming separately before you commit a team to this path. I found no current official documentation stating whether DynamoDB Local supports vector indexes, so treat it as an open question to verify rather than an assumption either way.&lt;/p&gt;

&lt;h2&gt;
  
  
  Choosing Between DynamoDB, S3 Vectors, OpenSearch, and Postgres
&lt;/h2&gt;

&lt;p&gt;The “goodbye S3 Vectors” reaction is the wrong conclusion, and the pricing structures show why they are built for different jobs.&lt;/p&gt;

&lt;p&gt;S3 Vectors charges $0.06 per GB-month for storage against DynamoDB’s $0.25 on the Standard table class, roughly 4.2 times cheaper, and $0.20 per GB uploaded against $0.52. A table class is which pricing plan a DynamoDB table sits on, and that storage gap narrows if you move to the Standard-Infrequent Access class, which cuts storage to 40% of Standard while raising request charges to 125%.&lt;/p&gt;

&lt;p&gt;S3 Vectors also charges a fixed $2.50 per million queries plus tiered data processing that gets cheaper as the index grows, plus a charge for data returned above a small free allowance.&lt;/p&gt;

&lt;p&gt;DynamoDB has no fixed per-request fee at all; you pay purely for bytes processed and returned.&lt;/p&gt;

&lt;p&gt;Read those structures side by side and the split is clear. S3 Vectors is priced to reward large indexes queried at moderate rates. DynamoDB is priced to reward small partitions queried at high rates with low latency.&lt;/p&gt;

&lt;p&gt;Rather than assigning use cases absolutely, it is more accurate to say which way each option leans:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;DynamoDB vector search&lt;/strong&gt; favors data already living in DynamoDB, single-digit millisecond requirements, high query rates, and searches that naturally scope to one partition.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;S3 Vectors&lt;/strong&gt; favors much larger volumes of vectors, lower query frequency, and workloads where roughly 100 millisecond to sub-second latency is acceptable and storage economics dominate. AWS positions it for agentic workloads too, so this is a leaning, not a boundary.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;OpenSearch&lt;/strong&gt; favors rich literal-word search, range and metadata filtering, aggregations, and native hybrid retrieval combining keyword and vector scoring in one query.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PostgreSQL with pgvector&lt;/strong&gt; favors cases where you need joins, range conditions, aggregations, and vector similarity together in one database with transactional guarantees.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Two capabilities in that list carry the weight, because the loose phrase “you no longer need Postgres or OpenSearch” leans on both.&lt;/p&gt;

&lt;p&gt;An aggregation answers a question about a group rather than about individual rows, such as how many matches fall in each category, and vector search returns neither. A join answers one question from two separately stored collections at once, such as returning matching products along with each supplier’s name.&lt;/p&gt;

&lt;p&gt;PostgreSQL does joins natively.&lt;/p&gt;

&lt;p&gt;OpenSearch is a search engine, excellent at literal and hybrid retrieval, but it does not do relational joins.&lt;/p&gt;

&lt;p&gt;They are not interchangeable, and neither is replaced by a SearchVectors call.&lt;/p&gt;

&lt;p&gt;Several practitioners raised fair objections in the launch discussion. One noted that OpenSearch is frequently among the most expensive data stores in an AWS estate and that schema changes on a large cluster are painful.&lt;/p&gt;

&lt;p&gt;Both observations are accurate, and neither makes DynamoDB a drop-in substitute for a search engine.&lt;/p&gt;

&lt;p&gt;The sharpest pushback came from someone running a self-managed MongoDB cluster: five substantial nodes, 2 TB of data, about $1,400 per month, with a DynamoDB migration estimated above $15,000.&lt;/p&gt;

&lt;p&gt;The counterargument arrived immediately and is equally valid: what does the engineer maintaining that cluster cost, and what availability, durability, and tail latency does it actually deliver?&lt;/p&gt;

&lt;p&gt;Both sides are right, which is precisely why this is an architecture decision and not a price lookup.&lt;/p&gt;

&lt;p&gt;If you already run reliable infrastructure and employ the people to keep running it, the premium you pay AWS to operate the thing for you buys you less. If your alternative is turning a small product team into a part-time database operations group, it buys you a great deal.&lt;/p&gt;

&lt;p&gt;The genuinely new option is narrower and more useful than the headline suggests: you can now add similarity search to an application whose data already lives in DynamoDB, without standing up a second database and without maintaining a synchronization pipeline to keep the two copies aligned.&lt;/p&gt;

&lt;p&gt;That pipeline was never the interesting part of anyone’s architecture, and it broke often enough to matter. Deleting it is the real win.&lt;/p&gt;

&lt;h2&gt;
  
  
  Working Through the Decision
&lt;/h2&gt;

&lt;p&gt;Take this path if your data already lives in DynamoDB, your table uses on-demand capacity or can reasonably move to it, you can name a partition key today that both matches your query scope and holds a real population of vectors per value, your filters are exact-match or can be bucketed into exact matches, and either your tenants do not require data-layer isolation or you are willing to give each one its own table or index. Under those conditions this beats the alternative comfortably, because the alternative means a second database and a synchronization pipeline you would rather not own.&lt;/p&gt;

&lt;p&gt;Look elsewhere if you need range filters or hybrid keyword-and-semantic retrieval, if your indexed data is large and cold and your query volume is modest, which is the S3 Vectors profile, if your access pattern requires searching all of it at once, or if strict multi-tenant isolation at the IAM layer is non-negotiable.&lt;/p&gt;

&lt;p&gt;Whichever way you lean, measure three things before committing.&lt;/p&gt;

&lt;p&gt;Track VectorSearchRequestBytes and VectorWriteRequestBytes against your real data and query mix, because they replace every estimate in this post with a number from your own workload.&lt;/p&gt;

&lt;p&gt;Measure actual propagation lag between writing a vector and finding it.&lt;/p&gt;

&lt;p&gt;And test &lt;strong&gt;p99&lt;/strong&gt; search latency while ordinary key-value traffic runs against the same table at production volume.&lt;/p&gt;

&lt;p&gt;The p99, or 99th percentile, is the time within which 99 out of 100 requests complete, which makes it a measure of your worst hundredth rather than your typical case.&lt;/p&gt;

&lt;p&gt;Averages hide that request entirely, and it is the one the user notices and complains about, which is why it is the number worth watching. Test it under mixed load because vector search shares the table’s underlying infrastructure and mixed-workload behavior under load is the one variable nobody has published numbers for.&lt;/p&gt;

&lt;p&gt;Whatever you find there will tell you more than any launch post, including this one.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I publish every week at &lt;a href="https://buildwithaws.substack.com" rel="noopener noreferrer"&gt;buildwithaws.substack.com&lt;/a&gt;. Subscribe. It's free.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>aws</category>
      <category>dynamodb</category>
      <category>vectorsearch</category>
      <category>serverless</category>
    </item>
    <item>
      <title>Amazon Bedrock Service Tiers: Priority vs Flex</title>
      <dc:creator>Kayne Rodrigo</dc:creator>
      <pubDate>Tue, 18 Aug 2026 13:47:06 +0000</pubDate>
      <link>https://dev.to/aws-builders/amazon-bedrock-service-tiers-priority-vs-flex-42dk</link>
      <guid>https://dev.to/aws-builders/amazon-bedrock-service-tiers-priority-vs-flex-42dk</guid>
      <description>&lt;h2&gt;
  
  
  The Problem: Every Request Gets the Same Tier
&lt;/h2&gt;

&lt;p&gt;When I first saw Bedrock's new service tiers, I thought the decision was going to be&lt;br&gt;
pretty straightforward: use Priority when latency matters, Default for normal traffic,&lt;br&gt;
and Flex for anything that can wait.&lt;/p&gt;

&lt;p&gt;But before putting that logic into a router, I wanted to answer a simpler question:&lt;br&gt;
does Priority actually make my requests faster?&lt;/p&gt;

&lt;p&gt;The pricing table makes the case seem obvious — Priority costs roughly 1.75× Default,&lt;br&gt;
Flex costs roughly 0.5× Default. The implication baked into that pricing is that you&lt;br&gt;
get something proportional in return. I didn't want to build a router on top of that&lt;br&gt;
assumption without testing it first. So I tested it.&lt;/p&gt;

&lt;p&gt;The results changed how I thought about the whole problem.&lt;/p&gt;


&lt;h2&gt;
  
  
  Why This Experiment, Why Now
&lt;/h2&gt;

&lt;p&gt;AWS documentation tells you what each tier is intended for. Pricing tells you the&lt;br&gt;
obvious economic incentive. But neither tells you what happens with your specific&lt;br&gt;
workload, region, and traffic pattern.&lt;/p&gt;

&lt;p&gt;I didn't want to build another "Flex is for batch, Priority is for real-time" demo.&lt;br&gt;
I wanted to see what actually happened when I ran real requests through all three&lt;br&gt;
tiers, modeled the economics across different traffic distributions, and built a&lt;br&gt;
routing layer I could actually read, reason about, and change without guessing at&lt;br&gt;
the consequences.&lt;/p&gt;

&lt;p&gt;That is what led to this project: a measurable, reproducible experiment rather than&lt;br&gt;
a confident assumption presented as a tutorial.&lt;/p&gt;


&lt;h2&gt;
  
  
  Architecture: Four Small Modules, One Clean Pipeline
&lt;/h2&gt;

&lt;p&gt;I intentionally kept the router boring.&lt;/p&gt;

&lt;p&gt;No LangChain. No LLM classifier. No abstraction layers. Just four small Python&lt;br&gt;
modules doing one thing each: validate the workload label, choose a tier, check&lt;br&gt;
whether that tier is available, and return a decision. I wanted to look at any&lt;br&gt;
routing outcome and immediately understand why it happened.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;classify → policy → capability check → routing decision
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fn4no58rgsubh9jpu3rf5.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fn4no58rgsubh9jpu3rf5.png" alt="aws-bedrock-service-tier-architecture"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Component responsibilities:&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Module&lt;/th&gt;
&lt;th&gt;Role&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;router/classifier.py&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Validates and normalizes workload category strings&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;router/policy.py&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Maps workload categories to preferred service tiers&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;router/capability.py&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Checks tier availability; selects deterministic fallbacks&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;router/router.py&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Orchestrates classify → policy → capability → decision&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Why four modules instead of one function?&lt;/strong&gt; I split the concerns because the&lt;br&gt;
policy is the part I expect to change most often. By isolating it in a frozen&lt;br&gt;
dataclass, I can swap in a different policy — database-backed, A/B split,&lt;br&gt;
per-tenant — without touching the capability checker or the orchestrator.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;AWS services used:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Amazon Bedrock Runtime&lt;/strong&gt; (&lt;code&gt;bedrock-runtime&lt;/code&gt;) — the &lt;code&gt;converse&lt;/code&gt; API with the
&lt;code&gt;serviceTier&lt;/code&gt; parameter. This is how you specify which tier to use; omitting
it defaults to Default.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;boto3&lt;/strong&gt; — the AWS SDK for Python.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Why not a framework?&lt;/strong&gt; The routing logic is a pure decision function. Adding&lt;br&gt;
LangChain or a similar framework would add indirection without adding value. The&lt;br&gt;
router is a component that plugs into your existing Bedrock call, not a&lt;br&gt;
replacement for it.&lt;/p&gt;


&lt;h2&gt;
  
  
  Implementation Walkthrough
&lt;/h2&gt;
&lt;h3&gt;
  
  
  Step 1: Validate the workload category
&lt;/h3&gt;

&lt;p&gt;I wanted invalid workload labels to fail early rather than silently becoming a&lt;br&gt;
potentially expensive routing decision.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# router/classifier.py
&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;WorkloadCategory&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Enum&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;CRITICAL&lt;/span&gt;    &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;critical&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;INTERACTIVE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;interactive&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;BACKGROUND&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;background&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;classify_workload&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;workload_category&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;WorkloadCategory&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;WorkloadCategory&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;workload_category&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;InvalidWorkloadCategoryError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Workload category is required.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;workload_category&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;WorkloadCategory&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;workload_category&lt;/span&gt;

    &lt;span class="n"&gt;normalized&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;workload_category&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;WorkloadCategory&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;normalized&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;ValueError&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;exc&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;valid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;, &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;WorkloadCategory&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;InvalidWorkloadCategoryError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Unknown workload category: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;workload_category&lt;/span&gt;&lt;span class="si"&gt;!r}&lt;/span&gt;&lt;span class="s"&gt;. &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
            &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Expected one of: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;valid&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;exc&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;str&lt;/code&gt; enum base class means a &lt;code&gt;WorkloadCategory&lt;/code&gt; behaves like a string in&lt;br&gt;
comparisons and CSV output — no &lt;code&gt;.value&lt;/code&gt; everywhere.&lt;/p&gt;
&lt;h3&gt;
  
  
  Step 2: Apply the routing policy
&lt;/h3&gt;

&lt;p&gt;Keeping the policy in its own module was deliberate. I expect this to be the part&lt;br&gt;
I change most often as I learn more about the actual workload.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# router/policy.py
&lt;/span&gt;
&lt;span class="nd"&gt;@dataclass&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;frozen&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;RoutingPolicy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;mapping&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;WorkloadCategory&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ServiceTier&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

    &lt;span class="nd"&gt;@classmethod&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;default&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cls&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;RoutingPolicy&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;cls&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;mapping&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;WorkloadCategory&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CRITICAL&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="n"&gt;ServiceTier&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PRIORITY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;WorkloadCategory&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;INTERACTIVE&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;ServiceTier&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DEFAULT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;WorkloadCategory&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;BACKGROUND&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="n"&gt;ServiceTier&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;FLEX&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;preferred_tier&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;workload&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;WorkloadCategory&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;ServiceTier&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;mapping&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;workload&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;KeyError&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;exc&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;UnsupportedPolicyError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;No routing policy exists for workload: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;workload&lt;/span&gt;&lt;span class="si"&gt;!r}&lt;/span&gt;&lt;span class="s"&gt;.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
            &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;exc&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 3: Check capability and resolve fallbacks
&lt;/h3&gt;

&lt;p&gt;This ended up being more important than I initially expected. Choosing a tier is&lt;br&gt;
one thing; actually being able to use it is another. The capability checker&lt;br&gt;
resolves to an available fallback using a configured chain:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# router/capability.py (fallback defaults)
#   priority → default
#   flex     → default
#   default  → no fallback (RuntimeError if unavailable)
&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;requested_tier&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;ServiceTier&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;tuple&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;ServiceTier&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="n"&gt;requested&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;normalize_tier&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;requested_tier&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;is_available&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;requested&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;requested&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;       &lt;span class="c1"&gt;# (resolved_tier, used_fallback)
&lt;/span&gt;
    &lt;span class="n"&gt;fallback&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_fallback_order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;requested&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;fallback&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;is_available&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fallback&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;fallback&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;

    &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;RuntimeError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Requested tier &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;requested&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt; is unavailable and no &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;available fallback is configured.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 4: Compose in the Router
&lt;/h3&gt;

&lt;p&gt;At this point the router is intentionally simple: it turns a workload label into&lt;br&gt;
a concrete tier decision.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# router/router.py
&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Router&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;__init__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;policy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;RoutingPolicy&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;capability_checker&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;TierCapabilityChecker&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;policy&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;RoutingPolicy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;default&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;capability_checker&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;capability_checker&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="nc"&gt;TierCapabilityChecker&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;route&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;workload_category&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;WorkloadCategory&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;RoutingDecision&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;workload&lt;/span&gt;       &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;classify_workload&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;workload_category&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;requested_tier&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;policy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;preferred_tier&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;workload&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;resolved_tier&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;used_fallback&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;capability_checker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;requested_tier&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;RoutingDecision&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;workload&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;workload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;requested_tier&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;requested_tier&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;resolved_tier&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;resolved_tier&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;used_fallback&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;used_fallback&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 5: Pass the resolved tier to Bedrock
&lt;/h3&gt;

&lt;p&gt;This is the part that makes the project more than a routing abstraction — the&lt;br&gt;
decision ultimately becomes a real &lt;code&gt;serviceTier&lt;/code&gt; parameter in a live Bedrock call.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;decision&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;router&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;route&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;background&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# → RoutingDecision(resolved_tier=FLEX, ...)
&lt;/span&gt;
&lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;bedrock&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;converse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;modelId&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;apac.amazon.nova-pro-v1:0&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;role&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;}]}],&lt;/span&gt;
    &lt;span class="n"&gt;inferenceConfig&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;maxTokens&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;128&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;temperature&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="n"&gt;serviceTier&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;resolved_tier&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;   &lt;span class="c1"&gt;# ← this is the key param
&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;serviceTier&lt;/code&gt; parameter was added to the &lt;code&gt;converse&lt;/code&gt; API alongside the tier&lt;br&gt;
launch. If you are on an older boto3 version (pre-1.34), it will not exist —&lt;br&gt;
upgrade to at least &lt;code&gt;boto3==1.43&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;IAM permissions required&lt;/strong&gt; — no new permissions beyond standard model invocation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Effect"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Allow"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Action"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"bedrock:InvokeModel"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Resource"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"arn:aws:bedrock:ap-southeast-1::foundation-model/amazon.nova-pro-v1:0"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Tier selection is a parameter in the existing &lt;code&gt;converse&lt;/code&gt; call, not a separate API.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Gotcha: Two Things I Expected That Turned Out to Be Wrong
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Gotcha 1: Priority did not demonstrate lower latency in the experiment
&lt;/h3&gt;

&lt;p&gt;This was the assumption I was most confident about going in: Priority should be&lt;br&gt;
faster. The documentation describes it as the fastest tier and says it prioritizes&lt;br&gt;
requests over Default and Flex. I fully expected to see that in the data.&lt;/p&gt;

&lt;p&gt;The controlled tier experiment ran 300 requests across all three tiers in&lt;br&gt;
randomized order. Here is what came back:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tier&lt;/th&gt;
&lt;th&gt;Mean (ms)&lt;/th&gt;
&lt;th&gt;P95 (ms)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Priority&lt;/td&gt;
&lt;td&gt;~803&lt;/td&gt;
&lt;td&gt;~1,080&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Default&lt;/td&gt;
&lt;td&gt;~733&lt;/td&gt;
&lt;td&gt;~1,045&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Flex&lt;/td&gt;
&lt;td&gt;~711&lt;/td&gt;
&lt;td&gt;~903&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Honestly, I was surprised. The documentation says Priority is the fastest tier,&lt;br&gt;
but the experiment didn't show a statistically significant latency advantage for it.&lt;/p&gt;

&lt;p&gt;I don't think those two things are actually contradictory — I think the experiment&lt;br&gt;
just didn't create conditions where the difference becomes visible. The requests&lt;br&gt;
were intentionally tiny: &lt;em&gt;"Explain Amazon SQS in exactly three concise sentences."&lt;/em&gt;&lt;br&gt;
That is roughly 10 input tokens and ~58 output tokens per request, running at 20 RPM&lt;br&gt;
under light load. There is no real demand contention for Priority to resolve in your&lt;br&gt;
favor under those conditions.&lt;/p&gt;

&lt;p&gt;My hypothesis is that the difference would become more observable with larger prompts&lt;br&gt;
and at higher request rates, where tiers would actually compete for inference capacity.&lt;br&gt;
I haven't tested that yet, so I won't claim it — but it is the next experiment I would&lt;br&gt;
run.&lt;/p&gt;

&lt;p&gt;For now, if I had to explain Priority tier to a stakeholder, I would frame it this&lt;br&gt;
way: you are paying for prioritized access, not raw speed. That distinction matters&lt;br&gt;
more when there is actual demand competing for capacity. For short, low-frequency&lt;br&gt;
requests, you are unlikely to feel it.&lt;/p&gt;
&lt;h3&gt;
  
  
  Gotcha 2: Workload-aware routing can cost more, not less
&lt;/h3&gt;

&lt;p&gt;My second assumption was even simpler: if Flex is cheaper, sending batch workloads&lt;br&gt;
there should obviously save money.&lt;/p&gt;

&lt;p&gt;It does — but only if the rest of your workload does not contain too much Priority&lt;br&gt;
traffic.&lt;/p&gt;

&lt;p&gt;Here is the break-even rule I derived from the observed token profile:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;~1.48 Flex-routed background requests&lt;/strong&gt; are required to offset the cost of&lt;br&gt;
&lt;strong&gt;one Priority-routed critical request.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The policy sensitivity model confirmed the consequence. Of 231 simulated workload&lt;br&gt;
mixes, only &lt;strong&gt;96 (42%)&lt;/strong&gt; produced a cheaper result than All-Default. &lt;strong&gt;134 (58%)&lt;/strong&gt;&lt;br&gt;
were more expensive. The model makes the extremes concrete:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Workload mix&lt;/th&gt;
&lt;th&gt;Savings vs. All-Default&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;0% critical / 100% background&lt;/td&gt;
&lt;td&gt;+50.00%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;100% critical / 0% background&lt;/td&gt;
&lt;td&gt;−75.00%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If your batch workload is genuinely deferrable, Flex is a compelling default. But&lt;br&gt;
I wouldn't route something to Flex just because it's called "batch." The real&lt;br&gt;
question is how much latency and availability variance you are willing to trade for&lt;br&gt;
the lower cost. "Batch" tells you how the job runs; it doesn't tell you how tolerant&lt;br&gt;
that job is to degraded service. Those are different questions, and the routing&lt;br&gt;
decision should answer the second one.&lt;/p&gt;


&lt;h2&gt;
  
  
  Results: What the 100-Request Benchmark Actually Showed
&lt;/h2&gt;

&lt;p&gt;The benchmark ran 100 live Bedrock requests in &lt;code&gt;ap-southeast-1&lt;/code&gt; against&lt;br&gt;
&lt;code&gt;apac.amazon.nova-pro-v1:0&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;20 critical → Priority&lt;/li&gt;
&lt;li&gt;50 interactive → Default&lt;/li&gt;
&lt;li&gt;30 background → Flex&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;
  
  
  Routing
&lt;/h3&gt;

&lt;p&gt;This was the part I most wanted to verify: did the router actually do what I told&lt;br&gt;
it to do?&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Policy mapping accuracy&lt;/td&gt;
&lt;td&gt;100.00%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tier resolution accuracy&lt;/td&gt;
&lt;td&gt;100.00%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Request success rate&lt;/td&gt;
&lt;td&gt;100.00%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fallback rate&lt;/td&gt;
&lt;td&gt;0.00%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;In this run, yes. Every workload resolved to the intended tier, every request&lt;br&gt;
succeeded, and the fallback path was never needed.&lt;/p&gt;
&lt;h3&gt;
  
  
  Cost
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Estimated router cost&lt;/td&gt;
&lt;td&gt;$0.019490&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;All-Default baseline&lt;/td&gt;
&lt;td&gt;$0.019523&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Savings&lt;/td&gt;
&lt;td&gt;$0.000034 (0.17%)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;This was smaller than I expected. The Priority premium on 20 critical requests&lt;br&gt;
almost exactly cancelled the Flex savings on 30 background requests. The takeaway&lt;br&gt;
is not that the router fails — it is that workload composition matters more than&lt;br&gt;
simply "using tiers." The router gives you the lever; your traffic distribution&lt;br&gt;
determines whether that lever saves money or costs you more.&lt;/p&gt;
&lt;h3&gt;
  
  
  Token usage
&lt;/h3&gt;

&lt;p&gt;The benchmark deliberately used a small, standardized prompt so the tier comparison&lt;br&gt;
stayed controlled: 1,000 input tokens and 5,851 output tokens across 100 requests,&lt;br&gt;
averaging about 10 input and 58 output tokens per call. That also means the result&lt;br&gt;
should not be generalized to large-context workloads.&lt;/p&gt;


&lt;h2&gt;
  
  
  Lessons Learned: What I Would Do Differently
&lt;/h2&gt;
&lt;h3&gt;
  
  
  1. Randomize request order from the start
&lt;/h3&gt;

&lt;p&gt;Running critical → interactive → background in contiguous blocks means workload&lt;br&gt;
category is correlated with request time. Any time-varying service behavior&lt;br&gt;
contaminates the comparison. In the next experiment, I randomize.&lt;/p&gt;
&lt;h3&gt;
  
  
  2. Don't leave classification until the end
&lt;/h3&gt;

&lt;p&gt;I built the router around an explicit label first because it made the experiment&lt;br&gt;
cleaner. But that exposed a gap: in a real application, something still has to&lt;br&gt;
decide whether a request is critical, interactive, or background. That problem is&lt;br&gt;
non-trivial, and I would solve it before calling this production-ready. A&lt;br&gt;
lightweight heuristic, or a Haiku-class model classifying on request metadata, is&lt;br&gt;
probably the next piece.&lt;/p&gt;
&lt;h3&gt;
  
  
  3. Instrument the workload mix before you ship
&lt;/h3&gt;

&lt;p&gt;This became obvious only after I saw the 0.17% result. The economic value of this&lt;br&gt;
router is entirely determined by your critical-to-background ratio. Without a&lt;br&gt;
dashboard showing that ratio in real time, you have no idea whether the router is&lt;br&gt;
saving or costing money on any given day. Add the metric before the feature,&lt;br&gt;
not after.&lt;/p&gt;
&lt;h3&gt;
  
  
  4. Stress-test Flex before relying on it
&lt;/h3&gt;

&lt;p&gt;Flex is the only tier with an explicit throttling caveat in the AWS documentation.&lt;br&gt;
I tested it at 20 RPM under light load and saw zero fallbacks. That is not a stress&lt;br&gt;
test. Before routing anything important to Flex, verify how it behaves at or above&lt;br&gt;
expected peak load.&lt;/p&gt;
&lt;h3&gt;
  
  
  5. Re-verify pricing per region before any billing claim
&lt;/h3&gt;

&lt;p&gt;The pricing table I used reflects &lt;code&gt;ap-southeast-1&lt;/code&gt; at project time. Cross-region&lt;br&gt;
inference profiles, other regions, and tier pricing can all differ. Any cost claim&lt;br&gt;
in production needs current figures from the AWS pricing page for the exact model&lt;br&gt;
and configuration.&lt;/p&gt;
&lt;h3&gt;
  
  
  6. Test different prompt sizes
&lt;/h3&gt;

&lt;p&gt;This is probably the biggest open question I have after finishing the experiment.&lt;br&gt;
Tiny prompts are good for controlled comparison but limit how much the latency&lt;br&gt;
result generalizes. A follow-up should stratify by token count to test whether tier&lt;br&gt;
differences become more observable as input and output sizes grow — which is also&lt;br&gt;
where the cost differences become more meaningful.&lt;/p&gt;


&lt;h2&gt;
  
  
  What This Is, and What It Isn't
&lt;/h2&gt;

&lt;p&gt;After running the experiments, this is the claim I am comfortable making:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A deterministic workload-aware router can successfully map critical,&lt;br&gt;
interactive, and background workloads to Priority, Default, and Flex service&lt;br&gt;
tiers respectively — with 100% routing accuracy in a 100-request benchmark,&lt;br&gt;
an explicit fallback mechanism, and a measurable economic model whose value&lt;br&gt;
is determined by workload distribution.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;It is not a claim that the router universally saves money, that Priority is&lt;br&gt;
reliably faster, or that this policy is production-optimal. Those claims would&lt;br&gt;
require more evidence than one benchmark run against one model in one region&lt;br&gt;
at light load.&lt;/p&gt;

&lt;p&gt;I started this project thinking the tier decision would be straightforward.&lt;br&gt;
The experiments made that mental model considerably less simple.&lt;/p&gt;

&lt;p&gt;Priority didn't produce the latency advantage I expected under small-prompt&lt;br&gt;
conditions. Flex can reduce cost, but only when enough of the workload can safely&lt;br&gt;
tolerate it. And the economics can flip quickly once Priority traffic becomes a&lt;br&gt;
large share of the mix.&lt;/p&gt;

&lt;p&gt;That is ultimately why I think the router is useful — not because it magically&lt;br&gt;
makes Bedrock cheaper, but because it forces the tier decision to become explicit,&lt;br&gt;
measurable, and testable. Before building this, I was making implicit assumptions.&lt;br&gt;
The router turned those assumptions into a policy I can read, change, and validate&lt;br&gt;
against data.&lt;/p&gt;


&lt;h2&gt;
  
  
  Get the Code
&lt;/h2&gt;

&lt;p&gt;The full project — router, benchmark scripts, analysis, and results — is on&lt;br&gt;
GitHub: &lt;strong&gt;&lt;a href="https://github.com/kayndrigs/bedrock-tier-router" rel="noopener noreferrer"&gt;bedrock-tier-router&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;To run the unit tests locally (no AWS credentials needed):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/&amp;lt;your-org&amp;gt;/bedrock-tier-router.git
&lt;span class="nb"&gt;cd &lt;/span&gt;bedrock-tier-router
python &lt;span class="nt"&gt;-m&lt;/span&gt; venv .venv &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;source&lt;/span&gt; .venv/bin/activate
pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; requirements.txt
pytest &lt;span class="nt"&gt;-v&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Questions, corrections, and production war stories about Bedrock tier behavior&lt;br&gt;
are welcome in the repo issues or in the AWS Community Builders Slack.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Kayne Rodrigo — AWS Community Builder, AI/ML Engineering Track&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Appendix: Pricing Assumptions
&lt;/h2&gt;

&lt;p&gt;These are the analytical rates used in all cost calculations in this post.&lt;br&gt;
Re-verify against current AWS pricing before using for any billing estimate.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tier&lt;/th&gt;
&lt;th&gt;Input / 1M tokens&lt;/th&gt;
&lt;th&gt;Output / 1M tokens&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Default&lt;/td&gt;
&lt;td&gt;$0.80&lt;/td&gt;
&lt;td&gt;$3.20&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Priority&lt;/td&gt;
&lt;td&gt;$1.40&lt;/td&gt;
&lt;td&gt;$5.60&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Flex&lt;/td&gt;
&lt;td&gt;$0.40&lt;/td&gt;
&lt;td&gt;$1.60&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Model: &lt;code&gt;apac.amazon.nova-pro-v1:0&lt;/code&gt;, Region: &lt;code&gt;ap-southeast-1&lt;/code&gt;,&lt;br&gt;
cross-region inference profile.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>agents</category>
      <category>aws</category>
      <category>bedrock</category>
    </item>
    <item>
      <title>How I Built a Kiro Crew App in 5 Minutes - Full Tutorial With Code</title>
      <dc:creator>Sarvar Nadaf</dc:creator>
      <pubDate>Tue, 18 Aug 2026 12:05:57 +0000</pubDate>
      <link>https://dev.to/aws-builders/how-i-built-a-kiro-crew-app-in-5-minutes-full-tutorial-with-code-3el0</link>
      <guid>https://dev.to/aws-builders/how-i-built-a-kiro-crew-app-in-5-minutes-full-tutorial-with-code-3el0</guid>
      <description>&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/-TkMTNAKcAY"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;




&lt;p&gt;Parts 1-4 showed you what Kiro Crew can do. Investigate incidents. Automate weekly toil. Block dangerous commands. All using the built-in agent.&lt;/p&gt;


&lt;div class="ltag__link--embedded"&gt;
  &lt;div class="crayons-story "&gt;
  &lt;a href="https://dev.to/aws-builders/introducing-kiro-crew-awss-open-source-ai-agent-orchestrator-1e63" class="crayons-story__hidden-navigation-link"&gt;Introducing Kiro Crew: AWS's Open-Source AI Agent Orchestrator&lt;/a&gt;


  &lt;div class="crayons-story__body crayons-story__body-full_post"&gt;
      &lt;a href="https://dev.to/aws-builders/introducing-kiro-crew-awss-open-source-ai-agent-orchestrator-1e63" class="crayons-article__context-note crayons-article__context-note__feed"&gt;&lt;p&gt;Solves persistent cross-session memory limits&lt;/p&gt;

&lt;/a&gt;
    &lt;div class="crayons-story__top"&gt;
      &lt;div class="crayons-story__meta"&gt;
        &lt;div class="crayons-story__author-pic"&gt;
          &lt;a class="crayons-logo crayons-logo--l" href="/aws-builders"&gt;
            &lt;img alt="AWS Community Builders  logo" src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Forganization%2Fprofile_image%2F2794%2F88da75b6-aadd-4ea1-8083-ae2dfca8be94.png" class="crayons-logo__image" width="350" height="350"&gt;
          &lt;/a&gt;

          &lt;a href="/sarvar_04" class="crayons-avatar  crayons-avatar--s absolute -right-2 -bottom-2 border-solid border-2 border-base-inverted  "&gt;
            &lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1163149%2F5afa2902-591e-4944-b6fa-9bbba80c6e95.png" alt="sarvar_04 profile" class="crayons-avatar__image" width="800" height="559"&gt;
          &lt;/a&gt;
        &lt;/div&gt;
        &lt;div&gt;
          &lt;div&gt;
            &lt;a href="/sarvar_04" class="crayons-story__secondary fw-medium m:hidden"&gt;
              Sarvar Nadaf
            &lt;/a&gt;
            &lt;div class="profile-preview-card relative mb-4 s:mb-0 fw-medium hidden m:inline-block"&gt;
              
                Sarvar Nadaf
                
                
              
              &lt;div id="story-author-preview-content-4323052" class="profile-preview-card__content crayons-dropdown branded-7 p-4 pt-0"&gt;
                &lt;div class="gap-4 grid"&gt;
                  &lt;div class="-mt-4"&gt;
                    &lt;a href="/sarvar_04" class="flex"&gt;
                      &lt;span class="crayons-avatar crayons-avatar--xl mr-2 shrink-0"&gt;
                        &lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1163149%2F5afa2902-591e-4944-b6fa-9bbba80c6e95.png" class="crayons-avatar__image" alt="" width="800" height="559"&gt;
                      &lt;/span&gt;
                      &lt;span class="crayons-link crayons-subtitle-2 mt-5"&gt;Sarvar Nadaf&lt;/span&gt;
                    &lt;/a&gt;
                  &lt;/div&gt;
                  &lt;div class="print-hidden"&gt;
                    
                      Follow
                    
                  &lt;/div&gt;
                  &lt;div class="author-preview-metadata-container"&gt;&lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
            &lt;/div&gt;

            &lt;span&gt;
              &lt;span class="crayons-story__tertiary fw-normal"&gt; for &lt;/span&gt;&lt;a href="/aws-builders" class="crayons-story__secondary fw-medium"&gt;AWS Community Builders &lt;/a&gt;
            &lt;/span&gt;
          &lt;/div&gt;
          &lt;a href="https://dev.to/aws-builders/introducing-kiro-crew-awss-open-source-ai-agent-orchestrator-1e63" class="crayons-story__tertiary fs-xs"&gt;&lt;time&gt;Aug 5&lt;/time&gt;&lt;span class="time-ago-indicator-initial-placeholder"&gt;&lt;/span&gt;&lt;/a&gt;
        &lt;/div&gt;
      &lt;/div&gt;

    &lt;/div&gt;

    &lt;div class="crayons-story__indention"&gt;
      &lt;h2 class="crayons-story__title crayons-story__title-full_post"&gt;
        &lt;a href="https://dev.to/aws-builders/introducing-kiro-crew-awss-open-source-ai-agent-orchestrator-1e63" id="article-link-4323052"&gt;
          Introducing Kiro Crew: AWS's Open-Source AI Agent Orchestrator
        &lt;/a&gt;
      &lt;/h2&gt;
        &lt;div class="crayons-story__tags"&gt;
            &lt;a class="crayons-tag crayons-tag--filled  " href="/t/discuss"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;discuss&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/agents"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;agents&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/ai"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;ai&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/aws"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;aws&lt;/a&gt;
        &lt;/div&gt;
      &lt;div class="crayons-story__bottom"&gt;
        &lt;div class="crayons-story__details"&gt;
          &lt;a href="https://dev.to/aws-builders/introducing-kiro-crew-awss-open-source-ai-agent-orchestrator-1e63" class="crayons-btn crayons-btn--s crayons-btn--ghost crayons-btn--icon-left"&gt;
            &lt;div class="multiple_reactions_aggregate"&gt;
              &lt;span class="multiple_reactions_icons_container"&gt;
                  &lt;span class="crayons_icon_container"&gt;
                    &lt;img src="https://assets.dev.to/assets/multi-unicorn-b44d6f8c23cdd00964192bedc38af3e82463978aa611b4365bd33a0f1f4f3e97.svg" width="24" height="24"&gt;
                  &lt;/span&gt;
                  &lt;span class="crayons_icon_container"&gt;
                    &lt;img src="https://assets.dev.to/assets/fire-f60e7a582391810302117f987b22a8ef04a2fe0df7e3258a5f49332df1cec71e.svg" width="24" height="24"&gt;
                  &lt;/span&gt;
                  &lt;span class="crayons_icon_container"&gt;
                    &lt;img src="https://assets.dev.to/assets/sparkle-heart-5f9bee3767e18deb1bb725290cb151c25234768a0e9a2bd39370c382d02920cf.svg" width="24" height="24"&gt;
                  &lt;/span&gt;
              &lt;/span&gt;
              &lt;span class="aggregate_reactions_counter"&gt;38&lt;span class="hidden s:inline"&gt;&amp;nbsp;reactions&lt;/span&gt;&lt;/span&gt;
            &lt;/div&gt;
          &lt;/a&gt;
            &lt;a href="https://dev.to/aws-builders/introducing-kiro-crew-awss-open-source-ai-agent-orchestrator-1e63#comments" class="crayons-btn crayons-btn--s crayons-btn--ghost crayons-btn--icon-left flex items-center"&gt;
              

              34&lt;span class="hidden s:inline"&gt;&amp;nbsp;comments&lt;/span&gt;
            &lt;/a&gt;
        &lt;/div&gt;
        &lt;div class="crayons-story__save"&gt;
          &lt;small class="crayons-story__tertiary fs-xs mr-2"&gt;
            12 min read
          &lt;/small&gt;
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;/div&gt;



&lt;div class="ltag__link--embedded"&gt;
  &lt;div class="crayons-story "&gt;
  &lt;a href="https://dev.to/aws-builders/how-kiro-crews-cron-jobs-replaced-4-hours-of-weekly-toil-37h" class="crayons-story__hidden-navigation-link"&gt;How Kiro Crew's Cron Jobs Replaced 4 Hours of Weekly Toil&lt;/a&gt;


  &lt;div class="crayons-story__body crayons-story__body-full_post"&gt;
      &lt;a href="https://dev.to/aws-builders/how-kiro-crews-cron-jobs-replaced-4-hours-of-weekly-toil-37h" class="crayons-article__context-note crayons-article__context-note__feed"&gt;&lt;p&gt;Unsupervised agents cost $2.10 weekly&lt;/p&gt;

&lt;/a&gt;
    &lt;div class="crayons-story__top"&gt;
      &lt;div class="crayons-story__meta"&gt;
        &lt;div class="crayons-story__author-pic"&gt;
          &lt;a class="crayons-logo crayons-logo--l" href="/aws-builders"&gt;
            &lt;img alt="AWS Community Builders  logo" src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Forganization%2Fprofile_image%2F2794%2F88da75b6-aadd-4ea1-8083-ae2dfca8be94.png" class="crayons-logo__image" width="350" height="350"&gt;
          &lt;/a&gt;

          &lt;a href="/sarvar_04" class="crayons-avatar  crayons-avatar--s absolute -right-2 -bottom-2 border-solid border-2 border-base-inverted  "&gt;
            &lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1163149%2F5afa2902-591e-4944-b6fa-9bbba80c6e95.png" alt="sarvar_04 profile" class="crayons-avatar__image" width="800" height="559"&gt;
          &lt;/a&gt;
        &lt;/div&gt;
        &lt;div&gt;
          &lt;div&gt;
            &lt;a href="/sarvar_04" class="crayons-story__secondary fw-medium m:hidden"&gt;
              Sarvar Nadaf
            &lt;/a&gt;
            &lt;div class="profile-preview-card relative mb-4 s:mb-0 fw-medium hidden m:inline-block"&gt;
              
                Sarvar Nadaf
                
                
              
              &lt;div id="story-author-preview-content-4341137" class="profile-preview-card__content crayons-dropdown branded-7 p-4 pt-0"&gt;
                &lt;div class="gap-4 grid"&gt;
                  &lt;div class="-mt-4"&gt;
                    &lt;a href="/sarvar_04" class="flex"&gt;
                      &lt;span class="crayons-avatar crayons-avatar--xl mr-2 shrink-0"&gt;
                        &lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1163149%2F5afa2902-591e-4944-b6fa-9bbba80c6e95.png" class="crayons-avatar__image" alt="" width="800" height="559"&gt;
                      &lt;/span&gt;
                      &lt;span class="crayons-link crayons-subtitle-2 mt-5"&gt;Sarvar Nadaf&lt;/span&gt;
                    &lt;/a&gt;
                  &lt;/div&gt;
                  &lt;div class="print-hidden"&gt;
                    
                      Follow
                    
                  &lt;/div&gt;
                  &lt;div class="author-preview-metadata-container"&gt;&lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
            &lt;/div&gt;

            &lt;span&gt;
              &lt;span class="crayons-story__tertiary fw-normal"&gt; for &lt;/span&gt;&lt;a href="/aws-builders" class="crayons-story__secondary fw-medium"&gt;AWS Community Builders &lt;/a&gt;
            &lt;/span&gt;
          &lt;/div&gt;
          &lt;a href="https://dev.to/aws-builders/how-kiro-crews-cron-jobs-replaced-4-hours-of-weekly-toil-37h" class="crayons-story__tertiary fs-xs"&gt;&lt;time&gt;Aug 7&lt;/time&gt;&lt;span class="time-ago-indicator-initial-placeholder"&gt;&lt;/span&gt;&lt;/a&gt;
        &lt;/div&gt;
      &lt;/div&gt;

    &lt;/div&gt;

    &lt;div class="crayons-story__indention"&gt;
      &lt;h2 class="crayons-story__title crayons-story__title-full_post"&gt;
        &lt;a href="https://dev.to/aws-builders/how-kiro-crews-cron-jobs-replaced-4-hours-of-weekly-toil-37h" id="article-link-4341137"&gt;
          How Kiro Crew's Cron Jobs Replaced 4 Hours of Weekly Toil
        &lt;/a&gt;
      &lt;/h2&gt;
        &lt;div class="crayons-story__tags"&gt;
            &lt;a class="crayons-tag crayons-tag--filled  " href="/t/showdev"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;showdev&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/agents"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;agents&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/ai"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;ai&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/devops"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;devops&lt;/a&gt;
        &lt;/div&gt;
      &lt;div class="crayons-story__bottom"&gt;
        &lt;div class="crayons-story__details"&gt;
          &lt;a href="https://dev.to/aws-builders/how-kiro-crews-cron-jobs-replaced-4-hours-of-weekly-toil-37h" class="crayons-btn crayons-btn--s crayons-btn--ghost crayons-btn--icon-left"&gt;
            &lt;div class="multiple_reactions_aggregate"&gt;
              &lt;span class="multiple_reactions_icons_container"&gt;
                  &lt;span class="crayons_icon_container"&gt;
                    &lt;img src="https://assets.dev.to/assets/exploding-head-daceb38d627e6ae9b730f36a1e390fca556a4289d5a41abb2c35068ad3e2c4b5.svg" width="24" height="24"&gt;
                  &lt;/span&gt;
                  &lt;span class="crayons_icon_container"&gt;
                    &lt;img src="https://assets.dev.to/assets/multi-unicorn-b44d6f8c23cdd00964192bedc38af3e82463978aa611b4365bd33a0f1f4f3e97.svg" width="24" height="24"&gt;
                  &lt;/span&gt;
                  &lt;span class="crayons_icon_container"&gt;
                    &lt;img src="https://assets.dev.to/assets/sparkle-heart-5f9bee3767e18deb1bb725290cb151c25234768a0e9a2bd39370c382d02920cf.svg" width="24" height="24"&gt;
                  &lt;/span&gt;
              &lt;/span&gt;
              &lt;span class="aggregate_reactions_counter"&gt;12&lt;span class="hidden s:inline"&gt;&amp;nbsp;reactions&lt;/span&gt;&lt;/span&gt;
            &lt;/div&gt;
          &lt;/a&gt;
            &lt;a href="https://dev.to/aws-builders/how-kiro-crews-cron-jobs-replaced-4-hours-of-weekly-toil-37h#comments" class="crayons-btn crayons-btn--s crayons-btn--ghost crayons-btn--icon-left flex items-center"&gt;
              

              5&lt;span class="hidden s:inline"&gt;&amp;nbsp;comments&lt;/span&gt;
            &lt;/a&gt;
        &lt;/div&gt;
        &lt;div class="crayons-story__save"&gt;
          &lt;small class="crayons-story__tertiary fs-xs mr-2"&gt;
            7 min read
          &lt;/small&gt;
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;/div&gt;


&lt;p&gt;But here's what nobody's talking about: Kiro Crew has an App Store. And you can build your own apps for it. In five minutes.&lt;/p&gt;

&lt;p&gt;Not plugins. Not scripts. Full apps with their own agents, skills, cron jobs, and dashboard pages. Package them. Publish them. Other users install with one click.&lt;/p&gt;

&lt;p&gt;I built one. A Daily Standup Bot. It reads my git commits every morning and generates standup notes so I never have to write "worked on X" again. Let me show you how.&lt;/p&gt;




&lt;h2&gt;
  
  
  Table of Contents
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;What the App Kit actually is&lt;/li&gt;
&lt;li&gt;What we're building&lt;/li&gt;
&lt;li&gt;Step 1: The manifest (app.json)&lt;/li&gt;
&lt;li&gt;Step 2: The agent&lt;/li&gt;
&lt;li&gt;Step 3: The skill&lt;/li&gt;
&lt;li&gt;Step 4: The dashboard page&lt;/li&gt;
&lt;li&gt;Step 5: The cron job&lt;/li&gt;
&lt;li&gt;Install and run&lt;/li&gt;
&lt;li&gt;What it looks like live&lt;/li&gt;
&lt;li&gt;Publishing to the App Store&lt;/li&gt;
&lt;li&gt;What else you could build&lt;/li&gt;
&lt;li&gt;Try it yourself&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  What the App Kit actually is
&lt;/h2&gt;

&lt;p&gt;An app is a package that contributes any combination of:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Component&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Agents&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Custom AI agent with its own model, prompt, and tool access&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Skills&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;On-demand knowledge files that teach the agent specific capabilities&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;MCP servers&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;New tools the LLM can call&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Cron jobs&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Scheduled tasks the app owns&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;UI pages&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Custom pages in the dashboard sidebar&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Backend processes&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;HTTP servers reverse-proxied through the gateway&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;An app that only ships a skill is one markdown file. An app that ships everything is a full project. You decide the scope.&lt;/p&gt;

&lt;p&gt;The key difference from "just adding a skill": apps are installable, versioned, publishable, and isolated. Crew manages their lifecycle. Users install from the App Store with one click.&lt;/p&gt;




&lt;h2&gt;
  
  
  What we're building
&lt;/h2&gt;

&lt;p&gt;A &lt;strong&gt;Daily Standup Bot&lt;/strong&gt; that:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Reads git commits from the last 24 hours&lt;/li&gt;
&lt;li&gt;Formats them as "What I Did / What's Blocked / What's Next"&lt;/li&gt;
&lt;li&gt;Runs every weekday at 9 AM automatically&lt;/li&gt;
&lt;li&gt;Shows standup history in a custom dashboard page&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Five files. Five minutes. A real app you'd actually use.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;standup-bot/
├── app.json                    ← manifest (identity + resources)
├── agents/
│   └── standup-agent.json      ← agent definition
├── skills/
│   └── standup-format/
│       └── SKILL.md            ← formatting rules
└── ui/
    └── src/App.tsx             ← dashboard page
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 1: The manifest (app.json)
&lt;/h2&gt;

&lt;p&gt;Every app needs one file: &lt;code&gt;app.json&lt;/code&gt;. This is the single source of truth.&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"standup-bot"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"version"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"1.0.0"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"displayName"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Daily Standup Bot"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"description"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Auto-generates standup notes from git commits."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"author"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"sarvar_04"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"agents"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"agents/standup-agent.json"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"skills"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"skills/standup-format"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"ui"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"entry"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"dist/index.mjs"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"pages"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"route"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"/apps/standup-bot"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"label"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Standups"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"icon"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ClipboardList"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}]&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"crons"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"morning-standup"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"cron_expr"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"0 9 * * 1-5"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"message"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Generate today's standup summary from yesterday's git activity"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"agent"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"standup-agent"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;That's agents, skills, a dashboard page, and a cron job. All declared in one file. Crew reads this and wires everything up.&lt;/p&gt;


&lt;h2&gt;
  
  
  Step 2: The agent
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;agents/standup-agent.json&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"standup-agent"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"model"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"auto"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"description"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Generates standup summaries from git activity"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"prompt"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"You are a standup summary assistant. Analyze git commits from the last 24 hours and generate concise standup notes. Format: What I Did, What's Blocked, What's Next."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"tools"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"@kirocrew-core"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Eight lines. The &lt;code&gt;@kirocrew-core&lt;/code&gt; tool reference gives it access to spawn processes, read files, and interact with the system. The &lt;code&gt;model: "auto"&lt;/code&gt; lets Crew pick the best available model.&lt;/p&gt;


&lt;h2&gt;
  
  
  Step 3: The skill
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;skills/standup-format/SKILL.md&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;standup-format&lt;/span&gt;
&lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;How to format daily standup updates&lt;/span&gt;
&lt;span class="na"&gt;triggers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;standup&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;daily&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;summary&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;morning&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;span class="na"&gt;always&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;

&lt;span class="gh"&gt;# Standup Format&lt;/span&gt;

When generating standup notes:
&lt;span class="p"&gt;
1.&lt;/span&gt; &lt;span class="gs"&gt;**What I did**&lt;/span&gt; - List completed work from git commits (group by feature/fix)
&lt;span class="p"&gt;2.&lt;/span&gt; &lt;span class="gs"&gt;**What's blocked**&lt;/span&gt; - Identify stale PRs, failing CI, unresolved issues
&lt;span class="p"&gt;3.&lt;/span&gt; &lt;span class="gs"&gt;**What's next**&lt;/span&gt; - Infer from branch names and open issues

Rules:
&lt;span class="p"&gt;-&lt;/span&gt; One line per bullet
&lt;span class="p"&gt;-&lt;/span&gt; Past tense for "did", present for "blocked", future for "next"
&lt;span class="p"&gt;-&lt;/span&gt; Group related commits into one bullet
&lt;span class="p"&gt;-&lt;/span&gt; Skip merge commits and dependency bumps
&lt;span class="p"&gt;-&lt;/span&gt; Flag anything unmerged for &amp;gt;24 hours
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Skills are markdown. They load on-demand when trigger words appear in the conversation. No code. No compilation. Just knowledge the agent uses when relevant.&lt;/p&gt;


&lt;h2&gt;
  
  
  Step 4: The dashboard page
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;ui/src/App.tsx&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;useAppApi&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;useAppEvents&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@kirocrew/app-sdk&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Card&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;CardTitle&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;PageHeader&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;StatCard&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Badge&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@kirocrew/app-sdk/ui&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;useEffect&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;react&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;StandupDashboard&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;api&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useAppApi&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;standups&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setStandups&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;([])&lt;/span&gt;

  &lt;span class="nf"&gt;useEffect&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;api&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/api/apps/standup-bot/history&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;setStandups&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;[])&lt;/span&gt;

  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;PageHeader&lt;/span&gt; &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"Daily Standups"&lt;/span&gt; &lt;span class="na"&gt;subtitle&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"Auto-generated from git activity"&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"px-6 pb-8"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"grid gap-3.5 grid-cols-4 mb-6"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
          &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;StatCard&lt;/span&gt; &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"Today"&lt;/span&gt; &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"Pending"&lt;/span&gt; &lt;span class="na"&gt;accent&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
          &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;StatCard&lt;/span&gt; &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"This Week"&lt;/span&gt; &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;standups&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; standups`&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
          &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;StatCard&lt;/span&gt; &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"Total Commits"&lt;/span&gt; &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"0"&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
          &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;StatCard&lt;/span&gt; &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"Next Run"&lt;/span&gt; &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"Mon 9:00 AM"&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;You don't &lt;code&gt;npm install @kirocrew/app-sdk&lt;/code&gt;. The dashboard provides it at runtime. Your app stays tiny. Build with Vite, mark Crew's SDK as external, output a single &lt;code&gt;.mjs&lt;/code&gt; file.&lt;/p&gt;


&lt;h2&gt;
  
  
  Step 5: The cron job
&lt;/h2&gt;

&lt;p&gt;Already declared in &lt;code&gt;app.json&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"crons"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"morning-standup"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"cron_expr"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"0 9 * * 1-5"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"message"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Generate today's standup summary from yesterday's git activity"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"agent"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"standup-agent"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Crew registers the cron on enable. Deregisters on disable. Every weekday at 9 AM, it spawns a session, runs the message through &lt;code&gt;standup-agent&lt;/code&gt;, and stores the result. No daemon. No systemd timer. Just a line in your manifest.&lt;/p&gt;


&lt;h2&gt;
  
  
  Install and run
&lt;/h2&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Get your auth token&lt;/span&gt;
&lt;span class="nv"&gt;TOKEN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;kirocrew token | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-oP&lt;/span&gt; &lt;span class="s1"&gt;'token=\K[^&amp;amp;]+'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;

&lt;span class="c"&gt;# Install (one command - point to your app directory)&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="s2"&gt;"http://localhost:5476/api/apps/install?token=&lt;/span&gt;&lt;span class="nv"&gt;$TOKEN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"source": "./standup-bot"}'&lt;/span&gt; | python3 &lt;span class="nt"&gt;-m&lt;/span&gt; json.tool

&lt;span class="c"&gt;# Enable - agents, skills, crons all activate&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="s2"&gt;"http://localhost:5476/api/apps/standup-bot/enable?token=&lt;/span&gt;&lt;span class="nv"&gt;$TOKEN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | python3 &lt;span class="nt"&gt;-m&lt;/span&gt; json.tool
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Response:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"ok"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"standup-bot"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"message"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"enabled standup-bot"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"registration"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"agents"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"standup-bot/standup-agent"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"skills"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"standup-bot/standup-format"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"crons"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"standup-bot/morning-standup"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"mcp_servers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[],&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"errors"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"hooks"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"crons_registered"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"standup-bot/morning-standup"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Agent registered. Skill loaded. Cron scheduled. Dashboard page live.&lt;/p&gt;

&lt;p&gt;Refresh the dashboard. "Standups" is now in your sidebar. That's it.&lt;/p&gt;


&lt;h2&gt;
  
  
  What it looks like live
&lt;/h2&gt;

&lt;p&gt;After installation, "Standups" appears in the sidebar. The dashboard shows stat cards and an empty state waiting for the first standup.&lt;/p&gt;

&lt;p&gt;Trigger it manually in a chat session:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Use the standup-agent to generate today's standup from ~/projects/payment-api.
Run git log, analyze every commit, group by feature area.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;The agent runs &lt;code&gt;git log --since="24 hours ago" --oneline --no-merges&lt;/code&gt;, analyzes each commit, and produces:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What I Did:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Payment Processing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Implemented rate limiting middleware for /api/payments (max 100 req/min per API key)&lt;/li&gt;
&lt;li&gt;Fixed currency conversion rounding bug - was truncating before conversion&lt;/li&gt;
&lt;li&gt;Added retry logic for failed Stripe webhook deliveries (exponential backoff, max 5)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;API &amp;amp; Docs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Updated OpenAPI spec with new error codes (429, 503, 504)&lt;/li&gt;
&lt;li&gt;Added request validation for multi-currency checkout (USD, EUR, GBP, JPY)&lt;/li&gt;
&lt;li&gt;Refactored payment intent creation to use idempotency keys&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Infrastructure:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Configured DynamoDB TTL for expired sessions (7-day retention)&lt;/li&gt;
&lt;li&gt;Added CloudWatch alarms for payment failure rate &amp;gt; 5%&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;What's Blocked:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;PCI compliance security review - waiting on AppSec team (2 days)&lt;/li&gt;
&lt;li&gt;Stripe Connect onboarding - blocked on legal approval&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;What's Next:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Subscription billing with usage-based metering&lt;/li&gt;
&lt;li&gt;Payment analytics dashboard (revenue, failure rates, top merchants)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;11 commits analyzed. 9 seconds. Navigate to the Standups page - it's already there.&lt;/p&gt;


&lt;h2&gt;
  
  
  Publishing to the App Store
&lt;/h2&gt;

&lt;p&gt;The App Store is a curated registry. Publishing means opening a PR:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;In&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;app-registry.json:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"standup-bot"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"gitUrl"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://github.com/simplynadaf/kiro-crew-standup-bot"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"branch"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"main"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Once merged, your app shows up in Explore → Library for all Crew users. Search "standup" and there it is:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Daily Standup Bot
v1.0.0 · Enabled · Registry

Auto-generates standup notes from git commits. Runs daily at 9 AM Mon-Fri.

sarvar_04
1 agent · 1 skill · 1 cron · 1 page

[Open]  [Disable]  [Sync]  [Uninstall]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Your app sits alongside the built-in ones - Code Review Sage, Research Lab, Task Runner. First-class citizen. Teams can also host private registries for internal apps that shouldn't be public.&lt;/p&gt;


&lt;h2&gt;
  
  
  What else you could build
&lt;/h2&gt;

&lt;p&gt;The standup bot took 5 files and 5 minutes. Here's what's possible with the same pattern:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;App idea&lt;/th&gt;
&lt;th&gt;Components&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;PR Review Bot&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Agent + skill (code review rules) + cron (check PRs hourly)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Incident Postmortem Generator&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Agent + skill (postmortem template) + UI (history page)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Cost Anomaly Alerter&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Agent + cron (daily AWS cost check) + Slack notification&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Onboarding Buddy&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Agent + skill (team knowledge) + UI (progress tracker)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Sprint Health Monitor&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Agent + cron (daily Jira check) + UI (burndown chart)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Any workflow that's "check something + format it + deliver it on schedule" is a Crew app waiting to happen.&lt;/p&gt;


&lt;h2&gt;
  
  
  Try it yourself
&lt;/h2&gt;

&lt;p&gt;Kiro Crew is open source (Apache 2.0). The standup-bot code is in this article.&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Install Crew&lt;/span&gt;
curl &lt;span class="nt"&gt;-fsSL&lt;/span&gt; https://download.crew.kiro.dev/cli.sh | sh
kirocrew gateway

&lt;span class="c"&gt;# Enable third-party apps&lt;/span&gt;
&lt;span class="c"&gt;# In ~/.kiro/crew/config.json set: "apps_allow_third_party": true&lt;/span&gt;

&lt;span class="c"&gt;# Create the app&lt;/span&gt;
&lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; standup-bot/agents standup-bot/skills/standup-format standup-bot/ui/src
&lt;span class="c"&gt;# Create the 5 files shown above (app.json, agent, skill, UI, vite config)&lt;/span&gt;

&lt;span class="c"&gt;# Build UI&lt;/span&gt;
&lt;span class="nb"&gt;cd &lt;/span&gt;standup-bot/ui &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; npm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; npm run build &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cd&lt;/span&gt; ../..

&lt;span class="c"&gt;# Install + enable&lt;/span&gt;
&lt;span class="nv"&gt;TOKEN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;kirocrew token | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-oP&lt;/span&gt; &lt;span class="s1"&gt;'token=\K[^&amp;amp;]+'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="s2"&gt;"http://localhost:5476/api/apps/install?token=&lt;/span&gt;&lt;span class="nv"&gt;$TOKEN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"source": "./standup-bot"}'&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="s2"&gt;"http://localhost:5476/api/apps/standup-bot/enable?token=&lt;/span&gt;&lt;span class="nv"&gt;$TOKEN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="c"&gt;# Open dashboard - "Standups" is in the sidebar&lt;/span&gt;
kirocrew open
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;The full app code and docs: &lt;a href="https://kiro.dev/docs/crew/apps/build-first-app/" rel="noopener noreferrer"&gt;Build your first app&lt;/a&gt;&lt;/p&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/simplynadaf" rel="noopener noreferrer"&gt;
        simplynadaf
      &lt;/a&gt; / &lt;a href="https://github.com/simplynadaf/kiro-crew-standup-bot" rel="noopener noreferrer"&gt;
        kiro-crew-standup-bot
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      Daily Standup Bot — A Kiro Crew app that reads git commits and generates standup notes. Agent + Skill + Cron + Dashboard in 5 files.
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div&gt;
&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;🤖 Daily Standup Bot&lt;/h1&gt;
&lt;/div&gt;

&lt;div class="markdown-heading"&gt;
&lt;h3 class="heading-element"&gt;A Kiro Crew App That Writes Your Standups For You&lt;/h3&gt;
&lt;/div&gt;

&lt;p&gt;&lt;a href="https://github.com/kirodotdev/KiroCrew" rel="noopener noreferrer"&gt;&lt;img src="https://camo.githubusercontent.com/21d56f0f4794eb9ee0dbba3bef3d790be4116f4e1b609b3010a7126463b6ddb0/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f4b69726f5f437265772d4170705f4b69742d3633363666313f7374796c653d666f722d7468652d6261646765266c6f676f3d646174613a696d6167652f7376672b786d6c3b6261736536342c50484e325a79423462577875637a30696148523063446f764c336433647935334d793576636d63764d6a41774d43397a646d6369494864705a48526f505349794e434967614756705a326830505349794e434967646d6c6c64304a76654430694d43417749444930494449304969426d6157787350534a3361476c305a53492b50484268644767675a4430695454457949444a4d4d794133646a457762446b674e5341354c5456574e3277744f53303165694976506a777663335a6e50673d3d" alt="Kiro Crew"&gt;&lt;/a&gt;
&lt;a href="https://github.com/simplynadaf/kiro-crew-standup-bot/LICENSE" rel="noopener noreferrer"&gt;&lt;img src="https://camo.githubusercontent.com/7a1226d14a365d288bfe51ece915ee0c7e754a16faa51ff06436504de29b33b4/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f4c6963656e73652d4d49542d79656c6c6f772e7376673f7374796c653d666f722d7468652d6261646765" alt="License: MIT"&gt;&lt;/a&gt;
&lt;a href=""&gt;&lt;img src="https://camo.githubusercontent.com/17408e0296af2c9c306e9fcf68d55ea3b5132cdfa4212e6f591f86a5ffe21e45/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f355f46696c65732d355f4d696e757465732d3130623938313f7374796c653d666f722d7468652d6261646765" alt="5 Files"&gt;&lt;/a&gt;
&lt;a href="https://github.com/kirodotdev/KiroCrew" rel="noopener noreferrer"&gt;&lt;img src="https://camo.githubusercontent.com/11fb7521b21216752475202a7bff7c5946f98f825b09d9dadeeab0db6868aa4c/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f4f70656e5f536f757263652d4170616368655f322e302d6666366236623f7374796c653d666f722d7468652d6261646765" alt="Open Source"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;br&gt;
&lt;p&gt;&lt;strong&gt;5 files. 5 minutes. Never write "worked on X" again.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;An AI agent that reads your git commits every morning and generates formatted standup notes - installed with one command on &lt;a href="https://github.com/kirodotdev/KiroCrew" rel="noopener noreferrer"&gt;Kiro Crew&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;&lt;a href="https://youtu.be/-TkMTNAKcAY" rel="nofollow noopener noreferrer"&gt;📺 Watch the Demo&lt;/a&gt; · &lt;a href="https://github.com/simplynadaf/kiro-crew-standup-bot#-quick-start" rel="noopener noreferrer"&gt;🚀 Quick Start&lt;/a&gt; · &lt;a href="https://github.com/simplynadaf/kiro-crew-standup-bot#-app-structure" rel="noopener noreferrer"&gt;📦 App Structure&lt;/a&gt; · &lt;a href="https://dev.to/sarvar_04/i-built-a-custom-kiro-crew-app-in-5-minutes-the-app-kit-nobodys-talking-about" rel="nofollow"&gt;📝 Article&lt;/a&gt;&lt;/p&gt;
&lt;/div&gt;

&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;✨ Features&lt;/h2&gt;
&lt;/div&gt;
&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Component&lt;/th&gt;
&lt;th&gt;What It Does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;🤖 &lt;strong&gt;Agent&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;Reads git commits from the last 24 hours, groups by feature area&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;📚 &lt;strong&gt;Skill&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;Teaches the agent the standup format (What I Did / Blocked / Next)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;⏰ &lt;strong&gt;Cron&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;Runs every weekday at 9 AM automatically&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;📊 &lt;strong&gt;Dashboard&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;Shows standup history, stats, and today's summary in the sidebar&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;🎬 Demo&lt;/h2&gt;

&lt;/div&gt;
&lt;div&gt;
&lt;p&gt;&lt;a href="https://youtu.be/-TkMTNAKcAY" rel="nofollow noopener noreferrer"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2Fsimplynadaf%2Fkiro-crew-standup-bot%2FHEAD%2Fassets%2Fthumbnail.png" alt="Watch the Demo"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Agent analyzes 11 commits → generates standup in 9 seconds → dashboard updates live&lt;/em&gt;&lt;/p&gt;
&lt;/div&gt;

&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;🚀 Quick Start&lt;/h2&gt;

&lt;/div&gt;
&lt;div class="highlight highlight-source-shell notranslate position-relative overflow-auto js-code-highlight"&gt;
&lt;pre&gt;&lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt; Prerequisites: Kiro Crew running&lt;/span&gt;
curl -fsSL https://download.crew.kiro.dev/cli.sh &lt;span class="pl-k"&gt;|&lt;/span&gt; sh
kirocrew gateway
&lt;/pre&gt;…
&lt;/div&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/simplynadaf/kiro-crew-standup-bot" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;



&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/kirodotdev" rel="noopener noreferrer"&gt;
        kirodotdev
      &lt;/a&gt; / &lt;a href="https://github.com/kirodotdev/KiroCrew" rel="noopener noreferrer"&gt;
        KiroCrew
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      A persistent workspace for development work that self-improves and continues beyond one session.
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;p&gt;
  &lt;a rel="noopener noreferrer" href="https://github.com/kirodotdev/KiroCrew/assets/banner.svg"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2Fkirodotdev%2FKiroCrew%2FHEAD%2Fassets%2Fbanner.svg" alt="Kiro Crew. Keep work moving. Runs on your hardware, remembers across sessions, keeps working unattended."&gt;&lt;/a&gt;
&lt;/p&gt;

&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;Kiro Crew&lt;/h1&gt;
&lt;/div&gt;

&lt;p&gt;
  &lt;strong&gt;A persistent workspace for development work that self-improves and continues beyond one session.&lt;/strong&gt;
&lt;/p&gt;

&lt;p&gt;
  &lt;a href="https://trendshift.io/repositories/103032" rel="nofollow noopener noreferrer"&gt;&lt;img src="https://camo.githubusercontent.com/20d26869a6389d7fba902f5cddb75d8268977b531845622481a8d57020feaa3c/68747470733a2f2f7472656e6473686966742e696f2f6170692f62616467652f7472656e6473686966742f7265706f7369746f726965732f3130333033322f6461696c793f6c616e67756167653d507974686f6e" alt="Kiro Crew on Trendshift" width="250" height="55" class="js-gh-image-fallback"&gt;&lt;/a&gt;
&lt;/p&gt;

&lt;p&gt;
  Kiro Crew is an open source development workspace that runs locally or remotely on
  your hardware. It is persistent, self-learning, and self-evolving. Work with it
  from the desktop app, web dashboard, and CLI, or continue the same work through
  connection tools like Slack and Discord
  Your multi-step tasks can run unattended, recurring jobs run on your schedule
  and heartbeats monitor systems until something needs attention. Kiro Crew Apps
  tailor that experience to a specific job, combining a purpose-built interface
  with agents, skills, schedules, integrations, and backend services.
&lt;/p&gt;

&lt;p&gt;
  &lt;a href="https://github.com/kirodotdev/KiroCrew/releases" rel="noopener noreferrer"&gt;&lt;img src="https://camo.githubusercontent.com/77daf2f75c61d140b3cc2c4aedeabb61de33c4123b82514a1ada2a890836f2a5/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f446f776e6c6f61642d6d61634f532532302537432532304c696e75782d3266366665623f7374796c653d666c61742d737175617265" alt="Download Kiro Crew for macOS or Linux"&gt;&lt;/a&gt;
  &lt;a href="https://github.com/kirodotdev/KiroCrew/docs/README.md" rel="noopener noreferrer"&gt;&lt;img src="https://camo.githubusercontent.com/852641c2b061138b6ee4a6d24baf3d7935ce1e3cc9f7a6b55ceedab8e7183191/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f446f63756d656e746174696f6e2d3166366665623f7374796c653d666c61742d737175617265" alt="Read the documentation"&gt;&lt;/a&gt;
  &lt;a href="https://github.com/kirodotdev/KiroCrew/docs/guides/install.md" rel="noopener noreferrer"&gt;&lt;img src="https://camo.githubusercontent.com/033bc31482c8749b864e135c691a7d174ab4677bceb1df2f704e9043fb29d146/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f496e7374616c6c25323067756964652d6d61634f532532302537432532304c696e757825323025374325323057696e646f77732d3665373738313f7374796c653d666c61742d737175617265" alt="Install guide for macOS, Linux, and Windows"&gt;&lt;/a&gt;
  &lt;a href="https://github.com/kirodotdev/KiroCrew/CONTRIBUTING.md" rel="noopener noreferrer"&gt;&lt;img src="https://camo.githubusercontent.com/0fb57fc16e5b1e9b219f905ec9baf71c552ec6d747e3ea9aa1a19fa1cb7e1f55/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f436f6e747269627574696e672d3233383633363f7374796c653d666c61742d737175617265" alt="Contributing guide"&gt;&lt;/a&gt;
  &lt;a href="https://github.com/kirodotdev/KiroCrew/SECURITY.md" rel="noopener noreferrer"&gt;&lt;img src="https://camo.githubusercontent.com/dc4e9a0d3d8d49543683714c015505dd8378557c539253626f8f920f515a9beb/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f53656375726974792d3832353064663f7374796c653d666c61742d737175617265" alt="Security policy"&gt;&lt;/a&gt;
  &lt;a href="https://github.com/kirodotdev/KiroCrew/LICENSE" rel="noopener noreferrer"&gt;&lt;img src="https://camo.githubusercontent.com/a84955b84a279eafcaeb1508bf99c1ad0929e84623fb47466c6cf875c436e866/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f4c6963656e73652d417061636865253230322e302d3635366437363f7374796c653d666c61742d737175617265" alt="Apache 2.0 license"&gt;&lt;/a&gt;
&lt;/p&gt;

&lt;p&gt;
  &lt;a href="https://github.com/kirodotdev/KiroCrew#quick-start" rel="noopener noreferrer"&gt;Quick start&lt;/a&gt; ·
  &lt;a href="https://github.com/kirodotdev/KiroCrew#build-from-source" rel="noopener noreferrer"&gt;Build from source&lt;/a&gt; ·
  &lt;a href="https://github.com/kirodotdev/KiroCrew#why-kiro-crew" rel="noopener noreferrer"&gt;Why Kiro Crew&lt;/a&gt; ·
  &lt;a href="https://github.com/kirodotdev/KiroCrew#what-kiro-crew-does" rel="noopener noreferrer"&gt;Capabilities&lt;/a&gt; ·
  &lt;a href="https://github.com/kirodotdev/KiroCrew#how-it-works" rel="noopener noreferrer"&gt;How it works&lt;/a&gt; ·
  &lt;a href="https://github.com/kirodotdev/KiroCrew#security-and-control" rel="noopener noreferrer"&gt;Security&lt;/a&gt; ·
  &lt;a href="https://github.com/kirodotdev/KiroCrew#install-configure-and-operate" rel="noopener noreferrer"&gt;Install&lt;/a&gt; ·
  &lt;a href="https://github.com/kirodotdev/KiroCrew#anonymous-usage-telemetry" rel="noopener noreferrer"&gt;Telemetry&lt;/a&gt; ·
  &lt;a href="https://github.com/kirodotdev/KiroCrew#docs-and-contributing" rel="noopener noreferrer"&gt;Docs&lt;/a&gt;
&lt;/p&gt;

&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Quick start&lt;/h2&gt;

&lt;/div&gt;

&lt;p&gt;You choose how to run Kiro Crew: the desktop app with automatic updates, a
one-line install on your machine or a remote…&lt;/p&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/kirodotdev/KiroCrew" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;





&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;p&gt;Part 6 will show the multi-interface story. Start a task on CLI. Continue it on Slack. Check progress on the dashboard. Get notified on your phone. Same agent, same memory, zero context loss.&lt;/p&gt;

&lt;p&gt;The App Kit is what turns Kiro Crew from "my AI coding assistant" into "my team's AI platform." The store is empty right now. First movers win.&lt;/p&gt;

&lt;p&gt;What would you build? A PR reviewer? A docs-from-code generator? An automated changelog? Drop it in the comments. If it's interesting enough, I'll build it in Part 7.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Follow me for more on AWS architecture, DevOps, and AI Infrastructure:&lt;/em&gt;&lt;br&gt;
&lt;em&gt;&lt;a href="https://sarvarnadaf.com" rel="noopener noreferrer"&gt;Portfolio&lt;/a&gt; | &lt;a href="https://www.linkedin.com/in/sarvar04/" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt; | &lt;a href="https://dev.to/sarvar_04"&gt;Dev.to&lt;/a&gt; | &lt;a href="https://www.youtube.com/@TechwithSarvar" rel="noopener noreferrer"&gt;YouTube&lt;/a&gt; | &lt;a href="mailto:simplynadaf@gmail.com"&gt;Email&lt;/a&gt; | &lt;a href="https://builder.aws.com/community/@sarvar" rel="noopener noreferrer"&gt;AWS Builder Center&lt;/a&gt; | &lt;a href="https://x.com/SarvarN_04" rel="noopener noreferrer"&gt;X&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>agents</category>
      <category>ai</category>
      <category>showdev</category>
      <category>opensource</category>
    </item>
  </channel>
</rss>
