<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://sebsv123.github.io/feed.xml" rel="self" type="application/atom+xml" /><link href="https://sebsv123.github.io/" rel="alternate" type="text/html" /><updated>2026-05-14T13:30:22+02:00</updated><id>https://sebsv123.github.io/feed.xml</id><title type="html">Sebastián Sifontes</title><subtitle>Full-Stack AI Engineer · Building production AI systems · Madrid</subtitle><author><name>Sebastián Sifontes</name><email>sebastiansvsv@gmail.com</email><uri>https://sebsv123.github.io</uri></author><entry><title type="html">Future Blog Post</title><link href="https://sebsv123.github.io/posts/2012/08/blog-post-4/" rel="alternate" type="text/html" title="Future Blog Post" /><published>2199-01-01T00:00:00+01:00</published><updated>2199-01-01T00:00:00+01:00</updated><id>https://sebsv123.github.io/posts/2012/08/future-post</id><content type="html" xml:base="https://sebsv123.github.io/posts/2012/08/blog-post-4/"><![CDATA[<p>This post will show up by default. To disable scheduling of future posts, edit <code class="language-plaintext highlighter-rouge">config.yml</code> and set <code class="language-plaintext highlighter-rouge">future: false</code>.</p>]]></content><author><name>Sebastián Sifontes</name><email>sebastiansvsv@gmail.com</email><uri>https://sebsv123.github.io</uri></author><category term="cool posts" /><category term="category1" /><category term="category2" /><summary type="html"><![CDATA[This post will show up by default. To disable scheduling of future posts, edit config.yml and set future: false.]]></summary></entry><entry><title type="html">Safe Alembic Migrations in Production: A Practical Guide</title><link href="https://sebsv123.github.io/posts/2026/05/alembic-migrations-production/" rel="alternate" type="text/html" title="Safe Alembic Migrations in Production: A Practical Guide" /><published>2026-05-14T00:00:00+02:00</published><updated>2026-05-14T00:00:00+02:00</updated><id>https://sebsv123.github.io/posts/2026/05/alembic-migrations-production</id><content type="html" xml:base="https://sebsv123.github.io/posts/2026/05/alembic-migrations-production/"><![CDATA[<p>Running database migrations in production is one of those tasks that’s routine until it isn’t. Here’s the approach I use with Alembic to keep migrations safe and reversible.</p>

<h2 id="the-golden-rules">The Golden Rules</h2>

<p><strong>1. Never edit a migration that has already been applied to production.</strong></p>

<p>If a migration is live, create a new one to fix it. Editing applied migrations breaks the revision chain and causes <code class="language-plaintext highlighter-rouge">alembic upgrade head</code> to fail or apply unexpected changes.</p>

<p><strong>2. Always test the downgrade.</strong></p>

<p>Write <code class="language-plaintext highlighter-rouge">downgrade()</code> properly, not just <code class="language-plaintext highlighter-rouge">pass</code>. You’ll need it the day a deploy goes wrong:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">downgrade</span><span class="p">():</span>
    <span class="n">op</span><span class="p">.</span><span class="n">drop_column</span><span class="p">(</span><span class="s">'users'</span><span class="p">,</span> <span class="s">'new_column'</span><span class="p">)</span>  <span class="c1"># explicit
</span>    <span class="c1"># NOT just: pass
</span></code></pre></div></div>

<p><strong>3. Check current revision before deploying.</strong></p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>alembic current
alembic <span class="nb">history</span> <span class="nt">--verbose</span>
</code></pre></div></div>

<p>Always verify the database is at the expected revision before running <code class="language-plaintext highlighter-rouge">upgrade head</code> in CI/CD.</p>

<h2 id="the-pattern-i-use-in-docker">The Pattern I Use in Docker</h2>

<p>In ViraClip, migrations run as a one-shot step before the API starts:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># docker-compose.yaml</span>
<span class="na">backend</span><span class="pi">:</span>
  <span class="na">command</span><span class="pi">:</span> <span class="pi">&gt;</span>
    <span class="s">sh -c "alembic upgrade head &amp;&amp;</span>
           <span class="s">uvicorn src.main:app --host 0.0.0.0 --port 8000"</span>
  <span class="na">depends_on</span><span class="pi">:</span>
    <span class="na">postgres</span><span class="pi">:</span>
      <span class="na">condition</span><span class="pi">:</span> <span class="s">service_healthy</span>
</code></pre></div></div>

<p>This ensures the schema is always up to date when the API starts, and the <code class="language-plaintext highlighter-rouge">service_healthy</code> condition prevents migrations from running before PostgreSQL is ready.</p>

<h2 id="dangerous-operations-to-avoid">Dangerous Operations to Avoid</h2>

<table>
  <thead>
    <tr>
      <th>Operation</th>
      <th>Why dangerous</th>
      <th>Safe alternative</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">DROP COLUMN</code> immediately</td>
      <td>Old code still references it</td>
      <td>Deprecate first, drop in next deploy</td>
    </tr>
    <tr>
      <td>Rename column in one step</td>
      <td>Breaks existing queries</td>
      <td>Add new column, migrate data, drop old</td>
    </tr>
    <tr>
      <td>Add NOT NULL without default</td>
      <td>Fails on existing rows</td>
      <td>Add with default, then remove default</td>
    </tr>
  </tbody>
