<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/">
  <channel>
    <title>Infra on Krishnatejaswi S</title>
    <link>https://KTS-o7.github.io/bear/infra/</link>
    <description>Recent content in Infra on Krishnatejaswi S</description>
    <generator>Hugo -- gohugo.io</generator>
    <language>en-us</language>
    <managingEditor>shentharkrishnatejaswi@gmail.com (Krishnatejaswi S)</managingEditor>
    <webMaster>shentharkrishnatejaswi@gmail.com (Krishnatejaswi S)</webMaster>
    <copyright>Krishnatejaswi S</copyright>
    <lastBuildDate>Sat, 02 May 2026 00:00:00 +0530</lastBuildDate>
    <atom:link href="https://KTS-o7.github.io/bear/infra/index.xml" rel="self" type="application/rss+xml" />
    <item>
      <title>A $22/Year VPS Running Three Self-Hosted Services</title>
      <link>https://KTS-o7.github.io/bear/infra/vps_self_hosting/</link>
      <pubDate>Sat, 02 May 2026 00:00:00 +0530</pubDate><author>shentharkrishnatejaswi@gmail.com (Krishnatejaswi S)</author>
      <guid>https://KTS-o7.github.io/bear/infra/vps_self_hosting/</guid>
      <description>&lt;p&gt;I wanted three things: a private Obsidian sync server, a Matrix chat instance for personal use, and a self-hosted book library. The obvious path is a cheap VPS, a few Docker containers, and nginx as a reverse proxy in front of everything.&lt;/p&gt;&#xA;&lt;p&gt;The whole stack runs on a budget VPS &amp;ndash; 1 vCPU, 2.9GB RAM, 30GB disk, $22/year. All three services are running with room to spare.&lt;/p&gt;&#xA;&lt;h2 id=&#34;initial-setup&#34;&gt;Initial Setup&lt;/h2&gt;&#xA;&lt;p&gt;&lt;strong&gt;SSH key auth first, password auth off:&lt;/strong&gt;&lt;/p&gt;</description>
      <content:encoded><![CDATA[<p>I wanted three things: a private Obsidian sync server, a Matrix chat instance for personal use, and a self-hosted book library. The obvious path is a cheap VPS, a few Docker containers, and nginx as a reverse proxy in front of everything.</p>
<p>The whole stack runs on a budget VPS &ndash; 1 vCPU, 2.9GB RAM, 30GB disk, $22/year. All three services are running with room to spare.</p>
<h2 id="initial-setup">Initial Setup</h2>
<p><strong>SSH key auth first, password auth off:</strong></p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># local: generate key if you don&#39;t have one</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">ssh-keygen -t ed25519 -C <span class="s2">&#34;your_email@example.com&#34;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1"># paste ~/.ssh/id_ed25519.pub into your provider&#39;s SSH key field</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1"># on the server: disable password login</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">sed -i <span class="s1">&#39;s/^PasswordAuthentication yes/PasswordAuthentication no/&#39;</span> /etc/ssh/sshd_config
</span></span><span class="line"><span class="ln">7</span><span class="cl">systemctl restart sshd</span></span></code></pre></div><p>Test key login before disabling password auth. If you lock yourself out, you&rsquo;re recovering via the provider&rsquo;s console.</p>
<p><strong>Swap &ndash; critical on 3GB RAM:</strong></p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">fallocate -l 2G /swapfile
</span></span><span class="line"><span class="ln">2</span><span class="cl">chmod <span class="m">600</span> /swapfile
</span></span><span class="line"><span class="ln">3</span><span class="cl">mkswap /swapfile
</span></span><span class="line"><span class="ln">4</span><span class="cl">swapon /swapfile
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="nb">echo</span> <span class="s1">&#39;/swapfile none swap sw 0 0&#39;</span> &gt;&gt; /etc/fstab</span></span></code></pre></div><p>Without this, CouchDB and Conduit together will occasionally OOM under load.</p>
<p><strong>Firewall:</strong></p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">ufw allow ssh
</span></span><span class="line"><span class="ln">2</span><span class="cl">ufw allow 80/tcp
</span></span><span class="line"><span class="ln">3</span><span class="cl">ufw allow 443/tcp
</span></span><span class="line"><span class="ln">4</span><span class="cl">ufw allow <span class="m">3478</span>        <span class="c1"># TURN (UDP+TCP)</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">ufw allow <span class="m">5349</span>        <span class="c1"># TURN TLS</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">ufw allow 49152:65535/udp  <span class="c1"># TURN relay range</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">ufw enable</span></span></code></pre></div><p>The TURN ports are for Matrix voice/video &ndash; skip them if you&rsquo;re not setting up coturn.</p>
<p><strong>Docker:</strong></p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">curl -fsSL https://get.docker.com <span class="p">|</span> sh</span></span></code></pre></div><h2 id="service-1-obsidian-livesync-couchdb">Service 1: Obsidian LiveSync (CouchDB)</h2>
<p>LiveSync uses CouchDB as the sync backend. Install it directly on the host rather than in Docker &ndash; it&rsquo;s simpler to configure and easier to troubleshoot.</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">apt install -y couchdb</span></span></code></pre></div><p>The installer prompts for admin credentials. Set them and keep them somewhere safe.</p>
<p>Configure <code>/opt/couchdb/etc/local.ini</code>:</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ini" data-lang="ini"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="k">[chttpd]</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="na">bind_address</span> <span class="o">=</span> <span class="s">127.0.0.1</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="k">[couch_httpd_auth]</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="na">require_valid_user</span> <span class="o">=</span> <span class="s">true</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="k">[httpd]</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="na">enable_cors</span> <span class="o">=</span> <span class="s">true</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="k">[cors]</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="na">origins</span> <span class="o">=</span> <span class="s">app://obsidian.md, capacitor://localhost, http://localhost</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="na">credentials</span> <span class="o">=</span> <span class="s">true</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="na">headers</span> <span class="o">=</span> <span class="s">accept, authorization, content-type, origin, referer</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="na">methods</span> <span class="o">=</span> <span class="s">GET, PUT, POST, HEAD, DELETE</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="na">max_age</span> <span class="o">=</span> <span class="s">3600</span></span></span></code></pre></div>




<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">systemctl restart couchdb
</span></span><span class="line"><span class="ln">2</span><span class="cl">curl -X PUT http://admin:PASSWORD@localhost:5984/obsidian-sync</span></span></code></pre></div><p>nginx config:</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-nginx" data-lang="nginx"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="k">server</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="kn">listen</span> <span class="mi">80</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="kn">server_name</span> <span class="s">sync.your.domain</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="kn">location</span> <span class="s">/</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">        <span class="kn">proxy_pass</span> <span class="s">http://127.0.0.1:5984</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="kn">proxy_set_header</span> <span class="s">Host</span> <span class="nv">$host</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="kn">proxy_set_header</span> <span class="s">X-Real-IP</span> <span class="nv">$remote_addr</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="kn">proxy_set_header</span> <span class="s">X-Forwarded-For</span> <span class="nv">$proxy_add_x_forwarded_for</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>Get a cert: <code>certbot --nginx -d sync.your.domain</code></p>
<p>In Obsidian: install the LiveSync community plugin, set the CouchDB URI to your domain, enter credentials, set a database name and an E2E passphrase. Enable sync. The passphrase encrypts vault content before it leaves your device &ndash; CouchDB never sees plaintext.</p>
<h2 id="service-2-private-matrix-chat-conduit--coturn">Service 2: Private Matrix Chat (Conduit + coturn)</h2>
<p>Conduit is a Matrix homeserver written in Rust &ndash; much lighter than Synapse, which would be too heavy for 3GB RAM. I run it with federation disabled &ndash; this is a personal instance, not connected to the public Matrix network.</p>
<p><code>/opt/conduit/docker-compose.yml</code>:</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="nt">services</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="w">  </span><span class="nt">conduit</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">matrixconduit/matrix-conduit:latest</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="w">    </span><span class="nt">restart</span><span class="p">:</span><span class="w"> </span><span class="l">unless-stopped</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="w">    </span><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="w">      </span>- <span class="l">conduit_data:/var/lib/matrix-conduit</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="w">    </span><span class="nt">environment</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="w">      </span><span class="nt">CONDUIT_SERVER_NAME</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;chat.your.domain&#34;</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="w">      </span><span class="nt">CONDUIT_DATABASE_BACKEND</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;rocksdb&#34;</span><span class="w">
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="w">      </span><span class="nt">CONDUIT_ALLOW_REGISTRATION</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;true&#34;</span><span class="w">      </span><span class="c"># flip to false after accounts created</span><span class="w">
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="w">      </span><span class="nt">CONDUIT_ALLOW_FEDERATION</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;false&#34;</span><span class="w">
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="w">      </span><span class="nt">CONDUIT_MAX_REQUEST_SIZE</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;20000000&#34;</span><span class="w">
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="w">      </span><span class="nt">CONDUIT_LOG</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;warn&#34;</span><span class="w">
</span></span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="w">    </span><span class="nt">ports</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="w">      </span>- <span class="s2">&#34;127.0.0.1:6167:6167&#34;</span><span class="w">
</span></span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="w">  </span><span class="l">conduit_data:</span></span></span></code></pre></div>




<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="nb">cd</span> /opt/conduit <span class="o">&amp;&amp;</span> docker compose up -d</span></span></code></pre></div><p>For voice and video calls, Matrix uses WebRTC &ndash; which needs a TURN server to traverse NAT. coturn handles this:</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">apt install -y coturn
</span></span><span class="line"><span class="ln">2</span><span class="cl">openssl rand -hex <span class="m">32</span>   <span class="c1"># generate a static auth secret, keep it</span></span></span></code></pre></div><p><code>/etc/turnserver.conf</code>:</p>





<pre tabindex="0"><code>listening-port=3478
tls-listening-port=5349
fingerprint
use-auth-secret
static-auth-secret=YOUR_SECRET
realm=chat.your.domain
cert=/etc/letsencrypt/live/chat.your.domain/fullchain.pem
pkey=/etc/letsencrypt/live/chat.your.domain/privkey.pem
min-port=49152
max-port=65535</code></pre><p>nginx config:</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-nginx" data-lang="nginx"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="k">server</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="kn">listen</span> <span class="mi">80</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="kn">server_name</span> <span class="s">chat.your.domain</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="kn">location</span> <span class="s">/</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">        <span class="kn">proxy_pass</span> <span class="s">http://127.0.0.1:6167</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="kn">proxy_set_header</span> <span class="s">Host</span> <span class="nv">$host</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="kn">proxy_set_header</span> <span class="s">X-Real-IP</span> <span class="nv">$remote_addr</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="kn">proxy_set_header</span> <span class="s">Upgrade</span> <span class="nv">$http_upgrade</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">        <span class="kn">proxy_set_header</span> <span class="s">Connection</span> <span class="s">&#34;upgrade&#34;</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>Once Conduit is up: go to Element Web, set homeserver to your domain, register your accounts while <code>ALLOW_REGISTRATION</code> is true, then flip it to false and restart the container. Anyone who finds your homeserver URL can register while it&rsquo;s open.</p>
<h2 id="service-3-calibre-web">Service 3: Calibre-Web</h2>
<p>Calibre-Web gives you a browser-based library for EPUB, PDF, and MOBI files. The Docker image is from linuxserver.io.</p>
<p><strong>Critical step before starting the container:</strong> Calibre-Web needs a properly initialized <code>metadata.db</code>. Not an empty file, not a directory &ndash; a valid Calibre library database. It must be created by <code>calibredb</code>.</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">apt install -y calibre   <span class="c1"># ~300MB, takes a few minutes</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">mkdir -p /opt/calibre/books
</span></span><span class="line"><span class="ln">3</span><span class="cl">calibredb --with-library /opt/calibre/books list   <span class="c1"># initializes metadata.db</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">chown -R 1000:1000 /opt/calibre/books              <span class="c1"># container runs as uid 1000</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">chmod -R <span class="m">755</span> /opt/calibre/books</span></span></code></pre></div><p>Do this before starting the container. If you start it first and hit &ldquo;invalid path&rdquo; or &ldquo;DB not writable&rdquo;, this is why.</p>
<p><code>/opt/calibre/docker-compose.yml</code>:</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="nt">services</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="w">  </span><span class="nt">calibre-web</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">lscr.io/linuxserver/calibre-web:latest</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="w">    </span><span class="nt">restart</span><span class="p">:</span><span class="w"> </span><span class="l">unless-stopped</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="w">    </span><span class="nt">environment</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="w">      </span>- <span class="l">PUID=1000</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="w">      </span>- <span class="l">PGID=1000</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="w">      </span>- <span class="l">TZ=Asia/Kolkata</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="w">    </span><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="w">      </span>- <span class="l">calibre_config:/config</span><span class="w">
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="w">      </span>- <span class="l">/opt/calibre/books:/books</span><span class="w">
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="w">    </span><span class="nt">ports</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="w">      </span>- <span class="s2">&#34;127.0.0.1:8083:8083&#34;</span><span class="w">
</span></span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="w">  </span><span class="l">calibre_config:</span></span></span></code></pre></div><p>nginx config:</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-nginx" data-lang="nginx"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">server</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="kn">listen</span> <span class="mi">80</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="kn">server_name</span> <span class="s">books.your.domain</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="kn">location</span> <span class="s">/</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">        <span class="kn">proxy_pass</span> <span class="s">http://127.0.0.1:8083</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">        <span class="kn">proxy_set_header</span> <span class="s">Host</span> <span class="nv">$host</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">        <span class="kn">proxy_set_header</span> <span class="s">X-Real-IP</span> <span class="nv">$remote_addr</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">9</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>Default login is <code>admin</code> / <code>admin123</code>. Change it immediately. Set library path to <code>/books</code>.</p>
<h2 id="cloudflare-in-front">Cloudflare in Front</h2>
<p>All three subdomains go through Cloudflare. DNS A records point to the VPS IP, proxy status set to orange cloud (proxied) for all three. SSL mode set to Full &ndash; not Full Strict, since the Let&rsquo;s Encrypt cert on origin is valid enough and Full Strict adds complexity for no real benefit here.</p>
<p>certbot handles the Let&rsquo;s Encrypt certs on the origin:</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">certbot --nginx -d sync.your.domain
</span></span><span class="line"><span class="ln">2</span><span class="cl">certbot --nginx -d chat.your.domain
</span></span><span class="line"><span class="ln">3</span><span class="cl">certbot --nginx -d books.your.domain</span></span></code></pre></div><h2 id="resource-usage">Resource Usage</h2>
<p>All three services running simultaneously:</p>
<table>
  <thead>
      <tr>
          <th>Service</th>
          <th>RAM</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Calibre-Web</td>
          <td>~159MB</td>
      </tr>
      <tr>
          <td>Conduit</td>
          <td>~62MB</td>
      </tr>
      <tr>
          <td>CouchDB</td>
          <td>~80MB</td>
      </tr>
      <tr>
          <td>nginx + OS</td>
          <td>~453MB</td>
      </tr>
      <tr>
          <td><strong>Total</strong></td>
          <td><strong>~754MB / 2.9GB (26%)</strong></td>
      </tr>
  </tbody>
</table>
<p>Swap barely touched. Disk at ~6GB of 30GB. Plenty of headroom for adding services.</p>
<h2 id="pitfalls">Pitfalls</h2>
<table>
  <thead>
      <tr>
          <th>Problem</th>
          <th>Cause</th>
          <th>Fix</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>SSH asks for password after key setup</td>
          <td>Public key not on server</td>
          <td><code>ssh-copy-id</code> or paste key in provider panel</td>
      </tr>
      <tr>
          <td>Calibre-Web says &ldquo;invalid path&rdquo;</td>
          <td>metadata.db missing or wrong schema</td>
          <td>Install calibre on host, run <code>calibredb list</code> to init</td>
      </tr>
      <tr>
          <td>Calibre-Web says &ldquo;DB not writable&rdquo;</td>
          <td>Files owned by root, container runs as uid 1000</td>
          <td><code>chown -R 1000:1000 /opt/calibre/books</code></td>
      </tr>
      <tr>
          <td>Voice/video calls fail in Matrix</td>
          <td>WebRTC can&rsquo;t traverse NAT</td>
          <td>Install coturn, configure static-auth-secret</td>
      </tr>
      <tr>
          <td>CouchDB sync drops randomly</td>
          <td>Missing CORS headers</td>
          <td>Double-check the <code>[cors]</code> section in local.ini</td>
      </tr>
  </tbody>
</table>
<p>The whole thing took an afternoon to set up. $22/year for services I fully control and can inspect end to end.</p>
]]></content:encoded>
    </item>
    <item>
      <title>Three Useful Services on a $22/Year VPS</title>
      <link>https://KTS-o7.github.io/bear/infra/vps_self_hosting_stack/</link>
      <pubDate>Wed, 22 Apr 2026 00:00:00 +0530</pubDate><author>shentharkrishnatejaswi@gmail.com (Krishnatejaswi S)</author>
      <guid>https://KTS-o7.github.io/bear/infra/vps_self_hosting_stack/</guid>
      <description>&lt;p&gt;I bought a budget VPS for $22/year &amp;ndash; 1 vCPU, 2.9GB RAM, 30GB disk, Dallas datacenter, Ubuntu 24.04. The plan was to run bots and experiments. I ended up setting up three services that are immediately useful: Obsidian note sync, a private Matrix chat server, and a personal book library.&lt;/p&gt;&#xA;&lt;p&gt;This post covers what I set up, what broke, and what actually works now.&lt;/p&gt;&#xA;&lt;h2 id=&#34;starting-point&#34;&gt;Starting point&lt;/h2&gt;&#xA;&lt;p&gt;The provider gives you a root password on first boot. First thing I did was generate an ed25519 SSH key pair locally, paste the public key into the VPS control panel, add a &lt;code&gt;Host personal&lt;/code&gt; entry to &lt;code&gt;~/.ssh/config&lt;/code&gt;, and verify key auth works before touching anything else. Then disabling password login:&lt;/p&gt;</description>
      <content:encoded><![CDATA[<p>I bought a budget VPS for $22/year &ndash; 1 vCPU, 2.9GB RAM, 30GB disk, Dallas datacenter, Ubuntu 24.04. The plan was to run bots and experiments. I ended up setting up three services that are immediately useful: Obsidian note sync, a private Matrix chat server, and a personal book library.</p>
<p>This post covers what I set up, what broke, and what actually works now.</p>
<h2 id="starting-point">Starting point</h2>
<p>The provider gives you a root password on first boot. First thing I did was generate an ed25519 SSH key pair locally, paste the public key into the VPS control panel, add a <code>Host personal</code> entry to <code>~/.ssh/config</code>, and verify key auth works before touching anything else. Then disabling password login:</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">sed -i <span class="s1">&#39;s/^PasswordAuthentication yes/PasswordAuthentication no/&#39;</span> /etc/ssh/sshd_config
</span></span><span class="line"><span class="ln">2</span><span class="cl">systemctl restart sshd</span></span></code></pre></div><p>Don&rsquo;t close your existing SSH session before confirming you can open a new one with key auth. Classic footgun.</p>
<p>Before any services: add swap. 2.9GB of RAM is workable but tight once Docker gets involved.</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">fallocate -l 2G /swapfile
</span></span><span class="line"><span class="ln">2</span><span class="cl">chmod <span class="m">600</span> /swapfile
</span></span><span class="line"><span class="ln">3</span><span class="cl">mkswap /swapfile
</span></span><span class="line"><span class="ln">4</span><span class="cl">swapon /swapfile
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="nb">echo</span> <span class="s1">&#39;/swapfile none swap sw 0 0&#39;</span> &gt;&gt; /etc/fstab</span></span></code></pre></div><p>Then update packages, set up UFW to allow SSH, HTTP, HTTPS, and the TURN port range (3478, 5349, 49152:65535), and drop everything else.</p>
<p>After all that, the box looked like this:</p>





<pre tabindex="0"><code>OS: Ubuntu 24.04 LTS
RAM: 2.9GB (470MB used, 2.4GB free)
Disk: 30GB (2.4GB used, 26GB free)
Swap: 2GB (none used yet)
CPU: 1 core</code></pre><p>Plenty of room to work with.</p>
<h2 id="service-1-obsidian-livesync">Service 1: Obsidian LiveSync</h2>
<p>CouchDB as the backend for Obsidian&rsquo;s LiveSync community plugin. The plugin syncs your vault across devices using a CouchDB instance you control. End-to-end encrypted via a passphrase set in the plugin &ndash; CouchDB stores ciphertext only.</p>
<p>Install CouchDB natively (not Docker &ndash; LiveSync requires specific CouchDB config that&rsquo;s simpler to manage directly):</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">apt install -y couchdb</span></span></code></pre></div><p>The installer asks for admin credentials. Set them and remember them. Then configure CouchDB to accept external connections by editing <code>/opt/couchdb/etc/local.ini</code>:</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ini" data-lang="ini"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="k">[chttpd]</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="na">bind_address</span> <span class="o">=</span> <span class="s">127.0.0.1  ; nginx will proxy, no need to expose directly</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="k">[couch_httpd_auth]</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="na">require_valid_user</span> <span class="o">=</span> <span class="s">true</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="k">[httpd]</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="na">enable_cors</span> <span class="o">=</span> <span class="s">true</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="k">[cors]</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="na">origins</span> <span class="o">=</span> <span class="s">app://obsidian.md, capacitor://localhost, http://localhost</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="na">credentials</span> <span class="o">=</span> <span class="s">true</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="na">headers</span> <span class="o">=</span> <span class="s">accept, authorization, content-type, origin, referer</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="na">methods</span> <span class="o">=</span> <span class="s">GET, PUT, POST, HEAD, DELETE</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="na">max_age</span> <span class="o">=</span> <span class="s">3600</span></span></span></code></pre></div><p>Then create the sync database:</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">curl -X PUT http://admin:password@localhost:5984/obsidian-sync</span></span></code></pre></div><p>nginx proxies the sync subdomain to <code>localhost:5984</code>. Cloudflare sits in front with Full SSL mode. In the Obsidian LiveSync plugin settings, point it at your CouchDB subdomain, enter the credentials, set your E2E passphrase, and enable sync.</p>
<p>It just works after that. Notes push from Mac, pull on iPhone, no iCloud dependency.</p>
<h2 id="service-2-private-matrix-chat">Service 2: Private Matrix Chat</h2>
<p>Conduit is a Matrix homeserver written in Rust. It&rsquo;s small (62MB RAM), single-binary, and handles everything a private two-person server needs. Element Web as the client &ndash; works in any browser, no app required.</p>
<p>Docker Compose setup:</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="nt">services</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="w">  </span><span class="nt">conduit</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">matrixconduit/matrix-conduit:latest</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="w">    </span><span class="nt">restart</span><span class="p">:</span><span class="w"> </span><span class="l">unless-stopped</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="w">    </span><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="w">      </span>- <span class="l">conduit_data:/var/lib/matrix-conduit</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="w">    </span><span class="nt">environment</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="w">      </span><span class="nt">CONDUIT_SERVER_NAME</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;your.chat.domain&#34;</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="w">      </span><span class="nt">CONDUIT_DATABASE_BACKEND</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;rocksdb&#34;</span><span class="w">
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="w">      </span><span class="nt">CONDUIT_ALLOW_REGISTRATION</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;true&#34;</span><span class="w">
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="w">      </span><span class="nt">CONDUIT_ALLOW_FEDERATION</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;false&#34;</span><span class="w">
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="w">      </span><span class="nt">CONDUIT_MAX_REQUEST_SIZE</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;20000000&#34;</span><span class="w">
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="w">      </span><span class="nt">CONDUIT_TRUSTED_SERVERS</span><span class="p">:</span><span class="w"> </span><span class="s1">&#39;[&#34;matrix.org&#34;]&#39;</span><span class="w">
</span></span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="w">      </span><span class="nt">CONDUIT_LOG</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;warn&#34;</span><span class="w">
</span></span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="w">    </span><span class="nt">ports</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="w">      </span>- <span class="s2">&#34;127.0.0.1:6167:6167&#34;</span><span class="w">
</span></span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">19</span><span class="cl"><span class="w">  </span><span class="l">conduit_data:</span></span></span></code></pre></div><p><code>ALLOW_FEDERATION: false</code> is important for a private server. No traffic leaves your machine to federate with matrix.org or anyone else.</p>
<p>For Element Web, the easiest route is the hosted version at <code>app.element.io</code> &ndash; just point it at your homeserver URL when logging in. If you want it self-hosted too, serve the static build from nginx.</p>
<p>For voice and video calls, WebRTC needs a TURN server or calls fail behind NAT. coturn handles this:</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">apt install -y coturn</span></span></code></pre></div><p>Configure <code>/etc/turnserver.conf</code>:</p>





<pre tabindex="0"><code>listening-port=3478
tls-listening-port=5349
fingerprint
use-auth-secret
static-auth-secret=&lt;generate with: openssl rand -hex 32&gt;
realm=your.chat.domain
cert=/etc/letsencrypt/live/your.chat.domain/fullchain.pem
pkey=/etc/letsencrypt/live/your.chat.domain/privkey.pem</code></pre><p>Add the TURN server details in Conduit&rsquo;s config, then tell Element where to find it in the Synapse well-known file. After that, voice and video calls work reliably regardless of NAT.</p>
<p>Create user accounts via the Conduit admin API or by registering through Element while <code>ALLOW_REGISTRATION: true</code>. After both accounts are created, set <code>ALLOW_REGISTRATION: false</code> so no new accounts can be made.</p>
<h2 id="service-3-personal-book-library">Service 3: Personal Book Library</h2>
<p>Calibre-Web in Docker. Serves EPUB, PDF, MOBI with a clean browser interface. Can read books directly in the browser without downloading.</p>
<p>The setup hit two non-obvious problems worth documenting.</p>
<p><strong>Problem 1: Calibre-Web needs a real metadata.db</strong></p>
<p>The container starts and asks for a library path. I pointed it at <code>/books</code> (mapped to <code>/opt/calibre/books</code> on the host). It said &ldquo;invalid path.&rdquo;</p>
<p>The issue is that Calibre-Web expects a valid Calibre library database (<code>metadata.db</code>) to already exist at that path. An empty directory is not enough. The database needs to have the correct schema &ndash; not just the file.</p>
<p>The fix is to install Calibre on the host and use <code>calibredb</code> to initialize the library properly:</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">apt install -y calibre  <span class="c1"># takes a while, ~300MB</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">calibredb --with-library /opt/calibre/books list</span></span></code></pre></div><p>The <code>calibredb list</code> call creates a properly initialized <code>metadata.db</code> if one doesn&rsquo;t exist. After this, <code>/books</code> becomes a valid library path.</p>
<p><strong>Problem 2: Permissions</strong></p>
<p>Even after the database exists, Calibre-Web may refuse to write to it. The linuxserver/calibre-web Docker image runs as user 1000 internally. If the files on the host are owned by root, the container can&rsquo;t write.</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">chown -R 1000:1000 /opt/calibre/books
</span></span><span class="line"><span class="ln">2</span><span class="cl">chmod -R <span class="m">755</span> /opt/calibre/books</span></span></code></pre></div><p>After both fixes, the setup page accepted <code>/books</code> and the library came up clean.</p>
<p>Docker Compose:</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="nt">services</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="w">  </span><span class="nt">calibre-web</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">lscr.io/linuxserver/calibre-web:latest</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="w">    </span><span class="nt">restart</span><span class="p">:</span><span class="w"> </span><span class="l">unless-stopped</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="w">    </span><span class="nt">environment</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="w">      </span>- <span class="l">PUID=1000</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="w">      </span>- <span class="l">PGID=1000</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="w">      </span>- <span class="l">TZ=Asia/Kolkata</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="w">    </span><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="w">      </span>- <span class="l">calibre_config:/config</span><span class="w">
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="w">      </span>- <span class="l">/opt/calibre/books:/books</span><span class="w">
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="w">    </span><span class="nt">ports</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="w">      </span>- <span class="s2">&#34;127.0.0.1:8083:8083&#34;</span><span class="w">
</span></span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="w">  </span><span class="l">calibre_config:</span></span></span></code></pre></div><p>Default login after first start: <code>admin</code> / <code>admin123</code>. Change it immediately.</p>
<h2 id="nginx-and-cloudflare">nginx and Cloudflare</h2>
<p>All three services sit behind nginx. Each subdomain gets a server block that proxies to the local port:</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-nginx" data-lang="nginx"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">server</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="kn">listen</span> <span class="mi">80</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="kn">server_name</span> <span class="s">your.sync.domain</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="kn">location</span> <span class="s">/</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">        <span class="kn">proxy_pass</span> <span class="s">http://127.0.0.1:5984</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">        <span class="kn">proxy_set_header</span> <span class="s">Host</span> <span class="nv">$host</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">        <span class="kn">proxy_set_header</span> <span class="s">X-Real-IP</span> <span class="nv">$remote_addr</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">9</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>Cloudflare handles DNS and acts as the TLS terminator in Full mode. Let&rsquo;s Encrypt issues certs on each subdomain via certbot. The certs auto-renew &ndash; I won&rsquo;t have to touch them.</p>
<p>Credentials stored in <code>pass</code> on the Mac, encrypted with a GPG key. No plaintext passwords in config files or notes.</p>
<h2 id="resource-usage-after-all-three-services">Resource usage after all three services</h2>





<pre tabindex="0"><code>Service          RAM
---------        ----
Calibre-Web      159MB
Conduit          62MB
CouchDB          ~80MB
nginx + system   ~453MB
---------        ------
Total            754MB / 2.9GB  (26% used)
Swap used        780KB
Disk used        ~6GB / 30GB</code></pre><p>74% of RAM still free. The 2GB swap sits untouched. There&rsquo;s room for more things.</p>
<h2 id="whats-next">What&rsquo;s next</h2>
<p>The daily note has some ideas: run Bifrost on the VPS and wire up a blog-writing pipeline, use the unlimited bandwidth for a VPN, set up a small agent. The RAM budget will tighten but there&rsquo;s headroom to experiment.</p>
<p>For $22/year, the box is already earning its keep.</p>
<hr>
<p>Tools: Ubuntu 24.04, Docker, nginx, Cloudflare, Let&rsquo;s Encrypt, CouchDB, Conduit (Matrix), Element Web, Calibre-Web, coturn, UFW, pass + GPG, certbot.</p>
<h2 id="references">References</h2>
<ul>
<li>LandChad.net &ndash; practical self-hosting guides, covers nginx, certbot, and many services: <a href="https://landchad.net">https://landchad.net</a></li>
<li>Conduit (Matrix homeserver in Rust): <a href="https://conduit.rs">https://conduit.rs</a></li>
<li>Obsidian LiveSync plugin: <a href="https://github.com/vrtmrz/obsidian-livesync">https://github.com/vrtmrz/obsidian-livesync</a></li>
<li>Calibre-Web: <a href="https://github.com/janeczku/calibre-web">https://github.com/janeczku/calibre-web</a></li>
<li>coturn TURN server: <a href="https://github.com/coturn/coturn">https://github.com/coturn/coturn</a></li>
</ul>
]]></content:encoded>
    </item>
    <item>
      <title>From Manual Deploys to SHA-Tagged Containers: CI/CD for a K8s Platform</title>
      <link>https://KTS-o7.github.io/bear/infra/cicd_docker_buildx_sha_deploys/</link>
      <pubDate>Tue, 17 Feb 2026 00:00:00 +0000</pubDate><author>shentharkrishnatejaswi@gmail.com (Krishnatejaswi S)</author>
      <guid>https://KTS-o7.github.io/bear/infra/cicd_docker_buildx_sha_deploys/</guid>
      <description>&lt;p&gt;Someone tagged an image &lt;code&gt;latest&lt;/code&gt;, pushed it to ECR, and overwrote the image another service was using. Four hours of debugging later, we figured out we were running the wrong binary entirely. That was the week I decided to stop treating deployment as a manual process.&lt;/p&gt;&#xA;&lt;p&gt;This post covers three CI/CD problems I solved with Docker Buildx, SHA-based image tagging, and dynamic workflow generation — and one thing I broke along the way.&lt;/p&gt;</description>
      <content:encoded><![CDATA[<p>Someone tagged an image <code>latest</code>, pushed it to ECR, and overwrote the image another service was using. Four hours of debugging later, we figured out we were running the wrong binary entirely. That was the week I decided to stop treating deployment as a manual process.</p>
<p>This post covers three CI/CD problems I solved with Docker Buildx, SHA-based image tagging, and dynamic workflow generation — and one thing I broke along the way.</p>
<h2 id="why-latest-is-a-lie">Why <code>latest</code> is a lie</h2>
<p>Container registries store images by tag. Tags are mutable pointers. When you <code>docker push myapp:latest</code>, you&rsquo;re moving a pointer — the old image behind <code>latest</code> is now unreferenced. If two services share the tag <code>latest</code> or two people push to the same tag concurrently, you get a race condition on a mutable pointer. This is exactly the class of bug that version control systems solved decades ago.</p>
<p>The fundamental problem: <strong>image tags have no inherent ordering, no history, and no integrity guarantee.</strong> They&rsquo;re a name, not a version.</p>
<p>The solution is to use a content-addressable identifier you already have: the Git commit SHA.</p>
<h2 id="sha-based-image-tagging">SHA-based image tagging</h2>
<p>Every commit has a unique SHA-1 hash. Using it as the image tag creates an injective mapping from source code state → deployed artifact:</p>





<pre tabindex="0"><code>commit a1b2c3d → image registry/service:a1b2c3d → K8s pod spec</code></pre><p>The implementation in GitHub Actions:</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="ln">1</span><span class="cl">- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">Derive image tag from commit</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w">  </span><span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="p">|</span><span class="sd">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="sd">    # Short SHA is sufficient — collision probability
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="sd">    # across 2^28 commits is negligible for any single repo
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="sd">    echo &#34;IMAGE_TAG=${GITHUB_SHA::7}&#34; &gt;&gt; $GITHUB_ENV</span></span></span></code></pre></div><p>This gives you two things that <code>latest</code> never can:</p>
<ol>
<li><strong>Bijective traceability.</strong> Given a running pod, you can recover the exact source:</li>
</ol>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">$ kubectl get pod <span class="nv">$POD</span> -o <span class="nv">jsonpath</span><span class="o">=</span><span class="s1">&#39;{.spec.containers[0].image}&#39;</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">123456789.dkr.ecr.us-east-1.amazonaws.com/myservice:a1b2c3d
</span></span><span class="line"><span class="ln">3</span><span class="cl">
</span></span><span class="line"><span class="ln">4</span><span class="cl">$ git show a1b2c3d --stat</span></span></code></pre></div><ol start="2">
<li><strong>Immutable deployment records.</strong> The tag <code>a1b2c3d</code> either exists or it doesn&rsquo;t. It can&rsquo;t silently change meaning.</li>
</ol>
<h3 id="propagating-the-tag-to-kubernetes-manifests">Propagating the tag to Kubernetes manifests</h3>
<p>A tagged image is useless if the deployment spec still references <code>latest</code>. We close the loop by having CI update the manifest and commit the change:</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="ln"> 1</span><span class="cl">- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">Patch deployment manifest</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="w">  </span><span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="p">|</span><span class="sd">
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="sd">    sed -i &#34;s|image: $REGISTRY/$SERVICE:.*|image: $REGISTRY/$SERVICE:${IMAGE_TAG}|&#34; \
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="sd">      k8s/$ENVIRONMENT/${SERVICE}-deployment.yaml</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl">- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">Commit manifest update</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="w">  </span><span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="p">|</span><span class="sd">
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="sd">    git config user.name &#34;ci-bot&#34;
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="sd">    git config user.email &#34;ci-bot@noreply&#34;
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="sd">    git add k8s/
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="sd">    git diff --cached --quiet || \
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="sd">      git commit -m &#34;deploy($ENVIRONMENT): $SERVICE → $IMAGE_TAG&#34;
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="sd">    git push</span></span></span></code></pre></div><p>Now <code>git log k8s/staging/</code> is your deployment history. Every deployment is a commit. Every rollback is <code>git revert</code>. This is the minimal viable GitOps pattern — your Git repo is the single source of truth for cluster state.</p>
<p>The <code>git diff --cached --quiet</code> guard prevents empty commits when nothing actually changed (e.g., the service wasn&rsquo;t rebuilt).</p>
<h2 id="docker-buildx-layer-caching-in-ci">Docker Buildx: layer caching in CI</h2>
<p>The default <code>docker build</code> in CI has no cache. Every run downloads the base image, reinstalls all dependencies, copies all source files. For a Python service with 200+ packages, this is 8–12 minutes of work repeated identically on every push.</p>
<p>Docker Buildx provides two things that fix this: <strong>persistent builder instances</strong> and <strong>pluggable cache backends</strong>.</p>
<h3 id="how-docker-layer-caching-works">How Docker layer caching works</h3>
<p>Docker images are a stack of layers. Each Dockerfile instruction creates a layer. Docker caches layers by their input hash — if the instruction and all preceding layers are identical, the cached layer is reused.</p>





<pre tabindex="0"><code>Layer 4: COPY . .                    ← changes every commit
Layer 3: RUN pip install -r req.txt  ← changes weekly
Layer 2: RUN apt-get install gcc     ← changes monthly
Layer 1: FROM python:3.11-slim       ← changes rarely</code></pre><p>Cache invalidation is <strong>top-down</strong>: if Layer 2 changes, Layers 3 and 4 are invalidated too, even if <code>requirements.txt</code> hasn&rsquo;t changed. This means <strong>Dockerfile instruction ordering directly determines cache efficiency.</strong></p>
<p>The optimal ordering principle: <strong>sort instructions by change frequency, ascending.</strong> System deps → language deps → application source.</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dockerfile" data-lang="dockerfile"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="k">FROM</span><span class="w"> </span><span class="s">python:3.11-slim</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="s">base</span><span class="err">
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="k">WORKDIR</span><span class="w"> </span><span class="s">/app</span><span class="err">
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="err">
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="c"># Layer 1: System dependencies (changes ~monthly)</span><span class="err">
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="k">RUN</span> apt-get update <span class="o">&amp;&amp;</span> apt-get install -y --no-install-recommends <span class="se">\
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl">    gcc libpq-dev <span class="o">&amp;&amp;</span> rm -rf /var/lib/apt/lists/*<span class="err">
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="err">
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="c"># Layer 2: Python dependencies (changes ~weekly)</span><span class="err">
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="k">COPY</span> requirements.txt .<span class="err">
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="k">RUN</span> pip install --no-cache-dir -r requirements.txt<span class="err">
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="err">
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="c"># Layer 3: Application source (changes every commit)</span><span class="err">
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="k">COPY</span> . .<span class="err">
</span></span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="err">
</span></span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="k">CMD</span> <span class="p">[</span><span class="s2">&#34;uvicorn&#34;</span><span class="p">,</span> <span class="s2">&#34;main:app&#34;</span><span class="p">,</span> <span class="s2">&#34;--host&#34;</span><span class="p">,</span> <span class="s2">&#34;0.0.0.0&#34;</span><span class="p">,</span> <span class="s2">&#34;--port&#34;</span><span class="p">,</span> <span class="s2">&#34;8000&#34;</span><span class="p">]</span></span></span></code></pre></div><h3 id="github-actions-cache-backend">GitHub Actions cache backend</h3>
<p>The key Buildx configuration:</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="ln"> 1</span><span class="cl">- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">Set up Docker Buildx</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="w">  </span><span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">docker/setup-buildx-action@v3</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl">- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">Build and push</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="w">  </span><span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">docker/build-push-action@v5</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="w">  </span><span class="nt">with</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="w">    </span><span class="nt">context</span><span class="p">:</span><span class="w"> </span><span class="l">services/${{ matrix.service }}</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="w">    </span><span class="nt">push</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="w">    </span><span class="nt">tags</span><span class="p">:</span><span class="w"> </span><span class="p">|</span><span class="sd">
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="sd">      ${{ env.REGISTRY }}/${{ matrix.service }}:${{ env.IMAGE_TAG }}</span><span class="w">
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="w">    </span><span class="nt">cache-from</span><span class="p">:</span><span class="w"> </span><span class="l">type=gha</span><span class="w">
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="w">    </span><span class="nt">cache-to</span><span class="p">:</span><span class="w"> </span><span class="l">type=gha,mode=max</span></span></span></code></pre></div><p><code>type=gha</code> uses GitHub Actions&rsquo; built-in cache as the layer storage backend. This is important because CI runners are ephemeral — without an external cache backend, every run starts cold.</p>
<p><code>mode=max</code> is critical and often missed. By default, Buildx only exports layers from the final stage. <code>mode=max</code> exports <strong>all intermediate layers</strong>, including those from multi-stage build stages. Without it, your <code>pip install</code> layer from a builder stage won&rsquo;t be cached.</p>
<p>Under the hood, Buildx serializes each layer as a content-addressed blob and stores it via the GitHub Actions cache API. On subsequent runs, it checks the cache by layer digest before executing the instruction.</p>
<h3 id="cache-efficiency-in-practice">Cache efficiency in practice</h3>
<p>The difference is dramatic:</p>
<table>
  <thead>
      <tr>
          <th>Scenario</th>
          <th style="text-align: center">Without cache</th>
          <th style="text-align: center">With Buildx + GHA cache</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Full build (cold cache)</td>
          <td style="text-align: center">10 min</td>
          <td style="text-align: center">10 min</td>
      </tr>
      <tr>
          <td>Source-only change</td>
          <td style="text-align: center">10 min</td>
          <td style="text-align: center">45 sec</td>
      </tr>
      <tr>
          <td>Dependency change</td>
          <td style="text-align: center">10 min</td>
          <td style="text-align: center">4 min</td>
      </tr>
      <tr>
          <td>No change (skip with change detection)</td>
          <td style="text-align: center">10 min</td>
          <td style="text-align: center">0 sec</td>
      </tr>
  </tbody>
</table>
<p>The dominant cost axis shifts from build time to cache lookup time. For a source-only change, the only layer that rebuilds is <code>COPY . .</code>, which takes seconds.</p>
<h3 id="multi-stage-builds-and-image-size">Multi-stage builds and image size</h3>
<p>If your final image includes build tools (<code>gcc</code>, <code>python3-dev</code>, header files), you&rsquo;re shipping dead weight. Multi-stage builds separate the build environment from the runtime environment:</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dockerfile" data-lang="dockerfile"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c"># Stage 1: Build (includes compilers, headers)</span><span class="err">
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="k">FROM</span><span class="w"> </span><span class="s">python:3.11-slim</span><span class="w"> </span><span class="k">AS</span><span class="w"> </span><span class="s">builder</span><span class="err">
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="k">WORKDIR</span><span class="w"> </span><span class="s">/build</span><span class="err">
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="k">RUN</span> apt-get update <span class="o">&amp;&amp;</span> apt-get install -y gcc libffi-dev<span class="err">
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="k">COPY</span> requirements.txt .<span class="err">
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="k">RUN</span> pip install --no-cache-dir --prefix<span class="o">=</span>/install -r requirements.txt<span class="err">
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="err">
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="c"># Stage 2: Runtime (minimal)</span><span class="err">
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="k">FROM</span><span class="w"> </span><span class="s">python:3.11-slim</span><span class="err">
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="k">WORKDIR</span><span class="w"> </span><span class="s">/app</span><span class="err">
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="k">COPY</span> --from<span class="o">=</span>builder /install /usr/local<span class="err">
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="k">COPY</span> . .<span class="err">
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="k">CMD</span> <span class="p">[</span><span class="s2">&#34;uvicorn&#34;</span><span class="p">,</span> <span class="s2">&#34;main:app&#34;</span><span class="p">,</span> <span class="s2">&#34;--host&#34;</span><span class="p">,</span> <span class="s2">&#34;0.0.0.0&#34;</span><span class="p">,</span> <span class="s2">&#34;--port&#34;</span><span class="p">,</span> <span class="s2">&#34;8000&#34;</span><span class="p">]</span></span></span></code></pre></div><p>The <code>--prefix=/install</code> flag tells pip to install into a separate directory tree, which we then <code>COPY --from=builder</code> into the clean runtime image. The final image has no <code>gcc</code>, no header files, no pip cache.</p>
<p>Impact: image sizes dropped from 1.2GB to ~450MB. This matters for pull times during rollouts — a 450MB image pulls in ~15 seconds vs ~45 seconds for 1.2GB. During a rolling update across 6 replicas, that&rsquo;s 3 minutes saved per deployment.</p>
<h2 id="dynamic-service-discovery-in-ci">Dynamic service discovery in CI</h2>
<p>Hardcoding a service list in your workflow is a maintenance hazard:</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="ln">1</span><span class="cl"><span class="c"># ❌ This will drift</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="nt">strategy</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="w">  </span><span class="nt">matrix</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="w">    </span><span class="nt">service</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">auth, api, worker, processor]</span></span></span></code></pre></div><p>When someone deletes <code>processor/</code> and forgets to update the workflow, CI breaks. When someone adds a new service and forgets to add it, it never gets built. Both happen eventually.</p>
<h3 id="filesystem-driven-matrix-generation">Filesystem-driven matrix generation</h3>
<p>Instead of declaring services, discover them:</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="nt">jobs</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="w">  </span><span class="nt">discover</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="w">    </span><span class="nt">runs-on</span><span class="p">:</span><span class="w"> </span><span class="l">ubuntu-latest</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="w">    </span><span class="nt">outputs</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="w">      </span><span class="nt">services</span><span class="p">:</span><span class="w"> </span><span class="l">${{ steps.find.outputs.services }}</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="w">    </span><span class="nt">steps</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">actions/checkout@v4</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="w">      </span>- <span class="nt">id</span><span class="p">:</span><span class="w"> </span><span class="l">find</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="w">        </span><span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="p">|</span><span class="sd">
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="sd">          services=$(find services/ -name Dockerfile -maxdepth 2 \
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="sd">            | xargs -I{} dirname {} \
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="sd">            | xargs -I{} basename {} \
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="sd">            | jq -R -s -c &#39;split(&#34;\n&#34;) | map(select(length &gt; 0))&#39;)
</span></span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="sd">          echo &#34;services=$services&#34; &gt;&gt; $GITHUB_OUTPUT</span><span class="w">
</span></span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="w">  </span><span class="nt">build</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="w">    </span><span class="nt">needs</span><span class="p">:</span><span class="w"> </span><span class="l">discover</span><span class="w">
</span></span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="w">    </span><span class="nt">strategy</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">19</span><span class="cl"><span class="w">      </span><span class="nt">matrix</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">20</span><span class="cl"><span class="w">        </span><span class="nt">service</span><span class="p">:</span><span class="w"> </span><span class="l">${{ fromJson(needs.discover.outputs.services) }}</span></span></span></code></pre></div><p>The contract becomes: a directory under <code>services/</code> with a <code>Dockerfile</code> is a deployable service. No other registration needed. Add a service → add a directory with a Dockerfile. Remove a service → remove the directory.</p>
<h3 id="change-detection-only-build-whats-affected">Change detection: only build what&rsquo;s affected</h3>
<p>Even with caching, there&rsquo;s no reason to build 8 services when 1 file changed. The <code>git diff</code> between the previous and current commit tells you what changed:</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="ln"> 1</span><span class="cl">- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">actions/checkout@v4</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="w">  </span><span class="nt">with</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="w">    </span><span class="nt">fetch-depth</span><span class="p">:</span><span class="w"> </span><span class="m">0</span><span class="w">  </span><span class="c"># Required for accurate git diff</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl">- <span class="nt">id</span><span class="p">:</span><span class="w"> </span><span class="l">changes</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="w">  </span><span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="p">|</span><span class="sd">
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="sd">    changed=$(git diff --name-only ${{ github.event.before }} ${{ github.sha }})
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="sd">    if echo &#34;$changed&#34; | grep -q &#34;^services/${{ matrix.service }}/&#34;; then
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="sd">      echo &#34;build=true&#34; &gt;&gt; $GITHUB_OUTPUT
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="sd">    elif echo &#34;$changed&#34; | grep -q &#34;^libs/shared/&#34;; then
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="sd">      echo &#34;build=true&#34; &gt;&gt; $GITHUB_OUTPUT
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="sd">    else
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="sd">      echo &#34;build=false&#34; &gt;&gt; $GITHUB_OUTPUT
</span></span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="sd">    fi</span><span class="w">
</span></span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="ln">16</span><span class="cl">- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">Build and push</span><span class="w">
</span></span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="w">  </span><span class="nt">if</span><span class="p">:</span><span class="w"> </span><span class="l">steps.changes.outputs.build == &#39;true&#39;</span><span class="w">
</span></span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="w">  </span><span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">docker/build-push-action@v5</span><span class="w">
</span></span></span><span class="line"><span class="ln">19</span><span class="cl"><span class="w">  </span><span class="c"># ...</span></span></span></code></pre></div><p>The <code>fetch-depth: 0</code> is essential. GitHub Actions performs a shallow clone by default (<code>fetch-depth: 1</code>), which means <code>git diff</code> doesn&rsquo;t have enough history and will treat everything as &ldquo;new.&rdquo; We discovered this when our changelogs started showing every file in the repo as changed.</p>
<p>The <code>libs/shared/</code> check handles transitive dependencies — if a shared library changes, all services that could depend on it get rebuilt.</p>
<h2 id="edge-cases-that-will-bite-you">Edge cases that will bite you</h2>
<h3 id="ecr-lifecycle-policies">ECR lifecycle policies</h3>
<p>SHA tags accumulate. At 5 pushes/day × 8 services × ~800MB average image size, that&rsquo;s 32GB/day of new images. Without cleanup, you&rsquo;ll hit storage budget limits within weeks.</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">  <span class="nt">&#34;rules&#34;</span><span class="p">:</span> <span class="p">[</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">      <span class="nt">&#34;rulePriority&#34;</span><span class="p">:</span> <span class="mi">1</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">      <span class="nt">&#34;description&#34;</span><span class="p">:</span> <span class="s2">&#34;Keep last 20 tagged images&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">      <span class="nt">&#34;selection&#34;</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="nt">&#34;tagStatus&#34;</span><span class="p">:</span> <span class="s2">&#34;tagged&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="nt">&#34;countType&#34;</span><span class="p">:</span> <span class="s2">&#34;imageCountMoreThan&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">        <span class="nt">&#34;countNumber&#34;</span><span class="p">:</span> <span class="mi">20</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">      <span class="p">},</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">      <span class="nt">&#34;action&#34;</span><span class="p">:</span> <span class="p">{</span> <span class="nt">&#34;type&#34;</span><span class="p">:</span> <span class="s2">&#34;expire&#34;</span> <span class="p">}</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="p">},</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="p">{</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">      <span class="nt">&#34;rulePriority&#34;</span><span class="p">:</span> <span class="mi">2</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">      <span class="nt">&#34;description&#34;</span><span class="p">:</span> <span class="s2">&#34;Expire untagged images after 24h&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">      <span class="nt">&#34;selection&#34;</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">        <span class="nt">&#34;tagStatus&#34;</span><span class="p">:</span> <span class="s2">&#34;untagged&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">        <span class="nt">&#34;countType&#34;</span><span class="p">:</span> <span class="s2">&#34;sinceImagePushed&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">        <span class="nt">&#34;countUnit&#34;</span><span class="p">:</span> <span class="s2">&#34;days&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">        <span class="nt">&#34;countNumber&#34;</span><span class="p">:</span> <span class="mi">1</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">      <span class="p">},</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">      <span class="nt">&#34;action&#34;</span><span class="p">:</span> <span class="p">{</span> <span class="nt">&#34;type&#34;</span><span class="p">:</span> <span class="s2">&#34;expire&#34;</span> <span class="p">}</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">24</span><span class="cl">  <span class="p">]</span>
</span></span><span class="line"><span class="ln">25</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>20 tagged images gives you ~4 days of rollback runway at 5 pushes/day, which is plenty. Untagged images (intermediate layers from multi-stage builds) expire after 24 hours.</p>
<h3 id="concurrent-manifest-commits">Concurrent manifest commits</h3>
<p>If two workflow runs try to commit manifest changes simultaneously, the second <code>git push</code> fails. A retry loop with rebase handles this:</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="ln"> 1</span><span class="cl">- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">Push manifest update</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="w">  </span><span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="p">|</span><span class="sd">
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="sd">    for attempt in 1 2 3; do
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="sd">      git pull --rebase origin main
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="sd">      git add k8s/
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="sd">      git diff --cached --quiet &amp;&amp; exit 0
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="sd">      git commit -m &#34;deploy: $SERVICE → $IMAGE_TAG&#34;
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="sd">      git push &amp;&amp; exit 0
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="sd">      echo &#34;Push failed (attempt $attempt), retrying...&#34;
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="sd">      sleep $((attempt * 2))
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="sd">    done
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="sd">    echo &#34;::error::Manifest push failed after 3 attempts&#34;
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="sd">    exit 1</span></span></span></code></pre></div><p>The exponential backoff (<code>sleep $((attempt * 2))</code>) avoids thundering herd on concurrent pushes. In practice, this retries successfully on the first attempt 99% of the time.</p>
<h3 id="build-context-bloat">Build context bloat</h3>
<p>Docker sends the entire build context directory to the daemon before building. In a monorepo, that context can be enormous if you&rsquo;re not careful. Each service needs a <code>.dockerignore</code>:</p>





<pre tabindex="0"><code>**/__pycache__
*.pyc
.git
.env
tests/
docs/
*.md
node_modules/</code></pre><p>Without this, we were sending ~500MB of context for services that only needed ~20MB of source. The context upload alone added 2 minutes to every build.</p>
<h3 id="silent-auth-failures">Silent auth failures</h3>
<p>ECR login can silently succeed without actually authenticating (e.g., when secrets aren&rsquo;t available in fork PRs). The build proceeds, spends 10 minutes compiling, and only fails at <code>docker push</code>.</p>
<p>Add an explicit access check early:</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="ln">1</span><span class="cl">- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">Verify registry access</span><span class="w">
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="w">  </span><span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="p">|</span><span class="sd">
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="sd">    aws ecr describe-repositories \
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="sd">      --repository-names &#34;${{ matrix.service }}&#34; &gt; /dev/null 2&gt;&amp;1 \
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="sd">    || { echo &#34;::error::ECR access failed for ${{ matrix.service }}&#34;; exit 1; }</span></span></span></code></pre></div><p>Fail at second 5, not minute 12.</p>
<h2 id="the-pipeline-end-to-end">The pipeline end-to-end</h2>





<pre tabindex="0"><code>git push origin main
        │
        ▼
┌──────────────┐
│   Discover   │  find services/ -name Dockerfile
│   Services   │  → [&#34;auth&#34;, &#34;api&#34;, &#34;worker&#34;]
└──────┬───────┘
       │
       ▼
┌─────────────────────────────────────────────────┐
│  Per-service matrix job (parallel)              │
│                                                 │
│  1. Checkout (fetch-depth: 0)                   │
│  2. git diff → should this service build?       │
│     └── no → skip remaining steps               │
│  3. Verify ECR access                           │
│  4. Set up Buildx                               │
│  5. Build + push (SHA tag, GHA layer cache)     │
│  6. sed manifest with new SHA                   │
│  7. git commit + push manifest (retry loop)     │
└──────┬──────────────────────────────────────────┘
       │
       ▼
┌──────────────┐
│  GitOps sync │  ArgoCD / Flux / kubectl apply
│  picks up    │  detects manifest diff → rolling update
└──────────────┘</code></pre><p>Typical wall-clock time for a single-service source change: <strong>~90 seconds</strong> from push to new pod running.</p>
<h2 id="key-metrics">Key metrics</h2>
<table>
  <thead>
      <tr>
          <th>Metric</th>
          <th style="text-align: center">Manual process</th>
          <th style="text-align: center">Automated pipeline</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Deploy time</td>
          <td style="text-align: center">~15 min</td>
          <td style="text-align: center">~90 sec</td>
      </tr>
      <tr>
          <td>Builds per push</td>
          <td style="text-align: center">All services</td>
          <td style="text-align: center">Only changed</td>
      </tr>
      <tr>
          <td>Image traceability</td>
          <td style="text-align: center">None (<code>latest</code>)</td>
          <td style="text-align: center">Commit SHA</td>
      </tr>
      <tr>
          <td>Rollback mechanism</td>
          <td style="text-align: center">&ldquo;Redeploy the old one&rdquo;</td>
          <td style="text-align: center"><code>git revert</code> + sync</td>
      </tr>
      <tr>
          <td>CI minutes/week</td>
          <td style="text-align: center">~400</td>
          <td style="text-align: center">~90</td>
      </tr>
      <tr>
          <td>Wrong-image incidents</td>
          <td style="text-align: center">~1/month</td>
          <td style="text-align: center">0</td>
      </tr>
  </tbody>
</table>
<h2 id="checklist">Checklist</h2>
<ul>
<li><input disabled="" type="checkbox"> <strong>Tag with commit SHA, not <code>latest</code>.</strong> Mutable tags are mutable bugs.</li>
<li><input disabled="" type="checkbox"> <strong>Order Dockerfile by change frequency.</strong> System deps → language deps → source. Cache invalidation is top-down.</li>
<li><input disabled="" type="checkbox"> <strong>Use <code>cache-from: type=gha</code> with <code>mode=max</code>.</strong> Without <code>mode=max</code>, intermediate stage layers aren&rsquo;t cached.</li>
<li><input disabled="" type="checkbox"> <strong><code>fetch-depth: 0</code> on checkout.</strong> Shallow clones break <code>git diff</code> and changelog generation.</li>
<li><input disabled="" type="checkbox"> <strong>Discover services from the filesystem.</strong> Hardcoded lists drift. Dockerfiles are the contract.</li>
<li><input disabled="" type="checkbox"> <strong>Skip unchanged services.</strong> <code>git diff</code> + path matching. Don&rsquo;t forget shared library paths as rebuild triggers.</li>
<li><input disabled="" type="checkbox"> <strong>Set ECR lifecycle policies.</strong> 20 tagged images + 24h untagged expiry is a reasonable starting point.</li>
<li><input disabled="" type="checkbox"> <strong><code>.dockerignore</code> per service.</strong> Build context size ∝ upload time. Ship source, not tests.</li>
<li><input disabled="" type="checkbox"> <strong>Verify registry auth early.</strong> Don&rsquo;t spend 10 minutes building an image you can&rsquo;t push.</li>
<li><input disabled="" type="checkbox"> <strong>Retry manifest pushes.</strong> Concurrent CI runs will race on <code>git push</code>. Rebase + retry handles it.</li>
</ul>
]]></content:encoded>
    </item>
    <item>
      <title>Hunting OOMKilled in Kubernetes: Four Memory Leaks That Almost Took Down Our Platform</title>
      <link>https://KTS-o7.github.io/bear/infra/kubernetes_oom_memory_leaks/</link>
      <pubDate>Mon, 16 Feb 2026 00:00:00 +0000</pubDate><author>shentharkrishnatejaswi@gmail.com (Krishnatejaswi S)</author>
      <guid>https://KTS-o7.github.io/bear/infra/kubernetes_oom_memory_leaks/</guid>
      <description>&lt;p&gt;A pod restarts at 3 AM. Then again at 3:14. By 3:30 it&amp;rsquo;s in a &lt;code&gt;CrashLoopBackOff&lt;/code&gt;. The dashboard shows memory climbing in a clean line - no spikes, no sudden jumps - just a slow, relentless climb until the OOM killer steps in.&lt;/p&gt;&#xA;&lt;p&gt;This is the story of how I tracked down four distinct memory leaks across two Python services running on Kubernetes. Each one was a different flavor of &amp;ldquo;silent accumulation,&amp;rdquo; and each one taught me something about how easy it is to bleed memory in async Python without any obvious errors.&lt;/p&gt;</description>
      <content:encoded><![CDATA[<p>A pod restarts at 3 AM. Then again at 3:14. By 3:30 it&rsquo;s in a <code>CrashLoopBackOff</code>. The dashboard shows memory climbing in a clean line - no spikes, no sudden jumps - just a slow, relentless climb until the OOM killer steps in.</p>
<p>This is the story of how I tracked down four distinct memory leaks across two Python services running on Kubernetes. Each one was a different flavor of &ldquo;silent accumulation,&rdquo; and each one taught me something about how easy it is to bleed memory in async Python without any obvious errors.</p>
<p>No names. No product details. Just the bugs, the fixes, and the lessons.</p>
<h2 id="the-setup">The setup</h2>
<p>We had a multi-service platform running on Kubernetes. Two services are relevant here:</p>
<ul>
<li>
<p><strong>Service A</strong>: A document management service. It handled CRUD operations, search indexing, audit logging, and authorization precomputation. Built with FastAPI and async Python, backed by MongoDB and a vector store.</p>
</li>
<li>
<p><strong>Service B</strong>: A PDF processing service. It parsed documents, extracted text via OCR, and broke content into structured clauses. Heavy on PyMuPDF (fitz) for PDF rendering and Tesseract for OCR.</p>
</li>
</ul>
<p>Both services ran as single-container pods with resource limits. Service A had a 2Gi memory limit. Service B had 4Gi.</p>
<p>Both were getting OOMKilled. Regularly.</p>
<h2 id="the-symptoms">The symptoms</h2>
<p>Service A&rsquo;s memory graph looked like this:</p>





<pre tabindex="0"><code>Memory (MB)
2048 |                                              ╱ OOMKilled
     |                                           ╱
1536 |                                        ╱
     |                                     ╱
1024 |                                  ╱
     |                               ╱
 512 |                            ╱
     |                         ╱
   0 |________________________╱
     0h        6h        12h        18h        24h</code></pre><p>A steady climb at roughly 200MB per hour. No spikes. No correlation with traffic. Just a ramp.</p>
<p>Service B was different - it&rsquo;d spike during document processing batches and never come back down:</p>





<pre tabindex="0"><code>Memory (MB)
4096 |          ╱╲        ╱╲
     |        ╱   ╲     ╱   ╲    ╱ OOMKilled
3072 |      ╱      ╲  ╱      ╲ ╱
     |    ╱         ╲╱        ╱
2048 |  ╱                   ╱
     |╱                   ╱
1024 |                  ╱
     |               ╱
   0 |______________╱
     0h     2h     4h     6h     8h     10h</code></pre><p>Every batch pushed memory up. It came down a little, but never to baseline. Classic leak with GC partially reclaiming some objects but not the real offender.</p>
<h2 id="leak-1-the-coroutines-that-never-ran">Leak #1: The coroutines that never ran</h2>
<p>This was the most insidious one. It looked like perfectly normal Python code:</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="k">class</span> <span class="nc">BaseDoc</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="k">def</span> <span class="nf">_write_audit_log</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">action</span><span class="p">,</span> <span class="n">old_data</span><span class="p">,</span> <span class="n">new_data</span><span class="p">):</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">        <span class="n">enqueue_audit_create</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">action</span><span class="p">,</span> <span class="n">old_data</span><span class="p">,</span> <span class="n">new_data</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="k">async</span> <span class="k">def</span> <span class="nf">save</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="o">*</span><span class="n">args</span><span class="p">,</span> <span class="o">**</span><span class="n">kwargs</span><span class="p">):</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="n">old</span> <span class="o">=</span> <span class="k">await</span> <span class="bp">self</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="bp">self</span><span class="o">.</span><span class="n">id</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="n">result</span> <span class="o">=</span> <span class="k">await</span> <span class="nb">super</span><span class="p">()</span><span class="o">.</span><span class="n">save</span><span class="p">(</span><span class="o">*</span><span class="n">args</span><span class="p">,</span> <span class="o">**</span><span class="n">kwargs</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="bp">self</span><span class="o">.</span><span class="n">_write_audit_log</span><span class="p">(</span><span class="s2">&#34;update&#34;</span><span class="p">,</span> <span class="n">old</span><span class="p">,</span> <span class="n">result</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">        <span class="k">await</span> <span class="n">create_snapshots</span><span class="p">(</span><span class="bp">self</span><span class="o">.</span><span class="n">id</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">        <span class="k">await</span> <span class="n">insert_versions_docs</span><span class="p">(</span><span class="bp">self</span><span class="o">.</span><span class="n">id</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">        <span class="k">return</span> <span class="n">result</span></span></span></code></pre></div><p>Spot the bug?</p>
<p><code>_write_audit_log</code> calls <code>enqueue_audit_create()</code>, which is an <strong>async function</strong>. But <code>_write_audit_log</code> is not async, and it doesn&rsquo;t <code>await</code> the call. It just calls it.</p>
<p>When you call an async function without <code>await</code>, Python creates a coroutine object but never schedules it. The coroutine sits in memory, holding references to all its arguments - in this case, entire document objects with <code>old_data</code> and <code>new_data</code>.</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">Coroutine Object (0x7f...)
</span></span><span class="line"><span class="ln">2</span><span class="cl">  ├── State: CREATED (never scheduled)
</span></span><span class="line"><span class="ln">3</span><span class="cl">  └── Frame Object
</span></span><span class="line"><span class="ln">4</span><span class="cl">       ├── Arg: self (BaseDoc instance, 2KB)
</span></span><span class="line"><span class="ln">5</span><span class="cl">       ├── Arg: old_data (Dict, 5MB)  &lt;-- LEAKED
</span></span><span class="line"><span class="ln">6</span><span class="cl">       └── Arg: new_data (Dict, 5MB)  &lt;-- LEAKED
</span></span><span class="line"><span class="ln">7</span><span class="cl">       └── Local Vars: (captured closure scope)</span></span></code></pre></div><p>Python will eventually warn you about this (<code>RuntimeWarning: coroutine was never awaited</code>), but only if you have warnings enabled and you&rsquo;re looking at the right log. In a noisy production service running dozens of requests per second, these warnings get buried.</p>
<p>The same pattern appeared elsewhere:</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1"># In the CRUD endpoints</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="k">async</span> <span class="k">def</span> <span class="nf">create_document</span><span class="p">(</span><span class="n">request</span><span class="p">):</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="n">doc</span> <span class="o">=</span> <span class="k">await</span> <span class="n">Document</span><span class="o">.</span><span class="n">create</span><span class="p">(</span><span class="n">request</span><span class="o">.</span><span class="n">data</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="n">enqueue_audit_create</span><span class="p">(</span><span class="n">doc</span><span class="p">)</span>       <span class="c1"># ← async, not awaited</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="n">qdrant_upserts</span><span class="p">(</span><span class="n">doc</span><span class="p">)</span>             <span class="c1"># ← async, not awaited</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="k">return</span> <span class="n">doc</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="k">async</span> <span class="k">def</span> <span class="nf">update_document</span><span class="p">(</span><span class="n">request</span><span class="p">):</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="n">doc</span> <span class="o">=</span> <span class="k">await</span> <span class="n">Document</span><span class="o">.</span><span class="n">update</span><span class="p">(</span><span class="n">request</span><span class="o">.</span><span class="n">data</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="n">enqueue_audit_update</span><span class="p">(</span><span class="n">doc</span><span class="p">)</span>       <span class="c1"># ← async, not awaited</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="n">qdrant_upserts</span><span class="p">(</span><span class="n">doc</span><span class="p">)</span>             <span class="c1"># ← async, not awaited</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="k">return</span> <span class="n">doc</span></span></span></code></pre></div><p>Each unawaited call created a coroutine holding the full document. For a service processing hundreds of documents per hour, that&rsquo;s hundreds of document-sized objects pinned in memory with no way to be garbage collected.</p>
<h3 id="the-fix">The fix</h3>
<p>Straightforward once you see it:</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="k">class</span> <span class="nc">BaseDoc</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="k">async</span> <span class="k">def</span> <span class="nf">_write_audit_log</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">action</span><span class="p">,</span> <span class="n">old_data</span><span class="p">,</span> <span class="n">new_data</span><span class="p">):</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">        <span class="k">await</span> <span class="n">enqueue_audit_create</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">action</span><span class="p">,</span> <span class="n">old_data</span><span class="p">,</span> <span class="n">new_data</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="k">async</span> <span class="k">def</span> <span class="nf">save</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="o">*</span><span class="n">args</span><span class="p">,</span> <span class="o">**</span><span class="n">kwargs</span><span class="p">):</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="n">old</span> <span class="o">=</span> <span class="k">await</span> <span class="bp">self</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="bp">self</span><span class="o">.</span><span class="n">id</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="n">result</span> <span class="o">=</span> <span class="k">await</span> <span class="nb">super</span><span class="p">()</span><span class="o">.</span><span class="n">save</span><span class="p">(</span><span class="o">*</span><span class="n">args</span><span class="p">,</span> <span class="o">**</span><span class="n">kwargs</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="k">await</span> <span class="bp">self</span><span class="o">.</span><span class="n">_write_audit_log</span><span class="p">(</span><span class="s2">&#34;update&#34;</span><span class="p">,</span> <span class="n">old</span><span class="p">,</span> <span class="n">result</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">        <span class="k">await</span> <span class="n">create_snapshots</span><span class="p">(</span><span class="bp">self</span><span class="o">.</span><span class="n">id</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">        <span class="k">await</span> <span class="n">insert_versions_docs</span><span class="p">(</span><span class="bp">self</span><span class="o">.</span><span class="n">id</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">        <span class="k">return</span> <span class="n">result</span></span></span></code></pre></div><p>And in the CRUD endpoints:</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">async</span> <span class="k">def</span> <span class="nf">create_document</span><span class="p">(</span><span class="n">request</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="n">doc</span> <span class="o">=</span> <span class="k">await</span> <span class="n">Document</span><span class="o">.</span><span class="n">create</span><span class="p">(</span><span class="n">request</span><span class="o">.</span><span class="n">data</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="k">await</span> <span class="n">enqueue_audit_create</span><span class="p">(</span><span class="n">doc</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="k">await</span> <span class="n">qdrant_upserts</span><span class="p">(</span><span class="n">doc</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="k">return</span> <span class="n">doc</span></span></span></code></pre></div><h3 id="the-lesson">The lesson</h3>
<p><strong>Unawaited coroutines are silent memory leaks.</strong> They don&rsquo;t crash. They don&rsquo;t raise. They just accumulate. And because they hold references to their arguments, they can keep large objects alive long after the request that created them is done.</p>
<p>If you&rsquo;re running async Python, add this to your linting:</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># In your pytest conftest.py or startup</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="kn">import</span> <span class="nn">warnings</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="n">warnings</span><span class="o">.</span><span class="n">filterwarnings</span><span class="p">(</span><span class="s2">&#34;error&#34;</span><span class="p">,</span> <span class="n">category</span><span class="o">=</span><span class="ne">RuntimeWarning</span><span class="p">,</span> <span class="n">message</span><span class="o">=</span><span class="s2">&#34;coroutine.*was never awaited&#34;</span><span class="p">)</span></span></span></code></pre></div><p>This turns the warning into an exception, which makes it impossible to miss.</p>
<h2 id="leak-2-creating-a-new-mongoclient-on-every-authorization-check">Leak #2: Creating a new MongoClient on every authorization check</h2>
<p>The authorization layer needed to precompute user access contexts. To do this, it queried MongoDB directly (not through the async ODM) using PyMongo&rsquo;s synchronous <code>MongoClient</code>.</p>
<p>The code looked like this:</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="k">class</span> <span class="nc">AuthorizationPrecompute</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="k">def</span> <span class="nf">get_user_permissions</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">user_id</span><span class="p">):</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">        <span class="n">client</span> <span class="o">=</span> <span class="n">MongoClient</span><span class="p">(</span><span class="n">MONGO_URI</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">        <span class="n">db</span> <span class="o">=</span> <span class="n">client</span><span class="p">[</span><span class="s2">&#34;main_db&#34;</span><span class="p">]</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">        <span class="n">permissions</span> <span class="o">=</span> <span class="nb">list</span><span class="p">(</span><span class="n">db</span><span class="o">.</span><span class="n">permissions</span><span class="o">.</span><span class="n">find</span><span class="p">({</span><span class="s2">&#34;user&#34;</span><span class="p">:</span> <span class="n">user_id</span><span class="p">}))</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="n">client</span><span class="o">.</span><span class="n">close</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="k">return</span> <span class="n">permissions</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="k">def</span> <span class="nf">get_role_policies</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">role_id</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">        <span class="n">client</span> <span class="o">=</span> <span class="n">MongoClient</span><span class="p">(</span><span class="n">MONGO_URI</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">        <span class="n">db</span> <span class="o">=</span> <span class="n">client</span><span class="p">[</span><span class="s2">&#34;main_db&#34;</span><span class="p">]</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">        <span class="n">policies</span> <span class="o">=</span> <span class="nb">list</span><span class="p">(</span><span class="n">db</span><span class="o">.</span><span class="n">policies</span><span class="o">.</span><span class="n">find</span><span class="p">({</span><span class="s2">&#34;role&#34;</span><span class="p">:</span> <span class="n">role_id</span><span class="p">}))</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">        <span class="n">client</span><span class="o">.</span><span class="n">close</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">        <span class="k">return</span> <span class="n">policies</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">
</span></span><span class="line"><span class="ln">16</span><span class="cl">    <span class="k">def</span> <span class="nf">get_org_settings</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">org_id</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">        <span class="n">client</span> <span class="o">=</span> <span class="n">MongoClient</span><span class="p">(</span><span class="n">MONGO_URI</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">        <span class="n">db</span> <span class="o">=</span> <span class="n">client</span><span class="p">[</span><span class="s2">&#34;main_db&#34;</span><span class="p">]</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">        <span class="n">settings</span> <span class="o">=</span> <span class="n">db</span><span class="o">.</span><span class="n">settings</span><span class="o">.</span><span class="n">find_one</span><span class="p">({</span><span class="s2">&#34;org&#34;</span><span class="p">:</span> <span class="n">org_id</span><span class="p">})</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">        <span class="n">client</span><span class="o">.</span><span class="n">close</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">        <span class="k">return</span> <span class="n">settings</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">
</span></span><span class="line"><span class="ln">23</span><span class="cl">    <span class="c1"># ... 10 more methods, each creating its own MongoClient</span></span></span></code></pre></div><p>Thirteen methods. Thirteen <code>MongoClient()</code> instantiations per authorization check.</p>
<p>Each <code>MongoClient</code> creates a connection pool, background SDAM monitoring threads, and internal caches. Even with <code>client.close()</code>, the cleanup isn&rsquo;t instant - Python&rsquo;s garbage collector has to reclaim the thread stacks, socket buffers, and internal data structures.</p>
<p>And if an exception happens between <code>MongoClient()</code> and <code>client.close()</code>, the client never gets closed at all. The connection pool lives on, holding threads and sockets until the GC eventually (maybe) collects them.</p>
<p>At 50 requests/second, each triggering an auth check, we were creating <strong>650 MongoClient instances per second</strong>. Most were closed, but the transient memory pressure and leaked exceptions meant we were always carrying a few hundred orphaned connection pools.</p>
<h3 id="the-fix-1">The fix</h3>
<p>One shared client with a connection pool:</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="k">class</span> <span class="nc">AuthorizationPrecompute</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="n">_client</span> <span class="o">=</span> <span class="kc">None</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="nd">@classmethod</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="k">def</span> <span class="nf">_get_client</span><span class="p">(</span><span class="bp">cls</span><span class="p">):</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="k">if</span> <span class="bp">cls</span><span class="o">.</span><span class="n">_client</span> <span class="ow">is</span> <span class="kc">None</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">            <span class="bp">cls</span><span class="o">.</span><span class="n">_client</span> <span class="o">=</span> <span class="n">MongoClient</span><span class="p">(</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">                <span class="n">MONGO_URI</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">                <span class="n">maxPoolSize</span><span class="o">=</span><span class="mi">20</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">                <span class="n">serverSelectionTimeoutMS</span><span class="o">=</span><span class="mi">5000</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">            <span class="p">)</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">        <span class="k">return</span> <span class="bp">cls</span><span class="o">.</span><span class="n">_client</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">
</span></span><span class="line"><span class="ln">14</span><span class="cl">    <span class="k">def</span> <span class="nf">get_user_permissions</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">user_id</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">        <span class="n">db</span> <span class="o">=</span> <span class="bp">self</span><span class="o">.</span><span class="n">_get_client</span><span class="p">()[</span><span class="s2">&#34;main_db&#34;</span><span class="p">]</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">        <span class="k">return</span> <span class="nb">list</span><span class="p">(</span><span class="n">db</span><span class="o">.</span><span class="n">permissions</span><span class="o">.</span><span class="n">find</span><span class="p">({</span><span class="s2">&#34;user&#34;</span><span class="p">:</span> <span class="n">user_id</span><span class="p">}))</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">
</span></span><span class="line"><span class="ln">18</span><span class="cl">    <span class="k">def</span> <span class="nf">get_role_policies</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">role_id</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">        <span class="n">db</span> <span class="o">=</span> <span class="bp">self</span><span class="o">.</span><span class="n">_get_client</span><span class="p">()[</span><span class="s2">&#34;main_db&#34;</span><span class="p">]</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">        <span class="k">return</span> <span class="nb">list</span><span class="p">(</span><span class="n">db</span><span class="o">.</span><span class="n">policies</span><span class="o">.</span><span class="n">find</span><span class="p">({</span><span class="s2">&#34;role&#34;</span><span class="p">:</span> <span class="n">role_id</span><span class="p">}))</span></span></span></code></pre></div><p>Connection overhead went from ~50ms per call (TCP handshake + auth each time) to ~5ms (reused connection from pool).</p>
<h3 id="the-lesson-1">The lesson</h3>
<p><strong>Each <code>MongoClient</code> is an entire connection pool, not a single connection.</strong> Creating one per function call is like starting a new database server for every query. Always share a single client instance (or at most one per thread/worker). And if you can&rsquo;t guarantee <code>close()</code> will be called (i.e., exceptions exist), you definitely need a shared instance.</p>
<h2 id="leak-3-cache-entries-that-never-expire">Leak #3: Cache entries that never expire</h2>
<p>The authorization layer also had an in-memory cache for user access contexts:</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="k">class</span> <span class="nc">UserAccessContextCache</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="k">def</span> <span class="fm">__init__</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">ttl_seconds</span><span class="o">=</span><span class="mi">300</span><span class="p">):</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">        <span class="bp">self</span><span class="o">.</span><span class="n">_cache</span> <span class="o">=</span> <span class="p">{}</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">        <span class="bp">self</span><span class="o">.</span><span class="n">_ttl</span> <span class="o">=</span> <span class="n">ttl_seconds</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="k">def</span> <span class="nf">get</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">key</span><span class="p">):</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="n">entry</span> <span class="o">=</span> <span class="bp">self</span><span class="o">.</span><span class="n">_cache</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="n">key</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="k">if</span> <span class="n">entry</span> <span class="ow">and</span> <span class="n">time</span><span class="o">.</span><span class="n">time</span><span class="p">()</span> <span class="o">-</span> <span class="n">entry</span><span class="p">[</span><span class="s2">&#34;created&#34;</span><span class="p">]</span> <span class="o">&lt;</span> <span class="bp">self</span><span class="o">.</span><span class="n">_ttl</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">            <span class="k">return</span> <span class="n">entry</span><span class="p">[</span><span class="s2">&#34;value&#34;</span><span class="p">]</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">        <span class="k">return</span> <span class="kc">None</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="k">def</span> <span class="nf">set</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">key</span><span class="p">,</span> <span class="n">value</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">        <span class="bp">self</span><span class="o">.</span><span class="n">_cache</span><span class="p">[</span><span class="n">key</span><span class="p">]</span> <span class="o">=</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">            <span class="s2">&#34;value&#34;</span><span class="p">:</span> <span class="n">value</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">            <span class="s2">&#34;created&#34;</span><span class="p">:</span> <span class="n">time</span><span class="o">.</span><span class="n">time</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">        <span class="p">}</span></span></span></code></pre></div><p>See the problem?</p>
<p>When <code>get()</code> finds an expired entry, it just returns <code>None</code>. It doesn&rsquo;t delete the entry. The stale data sits in the dict forever.</p>
<p>And <code>set()</code> only adds entries. Nothing ever removes them.</p>
<p>Over time, this cache grows without bound. Every unique user who hits the service gets a cache entry that is never cleaned up. The access context objects themselves can be large - they contain permission trees, role hierarchies, and org settings.</p>
<p>With 200MB of stale cache entries after a day of operation, this was a significant contributor to the memory ramp.</p>
<h3 id="the-fix-2">The fix</h3>
<p>Add proactive cleanup:</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="k">class</span> <span class="nc">UserAccessContextCache</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="k">def</span> <span class="fm">__init__</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">ttl_seconds</span><span class="o">=</span><span class="mi">300</span><span class="p">,</span> <span class="n">cleanup_interval</span><span class="o">=</span><span class="mi">60</span><span class="p">):</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">        <span class="bp">self</span><span class="o">.</span><span class="n">_cache</span> <span class="o">=</span> <span class="p">{}</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">        <span class="bp">self</span><span class="o">.</span><span class="n">_ttl</span> <span class="o">=</span> <span class="n">ttl_seconds</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">        <span class="bp">self</span><span class="o">.</span><span class="n">_cleanup_interval</span> <span class="o">=</span> <span class="n">cleanup_interval</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="bp">self</span><span class="o">.</span><span class="n">_last_cleanup</span> <span class="o">=</span> <span class="n">time</span><span class="o">.</span><span class="n">time</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="k">def</span> <span class="nf">_cleanup_expired</span><span class="p">(</span><span class="bp">self</span><span class="p">):</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">        <span class="n">now</span> <span class="o">=</span> <span class="n">time</span><span class="o">.</span><span class="n">time</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">        <span class="k">if</span> <span class="n">now</span> <span class="o">-</span> <span class="bp">self</span><span class="o">.</span><span class="n">_last_cleanup</span> <span class="o">&lt;</span> <span class="bp">self</span><span class="o">.</span><span class="n">_cleanup_interval</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">            <span class="k">return</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">        <span class="bp">self</span><span class="o">.</span><span class="n">_last_cleanup</span> <span class="o">=</span> <span class="n">now</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">        <span class="n">expired_keys</span> <span class="o">=</span> <span class="p">[</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">            <span class="n">k</span> <span class="k">for</span> <span class="n">k</span><span class="p">,</span> <span class="n">v</span> <span class="ow">in</span> <span class="bp">self</span><span class="o">.</span><span class="n">_cache</span><span class="o">.</span><span class="n">items</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">            <span class="k">if</span> <span class="n">now</span> <span class="o">-</span> <span class="n">v</span><span class="p">[</span><span class="s2">&#34;created&#34;</span><span class="p">]</span> <span class="o">&gt;=</span> <span class="bp">self</span><span class="o">.</span><span class="n">_ttl</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">        <span class="p">]</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">        <span class="k">for</span> <span class="n">k</span> <span class="ow">in</span> <span class="n">expired_keys</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">            <span class="k">del</span> <span class="bp">self</span><span class="o">.</span><span class="n">_cache</span><span class="p">[</span><span class="n">k</span><span class="p">]</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">
</span></span><span class="line"><span class="ln">20</span><span class="cl">    <span class="k">def</span> <span class="nf">get</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">key</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">        <span class="bp">self</span><span class="o">.</span><span class="n">_cleanup_expired</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">        <span class="n">entry</span> <span class="o">=</span> <span class="bp">self</span><span class="o">.</span><span class="n">_cache</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="n">key</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl">        <span class="k">if</span> <span class="n">entry</span> <span class="ow">and</span> <span class="n">time</span><span class="o">.</span><span class="n">time</span><span class="p">()</span> <span class="o">-</span> <span class="n">entry</span><span class="p">[</span><span class="s2">&#34;created&#34;</span><span class="p">]</span> <span class="o">&lt;</span> <span class="bp">self</span><span class="o">.</span><span class="n">_ttl</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">24</span><span class="cl">            <span class="k">return</span> <span class="n">entry</span><span class="p">[</span><span class="s2">&#34;value&#34;</span><span class="p">]</span>
</span></span><span class="line"><span class="ln">25</span><span class="cl">        <span class="c1"># Delete on read if expired</span>
</span></span><span class="line"><span class="ln">26</span><span class="cl">        <span class="k">if</span> <span class="n">key</span> <span class="ow">in</span> <span class="bp">self</span><span class="o">.</span><span class="n">_cache</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">27</span><span class="cl">            <span class="k">del</span> <span class="bp">self</span><span class="o">.</span><span class="n">_cache</span><span class="p">[</span><span class="n">key</span><span class="p">]</span>
</span></span><span class="line"><span class="ln">28</span><span class="cl">        <span class="k">return</span> <span class="kc">None</span>
</span></span><span class="line"><span class="ln">29</span><span class="cl">
</span></span><span class="line"><span class="ln">30</span><span class="cl">    <span class="k">def</span> <span class="nf">set</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">key</span><span class="p">,</span> <span class="n">value</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">31</span><span class="cl">        <span class="bp">self</span><span class="o">.</span><span class="n">_cleanup_expired</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">32</span><span class="cl">        <span class="bp">self</span><span class="o">.</span><span class="n">_cache</span><span class="p">[</span><span class="n">key</span><span class="p">]</span> <span class="o">=</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">33</span><span class="cl">            <span class="s2">&#34;value&#34;</span><span class="p">:</span> <span class="n">value</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">34</span><span class="cl">            <span class="s2">&#34;created&#34;</span><span class="p">:</span> <span class="n">time</span><span class="o">.</span><span class="n">time</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">35</span><span class="cl">        <span class="p">}</span></span></span></code></pre></div><p>Cleanup runs at most once per minute and removes all entries older than TTL.</p>
<h3 id="the-lesson-2">The lesson</h3>
<p><strong>Every in-memory cache needs an eviction strategy.</strong> If there&rsquo;s no mechanism to remove old entries, you have a memory leak with extra steps. &ldquo;TTL on read&rdquo; is not enough - if users stop requesting a key, that entry stays forever.</p>
<p>Use <code>cachetools.TTLCache</code> or similar if you don&rsquo;t want to roll your own. Better yet, if the cache is big enough to matter, put it in Redis where you can set <code>EXPIRE</code> and forget about it.</p>
<h2 id="leak-4-pymupdf-documents-that-never-closed">Leak #4: PyMuPDF documents that never closed</h2>
<p>Service B was the PDF processing service. It used PyMuPDF (<code>fitz</code>) to render pages and extract text. The endpoints looked like this:</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="nd">@app.post</span><span class="p">(</span><span class="s2">&#34;/parse_pdf&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="k">async</span> <span class="k">def</span> <span class="nf">parse_pdf</span><span class="p">(</span><span class="n">file</span><span class="p">:</span> <span class="n">UploadFile</span><span class="p">):</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="n">content</span> <span class="o">=</span> <span class="k">await</span> <span class="n">file</span><span class="o">.</span><span class="n">read</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="n">doc</span> <span class="o">=</span> <span class="n">fitz</span><span class="o">.</span><span class="n">open</span><span class="p">(</span><span class="n">stream</span><span class="o">=</span><span class="n">content</span><span class="p">,</span> <span class="n">filetype</span><span class="o">=</span><span class="s2">&#34;pdf&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="n">pages</span> <span class="o">=</span> <span class="p">[]</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="k">for</span> <span class="n">page_num</span> <span class="ow">in</span> <span class="nb">range</span><span class="p">(</span><span class="nb">len</span><span class="p">(</span><span class="n">doc</span><span class="p">)):</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="n">page</span> <span class="o">=</span> <span class="n">doc</span><span class="p">[</span><span class="n">page_num</span><span class="p">]</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">        <span class="n">text</span> <span class="o">=</span> <span class="n">page</span><span class="o">.</span><span class="n">get_text</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">        <span class="n">pix</span> <span class="o">=</span> <span class="n">page</span><span class="o">.</span><span class="n">get_pixmap</span><span class="p">(</span><span class="n">dpi</span><span class="o">=</span><span class="mi">300</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">        <span class="n">image</span> <span class="o">=</span> <span class="n">pix</span><span class="o">.</span><span class="n">tobytes</span><span class="p">(</span><span class="s2">&#34;png&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">        <span class="n">pages</span><span class="o">.</span><span class="n">append</span><span class="p">({</span><span class="s2">&#34;text&#34;</span><span class="p">:</span> <span class="n">text</span><span class="p">,</span> <span class="s2">&#34;image&#34;</span><span class="p">:</span> <span class="n">image</span><span class="p">})</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">
</span></span><span class="line"><span class="ln">14</span><span class="cl">    <span class="k">return</span> <span class="p">{</span><span class="s2">&#34;pages&#34;</span><span class="p">:</span> <span class="n">pages</span><span class="p">}</span></span></span></code></pre></div><p>No <code>doc.close()</code>. No context manager. No <code>try/finally</code>.</p>
<p>PyMuPDF is a C library wrapper. When you call <code>fitz.open()</code>, it allocates memory in C-land for the document structure, page data, fonts, images, and rendering buffers. Python&rsquo;s garbage collector can eventually reclaim the Python wrapper object, but the C-side memory is only freed when <code>doc.close()</code> is explicitly called.</p>
<p>While the wrapper theoretically has a <code>__del__</code> method, relying on it in a high-throughput async loop is dangerous. Python&rsquo;s GC is lazy and may not run fast enough to keep up with the rate of C-memory allocation, leading to OOMs before the GC even realizes it needs to clean up.</p>
<p>For a 50-page PDF at 300 DPI, the pixmap rendering alone can consume 200–500MB of memory. If an exception occurs mid-processing (corrupt page, unsupported font), the doc handle leaks entirely.</p>
<h3 id="the-fix-3">The fix</h3>
<p>Two changes: proper resource cleanup, and parallel OCR with controlled concurrency.</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="nd">@app.post</span><span class="p">(</span><span class="s2">&#34;/parse_pdf&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="k">async</span> <span class="k">def</span> <span class="nf">parse_pdf</span><span class="p">(</span><span class="n">file</span><span class="p">:</span> <span class="n">UploadFile</span><span class="p">):</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="n">content</span> <span class="o">=</span> <span class="k">await</span> <span class="n">file</span><span class="o">.</span><span class="n">read</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="n">doc</span> <span class="o">=</span> <span class="n">fitz</span><span class="o">.</span><span class="n">open</span><span class="p">(</span><span class="n">stream</span><span class="o">=</span><span class="n">content</span><span class="p">,</span> <span class="n">filetype</span><span class="o">=</span><span class="s2">&#34;pdf&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="k">try</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="n">pages</span> <span class="o">=</span> <span class="p">[]</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="k">for</span> <span class="n">page_num</span> <span class="ow">in</span> <span class="nb">range</span><span class="p">(</span><span class="nb">len</span><span class="p">(</span><span class="n">doc</span><span class="p">)):</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">            <span class="n">page</span> <span class="o">=</span> <span class="n">doc</span><span class="p">[</span><span class="n">page_num</span><span class="p">]</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">            <span class="n">text</span> <span class="o">=</span> <span class="n">page</span><span class="o">.</span><span class="n">get_text</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">            <span class="n">pix</span> <span class="o">=</span> <span class="n">page</span><span class="o">.</span><span class="n">get_pixmap</span><span class="p">(</span><span class="n">dpi</span><span class="o">=</span><span class="mi">300</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">            <span class="n">image</span> <span class="o">=</span> <span class="n">pix</span><span class="o">.</span><span class="n">tobytes</span><span class="p">(</span><span class="s2">&#34;png&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">            <span class="c1"># Explicitly free the pixmap</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">            <span class="n">pix</span> <span class="o">=</span> <span class="kc">None</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">            <span class="n">pages</span><span class="o">.</span><span class="n">append</span><span class="p">({</span><span class="s2">&#34;text&#34;</span><span class="p">:</span> <span class="n">text</span><span class="p">,</span> <span class="s2">&#34;image&#34;</span><span class="p">:</span> <span class="n">image</span><span class="p">})</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">        <span class="k">return</span> <span class="p">{</span><span class="s2">&#34;pages&#34;</span><span class="p">:</span> <span class="n">pages</span><span class="p">}</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">    <span class="k">finally</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">        <span class="n">doc</span><span class="o">.</span><span class="n">close</span><span class="p">()</span></span></span></code></pre></div><p>This was applied to all four endpoints in the service: <code>/parse_pdf</code>, <code>/process_clauses</code>, <code>/process_clauses_sync</code>, and <code>/extract_links</code>.</p>
<p>We also added parallel strip processing for OCR (controlled by feature flags):</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kn">import</span> <span class="nn">os</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="kn">from</span> <span class="nn">concurrent.futures</span> <span class="kn">import</span> <span class="n">ThreadPoolExecutor</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="n">ENABLE_PARALLEL_OCR</span> <span class="o">=</span> <span class="n">os</span><span class="o">.</span><span class="n">getenv</span><span class="p">(</span><span class="s2">&#34;ENABLE_PARALLEL_OCR&#34;</span><span class="p">,</span> <span class="s2">&#34;false&#34;</span><span class="p">)</span><span class="o">.</span><span class="n">lower</span><span class="p">()</span> <span class="o">==</span> <span class="s2">&#34;true&#34;</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="n">STRIP_OCR_WORKERS</span> <span class="o">=</span> <span class="nb">int</span><span class="p">(</span><span class="n">os</span><span class="o">.</span><span class="n">getenv</span><span class="p">(</span><span class="s2">&#34;STRIP_OCR_WORKERS&#34;</span><span class="p">,</span> <span class="s2">&#34;8&#34;</span><span class="p">))</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="k">def</span> <span class="nf">process_page_strips</span><span class="p">(</span><span class="n">page</span><span class="p">,</span> <span class="n">strips</span><span class="p">):</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="k">if</span> <span class="ow">not</span> <span class="n">ENABLE_PARALLEL_OCR</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">        <span class="k">return</span> <span class="p">[</span><span class="n">ocr_strip</span><span class="p">(</span><span class="n">page</span><span class="p">,</span> <span class="n">s</span><span class="p">)</span> <span class="k">for</span> <span class="n">s</span> <span class="ow">in</span> <span class="n">strips</span><span class="p">]</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="k">with</span> <span class="n">ThreadPoolExecutor</span><span class="p">(</span><span class="n">max_workers</span><span class="o">=</span><span class="n">STRIP_OCR_WORKERS</span><span class="p">)</span> <span class="k">as</span> <span class="n">pool</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">        <span class="n">futures</span> <span class="o">=</span> <span class="p">[</span><span class="n">pool</span><span class="o">.</span><span class="n">submit</span><span class="p">(</span><span class="n">ocr_strip</span><span class="p">,</span> <span class="n">page</span><span class="p">,</span> <span class="n">s</span><span class="p">)</span> <span class="k">for</span> <span class="n">s</span> <span class="ow">in</span> <span class="n">strips</span><span class="p">]</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">        <span class="k">return</span> <span class="p">[</span><span class="n">f</span><span class="o">.</span><span class="n">result</span><span class="p">()</span> <span class="k">for</span> <span class="n">f</span> <span class="ow">in</span> <span class="n">futures</span><span class="p">]</span></span></span></code></pre></div><h3 id="the-deployment">The deployment</h3>
<p>We didn&rsquo;t flip everything on at once:</p>
<ol>
<li>Deploy with <code>ENABLE_PARALLEL_OCR=false</code> - just the <code>doc.close()</code> fixes</li>
<li>Monitor memory for 24 hours to confirm the leak is fixed</li>
<li>Bump resource limits from <code>4Gi</code> to <code>6Gi</code> to accommodate parallel processing headroom</li>
<li>Enable parallel OCR: <code>ENABLE_PARALLEL_OCR=true</code></li>
</ol>
<p>Memory dropped from a steady 4GB to a stable 1.8GB. That&rsquo;s a 55% reduction from just closing document handles properly.</p>
<h3 id="the-lesson-3">The lesson</h3>
<p><strong>C extension libraries manage their own memory.</strong> Python&rsquo;s GC has no visibility into <code>fitz</code>, <code>numpy</code>, <code>Pillow</code>, or any other C-backed library. If the library provides a <code>close()</code>, use it. If it supports context managers, use <code>with</code>. And always wrap C-heavy processing in <code>try/finally</code>.</p>
<h2 id="the-combined-impact">The combined impact</h2>
<p>Here&rsquo;s the before and after across both services:</p>
<table>
  <thead>
      <tr>
          <th>Issue</th>
          <th>Before</th>
          <th>After</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Unawaited coroutines</td>
          <td>~200MB/hour leak</td>
          <td>0</td>
      </tr>
      <tr>
          <td>MongoClient per call</td>
          <td>5–15 new connections per request</td>
          <td>1 pooled</td>
      </tr>
      <tr>
          <td>Stale cache entries</td>
          <td>Up to 200MB unbounded</td>
          <td>Cleaned every 60s</td>
      </tr>
      <tr>
          <td>PyMuPDF documents</td>
          <td>4GB+ with no release</td>
          <td>Stable at 1.8GB</td>
      </tr>
      <tr>
          <td>Embedding API calls</td>
          <td>2× per document save</td>
          <td>1× (cached concat)</td>
      </tr>
  </tbody>
</table>
<p>Service A went from daily OOMKills to zero restarts. Service B went from crashing every 8–10 hours to running for weeks without a restart.</p>
<h2 id="how-i-found-them">How I found them</h2>
<p>No fancy tooling. Here&rsquo;s the actual process:</p>
<ol>
<li>
<p><strong>Look at the memory graph.</strong> Linear growth = accumulation leak. Sawtooth that doesn&rsquo;t return to baseline = handle/resource leak.</p>
</li>
<li>
<p><strong>Read the code.</strong> Seriously. I read every function in the hot paths and asked &ldquo;what gets allocated here that doesn&rsquo;t get freed?&rdquo;</p>
</li>
<li>
<p><strong><code>grep</code> for patterns.</strong> Once I found one unawaited coroutine, I searched for the pattern across the entire codebase:</p>
</li>
</ol>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># Find async functions called without await</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">rg <span class="s2">&#34;^\s+[a-z_]+\(&#34;</span> --type py <span class="p">|</span> grep -v <span class="s2">&#34;await &#34;</span> <span class="p">|</span> grep -v <span class="s2">&#34;def &#34;</span> <span class="p">|</span> grep -v <span class="s2">&#34;#&#34;</span></span></span></code></pre></div><p>Not perfect, but it flagged the 13 MongoClient instantiations immediately.</p>
<ol start="4">
<li>
<p><strong>Check warnings.</strong> Enabled <code>RuntimeWarning</code> filtering and got 6 unawaited coroutine warnings within the first minute of startup.</p>
</li>
<li>
<p><strong>Kubernetes events.</strong> <code>kubectl describe pod</code> shows OOMKilled with the exact memory at death. Compare that to your resource limits and you know how fast it&rsquo;s growing.</p>
</li>
</ol>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">kubectl get events --field-selector <span class="nv">reason</span><span class="o">=</span>OOMKilling <span class="se">\
</span></span></span><span class="line"><span class="ln">2</span><span class="cl">  --sort-by<span class="o">=</span><span class="s1">&#39;.lastTimestamp&#39;</span> -n your-namespace</span></span></code></pre></div><h2 id="a-checklist-for-your-services">A checklist for your services</h2>
<p>If you&rsquo;re running Python services on Kubernetes and seeing memory growth, check these in order:</p>
<ul>
<li><input disabled="" type="checkbox"> <strong>Unawaited coroutines.</strong> Search for <code>RuntimeWarning: coroutine.*was never awaited</code> in your logs. Better yet, make it an error.</li>
<li><input disabled="" type="checkbox"> <strong>MongoClient instantiation.</strong> <code>grep -rn &quot;MongoClient(&quot; your_code/</code> - if it appears in more than one place (especially inside functions, not module level), you probably have a leak.</li>
<li><input disabled="" type="checkbox"> <strong>In-memory caches without eviction.</strong> Any <code>dict</code> that grows with usage and has no <code>del</code> or size cap is a leak. <code>TTLCache</code>, <code>LRU</code>, or <code>maxsize</code> - pick one.</li>
<li><input disabled="" type="checkbox"> <strong>C extension resources.</strong> PyMuPDF, Pillow, lxml, pdfplumber - anything that wraps C needs explicit <code>close()</code> or <code>with</code> statements.</li>
<li><input disabled="" type="checkbox"> <strong>Large objects in exception handlers.</strong> If your <code>except</code> block captures the full traceback and logs the local variables, those locals (which may include entire documents) are pinned until the log entry is flushed.</li>
<li><input disabled="" type="checkbox"> <strong>Background tasks holding request data.</strong> If <code>asyncio.create_task()</code> captures a closure over request objects, those objects live until the task completes.</li>
</ul>
<h2 id="final-thought">Final thought</h2>
<p>None of these bugs produced an error. No stack trace. No warning (well, one did, but it was buried). The services ran <em>fine</em> - they just got slower and slower until Kubernetes killed them, then they restarted and the cycle began again.</p>
<p>Memory leaks in garbage-collected languages are not the &ldquo;forgot to call <code>free()</code>&rdquo; kind. They&rsquo;re the &ldquo;I&rsquo;m holding a reference I didn&rsquo;t know about&rdquo; kind. The GC is doing its job perfectly - it&rsquo;s keeping alive exactly what you told it to.</p>
<p>You just told it wrong.</p>
]]></content:encoded>
    </item>
    <item>
      <title>SSE in FastAPI: The Tab Closed and My “Done” Code Never Ran</title>
      <link>https://KTS-o7.github.io/bear/infra/sse_disconnect_handling/</link>
      <pubDate>Fri, 23 Jan 2026 00:00:00 +0000</pubDate><author>shentharkrishnatejaswi@gmail.com (Krishnatejaswi S)</author>
      <guid>https://KTS-o7.github.io/bear/infra/sse_disconnect_handling/</guid>
      <description>&lt;p&gt;I hit this bug the annoying way.&lt;/p&gt;&#xA;&lt;p&gt;A coworker said: “I ran task generation, closed the tab, and the status never moved.”&lt;/p&gt;&#xA;&lt;p&gt;My first reaction was “no way.” The backend is doing the work, not the browser.&lt;/p&gt;&#xA;&lt;p&gt;Turns out… yeah. It was right. And once you see why, you stop trusting “code after the stream” forever.&lt;/p&gt;&#xA;&lt;p&gt;This post is about a very specific trap:&lt;/p&gt;&#xA;&lt;ul&gt;&#xA;&lt;li&gt;you stream progress with SSE from a FastAPI endpoint&lt;/li&gt;&#xA;&lt;li&gt;you put important side effects &lt;em&gt;after&lt;/em&gt; the streaming loop (DB status updates, poller kickoff, Slack notifications)&lt;/li&gt;&#xA;&lt;li&gt;a client disconnect cancels the stream&lt;/li&gt;&#xA;&lt;li&gt;your “after the loop” code never runs&lt;/li&gt;&#xA;&lt;/ul&gt;&#xA;&lt;p&gt;It doesn’t always fail. It fails just enough to make you doubt your own system.&lt;/p&gt;</description>
      <content:encoded><![CDATA[<p>I hit this bug the annoying way.</p>
<p>A coworker said: “I ran task generation, closed the tab, and the status never moved.”</p>
<p>My first reaction was “no way.” The backend is doing the work, not the browser.</p>
<p>Turns out… yeah. It was right. And once you see why, you stop trusting “code after the stream” forever.</p>
<p>This post is about a very specific trap:</p>
<ul>
<li>you stream progress with SSE from a FastAPI endpoint</li>
<li>you put important side effects <em>after</em> the streaming loop (DB status updates, poller kickoff, Slack notifications)</li>
<li>a client disconnect cancels the stream</li>
<li>your “after the loop” code never runs</li>
</ul>
<p>It doesn’t always fail. It fails just enough to make you doubt your own system.</p>
<h2 id="what-the-system-looked-like-so-you-dont-think-this-is-a-demo-problem">What the system looked like (so you don’t think this is a demo problem)</h2>
<p>We had a step-based pipeline. Each step was triggered by an HTTP call from the frontend and did a few things:</p>
<ol>
<li>Receive a request like <code>POST /process-timeline-done</code> (or similar)</li>
<li>Start a long-ish worker (in our case an AI agent, but it could be anything)</li>
<li>Stream progress back to the UI via <strong>Server‑Sent Events</strong></li>
<li>Update state in MongoDB (advance a status field)</li>
<li>Submit an external job</li>
<li>Start a long poller to watch that job (hours sometimes)</li>
<li>Send a Slack notification</li>
</ol>
<p>Only (3) needs a live HTTP connection. The rest should be able to finish even if the user refreshes or closes the tab.</p>
<p>We had code split into things like:</p>
<table>
  <thead>
      <tr>
          <th>Component</th>
          <th>What it does</th>
          <th>Should it depend on HTTP?</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>CircularProcessorService</code></td>
          <td>Orchestrates the step</td>
          <td>Only for streaming</td>
      </tr>
      <tr>
          <td><code>JobPollingService</code></td>
          <td>Poll external job completion</td>
          <td>No</td>
      </tr>
      <tr>
          <td><code>ComplianceOSClient</code></td>
          <td>DB/API writes</td>
          <td>No</td>
      </tr>
      <tr>
          <td><code>SlackAlerts</code></td>
          <td>Notify humans</td>
          <td>No</td>
      </tr>
  </tbody>
</table>
<p>The bug was that our implementation accidentally made a bunch of “No” things depend on HTTP anyway.</p>
<h2 id="the-bug-the-exact-pattern">The bug (the exact pattern)</h2>
<p>This is the shape of the broken code:</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># ❌ Broken: critical work is after streaming.</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="k">async</span> <span class="k">def</span> <span class="nf">process_step</span><span class="p">():</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="k">async</span> <span class="k">for</span> <span class="n">update</span> <span class="ow">in</span> <span class="n">agent</span><span class="p">():</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">        <span class="k">yield</span> <span class="n">sse</span><span class="p">(</span><span class="n">update</span><span class="p">)</span>  <span class="c1"># disconnect can cancel/raise here</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">
</span></span><span class="line"><span class="ln">6</span><span class="cl">    <span class="c1"># If the client disconnects, this may never run.</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">    <span class="k">await</span> <span class="n">db</span><span class="o">.</span><span class="n">update_status</span><span class="p">(</span><span class="s2">&#34;tasks_done&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl">    <span class="n">asyncio</span><span class="o">.</span><span class="n">create_task</span><span class="p">(</span><span class="n">start_polling</span><span class="p">(</span><span class="n">job_id</span><span class="p">))</span>
</span></span><span class="line"><span class="ln">9</span><span class="cl">    <span class="k">await</span> <span class="n">slack</span><span class="o">.</span><span class="n">notify</span><span class="p">(</span><span class="s2">&#34;done&#34;</span><span class="p">)</span></span></span></code></pre></div><p>It feels reasonable because it matches how we think:</p>
<p>“Stream updates while running… then when the loop ends, do the completion stuff.”</p>
<p>But in a streaming response, <em>the loop ending is not guaranteed</em>. The client can just leave.</p>
<h2 id="what-actually-happens-when-the-client-disconnects">What actually happens when the client disconnects</h2>
<p>Let’s make it concrete with a timeline.</p>
<p>Normal happy path:</p>





<pre tabindex="0"><code>Browser                  FastAPI/Starlette           MongoDB / External
   |                           |                          |
   | POST /process-step        |                          |
   |--------------------------&gt;|                          |
   | 200 OK (SSE stream)       |                          |
   |&lt;--------------------------|                          |
   | data: update #1           |                          |
   |&lt;--------------------------|                          |
   | data: update #2           |                          |
   |&lt;--------------------------|                          |
   | ...                       |                          |
   | data: final               |                          |
   |&lt;--------------------------|                          |
   | stream ends               |                          |
   |                           | update status            |
   |                           |-------------------------&gt;|
   |                           | start poller             |
   |                           |-------------------------&gt;|
   |                           | Slack notify             |
   |                           |-------------------------&gt;|</code></pre><p>Disconnect path:</p>





<pre tabindex="0"><code>Browser                  FastAPI/Starlette           MongoDB / External
   |                           |                          |
   | POST /process-step        |                          |
   |--------------------------&gt;|                          |
   | 200 OK (SSE stream)       |                          |
   |&lt;--------------------------|                          |
   | data: update #1           |                          |
   |&lt;--------------------------|                          |
   | data: update #2           |                          |
   |&lt;--------------------------|                          |
   | [tab closed]              |                          |
   X                           |                          |
                               | stream task canceled     |
                               | (right on a yield/send)  |
                               |                          |
                               | ❌ status update skipped  |
                               | ❌ poller never started   |
                               | ❌ Slack never sent       |</code></pre><p>The punchline is simple: <strong>SSE streaming is not “just a loop.” It’s a loop being consumed by a response writer that is allowed to stop at any time.</strong></p>
<h2 id="under-the-hood-why-starlette-cancels-your-generator">Under the hood (why Starlette cancels your generator)</h2>
<p>This is not the exact Starlette source code, but it matches the design.</p>
<p>Streaming responses typically run two things concurrently:</p>
<ul>
<li>a task that iterates your generator and sends chunks (<code>http.response.body</code>)</li>
<li>a task that listens for <code>http.disconnect</code></li>
</ul>
<p>When the disconnect happens, the server cancels the streaming task. That cancellation can hit while the framework is trying to send your last chunk… which was produced by your generator… which means your generator gets torn down mid-flight.</p>
<p>Two practical details that matter a lot:</p>
<ol>
<li>Cancellation shows up as <code>asyncio.CancelledError</code> (or sometimes as “write failed” type errors depending on stack).</li>
<li>In modern Python, <code>asyncio.CancelledError</code> is a <code>BaseException</code>, not an <code>Exception</code>. So <code>except Exception:</code> won’t catch it.</li>
</ol>
<p>That second one is why “but I wrapped it in try/except” doesn’t save you.</p>
<h2 id="a-minimal-repro-you-can-run-in-two-minutes">A minimal repro you can run in two minutes</h2>
<p>Here’s a tiny app that demonstrates the exact failure mode. The <code>print()</code> after the loop is your “update MongoDB status” in disguise.</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kn">import</span> <span class="nn">asyncio</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="kn">from</span> <span class="nn">fastapi</span> <span class="kn">import</span> <span class="n">FastAPI</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="kn">from</span> <span class="nn">starlette.responses</span> <span class="kn">import</span> <span class="n">StreamingResponse</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="n">app</span> <span class="o">=</span> <span class="n">FastAPI</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="k">async</span> <span class="k">def</span> <span class="nf">gen</span><span class="p">():</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="k">for</span> <span class="n">i</span> <span class="ow">in</span> <span class="nb">range</span><span class="p">(</span><span class="mi">1000</span><span class="p">):</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">        <span class="k">yield</span> <span class="sa">f</span><span class="s2">&#34;data: </span><span class="si">{</span><span class="n">i</span><span class="si">}</span><span class="se">\n\n</span><span class="s2">&#34;</span><span class="o">.</span><span class="n">encode</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">        <span class="k">await</span> <span class="n">asyncio</span><span class="o">.</span><span class="n">sleep</span><span class="p">(</span><span class="mf">0.2</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="nb">print</span><span class="p">(</span><span class="s2">&#34;stream ended; now do side effects&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="nd">@app.get</span><span class="p">(</span><span class="s2">&#34;/sse&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="k">async</span> <span class="k">def</span> <span class="nf">sse</span><span class="p">():</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">    <span class="k">return</span> <span class="n">StreamingResponse</span><span class="p">(</span><span class="n">gen</span><span class="p">(),</span> <span class="n">media_type</span><span class="o">=</span><span class="s2">&#34;text/event-stream&#34;</span><span class="p">)</span></span></span></code></pre></div><p>Run it, then:</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">curl -N http://localhost:8000/sse</span></span></code></pre></div><p>Wait for a few numbers, hit <code>ctrl+c</code> in the <code>curl</code> terminal.</p>
<p>You will not see the “now do side effects” line.</p>
<p>That’s the bug.</p>
<h2 id="the-real-fix-stop-making-your-job-depend-on-the-http-stream">The real fix: stop making your job depend on the HTTP stream</h2>
<p>Once you accept “the client can vanish at any time”, the design gets boring (in a good way):</p>
<ul>
<li>The job runs to completion in a background task.</li>
<li>Streaming is just a best-effort view of progress.</li>
</ul>
<p>I like to think in terms of ownership:</p>
<ul>
<li>The worker owns side effects (DB writes, poller, Slack).</li>
<li>The request handler owns only streaming bytes to the client.</li>
</ul>
<h3 id="what-i-tried-first-and-why-it-didnt-help-backgroundtasks">What I tried first (and why it didn’t help): <code>BackgroundTasks</code></h3>
<p>FastAPI has <code>BackgroundTasks</code>. It’s useful, but it’s not a magic “disconnect-proof” button.</p>
<p>The catch is simple: background tasks are executed <em>after</em> the response is done.</p>
<p>For a normal JSON response, “response is done” means “we returned a body, the server wrote it, done.”</p>
<p>For an SSE response, “response is done” means “the stream ended.”</p>
<p>Which is… exactly the thing we can’t rely on.</p>
<p>If the client disconnects and the streaming response gets canceled, your “run this after the response” work is still hanging off the same lifecycle. It’s not the separation you actually want.</p>
<h3 id="cant-i-just-move-the-status-update-into-finally">“Can’t I just move the status update into <code>finally</code>?”</h3>
<p>This is another tempting fix:</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">async</span> <span class="k">def</span> <span class="nf">gen</span><span class="p">():</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="k">try</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">        <span class="k">async</span> <span class="k">for</span> <span class="n">update</span> <span class="ow">in</span> <span class="n">agent</span><span class="p">():</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">            <span class="k">yield</span> <span class="n">sse</span><span class="p">(</span><span class="n">update</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="k">finally</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">        <span class="k">await</span> <span class="n">db</span><span class="o">.</span><span class="n">update_status</span><span class="p">(</span><span class="s2">&#34;tasks_done&#34;</span><span class="p">)</span></span></span></code></pre></div><p>Sometimes it works. Sometimes it doesn’t. It depends on where cancellation lands.</p>
<p>Cancellation can interrupt awaits inside <code>finally</code> too. So you end up with a fun situation where you <em>thought</em> you made it reliable, but you just changed the probability of it failing.</p>
<p>If you really want to run cleanup under cancellation, you need to start thinking about shielding and cancel scopes. At that point you’re already halfway to the “worker owns completion” design anyway, so I’d rather just do it properly.</p>
<h3 id="the-queue-pattern-with-one-important-correction">The queue pattern (with one important correction)</h3>
<p>The common implementation is “worker pushes updates into a queue, streamer drains it.”</p>
<p>That’s fine, but there’s a nasty gotcha:</p>
<p>If the client disconnects and the streamer stops draining, <strong>a bounded queue can block your worker forever</strong>.</p>
<p>So don’t do this in your worker:</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">await</span> <span class="n">queue</span><span class="o">.</span><span class="n">put</span><span class="p">(</span><span class="n">update</span><span class="p">)</span>  <span class="c1"># ❌ can hang if consumer is gone</span></span></span></code></pre></div><p>If you do, you just recreated the original bug: completion is again tied to streaming, just indirectly.</p>
<p>What I do instead:</p>
<ul>
<li>Keep the queue bounded (to protect memory)</li>
<li>Put updates in a non-blocking way and drop when full</li>
<li>Stop queueing progress once the client is gone</li>
</ul>
<p>Here’s a practical sketch:</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kn">import</span> <span class="nn">asyncio</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="kn">import</span> <span class="nn">json</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="kn">import</span> <span class="nn">logging</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="n">logger</span> <span class="o">=</span> <span class="n">logging</span><span class="o">.</span><span class="n">getLogger</span><span class="p">(</span><span class="vm">__name__</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="k">def</span> <span class="nf">sse</span><span class="p">(</span><span class="n">data</span><span class="p">:</span> <span class="nb">dict</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">bytes</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="k">return</span> <span class="sa">f</span><span class="s2">&#34;data: </span><span class="si">{</span><span class="n">json</span><span class="o">.</span><span class="n">dumps</span><span class="p">(</span><span class="n">data</span><span class="p">)</span><span class="si">}</span><span class="se">\n\n</span><span class="s2">&#34;</span><span class="o">.</span><span class="n">encode</span><span class="p">(</span><span class="s2">&#34;utf-8&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="k">def</span> <span class="nf">try_put</span><span class="p">(</span><span class="n">queue</span><span class="p">:</span> <span class="n">asyncio</span><span class="o">.</span><span class="n">Queue</span><span class="p">,</span> <span class="n">item</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="kc">None</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="k">try</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">        <span class="n">queue</span><span class="o">.</span><span class="n">put_nowait</span><span class="p">(</span><span class="n">item</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="k">except</span> <span class="n">asyncio</span><span class="o">.</span><span class="n">QueueFull</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">        <span class="c1"># progress is optional; completion isn&#39;t</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">        <span class="k">pass</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">
</span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="k">def</span> <span class="nf">log_task_result</span><span class="p">(</span><span class="n">task</span><span class="p">:</span> <span class="n">asyncio</span><span class="o">.</span><span class="n">Task</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="kc">None</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">    <span class="k">try</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">        <span class="n">task</span><span class="o">.</span><span class="n">result</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">    <span class="k">except</span> <span class="n">asyncio</span><span class="o">.</span><span class="n">CancelledError</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">        <span class="k">pass</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">    <span class="k">except</span> <span class="ne">Exception</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl">        <span class="n">logger</span><span class="o">.</span><span class="n">exception</span><span class="p">(</span><span class="s2">&#34;background worker crashed&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">24</span><span class="cl">
</span></span><span class="line"><span class="ln">25</span><span class="cl"><span class="k">async</span> <span class="k">def</span> <span class="nf">run_to_completion</span><span class="p">(</span><span class="n">queue</span><span class="p">:</span> <span class="n">asyncio</span><span class="o">.</span><span class="n">Queue</span><span class="p">,</span> <span class="n">client_gone</span><span class="p">:</span> <span class="n">asyncio</span><span class="o">.</span><span class="n">Event</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">26</span><span class="cl">    <span class="n">job_id</span> <span class="o">=</span> <span class="kc">None</span>
</span></span><span class="line"><span class="ln">27</span><span class="cl">    <span class="k">try</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">28</span><span class="cl">        <span class="k">async</span> <span class="k">for</span> <span class="n">update</span> <span class="ow">in</span> <span class="n">agent</span><span class="p">():</span>
</span></span><span class="line"><span class="ln">29</span><span class="cl">            <span class="n">job_id</span> <span class="o">=</span> <span class="n">job_id</span> <span class="ow">or</span> <span class="n">update</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&#34;job_id&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">30</span><span class="cl">            <span class="k">if</span> <span class="ow">not</span> <span class="n">client_gone</span><span class="o">.</span><span class="n">is_set</span><span class="p">():</span>
</span></span><span class="line"><span class="ln">31</span><span class="cl">                <span class="n">try_put</span><span class="p">(</span><span class="n">queue</span><span class="p">,</span> <span class="n">update</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">32</span><span class="cl">
</span></span><span class="line"><span class="ln">33</span><span class="cl">        <span class="c1"># ✅ These must run even if client disconnects.</span>
</span></span><span class="line"><span class="ln">34</span><span class="cl">        <span class="k">await</span> <span class="n">db</span><span class="o">.</span><span class="n">update_status</span><span class="p">(</span><span class="s2">&#34;tasks_done&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">35</span><span class="cl">        <span class="n">asyncio</span><span class="o">.</span><span class="n">create_task</span><span class="p">(</span><span class="n">start_polling</span><span class="p">(</span><span class="n">job_id</span><span class="p">))</span>
</span></span><span class="line"><span class="ln">36</span><span class="cl">        <span class="k">await</span> <span class="n">slack</span><span class="o">.</span><span class="n">notify</span><span class="p">(</span><span class="s2">&#34;done&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">37</span><span class="cl">    <span class="k">except</span> <span class="ne">Exception</span> <span class="k">as</span> <span class="n">e</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">38</span><span class="cl">        <span class="k">if</span> <span class="ow">not</span> <span class="n">client_gone</span><span class="o">.</span><span class="n">is_set</span><span class="p">():</span>
</span></span><span class="line"><span class="ln">39</span><span class="cl">            <span class="n">try_put</span><span class="p">(</span><span class="n">queue</span><span class="p">,</span> <span class="p">{</span><span class="s2">&#34;final&#34;</span><span class="p">:</span> <span class="kc">True</span><span class="p">,</span> <span class="s2">&#34;success&#34;</span><span class="p">:</span> <span class="kc">False</span><span class="p">,</span> <span class="s2">&#34;error&#34;</span><span class="p">:</span> <span class="nb">str</span><span class="p">(</span><span class="n">e</span><span class="p">)})</span>
</span></span><span class="line"><span class="ln">40</span><span class="cl">        <span class="k">raise</span>
</span></span><span class="line"><span class="ln">41</span><span class="cl">    <span class="k">finally</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">42</span><span class="cl">        <span class="k">if</span> <span class="ow">not</span> <span class="n">client_gone</span><span class="o">.</span><span class="n">is_set</span><span class="p">():</span>
</span></span><span class="line"><span class="ln">43</span><span class="cl">            <span class="n">try_put</span><span class="p">(</span><span class="n">queue</span><span class="p">,</span> <span class="kc">None</span><span class="p">)</span>  <span class="c1"># sentinel for streamer</span>
</span></span><span class="line"><span class="ln">44</span><span class="cl">
</span></span><span class="line"><span class="ln">45</span><span class="cl"><span class="k">async</span> <span class="k">def</span> <span class="nf">stream</span><span class="p">(</span><span class="n">queue</span><span class="p">:</span> <span class="n">asyncio</span><span class="o">.</span><span class="n">Queue</span><span class="p">,</span> <span class="n">client_gone</span><span class="p">:</span> <span class="n">asyncio</span><span class="o">.</span><span class="n">Event</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">46</span><span class="cl">    <span class="k">try</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">47</span><span class="cl">        <span class="k">while</span> <span class="kc">True</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">48</span><span class="cl">            <span class="n">item</span> <span class="o">=</span> <span class="k">await</span> <span class="n">queue</span><span class="o">.</span><span class="n">get</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">49</span><span class="cl">            <span class="k">if</span> <span class="n">item</span> <span class="ow">is</span> <span class="kc">None</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">50</span><span class="cl">                <span class="k">break</span>
</span></span><span class="line"><span class="ln">51</span><span class="cl">            <span class="k">yield</span> <span class="n">sse</span><span class="p">(</span><span class="n">item</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">52</span><span class="cl">    <span class="k">except</span> <span class="n">asyncio</span><span class="o">.</span><span class="n">CancelledError</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">53</span><span class="cl">        <span class="c1"># disconnected: stop streaming, signal worker to stop enqueueing</span>
</span></span><span class="line"><span class="ln">54</span><span class="cl">        <span class="n">client_gone</span><span class="o">.</span><span class="n">set</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">55</span><span class="cl">        <span class="k">raise</span>
</span></span><span class="line"><span class="ln">56</span><span class="cl">    <span class="k">finally</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">57</span><span class="cl">        <span class="n">client_gone</span><span class="o">.</span><span class="n">set</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">58</span><span class="cl">
</span></span><span class="line"><span class="ln">59</span><span class="cl"><span class="k">async</span> <span class="k">def</span> <span class="nf">handler</span><span class="p">():</span>
</span></span><span class="line"><span class="ln">60</span><span class="cl">    <span class="n">queue</span> <span class="o">=</span> <span class="n">asyncio</span><span class="o">.</span><span class="n">Queue</span><span class="p">(</span><span class="n">maxsize</span><span class="o">=</span><span class="mi">200</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">61</span><span class="cl">    <span class="n">client_gone</span> <span class="o">=</span> <span class="n">asyncio</span><span class="o">.</span><span class="n">Event</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">62</span><span class="cl">
</span></span><span class="line"><span class="ln">63</span><span class="cl">    <span class="n">task</span> <span class="o">=</span> <span class="n">asyncio</span><span class="o">.</span><span class="n">create_task</span><span class="p">(</span><span class="n">run_to_completion</span><span class="p">(</span><span class="n">queue</span><span class="p">,</span> <span class="n">client_gone</span><span class="p">))</span>
</span></span><span class="line"><span class="ln">64</span><span class="cl">    <span class="n">task</span><span class="o">.</span><span class="n">add_done_callback</span><span class="p">(</span><span class="n">log_task_result</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">65</span><span class="cl">
</span></span><span class="line"><span class="ln">66</span><span class="cl">    <span class="k">return</span> <span class="n">StreamingResponse</span><span class="p">(</span><span class="n">stream</span><span class="p">(</span><span class="n">queue</span><span class="p">,</span> <span class="n">client_gone</span><span class="p">),</span> <span class="n">media_type</span><span class="o">=</span><span class="s2">&#34;text/event-stream&#34;</span><span class="p">)</span></span></span></code></pre></div><p>Is it perfect? No. But it has the right shape:</p>
<ul>
<li>worker can finish without a consumer</li>
<li>progress streaming is best-effort</li>
<li>failures are logged instead of silently disappearing</li>
</ul>
<p>If you want something more durable (client reconnects and can replay progress), you need to store progress somewhere real (Redis/pubsub, DB, etc.). In-memory queues won’t do that. But that’s a different problem.</p>
<h2 id="the-architecture-i-like-better-when-this-gets-serious">The architecture I like better (when this gets serious)</h2>
<p>The queue pattern is fine when:</p>
<ul>
<li>you mostly care about “don’t lose completion when the client disconnects”</li>
<li>you don’t care about reconnecting and replaying a progress history</li>
</ul>
<p>But once you ship this to real users, you’ll hit two common problems:</p>
<ol>
<li>Browsers reconnect SSE automatically (especially <code>EventSource</code>), and you don’t want a reconnect to re-run the job.</li>
<li>People refresh and expect to see progress again (or at least not lose the final result).</li>
</ol>
<p>So the more robust shape is:</p>
<ol>
<li><code>POST /steps/{step}/start</code> → returns a <code>run_id</code></li>
<li>Worker runs independent of any SSE connection and persists state (status + maybe progress)</li>
<li><code>GET /runs/{run_id}/events</code> → SSE stream that is purely “subscribe”, not “start”</li>
</ol>
<p>Now reconnecting is safe. You might lose some live updates, but you can always read the current status from MongoDB, and you can keep polling running even if nobody is watching.</p>
<p>If you want a really clean UX, you can also persist event ids and support <code>Last-Event-ID</code>, but that’s optional. The big win is separating “start work” from “watch work.”</p>
<h2 id="sse-details-that-are-boring-but-matter">SSE details that are boring but matter</h2>
<p>SSE is “simple” in the same way DNS is “simple.”</p>
<p>It works great until you put it behind real infrastructure.</p>
<h3 id="event-formatting-is-strict">Event formatting is strict</h3>
<p>Each event ends with a blank line. If you forget the blank line, the browser buffers and you’ll think streaming is broken.</p>
<p>Simplest possible event:</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">data: {&#34;message&#34;:&#34;hi&#34;}</span></span></code></pre></div><p>(Yes, that extra newline is part of it.)</p>
<h3 id="keepalives">Keepalives</h3>
<p>If your worker can go quiet for a while, send a comment every ~15–30 seconds so proxies don’t decide the connection is “idle”:</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">yield</span> <span class="sa">b</span><span class="s2">&#34;: keep-alive</span><span class="se">\n\n</span><span class="s2">&#34;</span></span></span></code></pre></div><h3 id="nginx-buffering">Nginx buffering</h3>
<p>If your stream arrives in one big chunk at the end, it’s usually Nginx buffering.</p>
<p>You don’t fix buffering with Python. You fix it with config.</p>
<p>At minimum, you want buffering off for the SSE location and timeouts that won’t murder long streams.</p>
<p>Something like this (not gospel, but you get the idea):</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-nginx" data-lang="nginx"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">location</span> <span class="s">/runs/</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="kn">proxy_http_version</span> <span class="mi">1</span><span class="s">.1</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="kn">proxy_set_header</span> <span class="s">Connection</span> <span class="s">&#34;&#34;</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">  <span class="kn">proxy_buffering</span> <span class="no">off</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">  <span class="kn">proxy_cache</span> <span class="no">off</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">  <span class="kn">proxy_read_timeout</span> <span class="s">3600s</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">  <span class="kn">add_header</span> <span class="s">X-Accel-Buffering</span> <span class="s">no</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><h2 id="the-other-gotchas-the-stuff-that-actually-bites-later">The other gotchas (the stuff that actually bites later)</h2>
<h3 id="dont-use-request-scoped-dependencies-in-your-background-worker">Don’t use request-scoped dependencies in your background worker</h3>
<p>If you have <code>Depends(get_db)</code> where <code>get_db</code> is a <code>yield</code> dependency, it gets cleaned up when the request is done.</p>
<p>If your worker is still running, it may now be holding a dead session/client.</p>
<p>Solution: the worker should acquire its own long-lived clients (Mongo, HTTP client, etc.) that are not tied to the request lifecycle.</p>
<h3 id="treat-status-updates-like-a-state-machine">Treat status updates like a state machine</h3>
<p>If you do <code>timeline_done</code> → <code>tasks_done</code> → <code>final_done</code>, don’t just write <code>tasks_done</code> unconditionally.</p>
<p>Do “only update if current status is timeline_done” (compare-and-set). Otherwise retries and races will eventually mess up your state.</p>
<h3 id="dont-confuse-survives-client-disconnect-with-durable">Don’t confuse “survives client disconnect” with “durable”</h3>
<p>This pattern survives a client disconnect. It does not survive:</p>
<ul>
<li>the process dying</li>
<li>the pod being evicted</li>
<li>a deploy that SIGTERMs the worker mid-step</li>
</ul>
<p>If the job truly must finish no matter what, use a real job system. SSE should subscribe to a job id, not be the job.</p>
<h3 id="decide-if-disconnect-should-cancel-the-job-explicitly">Decide if disconnect should cancel the job (explicitly)</h3>
<p>Some people will ask for “closing the tab cancels the job.”</p>
<p>That’s a separate feature. Don’t accidentally implement it via “oops, the client disconnected so the worker got canceled.”</p>
<p>If you want cancellation, do it explicitly:</p>
<ul>
<li>create a job id</li>
<li>store cancel intent</li>
<li>have the worker check it and stop cleanly</li>
</ul>
<p>Client disconnect is not a reliable cancel signal. It’s just the network being the network.</p>
<h2 id="a-boring-checklist-the-stuff-i-verify-before-i-trust-the-fix">A boring checklist (the stuff I verify before I trust the fix)</h2>
<ul>
<li>Start a step, then close the tab mid-stream → status still advances later</li>
<li>Start a step, refresh rapidly → no duplicate runs / no broken state transitions</li>
<li>Simulate a proxy timeout → completion still happens</li>
<li>Kill the SSE client (<code>curl -N ...</code> then <code>ctrl+c</code>) → completion still happens</li>
<li>Worker crashes mid-way → status reflects failure, and it doesn’t get stuck “in progress” forever</li>
</ul>
<h2 id="performance-notes-because-someone-will-ask">Performance notes (because someone will ask)</h2>
<p>The queue approach is usually cheap, but it’s not free.</p>
<ul>
<li>Memory: <code>queue_size * avg_event_size</code> per in-flight stream. Keep event payloads small.</li>
<li>CPU: queue ops are basically noise; the expensive bits are your DB/network calls.</li>
<li>DB load: if you add read-after-write verification (I sometimes do), it’s extra reads. Worth it if your system lies to you otherwise.</li>
</ul>
<p>The bigger risk isn’t CPU. It’s “we launched 500 background tasks because 500 people clicked the button.” Put a cap somewhere.</p>
<h2 id="deployment-strategy-how-i-roll-it-out-without-getting-yelled-at">Deployment strategy (how I roll it out without getting yelled at)</h2>
<ol>
<li>Staging: disconnect tests, refresh tests, and “kill the client” tests.</li>
<li>Canary: small percentage of traffic. Watch for stuck statuses.</li>
<li>Full rollout: watch completion rate for a day. If it drops, roll back fast.</li>
</ol>
<h2 id="how-i-tested-it-and-how-id-recommend-you-test-it">How I tested it (and how I’d recommend you test it)</h2>
<p>Nothing fancy. I just did the thing that used to break it:</p>
<ul>
<li>start the step</li>
<li>wait for a couple SSE updates</li>
<li>close the tab / refresh / kill the connection</li>
<li>watch MongoDB and server logs</li>
</ul>
<p>What I wanted to see is boring:</p>
<ul>
<li>status advances anyway</li>
<li>poller starts anyway</li>
<li>Slack fires anyway</li>
</ul>
<p>Once you watch the system finish without a client attached, you stop trusting “after the stream” code forever.</p>
<p>And honestly you should. It’s a trap.</p>
]]></content:encoded>
    </item>
    <item>
      <title>End-to-End HTTPS with Cloudflare Origin Certificates and Nginx</title>
      <link>https://KTS-o7.github.io/bear/infra/reverse_proxy/</link>
      <pubDate>Sat, 27 Dec 2025 00:00:00 +0530</pubDate><author>shentharkrishnatejaswi@gmail.com (Krishnatejaswi S)</author>
      <guid>https://KTS-o7.github.io/bear/infra/reverse_proxy/</guid>
      <description>&lt;p&gt;Setting up proper SSL/TLS for your web application can be confusing, especially when Cloudflare is in the picture. This guide walks through configuring end-to-end encryption from browser to origin server using Cloudflare Origin Certificates with an Nginx reverse proxy running in Docker.&lt;/p&gt;&#xA;&lt;hr&gt;&#xA;&lt;h3 id=&#34;the-problem-cloudflare-521-errors&#34;&gt;The Problem: Cloudflare 521 Errors&lt;/h3&gt;&#xA;&lt;p&gt;If you&amp;rsquo;re running Nginx behind Cloudflare and seeing &lt;strong&gt;521 errors&lt;/strong&gt;, you likely have a mismatch between your Cloudflare SSL mode and what your origin server supports.&lt;/p&gt;</description>
      <content:encoded><![CDATA[<p>Setting up proper SSL/TLS for your web application can be confusing, especially when Cloudflare is in the picture. This guide walks through configuring end-to-end encryption from browser to origin server using Cloudflare Origin Certificates with an Nginx reverse proxy running in Docker.</p>
<hr>
<h3 id="the-problem-cloudflare-521-errors">The Problem: Cloudflare 521 Errors</h3>
<p>If you&rsquo;re running Nginx behind Cloudflare and seeing <strong>521 errors</strong>, you likely have a mismatch between your Cloudflare SSL mode and what your origin server supports.</p>
<p><strong>Common scenario:</strong></p>
<ul>
<li>Nginx is only listening on port 80 (HTTP)</li>
<li>Cloudflare SSL mode is set to &ldquo;Full&rdquo; or &ldquo;Full (Strict)&rdquo;</li>
<li>Cloudflare tries to connect to your origin on port 443</li>
<li>Origin doesn&rsquo;t respond → <strong>521 Error</strong></li>
</ul>
<p><a href="https://mermaid.live/edit#pako:eNplkE1qwzAQha9izNqOZUm2ZAdCnEJ3XXVVvBjLk1jEP0GSQ0Lw3avYpIvOaoZ5H_N4C8i2IshgaPlQ-5x2bum6i-_PvBp4NfmUjWaINofejGeySrXlpZnlZJy8zFfh6G3P_U3-YkCMt_JNJbzTZCz3r-bLdkZOlRQ8Dab3_JYvq9LJ67gHb0fHf7x3Xo9yPAz8ZthZKe8G7ozrLO_F5zd4M8xBBLJiO9qBMvDbsqaEgvYtFKAXr4MSattz1tBh0n4DJYxdw5mz2hicMujV-OfCEzIn-wZS0qTzFDLXrTfOlWdIf6D4AQP0bwI"><img src="https://mermaid.ink/img/pako:eNplkE1qwzAQha9izNqOZUm2ZAdCnEJ3XXVVvBjLk1jEP0GSQ0Lw3avYpIvOaoZ5H_N4C8i2IshgaPlQ-5x2bum6i-_PvBp4NfmUjWaINofejGeySrXlpZnlZJy8zFfh6G3P_U3-YkCMt_JNJbzTZCz3r-bLdkZOlRQ8Dab3_JYvq9LJ67gHb0fHf7x3Xo9yPAz8ZthZKe8G7ozrLO_F5zd4M8xBBLJiO9qBMvDbsqaEgvYtFKAXr4MSattz1tBh0n4DJYxdw5mz2hicMujV-OfCEzIn-wZS0qTzFDLXrTfOlWdIf6D4AQP0bwI?type=png" alt=""></a></p>
<hr>
<h3 id="understanding-cloudflare-ssltls-modes">Understanding Cloudflare SSL/TLS Modes</h3>
<p>Cloudflare offers four SSL modes. Understanding these is crucial:</p>
<table>
  <thead>
      <tr>
          <th>Mode</th>
          <th>Browser → Cloudflare</th>
          <th>Cloudflare → Origin</th>
          <th>Certificate Validation</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><strong>Off</strong></td>
          <td>HTTP</td>
          <td>HTTP</td>
          <td>None</td>
      </tr>
      <tr>
          <td><strong>Flexible</strong></td>
          <td>HTTPS ✅</td>
          <td>HTTP</td>
          <td>None</td>
      </tr>
      <tr>
          <td><strong>Full</strong></td>
          <td>HTTPS ✅</td>
          <td>HTTPS ✅</td>
          <td>Accepts any certificate</td>
      </tr>
      <tr>
          <td><strong>Full (Strict)</strong></td>
          <td>HTTPS ✅</td>
          <td>HTTPS ✅</td>
          <td>Validates certificate ✅</td>
      </tr>
  </tbody>
</table>
<p><strong>Key differences:</strong></p>
<ul>
<li>
<p><strong>Flexible</strong>: Quick fix if your origin can&rsquo;t do HTTPS, but traffic between Cloudflare and your server is unencrypted — vulnerable to MITM attacks on that leg.</p>
</li>
<li>
<p><strong>Full</strong>: Encrypts both legs, but accepts <em>any</em> certificate (even self-signed, expired, or attacker-provided). Provides encryption but not authentication.</p>
</li>
<li>
<p><strong>Full (Strict)</strong>: The gold standard. Cloudflare validates that your origin certificate is either:</p>
<ul>
<li>A valid public CA certificate</li>
<li>A Cloudflare Origin Certificate</li>
</ul>
<p>This prevents MITM attacks where an attacker presents a fake certificate.</p>
</li>
</ul>
<hr>
<h3 id="architecture-overview">Architecture Overview</h3>
<p>Our target architecture:</p>





<pre tabindex="0"><code>┌──────────────────────────────────────────────────────────────────────┐
│                          INTERNET                                    │
└──────────────────────────┬───────────────────────────────────────────┘
                           │ HTTPS (443)
                           ▼
┌──────────────────────────────────────────────────────────────────────┐
│                     CLOUDFLARE EDGE                                  │
│              (SSL Termination + CDN + WAF)                           │
└──────────────────────────┬───────────────────────────────────────────┘
                           │ HTTPS (443) - Origin Certificate
                           ▼
┌──────────────────────────────────────────────────────────────────────┐
│                    YOUR EC2 INSTANCE                                 │
│  ┌─────────────────────────────────────────────────────────────────┐ │
│  │                    DOCKER NETWORK                               │ │
│  │                                                                 │ │
│  │   ┌─────────────┐    HTTP    ┌─────────────────────────────┐    │ │
│  │   │   NGINX     │───────────▶│     APPLICATION CONTAINERS  │    │ │
│  │   │  (SSL/443)  │            │  - Frontend (Next.js:3000)  │    │ │
│  │   │  (80→443)   │            │  - Backend (FastAPI:8000)   │    │ │
│  │   └─────────────┘            │  - Cloud APIs               │    │ │
│  │         ▲                    └─────────────────────────────┘    │ │
│  │         │                                                       │ │
│  └─────────┼───────────────────────────────────────────────────────┘ │
│            │                                                         │
│     Ports 80, 443                                                    │
└──────────────────────────────────────────────────────────────────────┘</code></pre><p><strong>Traffic flow:</strong></p>
<ol>
<li>Browser connects to Cloudflare over HTTPS (public SSL certificate)</li>
<li>Cloudflare connects to your origin over HTTPS (Origin Certificate)</li>
<li>Nginx terminates SSL and proxies to backend containers over HTTP</li>
<li>Backend containers communicate on internal Docker network (isolated)</li>
</ol>
<hr>
<h3 id="prerequisites">Prerequisites</h3>
<p>Before starting, ensure you have:</p>
<ul>
<li>A domain proxied through Cloudflare (orange cloud enabled)</li>
<li>Docker and Docker Compose installed on your server</li>
<li>An existing Nginx configuration (we&rsquo;ll modify it)</li>
<li>Access to Cloudflare dashboard</li>
<li><strong>Firewall Rules</strong>: Ensure ports 80 and 443 are open in your cloud provider&rsquo;s firewall (AWS Security Group, etc.)</li>
</ul>
<hr>
<h3 id="step-1-generate-a-cloudflare-origin-certificate">Step 1: Generate a Cloudflare Origin Certificate</h3>
<p>Cloudflare Origin Certificates are free, trusted only by Cloudflare, and can be valid for up to 15 years.</p>
<ol>
<li>Log into <a href="https://dash.cloudflare.com">Cloudflare Dashboard</a></li>
<li>Select your domain</li>
<li>Navigate to <strong>SSL/TLS</strong> → <strong>Origin Server</strong></li>
<li>Click <strong>Create Certificate</strong></li>
<li>Configure:
<ul>
<li><strong>Private key type</strong>: RSA (2048)</li>
<li><strong>Hostnames</strong>: <code>*.yourdomain.com, yourdomain.com</code></li>
<li><strong>Validity</strong>: 15 years (recommended)</li>
</ul>
</li>
<li>Click <strong>Create</strong></li>
</ol>
<p><strong>⚠️ IMPORTANT</strong>: Copy both the <strong>Origin Certificate</strong> (PEM) and <strong>Private Key</strong> immediately. The private key is shown only once!</p>
<p>Save them to files:</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1"># Create SSL directory</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">mkdir -p ssl
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="c1"># Paste the certificate</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">cat &gt; ssl/origin.pem <span class="s">&lt;&lt; &#39;EOF&#39;
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="s">-----BEGIN CERTIFICATE-----
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="s">YOUR_CERTIFICATE_HERE
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="s">-----END CERTIFICATE-----
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="s">EOF</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="c1"># Paste the private key</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">cat &gt; ssl/origin.key <span class="s">&lt;&lt; &#39;EOF&#39;
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="s">-----BEGIN PRIVATE KEY-----
</span></span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="s">YOUR_PRIVATE_KEY_HERE
</span></span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="s">-----END PRIVATE KEY-----
</span></span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="s">EOF</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">
</span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="c1"># Set secure permissions</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">chmod <span class="m">600</span> ssl/origin.key
</span></span><span class="line"><span class="ln">20</span><span class="cl">chmod <span class="m">644</span> ssl/origin.pem</span></span></code></pre></div><p>Add to <code>.gitignore</code> to never commit these:</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="nb">echo</span> <span class="s2">&#34;ssl/*&#34;</span> &gt;&gt; .gitignore</span></span></code></pre></div><hr>
<h3 id="step-2-configure-nginx-for-https">Step 2: Configure Nginx for HTTPS</h3>
<p>Here&rsquo;s a complete Nginx configuration with SSL, security headers, rate limiting, and reverse proxy setup.</p>
<p><strong><code>nginx/nginx-main.conf</code></strong> - Main HTTP block configuration:</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-nginx" data-lang="nginx"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1"># Main nginx configuration with security settings
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="k">user</span> <span class="s">nginx</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="k">worker_processes</span> <span class="s">auto</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="k">error_log</span> <span class="s">/var/log/nginx/error.log</span> <span class="s">warn</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="k">pid</span> <span class="s">/var/run/nginx.pid</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="k">events</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="kn">worker_connections</span> <span class="mi">1024</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="kn">use</span> <span class="s">epoll</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="kn">multi_accept</span> <span class="no">on</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="k">http</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">    <span class="kn">include</span> <span class="s">/etc/nginx/mime.types</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">    <span class="kn">default_type</span> <span class="s">application/octet-stream</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">
</span></span><span class="line"><span class="ln">17</span><span class="cl">    <span class="c1"># Logging format with security information
</span></span></span><span class="line"><span class="ln">18</span><span class="cl">    <span class="kn">log_format</span> <span class="s">security</span> <span class="s">&#39;</span><span class="nv">$real_ip</span> <span class="s">-</span> <span class="nv">$remote_user</span> <span class="s">[</span><span class="nv">$time_local]</span> <span class="s">&#34;</span><span class="nv">$request&#34;</span> <span class="s">&#39;</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">                       <span class="s">&#39;</span><span class="nv">$status</span> <span class="nv">$body_bytes_sent</span> <span class="s">&#34;</span><span class="nv">$http_referer&#34;</span> <span class="s">&#39;</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">                       <span class="s">&#39;&#34;</span><span class="nv">$http_user_agent&#34;</span> <span class="s">&#34;</span><span class="nv">$http_x_forwarded_for&#34;</span> <span class="s">&#39;</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">                       <span class="s">&#39;rt=</span><span class="nv">$request_time</span> <span class="s">uct=&#34;</span><span class="nv">$upstream_connect_time&#34;</span> <span class="s">&#39;</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">                       <span class="s">&#39;uht=&#34;</span><span class="nv">$upstream_header_time&#34;</span> <span class="s">urt=&#34;</span><span class="nv">$upstream_response_time&#34;&#39;</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl">
</span></span><span class="line"><span class="ln">24</span><span class="cl">    <span class="kn">access_log</span> <span class="s">/var/log/nginx/access.log</span> <span class="s">security</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">25</span><span class="cl">
</span></span><span class="line"><span class="ln">26</span><span class="cl">    <span class="c1"># Pro Tip: use $real_ip in log_format so you see actual visitor IPs, not Cloudflare&#39;s.
</span></span></span><span class="line"><span class="ln">27</span><span class="cl">
</span></span><span class="line"><span class="ln">28</span><span class="cl">    <span class="c1"># Basic settings
</span></span></span><span class="line"><span class="ln">29</span><span class="cl">    <span class="kn">sendfile</span> <span class="no">on</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">30</span><span class="cl">    <span class="kn">tcp_nopush</span> <span class="no">on</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">31</span><span class="cl">    <span class="kn">tcp_nodelay</span> <span class="no">on</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">32</span><span class="cl">    <span class="kn">keepalive_timeout</span> <span class="mi">65</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">33</span><span class="cl">    <span class="kn">types_hash_max_size</span> <span class="mi">2048</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">34</span><span class="cl">    <span class="kn">server_tokens</span> <span class="no">off</span><span class="p">;</span>  <span class="c1"># Hide nginx version
</span></span></span><span class="line"><span class="ln">35</span><span class="cl">
</span></span><span class="line"><span class="ln">36</span><span class="cl">    <span class="c1"># Gzip compression
</span></span></span><span class="line"><span class="ln">37</span><span class="cl">    <span class="kn">gzip</span> <span class="no">on</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">38</span><span class="cl">    <span class="kn">gzip_vary</span> <span class="no">on</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">39</span><span class="cl">    <span class="kn">gzip_proxied</span> <span class="s">any</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">40</span><span class="cl">    <span class="kn">gzip_comp_level</span> <span class="mi">6</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">41</span><span class="cl">    <span class="kn">gzip_types</span> <span class="s">text/plain</span> <span class="s">text/css</span> <span class="s">text/xml</span> <span class="s">text/javascript</span> 
</span></span><span class="line"><span class="ln">42</span><span class="cl">               <span class="s">application/json</span> <span class="s">application/javascript</span> <span class="s">application/xml+rss</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">43</span><span class="cl">
</span></span><span class="line"><span class="ln">44</span><span class="cl">    <span class="c1"># Rate limiting zones
</span></span></span><span class="line"><span class="ln">45</span><span class="cl">    <span class="kn">limit_req_zone</span> <span class="nv">$binary_remote_addr</span> <span class="s">zone=general_limit:10m</span> <span class="s">rate=50r/s</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">46</span><span class="cl">    <span class="kn">limit_req_zone</span> <span class="nv">$binary_remote_addr</span> <span class="s">zone=api_limit:10m</span> <span class="s">rate=30r/s</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">47</span><span class="cl">    <span class="kn">limit_req_zone</span> <span class="nv">$binary_remote_addr</span> <span class="s">zone=burst_limit:10m</span> <span class="s">rate=200r/m</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">48</span><span class="cl">    <span class="kn">limit_conn_zone</span> <span class="nv">$binary_remote_addr</span> <span class="s">zone=conn_limit:10m</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">49</span><span class="cl">
</span></span><span class="line"><span class="ln">50</span><span class="cl">    <span class="kn">include</span> <span class="s">/etc/nginx/conf.d/*.conf</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">51</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><strong><code>nginx/nginx.conf</code></strong> - Server block configuration with SSL:</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-nginx" data-lang="nginx"><span class="line"><span class="ln">  1</span><span class="cl"><span class="c1"># Cloudflare IP handling - extract real client IP
</span></span></span><span class="line"><span class="ln">  2</span><span class="cl"><span class="k">map</span> <span class="nv">$http_cf_connecting_ip</span> <span class="nv">$real_ip</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">  3</span><span class="cl">    <span class="kn">&#34;&#34;</span> <span class="nv">$remote_addr</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">  4</span><span class="cl">    <span class="kn">default</span> <span class="nv">$http_cf_connecting_ip</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">  5</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln">  6</span><span class="cl">
</span></span><span class="line"><span class="ln">  7</span><span class="cl"><span class="c1"># Cloudflare protocol handling
</span></span></span><span class="line"><span class="ln">  8</span><span class="cl"><span class="k">map</span> <span class="nv">$http_x_forwarded_proto</span> <span class="nv">$forwarded_proto</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">  9</span><span class="cl">    <span class="kn">&#34;&#34;</span> <span class="nv">$scheme</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 10</span><span class="cl">    <span class="kn">default</span> <span class="nv">$http_x_forwarded_proto</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 11</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 12</span><span class="cl">
</span></span><span class="line"><span class="ln"> 13</span><span class="cl"><span class="c1"># HTTP Server (Port 80) - Redirect to HTTPS
</span></span></span><span class="line"><span class="ln"> 14</span><span class="cl"><span class="k">server</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 15</span><span class="cl">    <span class="kn">listen</span> <span class="mi">80</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 16</span><span class="cl">    <span class="kn">server_name</span> <span class="s">_</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 17</span><span class="cl">
</span></span><span class="line"><span class="ln"> 18</span><span class="cl">    <span class="c1"># Health check (keep on port 80 for internal monitoring)
</span></span></span><span class="line"><span class="ln"> 19</span><span class="cl">    <span class="kn">location</span> <span class="s">/health</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 20</span><span class="cl">        <span class="kn">access_log</span> <span class="no">off</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 21</span><span class="cl">        <span class="kn">return</span> <span class="mi">200</span> <span class="s">&#34;healthy\n&#34;</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 22</span><span class="cl">        <span class="kn">add_header</span> <span class="s">Content-Type</span> <span class="s">text/plain</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 23</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln"> 24</span><span class="cl">
</span></span><span class="line"><span class="ln"> 25</span><span class="cl">    <span class="c1"># Redirect all other traffic to HTTPS
</span></span></span><span class="line"><span class="ln"> 26</span><span class="cl">    <span class="kn">location</span> <span class="s">/</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 27</span><span class="cl">        <span class="kn">return</span> <span class="mi">301</span> <span class="s">https://</span><span class="nv">$host$request_uri</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 28</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln"> 29</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 30</span><span class="cl">
</span></span><span class="line"><span class="ln"> 31</span><span class="cl"><span class="c1"># HTTPS Server (Port 443)
</span></span></span><span class="line"><span class="ln"> 32</span><span class="cl"><span class="k">server</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 33</span><span class="cl">    <span class="kn">listen</span> <span class="mi">443</span> <span class="s">ssl</span> <span class="s">http2</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 34</span><span class="cl">    <span class="kn">server_name</span> <span class="s">_</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 35</span><span class="cl">
</span></span><span class="line"><span class="ln"> 36</span><span class="cl">    <span class="c1"># SSL Configuration - Cloudflare Origin Certificate
</span></span></span><span class="line"><span class="ln"> 37</span><span class="cl">    <span class="kn">ssl_certificate</span> <span class="s">/etc/nginx/ssl/origin.pem</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 38</span><span class="cl">    <span class="kn">ssl_certificate_key</span> <span class="s">/etc/nginx/ssl/origin.key</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 39</span><span class="cl">    
</span></span><span class="line"><span class="ln"> 40</span><span class="cl">    <span class="c1"># Modern SSL settings
</span></span></span><span class="line"><span class="ln"> 41</span><span class="cl">    <span class="kn">ssl_protocols</span> <span class="s">TLSv1.2</span> <span class="s">TLSv1.3</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 42</span><span class="cl">    <span class="kn">ssl_ciphers</span> <span class="s">ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 43</span><span class="cl">    <span class="kn">ssl_prefer_server_ciphers</span> <span class="no">off</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 44</span><span class="cl">    <span class="kn">ssl_session_timeout</span> <span class="s">1d</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 45</span><span class="cl">    <span class="kn">ssl_session_cache</span> <span class="s">shared:SSL:10m</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 46</span><span class="cl">    <span class="kn">ssl_session_tickets</span> <span class="no">off</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 47</span><span class="cl">
</span></span><span class="line"><span class="ln"> 48</span><span class="cl">    <span class="c1"># Connection and request limits
</span></span></span><span class="line"><span class="ln"> 49</span><span class="cl">    <span class="kn">limit_conn</span> <span class="s">conn_limit</span> <span class="mi">100</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 50</span><span class="cl">    <span class="kn">client_max_body_size</span> <span class="s">50M</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 51</span><span class="cl">    <span class="kn">client_body_buffer_size</span> <span class="s">4M</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 52</span><span class="cl">
</span></span><span class="line"><span class="ln"> 53</span><span class="cl">    <span class="c1"># Timeout limits (prevent slowloris attacks)
</span></span></span><span class="line"><span class="ln"> 54</span><span class="cl">    <span class="kn">client_body_timeout</span> <span class="s">10s</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 55</span><span class="cl">    <span class="kn">client_header_timeout</span> <span class="s">10s</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 56</span><span class="cl">    <span class="kn">send_timeout</span> <span class="s">10s</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 57</span><span class="cl">
</span></span><span class="line"><span class="ln"> 58</span><span class="cl">    <span class="c1"># Security headers
</span></span></span><span class="line"><span class="ln"> 59</span><span class="cl">    <span class="kn">add_header</span> <span class="s">X-Frame-Options</span> <span class="s">&#34;SAMEORIGIN&#34;</span> <span class="s">always</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 60</span><span class="cl">    <span class="kn">add_header</span> <span class="s">X-Content-Type-Options</span> <span class="s">&#34;nosniff&#34;</span> <span class="s">always</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 61</span><span class="cl">    <span class="kn">add_header</span> <span class="s">X-XSS-Protection</span> <span class="s">&#34;1</span><span class="p">;</span> <span class="kn">mode=block&#34;</span> <span class="s">always</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 62</span><span class="cl">    <span class="kn">add_header</span> <span class="s">Referrer-Policy</span> <span class="s">&#34;strict-origin-when-cross-origin&#34;</span> <span class="s">always</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 63</span><span class="cl">    <span class="kn">add_header</span> <span class="s">Permissions-Policy</span> <span class="s">&#34;geolocation=(),</span> <span class="s">microphone=(),</span> <span class="s">camera=()&#34;</span> <span class="s">always</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 64</span><span class="cl">    <span class="kn">add_header</span> <span class="s">Content-Security-Policy</span> <span class="s">&#34;default-src</span> <span class="s">&#39;self&#39;</span><span class="p">;</span> <span class="kn">script-src</span> <span class="s">&#39;self&#39;</span> <span class="s">&#39;unsafe-inline&#39;</span> <span class="s">&#39;unsafe-eval&#39;</span><span class="p">;</span> <span class="kn">style-src</span> <span class="s">&#39;self&#39;</span> <span class="s">&#39;unsafe-inline&#39;</span><span class="p">;</span> <span class="kn">img-src</span> <span class="s">&#39;self&#39;</span> <span class="s">data:</span> <span class="s">https:</span><span class="p">;</span> <span class="kn">font-src</span> <span class="s">&#39;self&#39;</span> <span class="s">data:</span><span class="p">;</span> <span class="kn">connect-src</span> <span class="s">&#39;self&#39;</span> <span class="s">https:</span><span class="p">;</span> <span class="kn">frame-src</span> <span class="s">&#39;self&#39;</span><span class="p">;</span><span class="kn">&#34;</span> <span class="s">always</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 65</span><span class="cl">
</span></span><span class="line"><span class="ln"> 66</span><span class="cl">    <span class="c1"># Frontend - Next.js
</span></span></span><span class="line"><span class="ln"> 67</span><span class="cl">    <span class="kn">location</span> <span class="s">/</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 68</span><span class="cl">        <span class="kn">limit_req</span> <span class="s">zone=general_limit</span> <span class="s">burst=100</span> <span class="s">nodelay</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 69</span><span class="cl">        
</span></span><span class="line"><span class="ln"> 70</span><span class="cl">        <span class="kn">proxy_pass</span> <span class="s">http://frontend:3000</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 71</span><span class="cl">        <span class="kn">proxy_http_version</span> <span class="mi">1</span><span class="s">.1</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 72</span><span class="cl">        <span class="kn">proxy_set_header</span> <span class="s">Upgrade</span> <span class="nv">$http_upgrade</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 73</span><span class="cl">        <span class="kn">proxy_set_header</span> <span class="s">Connection</span> <span class="s">&#39;upgrade&#39;</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 74</span><span class="cl">        <span class="kn">proxy_set_header</span> <span class="s">Host</span> <span class="nv">$host</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 75</span><span class="cl">        <span class="kn">proxy_set_header</span> <span class="s">X-Real-IP</span> <span class="nv">$real_ip</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 76</span><span class="cl">        <span class="kn">proxy_set_header</span> <span class="s">X-Forwarded-For</span> <span class="nv">$real_ip</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 77</span><span class="cl">        <span class="kn">proxy_set_header</span> <span class="s">X-Forwarded-Proto</span> <span class="nv">$forwarded_proto</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 78</span><span class="cl">        <span class="kn">proxy_cache_bypass</span> <span class="nv">$http_upgrade</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 79</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln"> 80</span><span class="cl">
</span></span><span class="line"><span class="ln"> 81</span><span class="cl">    <span class="c1"># Backend API
</span></span></span><span class="line"><span class="ln"> 82</span><span class="cl">    <span class="kn">location</span> <span class="s">/api</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 83</span><span class="cl">        <span class="kn">limit_req</span> <span class="s">zone=api_limit</span> <span class="s">burst=50</span> <span class="s">nodelay</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 84</span><span class="cl">        
</span></span><span class="line"><span class="ln"> 85</span><span class="cl">        <span class="kn">proxy_pass</span> <span class="s">http://backend:8000</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 86</span><span class="cl">        <span class="kn">proxy_http_version</span> <span class="mi">1</span><span class="s">.1</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 87</span><span class="cl">        <span class="kn">proxy_set_header</span> <span class="s">Host</span> <span class="nv">$host</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 88</span><span class="cl">        <span class="kn">proxy_set_header</span> <span class="s">X-Real-IP</span> <span class="nv">$real_ip</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 89</span><span class="cl">        <span class="kn">proxy_set_header</span> <span class="s">X-Forwarded-For</span> <span class="nv">$real_ip</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 90</span><span class="cl">        <span class="kn">proxy_set_header</span> <span class="s">X-Forwarded-Proto</span> <span class="nv">$forwarded_proto</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 91</span><span class="cl">        
</span></span><span class="line"><span class="ln"> 92</span><span class="cl">        <span class="c1"># Increased timeouts for long-running requests
</span></span></span><span class="line"><span class="ln"> 93</span><span class="cl">        <span class="kn">proxy_connect_timeout</span> <span class="s">300s</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 94</span><span class="cl">        <span class="kn">proxy_send_timeout</span> <span class="s">300s</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 95</span><span class="cl">        <span class="kn">proxy_read_timeout</span> <span class="s">300s</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 96</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln"> 97</span><span class="cl">
</span></span><span class="line"><span class="ln"> 98</span><span class="cl">    <span class="c1"># Health check
</span></span></span><span class="line"><span class="ln"> 99</span><span class="cl">    <span class="kn">location</span> <span class="s">/health</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">100</span><span class="cl">        <span class="kn">access_log</span> <span class="no">off</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">101</span><span class="cl">        <span class="kn">return</span> <span class="mi">200</span> <span class="s">&#34;healthy\n&#34;</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">102</span><span class="cl">        <span class="kn">add_header</span> <span class="s">Content-Type</span> <span class="s">text/plain</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">103</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">104</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><blockquote>
<p><strong>Docker DNS Caveat</strong>: Nginx caches DNS records at startup. If your backend container restarts and gets a new IP, Nginx might start returning 502 errors. To prevent this, use Docker&rsquo;s internal resolver <code>resolver 127.0.0.11 valid=30s;</code> and variables for <code>proxy_pass</code> targets.</p>
</blockquote>
<hr>
<h3 id="step-3-update-dockerfile">Step 3: Update Dockerfile</h3>
<p>Update your Nginx Dockerfile to expose both ports:</p>
<p><strong><code>nginx/Dockerfile</code></strong>:</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dockerfile" data-lang="dockerfile"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="k">FROM</span><span class="w"> </span><span class="s">nginx:alpine</span><span class="err">
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="err">
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="c"># Copy configurations</span><span class="err">
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="k">COPY</span> nginx-main.conf /etc/nginx/nginx.conf<span class="err">
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="k">COPY</span> nginx.conf /etc/nginx/conf.d/default.conf<span class="err">
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="err">
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="c"># Expose HTTP and HTTPS ports</span><span class="err">
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="k">EXPOSE</span><span class="w"> </span><span class="s">80</span> <span class="m">443</span><span class="err">
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="err">
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="k">CMD</span> <span class="p">[</span><span class="s2">&#34;nginx&#34;</span><span class="p">,</span> <span class="s2">&#34;-g&#34;</span><span class="p">,</span> <span class="s2">&#34;daemon off;&#34;</span><span class="p">]</span></span></span></code></pre></div><hr>
<h3 id="step-4-update-docker-compose">Step 4: Update Docker Compose</h3>
<p>Mount the SSL certificates and expose port 443:</p>
<p><strong><code>docker-compose.yml</code></strong> (nginx service section):</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="nt">services</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="w">  </span><span class="nt">nginx</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="w">    </span><span class="nt">build</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="w">      </span><span class="nt">context</span><span class="p">:</span><span class="w"> </span><span class="l">./nginx</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="w">      </span><span class="nt">dockerfile</span><span class="p">:</span><span class="w"> </span><span class="l">Dockerfile</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="w">    </span><span class="nt">container_name</span><span class="p">:</span><span class="w"> </span><span class="l">my-nginx</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="w">    </span><span class="nt">restart</span><span class="p">:</span><span class="w"> </span><span class="l">unless-stopped</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="w">    </span><span class="nt">ports</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="w">      </span>- <span class="s2">&#34;80:80&#34;</span><span class="w">
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="w">      </span>- <span class="s2">&#34;443:443&#34;</span><span class="w">
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="w">    </span><span class="nt">depends_on</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="w">      </span>- <span class="l">frontend</span><span class="w">
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="w">      </span>- <span class="l">backend</span><span class="w">
</span></span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="w">    </span><span class="nt">networks</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="w">      </span>- <span class="l">app-network</span><span class="w">
</span></span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="w">    </span><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="w">      </span>- <span class="l">./ssl:/etc/nginx/ssl:ro </span><span class="w"> </span><span class="c"># Read-only mount</span><span class="w">
</span></span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="w">    </span><span class="nt">healthcheck</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">19</span><span class="cl"><span class="w">      </span><span class="nt">test</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">&#34;CMD&#34;</span><span class="p">,</span><span class="w"> </span><span class="s2">&#34;curl&#34;</span><span class="p">,</span><span class="w"> </span><span class="s2">&#34;-f&#34;</span><span class="p">,</span><span class="w"> </span><span class="s2">&#34;http://localhost/health&#34;</span><span class="p">]</span><span class="w">
</span></span></span><span class="line"><span class="ln">20</span><span class="cl"><span class="w">      </span><span class="nt">interval</span><span class="p">:</span><span class="w"> </span><span class="l">30s</span><span class="w">
</span></span></span><span class="line"><span class="ln">21</span><span class="cl"><span class="w">      </span><span class="nt">timeout</span><span class="p">:</span><span class="w"> </span><span class="l">10s</span><span class="w">
</span></span></span><span class="line"><span class="ln">22</span><span class="cl"><span class="w">      </span><span class="nt">retries</span><span class="p">:</span><span class="w"> </span><span class="m">3</span><span class="w">
</span></span></span><span class="line"><span class="ln">23</span><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="ln">24</span><span class="cl"><span class="nt">networks</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">25</span><span class="cl"><span class="w">  </span><span class="nt">app-network</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">26</span><span class="cl"><span class="w">    </span><span class="nt">driver</span><span class="p">:</span><span class="w"> </span><span class="l">bridge</span></span></span></code></pre></div><hr>
<h3 id="step-5-deploy">Step 5: Deploy</h3>
<p>Rebuild and deploy only the nginx container (zero downtime for other services):</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># Build the new nginx image</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">docker compose build nginx
</span></span><span class="line"><span class="ln">3</span><span class="cl">
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"># Recreate nginx container without touching dependencies</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">docker compose up -d --no-deps nginx
</span></span><span class="line"><span class="ln">6</span><span class="cl">
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="c1"># Verify nginx is running</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl">docker compose logs nginx --tail<span class="o">=</span><span class="m">20</span></span></span></code></pre></div><p>If you have a deploy script like <code>prod.sh</code>, you might need:</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># If container name conflicts exist</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">docker stop my-nginx <span class="o">&amp;&amp;</span> docker rm my-nginx
</span></span><span class="line"><span class="ln">3</span><span class="cl">./prod.sh -d nginx</span></span></code></pre></div><hr>
<h3 id="step-6-enable-full-strict-mode-in-cloudflare">Step 6: Enable Full (Strict) Mode in Cloudflare</h3>
<p>Now that your origin supports HTTPS with a valid Origin Certificate:</p>
<ol>
<li>Go to <strong>Cloudflare Dashboard</strong> → <strong>SSL/TLS</strong> → <strong>Overview</strong></li>
<li>Change encryption mode from <code>Flexible</code> or <code>Full</code> to <strong><code>Full (Strict)</code></strong></li>
</ol>
<p>This change takes effect immediately.</p>
<hr>
<h3 id="step-7-verify">Step 7: Verify</h3>
<p>Test the connection:</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">curl -I https://yourdomain.com</span></span></code></pre></div><p>Expected output shows HTTP/2 200 with Cloudflare and security headers:</p>





<pre tabindex="0"><code>HTTP/2 200 
server: cloudflare
x-frame-options: SAMEORIGIN
x-content-type-options: nosniff
content-security-policy: default-src &#39;self&#39;...
cf-ray: xxxxxxxx-XXX</code></pre><hr>
<h3 id="troubleshooting">Troubleshooting</h3>
<h4 id="521-error-web-server-is-down">521 Error (Web server is down)</h4>
<ul>
<li>Origin not listening on port 443</li>
<li>Firewall blocking port 443</li>
<li>Nginx failed to start (check certificate paths)</li>
</ul>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># Check nginx logs</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">docker compose logs nginx
</span></span><span class="line"><span class="ln">3</span><span class="cl">
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"># Verify nginx config syntax</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">docker compose <span class="nb">exec</span> nginx nginx -t</span></span></code></pre></div><h4 id="525-error-ssl-handshake-failed">525 Error (SSL handshake failed)</h4>
<ul>
<li>Certificate/key mismatch</li>
<li>Invalid certificate format</li>
<li>Wrong file permissions</li>
</ul>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># Verify certificate and key match</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">openssl x509 -noout -modulus -in ssl/origin.pem <span class="p">|</span> openssl md5
</span></span><span class="line"><span class="ln">3</span><span class="cl">openssl rsa -noout -modulus -in ssl/origin.key <span class="p">|</span> openssl md5
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"># Both should output the same MD5 hash</span></span></span></code></pre></div><h4 id="526-error-invalid-ssl-certificate">526 Error (Invalid SSL certificate)</h4>
<ul>
<li>Only occurs in Full (Strict) mode</li>
<li>Origin certificate not trusted by Cloudflare</li>
<li>Certificate expired or for wrong hostname</li>
</ul>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># Check certificate details</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">openssl x509 -in ssl/origin.pem -text -noout <span class="p">|</span> grep -A2 <span class="s2">&#34;Subject:&#34;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">openssl x509 -in ssl/origin.pem -text -noout <span class="p">|</span> grep -A2 <span class="s2">&#34;Validity&#34;</span></span></span></code></pre></div><hr>
<h3 id="security-considerations">Security Considerations</h3>
<h4 id="what-this-setup-protects-against">What This Setup Protects Against</h4>
<table>
  <thead>
      <tr>
          <th>Attack</th>
          <th>Protected?</th>
          <th>How</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>MITM (Browser → Cloudflare)</td>
          <td>✅</td>
          <td>Public SSL certificate</td>
      </tr>
      <tr>
          <td>MITM (Cloudflare → Origin)</td>
          <td>✅</td>
          <td>Origin Certificate + Full (Strict)</td>
      </tr>
      <tr>
          <td>DDoS</td>
          <td>✅</td>
          <td>Cloudflare absorbs attack traffic</td>
      </tr>
      <tr>
          <td>SSL Downgrade</td>
          <td>✅</td>
          <td>HTTPS redirect + HSTS (via Cloudflare)</td>
      </tr>
  </tbody>
</table>
<h4 id="additional-hardening-optional">Additional Hardening (Optional)</h4>
<ol>
<li>
<p><strong>Authenticated Origin Pulls</strong>: Mutual TLS where Cloudflare presents a client certificate</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-nginx" data-lang="nginx"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">ssl_client_certificate</span> <span class="s">/etc/nginx/ssl/cloudflare-origin-pull.pem</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="k">ssl_verify_client</span> <span class="no">on</span><span class="p">;</span></span></span></code></pre></div></li>
<li>
<p><strong>Restrict Origin to Cloudflare IPs Only</strong>: Configure AWS Security Groups or iptables to only accept connections from <a href="https://www.cloudflare.com/ips/">Cloudflare IP ranges</a></p>
</li>
<li>
<p><strong>Enable HSTS in Cloudflare</strong>: SSL/TLS → Edge Certificates → Always Use HTTPS + HSTS</p>
</li>
</ol>
<hr>
<h3 id="quick-reference">Quick Reference</h3>
<table>
  <thead>
      <tr>
          <th>Task</th>
          <th>Command</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Create SSL directory</td>
          <td><code>mkdir -p ssl</code></td>
      </tr>
      <tr>
          <td>Set key permissions</td>
          <td><code>chmod 600 ssl/origin.key</code></td>
      </tr>
      <tr>
          <td>Build nginx</td>
          <td><code>docker compose build nginx</code></td>
      </tr>
      <tr>
          <td>Deploy nginx only</td>
          <td><code>docker compose up -d --no-deps nginx</code></td>
      </tr>
      <tr>
          <td>Check nginx logs</td>
          <td><code>docker compose logs nginx --tail=50</code></td>
      </tr>
      <tr>
          <td>Test nginx config</td>
          <td><code>docker compose exec nginx nginx -t</code></td>
      </tr>
      <tr>
          <td>Test HTTPS</td>
          <td><code>curl -I https://yourdomain.com</code></td>
      </tr>
  </tbody>
</table>
<hr>
<h3 id="trade-offs-and-limitations">Trade-offs and Limitations</h3>
<p><strong>1. Vendor Lock-in</strong>:
Cloudflare Origin Certificates are not trusted by major browsers, only by Cloudflare. This means you cannot bypass Cloudflare and connect directly to your origin server IP in a browser without getting severe security warnings. If you ever move away from Cloudflare, you will need to replace these certificates with standard ones (e.g., Let&rsquo;s Encrypt).</p>
<p><strong>2. Local Development</strong>:
Since these certificates are for specific public domains, you can&rsquo;t easily use them for <code>localhost</code> development. A common pattern is to have a separate <code>docker-compose.override.yml</code> for local development that overrides the Nginx config to listen on HTTP only or uses mkcert for locally trusted self-signed certificates.</p>
<hr>
<h3 id="conclusion">Conclusion</h3>
<p>With this setup, you now have:</p>
<p>✅ <strong>End-to-end encryption</strong> from browser to origin<br>
✅ <strong>MITM protection</strong> on all network segments<br>
✅ <strong>Zero-cost SSL</strong> with 15-year Cloudflare Origin Certificate<br>
✅ <strong>Rate limiting</strong> and security headers<br>
✅ <strong>Docker-based deployment</strong> for easy management</p>
<p>The key insight is that <strong>Full (Strict) mode</strong> is what provides actual security. Without it, Cloudflare will accept any certificate from your origin, including one presented by an attacker performing a MITM attack.</p>
<hr>
<h3 id="further-reading">Further Reading</h3>
<ul>
<li><a href="https://developers.cloudflare.com/ssl/origin-configuration/origin-ca/">Cloudflare Origin CA certificates</a></li>
<li><a href="https://developers.cloudflare.com/ssl/origin-configuration/ssl-modes/">Cloudflare SSL/TLS encryption modes</a></li>
<li><a href="https://developers.cloudflare.com/ssl/origin-configuration/authenticated-origin-pull/">Authenticated Origin Pulls</a></li>
<li><a href="https://ssl-config.mozilla.org/">Nginx SSL configuration best practices</a></li>
</ul>
]]></content:encoded>
    </item>
  </channel>
</rss>
