<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>Bleu — Blog</title>
    <link>https://bleu.builders/blog/</link>
    <description>Technical articles, field notes and decisions from inside the work.</description>
    <language>en</language>
    <atom:link href="https://bleu.builders/blog/feed.xml" rel="self" type="application/rss+xml" />
    <item>
      <title>Antes de automatizar, mapear a operação</title>
      <link>https://bleu.builders/pt/blog/o-pedido-era-automacao/</link>
      <guid isPermaLink="true">https://bleu.builders/pt/blog/o-pedido-era-automacao/</guid>
      <pubDate>Thu, 23 Jul 2026 12:00:00 GMT</pubDate>
      <dc:language>pt-BR</dc:language>
      <description>O que encontramos ao acompanhar uma operação que atende a indústria de mineração antes de propor qualquer sistema.</description>
      <category>descoberta</category>
      <category>operação</category>
      <category>automação</category>
      <content:encoded><![CDATA[<p>O fundador pediu automação. Eram quatro décadas de operação em mineração e meio ambiente, milhares de processos ativos e demandas importantes chegando diretamente pelo WhatsApp dele. À primeira vista, fazia sentido tirar a rotina manual do caminho.</p>
<p>Antes de propor qualquer sistema, passamos um dia no escritório da consultoria. Entrevistamos o fundador, a responsável administrativa que controla as exigências há 24 anos, o geólogo do fluxo minerário e o coordenador ambiental. Não para cumprir uma etapa de diagnóstico: o que aparece em uma reunião não é a operação inteira.</p>
<p>A imersão mostrou que a rotina reunia o conhecimento de poucas pessoas-chave. Automatizar apenas uma etapa preservaria a concentração de contexto. Por isso, o trabalho começou pelo mapa da operação inteira.</p>
<p>Mapeamos sete fluxos operacionais, agrupamos nove dores e nomeamos quatro problemas centrais. Isso virou um documento de decisão: diagnóstico, frentes priorizadas, o que fica pra depois. Liderança e equipe passaram a discutir a operação com os mesmos nomes.</p>
<p>A primeira entrega saiu antes de o plano fechar. Um monitor regulatório lê o Diário Oficial e o SEI todos os dias e cruza as publicações com os processos da consultoria. Quando a exigência aparece primeiro no SEI, o time ganha tempo antes da publicação no DOU. Em mineração, um prazo perdido pode parar a operação de um cliente.</p>
<p>A experiência da migração anterior orientou uma transição em camadas: o sistema sugere, uma pessoa confirma e a autonomia cresce conforme a equipe valida a ferramenta nova.</p>
<p>Na conversa de encerramento, o fundador resumiu o resultado que buscava: mais tempo para visitar clientes. Automatizar foi o gatilho do contato. O trabalho é mudar a operação em fases, sem parar o que está rodando.</p>]]></content:encoded>
    </item>
    <item>
      <title>Como validamos um marketplace antes da IA ficar pronta</title>
      <link>https://bleu.builders/pt/blog/validar-antes-da-ia-ficar-pronta/</link>
      <guid isPermaLink="true">https://bleu.builders/pt/blog/validar-antes-da-ia-ficar-pronta/</guid>
      <pubDate>Thu, 23 Jul 2026 12:00:00 GMT</pubDate>
      <dc:language>pt-BR</dc:language>
      <description>A decisão de separar a validação de mercado do treinamento do modelo em uma empresa de proteína animal.</description>
      <category>validação</category>
      <category>IA</category>
      <category>marketplace</category>
      <content:encoded><![CDATA[<p>A empresa chegou com dois produtos pra construir: um marketplace de carcaças e um sistema de IA para análise de qualidade. A tentação era tratar tudo como um projeto só, porque no desenho final um alimenta o outro.</p>
<p>Só que os dois têm relógios diferentes. Treinar e validar o modelo leva tempo. O marketplace começa a ensinar alguma coisa quando compradores reais respondem. Amarrar um ao outro significaria passar meses sem aprender sobre o mercado.</p>
<p>Dividimos o trabalho em duas frentes independentes. Uma ficou com o sistema de análise de qualidade, usando os dispositivos OAK-1 e o pipeline de IA. A outra ficou com o marketplace, que saiu com fotos enviadas manualmente pelos fundadores. Assim, os compradores puderam testar a vitrine antes de o modelo ficar pronto.</p>
<p>Essa separação levou a pergunta real a campo cedo. Passamos a primeira semana dentro do frigorífico, acompanhando o carregamento de caminhões, a escolha de carcaças e a gestão dos pedidos. Foi de lá, não do briefing, que saiu a decisão principal: um marketplace aberto reduziria o controle do frigorífico sobre a composição do estoque. O modelo virou estoque controlado.</p>
<p>A Bleu conduziu a validação de mercado enquanto os fundadores cuidavam da captação. Testamos a vitrine com açougues e supermercados, e os testes de usabilidade mudaram os fluxos antes da construção completa.</p>
<p>O piloto entrou no ar em um frigorífico real, com vendedores e compradores reais, enquanto o modelo ainda era treinado. O produto abriu conversas com 26 frigoríficos no pipeline da incubadora; esse número representa oportunidade comercial, não adoção concluída. Quando a IA ficar pronta, ela entrará em um produto ao qual o mercado já respondeu.</p>
<p>Quando uma parte do produto segue o prazo da pesquisa e outra precisa responder ao mercado, separar as duas não é gambiarra. É o que deixa a empresa apresentar evidência em vez de tese.</p>]]></content:encoded>
    </item>
    <item>
      <title>30 meses sem virar carga de gestão</title>
      <link>https://bleu.builders/pt/blog/30-meses-sem-virar-carga-de-gestao/</link>
      <guid isPermaLink="true">https://bleu.builders/pt/blog/30-meses-sem-virar-carga-de-gestao/</guid>
      <pubDate>Thu, 23 Jul 2026 12:00:00 GMT</pubDate>
      <dc:language>pt-BR</dc:language>
      <description>O que a relação com o CoW Protocol exigiu na prática: pegar contexto, decidir com pouca especificação e responder por iniciativas completas.</description>
      <category>parceria</category>
      <category>delegação</category>
      <category>autonomia</category>
      <content:encoded><![CDATA[<p>Existe um medo legítimo em delegar uma frente inteira: ganhar um fornecedor pra gerenciar. Se cada entrega exige especificação completa, acompanhamento e cobrança, o time não se livrou do trabalho. Trocou execução por coordenação.</p>
<p>Nossa parceria mais longa, mais de 30 meses com o CoW Protocol, funciona porque essa troca não aconteceu. Nesse período saíram 16 iniciativas: SDKs, re-arquitetura de frontend, ferramentas de AMM, dados de governança. E o time principal seguiu concentrado no protocolo.</p>
<p>Olhando pra trás, três práticas explicam por que isso não virou carga de gestão.</p>
<p>A primeira: pegar o problema, não o ticket. As iniciativas chegam com pouca especificação. A gente monta o plano, valida a direção e toca. O custo de escrever a especificação completa é exatamente o custo que o time quer evitar quando delega.</p>
<p>A segunda: decidir dentro do contexto, não em abstrato. Re-arquitetar o SDK TypeScript ao redor do Viem, com adaptadores pra Ethers v5, v6, Viem e Wagmi, exigiu entender por que o ecossistema estava concentrado em um único stack e como preservar a compatibilidade em cada caminho.</p>
<p>A terceira: responder por iniciativas completas, não por tarefas. O AMM Deployer saiu em duas semanas porque o pacote inteiro era nosso pra resolver, e não uma lista de tickets repartida entre dois times.</p>
<p>O teste honesto pra qualquer relação desse tipo vem depois de alguns meses: o cliente está gastando mais ou menos tempo com a frente que delegou? No caso da CoW, a resposta aparece na rotina do time principal, que segue no protocolo em si.</p>]]></content:encoded>
    </item>
    <item>
      <title>Zero-Downtime Deployments for Ponder Indexer</title>
      <link>https://bleu.builders/blog/ponder-zero-downtime-deployments/</link>
      <guid isPermaLink="true">https://bleu.builders/blog/ponder-zero-downtime-deployments/</guid>
      <pubDate>Wed, 28 Jan 2026 12:00:00 GMT</pubDate>
      <dc:language>en</dc:language>
      <description>Using a schema-based blue-green approach for Ponder deployments.</description>
      <category>ponder</category>
      <category>crypto</category>
      <content:encoded><![CDATA[<p>Blockchain indexers present a unique challenge for deployments. Unlike typical stateless web services, indexers maintain significant state. They need to process and store historical blockchain data, which can take hours or even days to synchronize. How do you deploy a new version without interrupting API consumers or losing indexed data?</p>
<p>This article explains how we implemented zero-downtime deployments for our <a href="https://ponder.sh/">Ponder</a> indexer on Kubernetes. Ponder was already designed with this problem in mind. It ships with <a href="https://ponder.sh/docs/api-reference/ponder/database">schema isolation</a>, a <a href="https://ponder.sh/docs/production/self-hosting">standalone serve mode</a> for horizontal scaling, and built-in health endpoints for orchestration. Our job was to wire those primitives into Kubernetes properly.</p>
<h2>The Problem</h2>
<p>Traditional deployment strategies fall short for blockchain indexers.</p>
<p><strong>Rolling deployments</strong> assume new pods can start serving traffic quickly. That&#39;s not the case here. A fresh indexer might need 12+ hours to process historical blockchain data before it can respond to queries. During that time, the deployment appears stuck. Worse, if the new version has bugs, you&#39;ve already terminated working pods.</p>
<p><strong>Traditional blue-green</strong> deployments would require duplicating the entire database. Syncing a new database from scratch defeats the purpose of quick switchovers, and resource costs double during deployment windows.</p>
<p><strong>Canary deployments</strong> split traffic between versions, but an unsynced indexer returns incomplete data. Users would get inconsistent responses depending on which pod handles their request.</p>
<p>We needed something different: a strategy that keeps serving traffic from the old version until the new one is fully ready, shares database resources between versions, allows the new indexer to take as long as needed to sync, and switches traffic atomically once ready.</p>
<h2>The Solution: Schema-Based Blue-Green</h2>
<p>Our approach uses <strong>PostgreSQL schema isolation</strong> combined with <strong>Kubernetes readiness probes</strong> to achieve zero-downtime deployments. The key insight:</p>
<blockquote>
<p>Instead of duplicating databases, we duplicate schemas within the same database. Each deployment version writes to its own schema, and Kubernetes routes traffic only to pods that are ready to serve.</p>
</blockquote>
<h3>Architecture Overview</h3>
<p><img src="/blog/arch-overview-zero-downtime-ponder-deployments.svg" alt="Zero-Downtime Ponder Deployment Architecture"></p>
<h3>Two-Tier Deployment Pattern</h3>
<p>We separate the indexer into two distinct Kubernetes deployments.</p>
<p>The <strong>Indexer Deployment</strong> (<code>bleu-indexer-indexer</code>) runs <code>pnpm ponder start</code>, the process that indexes blockchain data. It runs as a single replica since indexing is a sequential operation. This is where the heavy compute happens: it creates and populates its own database schema, consuming significant CPU and memory.</p>
<p>The <strong>API Deployment</strong> (<code>bleu-indexer-api</code>) runs <code>pnpm ponder serve</code>, a read-only HTTP server that Ponder provides specifically for this pattern. As described in their <a href="https://ponder.sh/docs/production/self-hosting">self-hosting guide</a>, <code>ponder serve</code> runs the API layer without the indexing engine, so multiple replicas can operate behind a load balancer reading from the same database schema. We run 3 replicas in production.</p>
<p>This separation is crucial: the API tier can have multiple replicas for availability and load distribution, while the indexer runs as a single instance doing the heavy lifting. Ponder was designed with this decoupling in mind.</p>
<h2>Implementation Details</h2>
<h3>Dynamic Schema Naming</h3>
<p><a href="https://ponder.sh/docs/api-reference/ponder/database">Ponder uses PostgreSQL schemas to isolate each deployment</a>. The target schema is controlled via the <code>DATABASE_SCHEMA</code> environment variable (or the <code>--schema</code> CLI flag). Ponder&#39;s docs suggest using Kubernetes pod names, git commit hashes, or deployment IDs. We compute it at pod startup:</p>
<pre><code class="language-bash"># From the deployment entrypoint
export DATABASE_SCHEMA=&quot;bleu-indexer-v1.2.3-$(env | grep -E &#39;RPC_URL(_[0-9]+)?=&#39; | sort | sha256sum | cut -c1-8)&quot;
exec pnpm ponder start
</code></pre>
<p>The schema name combines the release name prefix (<code>bleu-indexer</code>), the image tag (e.g., <code>v1.2.3</code>), and a hash of all RPC URL environment variables. This ensures different versions never collide when we push a new release, and changing RPC providers triggers a fresh re-index (intentionally). Multiple schemas coexist in the same database; we clean up old ones periodically.</p>
<p>Ponder enforces a safety rule here: once an instance claims a schema via <code>ponder start</code>, no other instance can use it, even after the original stops. This prevents data corruption during concurrent deployments. It also means crash recovery works automatically: restarting a pod with the same schema resumes indexing from the last checkpoint instead of starting over.</p>
<h3>The Magic: Readiness Probes as Traffic Gates</h3>
<p>The key to zero-downtime is in the readiness probe configuration:</p>
<pre><code class="language-yaml">readinessProbe:
  httpGet:
    path: /ready
    port: 3000
  initialDelaySeconds: 30
  periodSeconds: 10
  failureThreshold: 129600 # ~36 hours
</code></pre>
<p>That <code>failureThreshold: 129600</code> is not a typo.</p>
<p>A new indexer might need 12+ hours to process historical blockchain data. During that time, pods stay &quot;not ready&quot; and Kubernetes won&#39;t route traffic to them. The previous version continues handling all requests. Once <code>/ready</code> returns 200, Kubernetes <a href="https://kubernetes.io/docs/tasks/configure-pod-container/configure-liveness-readiness-startup-probes/">adds the pod to the Service endpoints</a> and traffic shifts automatically. No manual intervention, no deployment scripts.</p>
<p>Ponder ships with two health endpoints designed for exactly this orchestration: <code>/health</code> returns <code>200</code> immediately on process startup, while <code>/ready</code> returns <code>200</code> only when indexing has reached realtime across all chains, returning <code>503</code> during the backfill. We didn&#39;t build custom health checks; we just pointed Kubernetes at what Ponder already provides.</p>
<h3>Liveness vs Readiness</h3>
<p>We use both probes with different purposes:</p>
<pre><code class="language-yaml">livenessProbe:
  httpGet:
    path: /health
    port: 3000
  initialDelaySeconds: 30
  periodSeconds: 10
  failureThreshold: 3

readinessProbe:
  httpGet:
    path: /ready
    port: 3000
  initialDelaySeconds: 30
  periodSeconds: 10
  failureThreshold: 129600
</code></pre>
<p>The <strong>liveness probe</strong> (<code>/health</code>) answers &quot;Is the process alive?&quot; If it fails three times, Kubernetes restarts the pod. The <strong>readiness probe</strong> (<code>/ready</code>) answers &quot;Can this pod serve traffic?&quot; If it fails, Kubernetes removes the pod from the Service endpoints.</p>
<p>A pod can be alive but not ready. That&#39;s the syncing state, and it&#39;s perfectly fine.</p>
<h2>Deployment Flow</h2>
<p>Here&#39;s what happens when we deploy a new indexer version:</p>
<p><img src="/blog/final-arch-ponder-zero-downtime-deployments.svg" alt="Zero-Downtime Ponder Deployment Detect Change"></p>
<h2>Database Architecture</h2>
<p>Ponder requires PostgreSQL and <a href="https://ponder.sh/docs/production/self-hosting">recommends</a> keeping database roundtrip latency under 50ms. We use <a href="https://cloudnative-pg.io/">CloudNativePG</a> to run PostgreSQL directly inside the Kubernetes cluster with a 3-replica cluster:</p>
<pre><code class="language-yaml"># Simplified from our Helm template
apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
  name: bleu-indexer-postgres
spec:
  instances: 3
  postgresql:
    parameters:
      shared_buffers: &quot;256MB&quot;
      max_connections: &quot;200&quot;
  storage:
    size: 10Gi
</code></pre>
<p>All schemas live in the same database. Connection pools, memory, and storage are shared across versions. Ponder also maintains a shared <code>ponder_sync</code> schema that caches RPC requests across instances (it&#39;s lock-free, so multiple deployments can safely share it). Old indexing schemas stick around for easy rollback if needed. We just point the API back to a previous schema. Cleanup happens as a maintenance task: periodically drop schemas that are no longer in use.</p>
<h2>Configuration</h2>
<p>We use Helm with environment-specific value files. The base <code>values.yaml</code> defines defaults: 1 indexer replica, 2 API replicas, modest resource requests. Production overrides in <code>instances/vultr1.prod.yaml</code> bump resources and enable ingress:</p>
<pre><code class="language-yaml"># values.yaml
indexer:
  replicas: 1
  resources:
    requests:
      cpu: 500m
      memory: 512Mi

api:
  replicas: 2
  resources:
    requests:
      cpu: 500m
      memory: 512Mi

postgres:
  instances: 3
  storage:
    size: 10Gi
</code></pre>
<pre><code class="language-yaml"># -- production overrides `instances/vultr1.prod.yaml`
indexer:
  resources:
    requests:
      cpu: 1000m
      memory: 2Gi
    limits:
      memory: 4Gi

ingress:
  enabled: true
  hosts:
    - host: api-v3.bleu.builders
      paths:
        - path: /
          pathType: Prefix
  tls:
    - secretName: bleu-indexer-tls
      hosts:
        - api-v3.bleu.builders
</code></pre>
<p>Sensitive configuration like RPC URLs and API keys live in Kubernetes Secrets, referenced via <code>envFrom</code>. We use <a href="https://github.com/stakater/Reloader">Stakater Reloader</a> to automatically restart pods when secrets change. Just annotate the deployment with <code>reloader.stakater.com/auto: &quot;true&quot;</code>.</p>
<h2>Why This Works</h2>
<table>
<thead>
<tr>
<th>Aspect</th>
<th>Traditional Blue-Green</th>
<th>Schema-Based Blue-Green</th>
</tr>
</thead>
<tbody><tr>
<td>Database duplication</td>
<td>Full database copy required</td>
<td>Single database, multiple schemas</td>
</tr>
<tr>
<td>Resource cost</td>
<td>2x during deployment</td>
<td>Minimal overhead</td>
</tr>
<tr>
<td>Sync time</td>
<td>Must pre-sync before cutover</td>
<td>Sync happens in-place</td>
</tr>
<tr>
<td>Rollback</td>
<td>Switch DNS/LB back</td>
<td>Old schema still available</td>
</tr>
<tr>
<td>Complexity</td>
<td>Separate infrastructure</td>
<td>Single cluster, schema isolation</td>
</tr>
</tbody></table>
<p>The old version serves traffic until the new one is fully ready (true zero-downtime). No duplicate infrastructure needed, so it&#39;s cost efficient. Kubernetes handles all the orchestration; we don&#39;t maintain custom deployment scripts. Everything lives in version control (GitOps), and we get standard Kubernetes metrics and logs for observability.</p>
<h2>Monitoring</h2>
<p>We track deployments through Kubernetes events for pod lifecycle, Prometheus metrics for indexer sync progress and API latency, and BetterStack for external uptime monitoring:</p>
<pre><code class="language-hcl">resource &quot;betteruptime_monitor&quot; &quot;bleu_indexer&quot; {
  url             = &quot;https://api-v3.bleu.builders/ready&quot;
  monitor_type    = &quot;status&quot;
  check_frequency = 60
}
</code></pre>
<h2>What We Learned</h2>
<p>Readiness probes are your traffic switch—configure them carefully. That 36-hour failure threshold seemed aggressive at first, but it&#39;s exactly what we needed.</p>
<p>Separating indexing from serving was the right call. They have fundamentally different scaling needs: one is a single stateful process, the other is stateless and horizontally scalable.</p>
<p>Schema isolation beats database duplication. It&#39;s simpler, cheaper, and gives us easy rollback for free.</p>
<p>Let Kubernetes do the orchestration. We tried building custom deployment scripts early on. They were fragile and hard to maintain. The readiness probe approach is declarative and self-healing.</p>
<p>Plan for long syncs. Blockchain history only grows. What takes 4 hours today will take 8 hours next year.</p>
<h2>Conclusion</h2>
<p>Zero-downtime deployments for blockchain indexers don&#39;t require complex custom tooling. By combining schema-based isolation, Kubernetes readiness probes, and a two-tier architecture, we get reliable automated deployments where users never see downtime, even when the new indexer takes hours to synchronize.</p>
<p>The key insight is treating the indexer and API as separate concerns with different lifecycles. The indexer is a stateful, slow-starting process. The API is stateless and can scale horizontally. By separating them and using schemas for isolation, we get the benefits of blue-green deployments without the infrastructure overhead.</p>
<h2>References</h2>
<ul>
<li><a href="https://ponder.sh/docs/production/self-hosting">Ponder — Self-Hosting in Production</a> — schema isolation, <code>ponder serve</code>, health endpoints, and the views pattern</li>
<li><a href="https://ponder.sh/docs/api-reference/ponder/database">Ponder — Database Configuration</a> — schema naming, <code>ponder_sync</code> cache, build ID recovery, and lifecycle rules</li>
<li><a href="https://kubernetes.io/docs/tasks/configure-pod-container/configure-liveness-readiness-startup-probes/">Kubernetes — Configure Liveness, Readiness and Startup Probes</a> — how readiness probes control traffic routing</li>
<li><a href="https://kubernetes.io/docs/concepts/workloads/pods/pod-lifecycle/#container-probes">Kubernetes — Pod Lifecycle</a> — probe types and their effect on Service endpoints</li>
<li><a href="https://cloudnative-pg.io/">CloudNativePG</a> — PostgreSQL operator for Kubernetes</li>
<li><a href="https://github.com/stakater/Reloader">Stakater Reloader</a> — auto-restart pods on Secret/ConfigMap changes</li>
<li><a href="https://argo-cd.readthedocs.io/">ArgoCD</a> — GitOps continuous delivery for Kubernetes</li>
</ul>
]]></content:encoded>
    </item>
    <item>
      <title>mustachio-ruby: The Postmark template engine in Ruby</title>
      <link>https://bleu.builders/blog/mustachio-ruby/</link>
      <guid isPermaLink="true">https://bleu.builders/blog/mustachio-ruby/</guid>
      <pubDate>Wed, 21 Jan 2026 12:00:00 GMT</pubDate>
      <dc:language>en</dc:language>
      <description>Use your Postmark templates in Ruby with this template engine port.</description>
      <category>ruby</category>
      <category>rails</category>
      <content:encoded><![CDATA[<p>Mustachio is the template engine that powers Postmark templates. While similar to Mustache, it has key differences in syntax for handling arrays and destructuring that make it uniquely suited for email templating.</p>
<p>You might wonder: why create another template engine when we already have excellent solutions like ERB, HAML, or Slim? I wouldn&#39;t recommend abandoning these tools for general web development. However, if you&#39;re already using Postmark templates, mustachio-ruby can help reduce your dependency on Postmark&#39;s service.</p>
<p>At <a href="https://bleu.builders">Bleu</a>, most of our projects use Postmark for transactional emails. We love their service and build our templates directly in their platform. However, in 2024, Postmark experienced an incident where emails took significantly longer to deliver. OTP codes were delayed by 40+ minutes. While rare, this incident made us realize we couldn&#39;t be completely dependent on a single email service. We needed a fallback system using an alternative SMTP provider like Resend.</p>
<h2>The Problem: Template Lock-in</h2>
<p>The quick solution was to create a fallback system that used Resend to send emails:</p>
<pre><code class="language-ruby">module FallbackMailing
  extend ActiveSupport::Concern

  included do
    if Rails.application.config.use_fallback_mailer_service
      def template_model=(model)
        @template_model = model
      end

      def template_model
        @template_model
      end
    end
  end
end

class ApplicationMailer &lt; ActionMailer::Base
  default from: &quot;no-reply@example.com&quot;

  if Rails.application.config.use_fallback_mailer_service
    include FallbackMailing
  else
    include PostmarkRails::TemplatedMailerMixin

    def mail(headers = {}, &amp;block)
      # remove not allowed attributes when using Postmark
      %i[subject header body content_type].each { |key| headers.delete(key) }
      super
    end
  end
end
</code></pre>
<p>This solution allowed us to send emails through alternative SMTP providers, but we faced another challenge: our templates were stored exclusively in Postmark.</p>
<p>We didn&#39;t want to abandon Postmark or maintain duplicate template versions. Our solution was to sync templates—keeping identical copies of our Postmark templates in our codebase. However, we discovered that Postmark&#39;s <a href="https://github.com/ActiveCampaign/mustachio?tab=readme-ov-file">template engine</a> was written in C#, not Ruby. This led us to create <strong>mustachio-ruby</strong>: a Ruby port of the original Mustachio template engine.</p>
<h2>Installation</h2>
<p>Add this line to your application&#39;s Gemfile:</p>
<pre><code class="language-ruby">gem &#39;mustachio-ruby&#39;
</code></pre>
<p>And then execute:</p>
<pre><code class="language-bash">bundle install
</code></pre>
<h2>How mustachio-ruby Works</h2>
<h3>Core Components</h3>
<p>The template engine consists of four main components that work together to parse, compile, and render templates:</p>
<p><strong>1. Tokenizer</strong> (<a href="https://github.com/bleu/mustachio-ruby/tree/main/lib/mustachio_ruby/tokenizer.rb">lib/mustachio_ruby/tokenizer.rb</a>)</p>
<p>Parses raw template strings into structured tokens using regex patterns to identify template elements like variables, conditionals, and loops.</p>
<p><strong>2. Parser</strong> (<a href="https://github.com/bleu/mustachio-ruby/tree/main/lib/mustachio_ruby/parser.rb">lib/mustachio_ruby/parser.rb</a>)</p>
<p>Transforms tokens into executable template functions that can be called with data.</p>
<p><strong>3. Context Object</strong> (<a href="https://github.com/bleu/mustachio-ruby/tree/main/lib/mustachio_ruby/context_object.rb">lib/mustachio_ruby/context_object.rb</a>)</p>
<p>Manages data access during rendering, including:</p>
<ul>
<li>Dot-notation access (<code>user.profile.name</code>)</li>
<li>Parent scope access (<code>../</code>)</li>
<li>Existence checks (<code>exists?</code> method)</li>
</ul>
<p><strong>4. Token Types</strong> (<a href="https://github.com/bleu/mustachio-ruby/tree/main/lib/mustachio_ruby/token_tuple.rb">lib/mustachio_ruby/token_tuple.rb</a>)</p>
<p>Defines template element types: HTML-escaped variables, raw variables, conditional blocks, array iteration, and negated conditionals.</p>
<h2>Basic Usage</h2>
<h3>Simple Variable Interpolation</h3>
<pre><code class="language-ruby">template = MustachioRuby.parse(&quot;Hello {{name}}!&quot;)
content = template.call({&quot;name&quot; =&gt; &quot;World&quot;})
# =&gt; &quot;Hello World!&quot;
</code></pre>
<h3>Array Iteration</h3>
<pre><code class="language-ruby">template = &quot;{{#each Company.ceo.products}}&lt;li&gt;{{ name }} and {{version}} and has a CEO: {{../../last_name}}&lt;/li&gt;{{/each}}&quot;
renderer = MustachioRuby.parse(template)

model = {
  &quot;Company&quot; =&gt; {
    &quot;ceo&quot; =&gt; {
      &quot;last_name&quot; =&gt; &quot;Smith&quot;,
      &quot;products&quot; =&gt; [
        { &quot;name&quot; =&gt; &quot;name 0&quot;, &quot;version&quot; =&gt; &quot;version 0&quot; },
        { &quot;name&quot; =&gt; &quot;name 1&quot;, &quot;version&quot; =&gt; &quot;version 1&quot; },
      ]
    }
  }
}

result = renderer.call(model)
# =&gt;
# &lt;li&gt;name 0 and version 0 and has a CEO: Smith&lt;/li&gt;
# &lt;li&gt;name 1 and version 1 and has a CEO: Smith&lt;/li&gt;
</code></pre>
<h3>Conditional Rendering</h3>
<pre><code class="language-ruby"># Show content when user exists
template = MustachioRuby.parse(&quot;{{#user}}Welcome {{name}}!{{/user}}&quot;)

# Show content when user doesn&#39;t exist
template = MustachioRuby.parse(&quot;{{^user}}Please log in{{/user}}&quot;)
</code></pre>
<h3>Configuration Options</h3>
<pre><code class="language-ruby">options = MustachioRuby::ParsingOptions.new
options.disable_content_safety = true # Disable HTML escaping
options.source_name = &quot;my_template&quot; # For error reporting
options.token_expanders = [expander] # Custom extensions

template = MustachioRuby.parse(source, options)
</code></pre>
<h2>HTML Email Templates</h2>
<p>You can follow the Postmark <a href="https://postmarkapp.com/support/article/1077-template-syntax">template syntax</a> to build your email templates and then use mustachio-ruby (or the original C# version) to interpolate your variables:</p>
<pre><code class="language-ruby">mustachio_template = &lt;&lt;~HTML
  &lt;div&gt;
    &lt;h1&gt;Hello {{name}}!&lt;/h1&gt;
    {{#user}}
      &lt;p&gt;Welcome back!&lt;/p&gt;
    {{/user}}
    {{^user}} &lt;!-- negative check --&gt;
      &lt;p&gt;Please log in&lt;/p&gt;
    {{/user}}
    &lt;ul&gt;
      {{#each items}}
        &lt;li&gt;{{name}}: ${{price}}&lt;/li&gt;
      {{/each}}
    &lt;/ul&gt;
  &lt;/div&gt;
HTML

data = {
  &quot;name&quot; =&gt; &quot;John&quot;,
  &quot;user&quot; =&gt; { &quot;name&quot; =&gt; &quot;John&quot; },
  &quot;items&quot; =&gt; [
    { &quot;name&quot; =&gt; &quot;Apple&quot;, &quot;price&quot; =&gt; 1.50 },
    { &quot;name&quot; =&gt; &quot;Banana&quot;, &quot;price&quot; =&gt; 0.75 }
  ]
}

template = MustachioRuby.parse(mustachio_template)
result = template.call(data)

# =&gt;
# &lt;div&gt;
#   &lt;h1&gt;Hello John!&lt;/h1&gt;
#   &lt;p&gt;Welcome back!&lt;/p&gt;
#   &lt;ul&gt;
#     &lt;li&gt;Apple: $1.5&lt;/li&gt;
#     &lt;li&gt;Banana: $0.75&lt;/li&gt;
#   &lt;/ul&gt;
# &lt;/div&gt;
</code></pre>
<h2>Ruby on Rails Integration</h2>
<p>To use mustachio-ruby in Rails, set the body to be the parsed HTML content and the mail content_type to be &quot;text/html&quot; (or &quot;text/plain&quot; if you&#39;re using mustachio to interpolate variables in a plain text string):</p>
<pre><code class="language-ruby">def mail(headers = {}, &amp;)
  template_content = get_template(template_alias)
  options = MustachioRuby::ParsingOptions.new
  options.disable_content_safety = true
  renderer = MustachioRuby.parse(template_content, options)
  html_content = renderer.call(template_model.deep_stringify_keys)
  headers[:body] = html_content
  headers[:content_type] = &quot;text/html&quot;

  super
end
</code></pre>
<h2>Final Thoughts</h2>
<p>Postmark is a fantastic service, and we continue to use it every day. mustachio-ruby isn&#39;t a replacement—it&#39;s a safety net. It lets you render templates in pure Ruby and send emails even when you don&#39;t have access to Postmark.</p>
<p>If your team relies on Postmark templates, I hope mustachio-ruby gives you the flexibility to build resilient email systems without vendor lock-in.</p>
<h2>Learn More</h2>
<ul>
<li><a href="https://github.com/bleu/mustachio-ruby">mustachio-ruby on GitHub</a></li>
<li><a href="https://postmarkapp.com/support/article/1077-template-syntax">Postmark Template Syntax Documentation</a></li>
<li><a href="https://github.com/ActiveCampaign/mustachio">Original Mustachio (C#) Repository</a></li>
</ul>
]]></content:encoded>
    </item>
  </channel>
</rss>