</table>

<h2 id="zero-downtime-strategy">Zero-Downtime Strategy</h2>

<p>For tables with millions of rows, use <code class="language-plaintext highlighter-rouge">op.execute()</code> with <code class="language-plaintext highlighter-rouge">ALTER TABLE ... ADD COLUMN</code> and a <code class="language-plaintext highlighter-rouge">DEFAULT</code> to avoid locking:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">upgrade</span><span class="p">():</span>
    <span class="n">op</span><span class="p">.</span><span class="n">execute</span><span class="p">(</span>
        <span class="s">"ALTER TABLE videos ADD COLUMN processed BOOLEAN DEFAULT FALSE NOT NULL"</span>
    <span class="p">)</span>
</code></pre></div></div>

<p>This is faster than the ORM-generated equivalent and avoids table locks on PostgreSQL 11+.</p>]]></content><author><name>Sebastián Sifontes</name><email>sebastiansvsv@gmail.com</email><uri>https://sebsv123.github.io</uri></author><category term="PostgreSQL" /><category term="Alembic" /><category term="Python" /><category term="database" /><category term="DevOps" /><summary type="html"><![CDATA[Running database migrations in production is one of those tasks that’s routine until it isn’t. Here’s the approach I use with Alembic to keep migrations safe and reversible.]]></summary></entry><entry><title type="html">Building ViraClip: My Journey Creating an AI Video Processing Platform</title><link href="https://sebsv123.github.io/posts/2026/05/building-viraclip/" rel="alternate" type="text/html" title="Building ViraClip: My Journey Creating an AI Video Processing Platform" /><published>2026-05-14T00:00:00+02:00</published><updated>2026-05-14T00:00:00+02:00</updated><id>https://sebsv123.github.io/posts/2026/05/building-viraclip</id><content type="html" xml:base="https://sebsv123.github.io/posts/2026/05/building-viraclip/"><![CDATA[<p>For the past several months, I’ve been building <strong>ViraClip</strong> — an AI-powered platform that automates video creation and processing for social media content.</p>

<h2 id="the-problem">The Problem</h2>

<p>Content creators and marketers spend hours manually editing videos, adding subtitles, resizing for different platforms (TikTok, Instagram Reels, YouTube Shorts), and generating engaging clips. I wanted to automate all of this.</p>

<h2 id="the-stack">The Stack</h2>

<p>ViraClip is built with a multi-service architecture:</p>

<ul>
  <li><strong>Backend</strong>: FastAPI (Python) for the core API</li>
  <li><strong>Video Processing</strong>: FFmpeg with a custom pool manager for concurrent processing</li>
  <li><strong>AI Integration</strong>: Groq API, OpenAI, and custom ComfyUI workflows</li>
  <li><strong>Queue</strong>: Redis for job management and real-time progress tracking</li>
  <li><strong>Database</strong>: PostgreSQL with Alembic migrations</li>
  <li><strong>Orchestration</strong>: Docker Compose with dedicated services for each component</li>
  <li><strong>Rust Agent</strong>: A self-healing watchdog agent that monitors and restores services</li>
</ul>

<h2 id="key-challenges">Key Challenges</h2>

<h3 id="ffmpeg-concurrency">FFmpeg Concurrency</h3>

<p>Running multiple FFmpeg processes simultaneously is tricky. Too many concurrent jobs crash the server; too few and the queue backs up. I built a custom <code class="language-plaintext highlighter-rouge">ffmpeg_pool.py</code> that manages worker slots dynamically based on CPU and memory availability.</p>

<h3 id="comfyui-integration">ComfyUI Integration</h3>

<p>Integrating ComfyUI for AI video generation required building a custom workflow executor that translates API requests into ComfyUI node graphs, monitors progress via WebSocket, and handles GPU memory gracefully.</p>

<h3 id="self-healing-architecture">Self-Healing Architecture</h3>

<p>The Rust agent (<code class="language-plaintext highlighter-rouge">rust-agent/</code>) monitors all services and automatically restarts failed containers, notifies via webhook, and runs diagnostics — all without human intervention.</p>

<h2 id="whats-next">What’s Next</h2>

<ul>
  <li>LTX video model integration for high-quality AI video generation</li>
  <li>Multi-tenant support for agency clients</li>
  <li>Automated content pipelines with n8n integration</li>
</ul>

<p>If you’re building something similar or want to collaborate, feel free to reach out via <a href="https://github.com/sebsv123">GitHub</a>.</p>]]></content><author><name>Sebastián Sifontes</name><email>sebastiansvsv@gmail.com</email><uri>https://sebsv123.github.io</uri></author><category term="AI" /><category term="SaaS" /><category term="video-processing" /><category term="FastAPI" /><category term="Docker" /><summary type="html"><![CDATA[For the past several months, I’ve been building ViraClip — an AI-powered platform that automates video creation and processing for social media content.]]></summary></entry><entry><title type="html">Docker Multi-Service Architecture: Lessons from Running 6 Containers in Production</title><link href="https://sebsv123.github.io/posts/2026/05/docker-multiservice-architecture/" rel="alternate" type="text/html" title="Docker Multi-Service Architecture: Lessons from Running 6 Containers in Production" /><published>2026-05-14T00:00:00+02:00</published><updated>2026-05-14T00:00:00+02:00</updated><id>https://sebsv123.github.io/posts/2026/05/docker-multiservice-architecture</id><content type="html" xml:base="https://sebsv123.github.io/posts/2026/05/docker-multiservice-architecture/"><![CDATA[<p>Running a production system with 6+ Docker containers taught me more about resource management and failure modes than any tutorial ever could. Here’s what I learned building ViraClip’s infrastructure.</p>

<h2 id="the-setup">The Setup</h2>

<p>ViraClip runs as a Docker Compose stack with these services:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">services</span><span class="pi">:</span>
  <span class="na">backend</span><span class="pi">:</span>    <span class="c1"># FastAPI API server</span>
  <span class="na">worker</span><span class="pi">:</span>     <span class="c1"># FFmpeg processing workers</span>
  <span class="na">comfyui</span><span class="pi">:</span>    <span class="c1"># AI video generation</span>
  <span class="na">redis</span><span class="pi">:</span>      <span class="c1"># Job queue + progress tracking</span>
  <span class="na">postgres</span><span class="pi">:</span>   <span class="c1"># Primary database</span>
  <span class="na">rust-agent</span><span class="pi">:</span> <span class="c1"># Self-healing watchdog</span>
</code></pre></div></div>

<h2 id="lesson-1-resource-limits-are-not-optional">Lesson 1: Resource Limits Are Not Optional</h2>

<p>Without <code class="language-plaintext highlighter-rouge">mem_limit</code> and <code class="language-plaintext highlighter-rouge">cpus</code> constraints, ComfyUI will happily consume all available RAM when generating video, killing every other container. Always set explicit limits:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">comfyui</span><span class="pi">:</span>
  <span class="na">mem_limit</span><span class="pi">:</span> <span class="s">8g</span>
  <span class="na">cpus</span><span class="pi">:</span> <span class="s1">'</span><span class="s">4'</span>
</code></pre></div></div>

<h2 id="lesson-2-health-checks-save-your-sanity">Lesson 2: Health Checks Save Your Sanity</h2>

<p>Docker’s <code class="language-plaintext highlighter-rouge">depends_on</code> only waits for a container to <em>start</em>, not to be <em>ready</em>. A PostgreSQL container is “running” 3 seconds before it accepts connections. Add proper health checks:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">postgres</span><span class="pi">:</span>
  <span class="na">healthcheck</span><span class="pi">:</span>
    <span class="na">test</span><span class="pi">:</span> <span class="pi">[</span><span class="s2">"</span><span class="s">CMD-SHELL"</span><span class="pi">,</span> <span class="s2">"</span><span class="s">pg_isready</span><span class="nv"> </span><span class="s">-U</span><span class="nv"> </span><span class="s">$POSTGRES_USER"</span><span class="pi">]</span>
    <span class="na">interval</span><span class="pi">:</span> <span class="s">5s</span>
    <span class="na">timeout</span><span class="pi">:</span> <span class="s">5s</span>
    <span class="na">retries</span><span class="pi">:</span> <span class="m">10</span>
</code></pre></div></div>

<h2 id="lesson-3-the-rust-watchdog-pattern">Lesson 3: The Rust Watchdog Pattern</h2>

<p>Instead of relying solely on Docker’s restart policies, I built a Rust agent that:</p>

<ol>
  <li>Polls health endpoints every 30s</li>
  <li>Runs diagnostic commands on failure</li>
  <li>Attempts a graceful restart before forcing one</li>
  <li>Sends a webhook notification with full context</li>
</ol>

<p>This gives much richer failure information than a bare <code class="language-plaintext highlighter-rouge">restart: always</code>.</p>

<h2 id="lesson-4-redis-as-the-source-of-truth-for-job-state">Lesson 4: Redis as the Source of Truth for Job State</h2>

<p>When a worker crashes mid-job, you need to know exactly where it failed. Storing granular progress in Redis (not just “running/done”) lets you resume or retry intelligently:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">redis</span><span class="p">.</span><span class="n">hset</span><span class="p">(</span><span class="sa">f</span><span class="s">"job:</span><span class="si">{</span><span class="n">job_id</span><span class="si">}</span><span class="s">"</span><span class="p">,</span> <span class="n">mapping</span><span class="o">=</span><span class="p">{</span>
    <span class="s">"status"</span><span class="p">:</span> <span class="s">"processing"</span><span class="p">,</span>
    <span class="s">"step"</span><span class="p">:</span> <span class="s">"ffmpeg_encode"</span><span class="p">,</span>
    <span class="s">"progress"</span><span class="p">:</span> <span class="mi">67</span><span class="p">,</span>
    <span class="s">"started_at"</span><span class="p">:</span> <span class="n">timestamp</span>
<span class="p">})</span>
</code></pre></div></div>

<h2 id="lesson-5-shared-volumes-need-clear-ownership">Lesson 5: Shared Volumes Need Clear Ownership</h2>

<p>When multiple containers write to the same volume (e.g., the <code class="language-plaintext highlighter-rouge">output/</code> directory), file permission conflicts are inevitable. Use a single writer pattern — one container writes, others read via API.</p>

<hr />

<p>Building a robust multi-service system is mostly about designing for failure gracefully. Each of these lessons came from a real production outage — the best kind of teacher.</p>]]></content><author><name>Sebastián Sifontes</name><email>sebastiansvsv@gmail.com</email><uri>https://sebsv123.github.io</uri></author><category term="Docker" /><category term="DevOps" /><category term="architecture" /><category term="Redis" /><category term="PostgreSQL" /><summary type="html"><![CDATA[Running a production system with 6+ Docker containers taught me more about resource management and failure modes than any tutorial ever could. Here’s what I learned building ViraClip’s infrastructure.]]></summary></entry><entry><title type="html">FastAPI Background Tasks vs Celery: When to Use Each</title><link href="https://sebsv123.github.io/posts/2026/05/fastapi-background-tasks-vs-celery/" rel="alternate" type="text/html" title="FastAPI Background Tasks vs Celery: When to Use Each" /><published>2026-05-14T00:00:00+02:00</published><updated>2026-05-14T00:00:00+02:00</updated><id>https://sebsv123.github.io/posts/2026/05/fastapi-background-tasks</id><content type="html" xml:base="https://sebsv123.github.io/posts/2026/05/fastapi-background-tasks-vs-celery/"><![CDATA[<p>FastAPI ships with a built-in <code class="language-plaintext highlighter-rouge">BackgroundTasks</code> system. It’s tempting to use it for everything async — but there’s a clear line where you should switch to a proper task queue like Celery or Redis workers.</p>

<h2 id="fastapi-backgroundtasks-what-it-is">FastAPI BackgroundTasks: What It Is</h2>

<p><code class="language-plaintext highlighter-rouge">BackgroundTasks</code> runs a function <em>in the same process</em> after the HTTP response is sent. It shares memory with the web server and runs in the same event loop.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">@</span><span class="n">app</span><span class="p">.</span><span class="n">post</span><span class="p">(</span><span class="s">"/send-email"</span><span class="p">)</span>
<span class="k">async</span> <span class="k">def</span> <span class="nf">send_email</span><span class="p">(</span><span class="n">background_tasks</span><span class="p">:</span> <span class="n">BackgroundTasks</span><span class="p">,</span> <span class="n">email</span><span class="p">:</span> <span class="nb">str</span><span class="p">):</span>
    <span class="n">background_tasks</span><span class="p">.</span><span class="n">add_task</span><span class="p">(</span><span class="n">send_notification</span><span class="p">,</span> <span class="n">email</span><span class="p">)</span>
    <span class="k">return</span> <span class="p">{</span><span class="s">"status"</span><span class="p">:</span> <span class="s">"queued"</span><span class="p">}</span>
</code></pre></div></div>

<h2 id="when-backgroundtasks-is-fine">When BackgroundTasks Is Fine</h2>

<ul>
  <li>Sending a welcome email after registration</li>
  <li>Logging an event to a database</li>
  <li>Invalidating a cache entry</li>
  <li>Sending a webhook notification</li>
</ul>

<p>Anything that’s <strong>fast, stateless, and non-critical</strong>. If it fails, the user doesn’t need to know.</p>

<h2 id="when-you-need-a-real-queue">When You Need a Real Queue</h2>

<table>
  <thead>
    <tr>
      <th>Scenario</th>
      <th>Use</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Task takes &gt; 5 seconds</td>
      <td>Redis/Celery worker</td>
    </tr>
    <tr>
      <td>Task uses lots of CPU/RAM</td>
      <td>Separate worker process</td>
    </tr>
    <tr>
      <td>Task needs retry logic</td>
      <td>Celery with exponential backoff</td>
    </tr>
    <tr>
      <td>User needs progress tracking</td>
      <td>Redis + WebSocket</td>
    </tr>
    <tr>
      <td>Task must survive server restart</td>
      <td>Persistent queue</td>
    </tr>
  </tbody>
</table>

<p>For ViraClip, video rendering takes 30–300 seconds and uses all available CPU. Running that inside FastAPI would block every other request. It lives in a dedicated worker container.</p>

<h2 id="the-pattern-i-use">The Pattern I Use</h2>

<ol>
  <li>HTTP request comes in → validate input → create job record in PostgreSQL</li>
  <li>Push job ID to Redis queue</li>
  <li>Return <code class="language-plaintext highlighter-rouge">202 Accepted</code> with job ID immediately</li>
  <li>Separate worker container picks up job, processes it, updates Redis state</li>
  <li>Frontend polls <code class="language-plaintext highlighter-rouge">/jobs/{id}/status</code> or subscribes to WebSocket</li>
</ol>

<p>This keeps your API server snappy and your workers scalable independently.</p>]]></content><author><name>Sebastián Sifontes</name><email>sebastiansvsv@gmail.com</email><uri>https://sebsv123.github.io</uri></author><category term="FastAPI" /><category term="Python" /><category term="Celery" /><category term="backend" /><category term="architecture" /><summary type="html"><![CDATA[FastAPI ships with a built-in BackgroundTasks system. It’s tempting to use it for everything async — but there’s a clear line where you should switch to a proper task queue like Celery or Redis workers.]]></summary></entry><entry><title type="html">GitHub Actions CI/CD for Python Projects: My Minimal Setup</title><link href="https://sebsv123.github.io/posts/2026/05/github-actions-python-cicd/" rel="alternate" type="text/html" title="GitHub Actions CI/CD for Python Projects: My Minimal Setup" /><published>2026-05-14T00:00:00+02:00</published><updated>2026-05-14T00:00:00+02:00</updated><id>https://sebsv123.github.io/posts/2026/05/github-actions-python-cicd</id><content type="html" xml:base="https://sebsv123.github.io/posts/2026/05/github-actions-python-cicd/"><![CDATA[<p>After setting up CI/CD for ViraClip and agente_seguros_ai, I’ve distilled my GitHub Actions setup to the smallest config that still gives me real value.</p>

<h2 id="what-i-actually-need-from-ci">What I Actually Need from CI</h2>

<p>For a solo developer on a SaaS product, CI doesn’t need to be complex. My requirements:</p>

<ol>
  <li>Run tests on every push to <code class="language-plaintext highlighter-rouge">main</code> and on PRs</li>
  <li>Build and push Docker image on merge to <code class="language-plaintext highlighter-rouge">main</code></li>
  <li>Notify me if anything breaks</li>
</ol>

<p>That’s it. No staging environments, no approval gates — just fast feedback.</p>

<h2 id="the-workflow">The Workflow</h2>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># .github/workflows/ci.yml</span>
<span class="na">name</span><span class="pi">:</span> <span class="s">CI</span>

<span class="na">on</span><span class="pi">:</span>
  <span class="na">push</span><span class="pi">:</span>
    <span class="na">branches</span><span class="pi">:</span> <span class="pi">[</span><span class="nv">main</span><span class="pi">]</span>
  <span class="na">pull_request</span><span class="pi">:</span>
    <span class="na">branches</span><span class="pi">:</span> <span class="pi">[</span><span class="nv">main</span><span class="pi">]</span>

<span class="na">jobs</span><span class="pi">:</span>
  <span class="na">test</span><span class="pi">:</span>
    <span class="na">runs-on</span><span class="pi">:</span> <span class="s">ubuntu-latest</span>
    <span class="na">services</span><span class="pi">:</span>
      <span class="na">postgres</span><span class="pi">:</span>
        <span class="na">image</span><span class="pi">:</span> <span class="s">postgres:15</span>
        <span class="na">env</span><span class="pi">:</span>
          <span class="na">POSTGRES_PASSWORD</span><span class="pi">:</span> <span class="s">testpass</span>
          <span class="na">POSTGRES_DB</span><span class="pi">:</span> <span class="s">testdb</span>
        <span class="na">options</span><span class="pi">:</span> <span class="pi">&gt;-</span>
          <span class="s">--health-cmd pg_isready</span>
          <span class="s">--health-interval 5s</span>
          <span class="s">--health-timeout 5s</span>
          <span class="s">--health-retries 10</span>
      <span class="na">redis</span><span class="pi">:</span>
        <span class="na">image</span><span class="pi">:</span> <span class="s">redis:7</span>
        <span class="na">options</span><span class="pi">:</span> <span class="pi">&gt;-</span>
          <span class="s">--health-cmd "redis-cli ping"</span>
          <span class="s">--health-interval 5s</span>

    <span class="na">steps</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="na">uses</span><span class="pi">:</span> <span class="s">actions/checkout@v4</span>

      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Set up Python</span>
        <span class="na">uses</span><span class="pi">:</span> <span class="s">actions/setup-python@v5</span>
        <span class="na">with</span><span class="pi">:</span>
          <span class="na">python-version</span><span class="pi">:</span> <span class="s1">'</span><span class="s">3.11'</span>
          <span class="na">cache</span><span class="pi">:</span> <span class="s1">'</span><span class="s">pip'</span>

      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Install dependencies</span>
        <span class="na">run</span><span class="pi">:</span> <span class="s">pip install -r requirements.txt</span>

      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Run Alembic migrations</span>
        <span class="na">run</span><span class="pi">:</span> <span class="s">alembic upgrade head</span>
        <span class="na">env</span><span class="pi">:</span>
          <span class="na">DATABASE_URL</span><span class="pi">:</span> <span class="s">postgresql://postgres:testpass@localhost/testdb</span>

      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Run tests</span>
        <span class="na">run</span><span class="pi">:</span> <span class="s">pytest tests/ -v --tb=short</span>
        <span class="na">env</span><span class="pi">:</span>
          <span class="na">DATABASE_URL</span><span class="pi">:</span> <span class="s">postgresql://postgres:testpass@localhost/testdb</span>
          <span class="na">REDIS_URL</span><span class="pi">:</span> <span class="s">redis://localhost:6379</span>
</code></pre></div></div>

<h2 id="key-decisions">Key Decisions</h2>

<p><strong>Use <code class="language-plaintext highlighter-rouge">services:</code> for dependencies</strong> — GitHub Actions can spin up Postgres and Redis as sidecar containers. No mocking, no SQLite workarounds — your tests run against the real thing.</p>

<p><strong>Cache pip dependencies</strong> — <code class="language-plaintext highlighter-rouge">cache: 'pip'</code> in <code class="language-plaintext highlighter-rouge">setup-python</code> cuts install time from ~45s to ~5s on repeat runs.</p>

<p><strong>Run migrations in CI</strong> — This catches broken migrations before they hit production. If <code class="language-plaintext highlighter-rouge">alembic upgrade head</code> fails, the build fails.</p>

<h2 id="docker-build-on-merge">Docker Build on Merge</h2>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code>  <span class="na">build</span><span class="pi">:</span>
    <span class="na">needs</span><span class="pi">:</span> <span class="s">test</span>
    <span class="na">if</span><span class="pi">:</span> <span class="s">github.ref == 'refs/heads/main'</span>
    <span class="na">runs-on</span><span class="pi">:</span> <span class="s">ubuntu-latest</span>
    <span class="na">steps</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="na">uses</span><span class="pi">:</span> <span class="s">actions/checkout@v4</span>

      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Build and push Docker image</span>
        <span class="na">uses</span><span class="pi">:</span> <span class="s">docker/build-push-action@v5</span>
        <span class="na">with</span><span class="pi">:</span>
          <span class="na">push</span><span class="pi">:</span> <span class="no">true</span>
          <span class="na">tags</span><span class="pi">:</span> <span class="s">ghcr.io/$:latest</span>
</code></pre></div></div>

<p>This only runs after tests pass and only on <code class="language-plaintext highlighter-rouge">main</code> — safe and simple.</p>]]></content><author><name>Sebastián Sifontes</name><email>sebastiansvsv@gmail.com</email><uri>https://sebsv123.github.io</uri></author><category term="GitHub Actions" /><category term="CI/CD" /><category term="Python" /><category term="DevOps" /><category term="Docker" /><summary type="html"><![CDATA[After setting up CI/CD for ViraClip and agente_seguros_ai, I’ve distilled my GitHub Actions setup to the smallest config that still gives me real value.]]></summary></entry><entry><title type="html">Redis as a Job Queue: Patterns I Use in Production</title><link href="https://sebsv123.github.io/posts/2026/05/redis-job-queue-patterns/" rel="alternate" type="text/html" title="Redis as a Job Queue: Patterns I Use in Production" /><published>2026-05-14T00:00:00+02:00</published><updated>2026-05-14T00:00:00+02:00</updated><id>https://sebsv123.github.io/posts/2026/05/redis-job-queue-patterns</id><content type="html" xml:base="https://sebsv123.github.io/posts/2026/05/redis-job-queue-patterns/"><![CDATA[<p>After running Redis as a job queue in ViraClip for months, I’ve settled on a set of patterns that make async video processing reliable and observable.</p>

<h2 id="why-redis-over-a-dedicated-queue">Why Redis Over a Dedicated Queue</h2>

<p>Tools like Celery + RabbitMQ are powerful but heavy. For a single-product SaaS with predictable load, Redis gives you 90% of what you need with far less operational overhead. The key is being disciplined about your data structures.</p>

<h2 id="pattern-1-hash-based-job-state">Pattern 1: Hash-Based Job State</h2>

<p>Don’t store job state as a JSON blob in a string key. Use a Hash so you can update individual fields atomically:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Set initial state
</span><span class="n">redis</span><span class="p">.</span><span class="n">hset</span><span class="p">(</span><span class="sa">f</span><span class="s">"job:</span><span class="si">{</span><span class="n">job_id</span><span class="si">}</span><span class="s">"</span><span class="p">,</span> <span class="n">mapping</span><span class="o">=</span><span class="p">{</span>
    <span class="s">"status"</span><span class="p">:</span> <span class="s">"queued"</span><span class="p">,</span>
    <span class="s">"created_at"</span><span class="p">:</span> <span class="n">time</span><span class="p">.</span><span class="n">time</span><span class="p">(),</span>
    <span class="s">"user_id"</span><span class="p">:</span> <span class="n">user_id</span><span class="p">,</span>
    <span class="s">"type"</span><span class="p">:</span> <span class="s">"video_render"</span>
<span class="p">})</span>

<span class="c1"># Update just one field mid-processing
</span><span class="n">redis</span><span class="p">.</span><span class="n">hset</span><span class="p">(</span><span class="sa">f</span><span class="s">"job:</span><span class="si">{</span><span class="n">job_id</span><span class="si">}</span><span class="s">"</span><span class="p">,</span> <span class="s">"status"</span><span class="p">,</span> <span class="s">"processing"</span><span class="p">)</span>
<span class="n">redis</span><span class="p">.</span><span class="n">hset</span><span class="p">(</span><span class="sa">f</span><span class="s">"job:</span><span class="si">{</span><span class="n">job_id</span><span class="si">}</span><span class="s">"</span><span class="p">,</span> <span class="s">"progress"</span><span class="p">,</span> <span class="mi">42</span><span class="p">)</span>
</code></pre></div></div>

<h2 id="pattern-2-sorted-sets-for-priority-queues">Pattern 2: Sorted Sets for Priority Queues</h2>

<p>A plain <code class="language-plaintext highlighter-rouge">LPUSH/RPOP</code> list queue doesn’t support priority. Sorted sets do:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Enqueue with priority score (lower = higher priority)
</span><span class="n">redis</span><span class="p">.</span><span class="n">zadd</span><span class="p">(</span><span class="s">"job_queue"</span><span class="p">,</span> <span class="p">{</span><span class="n">job_id</span><span class="p">:</span> <span class="n">priority_score</span><span class="p">})</span>

<span class="c1"># Dequeue the highest priority job
</span><span class="n">job_id</span> <span class="o">=</span> <span class="n">redis</span><span class="p">.</span><span class="n">zpopmin</span><span class="p">(</span><span class="s">"job_queue"</span><span class="p">,</span> <span class="n">count</span><span class="o">=</span><span class="mi">1</span><span class="p">)</span>
</code></pre></div></div>

<h2 id="pattern-3-pubsub-for-real-time-progress">Pattern 3: Pub/Sub for Real-Time Progress</h2>

<p>For live progress bars in the UI, I publish updates to a channel and the frontend subscribes via WebSocket:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Worker publishes progress
</span><span class="n">redis</span><span class="p">.</span><span class="n">publish</span><span class="p">(</span><span class="sa">f</span><span class="s">"job:progress:</span><span class="si">{</span><span class="n">job_id</span><span class="si">}</span><span class="s">"</span><span class="p">,</span> <span class="n">json</span><span class="p">.</span><span class="n">dumps</span><span class="p">({</span>
    <span class="s">"progress"</span><span class="p">:</span> <span class="mi">67</span><span class="p">,</span>
    <span class="s">"step"</span><span class="p">:</span> <span class="s">"encoding"</span><span class="p">,</span>
    <span class="s">"eta_seconds"</span><span class="p">:</span> <span class="mi">12</span>
<span class="p">}))</span>
</code></pre></div></div>

<h2 id="pattern-4-ttl-on-everything">Pattern 4: TTL on Everything</h2>

<p>Always set a TTL on job keys. Completed jobs accumulate fast and will eventually fill your Redis memory:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># After job completes, keep state for 24h for UI polling
</span><span class="n">redis</span><span class="p">.</span><span class="n">expire</span><span class="p">(</span><span class="sa">f</span><span class="s">"job:</span><span class="si">{</span><span class="n">job_id</span><span class="si">}</span><span class="s">"</span><span class="p">,</span> <span class="mi">86400</span><span class="p">)</span>
</code></pre></div></div>

<h2 id="pattern-5-dead-letter-queue">Pattern 5: Dead Letter Queue</h2>

<p>Jobs that fail repeatedly go to a dead letter list so they don’t block the main queue:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">if</span> <span class="n">job_attempts</span> <span class="o">&gt;=</span> <span class="n">MAX_RETRIES</span><span class="p">:</span>
    <span class="n">redis</span><span class="p">.</span><span class="n">lpush</span><span class="p">(</span><span class="s">"job_queue:dead"</span><span class="p">,</span> <span class="n">job_id</span><span class="p">)</span>
    <span class="n">redis</span><span class="p">.</span><span class="n">hset</span><span class="p">(</span><span class="sa">f</span><span class="s">"job:</span><span class="si">{</span><span class="n">job_id</span><span class="si">}</span><span class="s">"</span><span class="p">,</span> <span class="s">"status"</span><span class="p">,</span> <span class="s">"failed"</span><span class="p">)</span>
</code></pre></div></div>

<p>These five patterns together give you a production-grade async system with zero external dependencies beyond Redis itself.</p>]]></content><author><name>Sebastián Sifontes</name><email>sebastiansvsv@gmail.com</email><uri>https://sebsv123.github.io</uri></author><category term="Redis" /><category term="Python" /><category term="architecture" /><category term="FastAPI" /><category term="backend" /><summary type="html"><![CDATA[After running Redis as a job queue in ViraClip for months, I’ve settled on a set of patterns that make async video processing reliable and observable.]]></summary></entry><entry><title type="html">Building a WhatsApp Lead Qualification Bot with n8n and LLMs</title><link href="https://sebsv123.github.io/posts/2026/05/whatsapp-bot-n8n-llm/" rel="alternate" type="text/html" title="Building a WhatsApp Lead Qualification Bot with n8n and LLMs" /><published>2026-05-14T00:00:00+02:00</published><updated>2026-05-14T00:00:00+02:00</updated><id>https://sebsv123.github.io/posts/2026/05/whatsapp-bot-n8n</id><content type="html" xml:base="https://sebsv123.github.io/posts/2026/05/whatsapp-bot-n8n-llm/"><![CDATA[<p>For the agente_seguros_ai project, I built a WhatsApp bot that qualifies insurance leads automatically — 24/7, no human needed unless the lead is hot. Here’s how the architecture works.</p>

<h2 id="why-n8n">Why n8n</h2>

<p>n8n is a self-hostable workflow automation tool (think Zapier, but open source and deployable on your own VPS). It handles the WhatsApp webhook, conversation state, and routing logic without writing a single line of code for those parts.</p>

<p>I only write code for the parts that need it: the LLM prompt, the database writes, and the lead scoring.</p>

<h2 id="the-flow">The Flow</h2>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>WhatsApp message
    ↓
n8n webhook trigger
    ↓
Load conversation history from PostgreSQL
    ↓
Build prompt with context + user message
    ↓
Groq API (Llama 3.1) generates response
    ↓
Parse structured data (name, phone, interest)
    ↓
Update PostgreSQL + score lead
    ↓
Send reply via WhatsApp Business API
    ↓
If hot lead → notify agent via Telegram
</code></pre></div></div>

<h2 id="the-prompt-strategy">The Prompt Strategy</h2>

<p>The system prompt has two modes:</p>

<ol>
  <li><strong>Qualification mode</strong>: The bot asks 4–5 questions naturally (not as a form) to gather: full name, type of insurance needed, current coverage, budget range.</li>
  <li><strong>Handoff mode</strong>: Once qualified, the bot says a human advisor will contact them and stops generating AI responses.</li>
</ol>

<p>The trick is injecting the conversation history as context so the model doesn’t repeat questions already answered.</p>

<h2 id="handling-state-in-postgresql">Handling State in PostgreSQL</h2>

<p>Each conversation has a <code class="language-plaintext highlighter-rouge">state</code> field: <code class="language-plaintext highlighter-rouge">qualifying</code>, <code class="language-plaintext highlighter-rouge">qualified</code>, <code class="language-plaintext highlighter-rouge">handed_off</code>, <code class="language-plaintext highlighter-rouge">cold</code>. n8n checks this before deciding whether to call the LLM or just send a static message.</p>

<h2 id="lessons-learned">Lessons Learned</h2>

<ul>
  <li><strong>Groq is fast enough for real-time chat</strong> — typical latency is 400–800ms including the WhatsApp API round-trip.</li>
  <li><strong>Structured output matters</strong> — ask the LLM to return JSON for extracted data, not free text.</li>
  <li><strong>Test edge cases manually</strong> — users who answer in voice notes, send images, or write in regional dialect.</li>
  <li><strong>n8n’s error handling is weak</strong> — wrap your custom function nodes in try/catch and log to a separate table.</li>
</ul>]]></content><author><name>Sebastián Sifontes</name><email>sebastiansvsv@gmail.com</email><uri>https://sebsv123.github.io</uri></author><category term="n8n" /><category term="WhatsApp" /><category term="AI" /><category term="automation" /><category term="insurance" /><summary type="html"><![CDATA[For the agente_seguros_ai project, I built a WhatsApp bot that qualifies insurance leads automatically — 24/7, no human needed unless the lead is hot. Here’s how the architecture works.]]></summary></entry><entry><title type="html">Blog Post number 4</title><link href="https://sebsv123.github.io/posts/2012/08/blog-post-4/" rel="alternate" type="text/html" title="Blog Post number 4" /><published>2015-08-14T00:00:00+02:00</published><updated>2015-08-14T00:00:00+02:00</updated><id>https://sebsv123.github.io/posts/2012/08/blog-post-4</id><content type="html" xml:base="https://sebsv123.github.io/posts/2012/08/blog-post-4/"><![CDATA[<p>This is a sample blog post. Lorem ipsum I can’t remember the rest of lorem ipsum and don’t have an internet connection right now. Testing testing testing this blog post. Blog posts are cool.</p>

<h1 id="headings-are-cool">Headings are cool</h1>

<h1 id="you-can-have-many-headings">You can have many headings</h1>

<h2 id="arent-headings-cool">Aren’t headings cool?</h2>]]></content><author><name>Sebastián Sifontes</name><email>sebastiansvsv@gmail.com</email><uri>https://sebsv123.github.io</uri></author><category term="cool posts" /><category term="category1" /><category term="category2" /><summary type="html"><![CDATA[This is a sample blog post. Lorem ipsum I can’t remember the rest of lorem ipsum and don’t have an internet connection right now. Testing testing testing this blog post. Blog posts are cool.]]></summary></entry><entry><title type="html">Blog Post number 3</title><link href="https://sebsv123.github.io/posts/2014/08/blog-post-3/" rel="alternate" type="text/html" title="Blog Post number 3" /><published>2014-08-14T00:00:00+02:00</published><updated>2014-08-14T00:00:00+02:00</updated><id>https://sebsv123.github.io/posts/2014/08/blog-post-3</id><content type="html" xml:base="https://sebsv123.github.io/posts/2014/08/blog-post-3/"><![CDATA[<p>This is a sample blog post. Lorem ipsum I can’t remember the rest of lorem ipsum and don’t have an internet connection right now. Testing testing testing this blog post. Blog posts are cool.</p>

<h1 id="headings-are-cool">Headings are cool</h1>

<h1 id="you-can-have-many-headings">You can have many headings</h1>

<h2 id="arent-headings-cool">Aren’t headings cool?</h2>]]></content><author><name>Sebastián Sifontes</name><email>sebastiansvsv@gmail.com</email><uri>https://sebsv123.github.io</uri></author><category term="cool posts" /><category term="category1" /><category term="category2" /><summary type="html"><![CDATA[This is a sample blog post. Lorem ipsum I can’t remember the rest of lorem ipsum and don’t have an internet connection right now. Testing testing testing this blog post. Blog posts are cool.]]></summary></entry></feed>