<?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/" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:media="http://search.yahoo.com/mrss/">
  <channel>
    <title>Docker · Jared Lynskey</title>
    <link>https://jared.lynskey.co.nz/en/tags/docker/</link>
    <description>Short stories of value on software engineering and engineering leadership — performance reviews, hiring and scaling teams, CI/CD, DevOps monitoring and agentic AI. Written from Seoul.</description>
    <generator>Hugo</generator>
    <language>en</language>
    <copyright>© 2026 Jared Lynskey</copyright>
    <lastBuildDate>Sat, 07 Nov 2026 00:00:00 +1300</lastBuildDate>
    <atom:link href="https://jared.lynskey.co.nz/en/tags/docker/index.xml" rel="self" type="application/rss+xml" />
    <item>
      <title>vLLM vs Ollama: When to Use Each for Self-Hosted LLMs</title>
      <link>https://jared.lynskey.co.nz/en/posts/2026/2026-10-30-vllm-vs-ollama/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/en/posts/2026/2026-10-30-vllm-vs-ollama/</guid>
      <pubDate>Fri, 30 Oct 2026 00:00:00 +1300</pubDate>
      <dc:creator>Jared Lynskey</dc:creator>
      <category>vLLM</category>
      <category>Ollama</category>
      <category>LLM</category>
      <category>Self-Hosted</category>
      <category>MLOps</category>
      <category>DevOps</category>
      <category>Docker</category>
      <category>Kubernetes</category>
      <description>vLLM vs Ollama from running both: Ollama for a laptop or a one-person tool, vLLM for serving many requests from a GPU server. How to install each, Docker and Kubernetes setups, and what bit me.</description>
      <content:encoded><![CDATA[<p>Use Ollama when one person, or one app on one machine, needs a model running in a few minutes. Use vLLM when a GPU server has to answer lots of requests at once and throughput matters. Ollama is a model runner: it pulls a quantised model, loads it when you ask for it and lets it go when you stop. vLLM is an inference server: it takes most of a GPU at startup and keeps it busy by batching every request in flight. Both speak the OpenAI API, so the code that calls them can be identical.</p>
<p>I use both every day. Ollama runs on my laptop and on the GPU workstation in the office, for development and for my own tools. vLLM serves a chat model from a single 96 GB GPU for a freelance project, first under Docker Compose and now in a small Kubernetes cluster.</p>

<h2 class="relative group">vLLM vs Ollama at a glance
    <div id="vllm-vs-ollama-at-a-glance" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#vllm-vs-ollama-at-a-glance" aria-label="Anchor">#</a>
    </span>
    
</h2>
<table>
	<thead>
			<tr>
					<th></th>
					<th>Ollama</th>
					<th>vLLM</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Best for</td>
					<td>One user: a laptop, a dev box, a personal tool</td>
					<td>Many users or a busy app, on a GPU server</td>
			</tr>
			<tr>
					<td>Runs on</td>
					<td>macOS, Linux, Windows; Apple silicon, NVIDIA, AMD, or CPU</td>
					<td>Linux with an NVIDIA GPU is the smooth path; other hardware works with more effort</td>
			</tr>
			<tr>
					<td>Models</td>
					<td>Quantised GGUF from its own library (<code>qwen3:8b</code>)</td>
					<td>Hugging Face weights in BF16, FP8, AWQ or GPTQ (<code>Qwen/Qwen3-8B</code>); GGUF loads, but is experimental</td>
			</tr>
			<tr>
					<td>Install</td>
					<td>An installer, then <code>ollama run</code></td>
					<td><code>pip install vllm</code>, or the <code>vllm/vllm-openai</code> Docker image</td>
			</tr>
			<tr>
					<td>GPU memory</td>
					<td>Loads a model on first use, unloads it after 5 idle minutes</td>
					<td>Reserves a fixed share of the card at startup (over 90% by default) and keeps it</td>
			</tr>
			<tr>
					<td>Many requests at once</td>
					<td>One request at a time by default; <code>OLLAMA_NUM_PARALLEL</code> adds slots</td>
					<td>Continuous batching; this is what it&rsquo;s for</td>
			</tr>
			<tr>
					<td>Startup</td>
					<td>Seconds for a small model</td>
					<td>Minutes for a large one</td>
			</tr>
			<tr>
					<td>LoRA adapters</td>
					<td>Baked into a new model with a Modelfile</td>
					<td>Several per server, chosen per request</td>
			</tr>
			<tr>
					<td>API</td>
					<td>Its own, plus OpenAI-compatible at <code>:11434/v1</code></td>
					<td>OpenAI-compatible at <code>:8000/v1</code></td>
			</tr>
	</tbody>
</table>

<h2 class="relative group">What Ollama is
    <div id="what-ollama-is" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#what-ollama-is" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>Ollama is the easiest way I know to get a model answering on your own machine. It&rsquo;s built on llama.cpp and runs quantised GGUF models, which is why an 8B model fits in about 5 GB and runs happily on a MacBook. You pull a model by name, it loads the first time you call it, and it drops out of memory after five minutes with nothing to do, so it shares a machine politely with everything else you have running.</p>
<p>That politeness is the trade-off. Ollama is built around one person at a time. By default it handles one request at a time; <code>OLLAMA_NUM_PARALLEL</code> adds slots, but each one costs a full context of memory, and it was never meant to batch dozens of users onto one GPU.</p>

<h2 class="relative group">What vLLM is
    <div id="what-vllm-is" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#what-vllm-is" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>vLLM is a serving engine, and it&rsquo;s built for exactly that batching. Two ideas carry most of the weight. PagedAttention stores each request&rsquo;s KV cache (the attention state a model builds up as it reads and writes tokens) in small fixed-size blocks, so GPU memory isn&rsquo;t lost to padding and fragmentation. Continuous batching lets new requests join the batch between generation steps instead of waiting for the current batch to finish. Between them, one GPU running vLLM serves far more concurrent users than the same GPU working through requests one at a time. I haven&rsquo;t benchmarked the two against each other myself, so this post has no throughput numbers of its own. Red Hat&rsquo;s <a href="https://developers.redhat.com/articles/2025/08/08/ollama-vs-vllm-deep-dive-performance-benchmarking"  target="_blank" rel="noreferrer">Ollama vs. vLLM benchmark</a>, one A100 running Llama 3.1 8B at 1 to 256 concurrent users, is the measured version of this claim: vLLM peaked at about 793 output tokens a second against Ollama&rsquo;s 41, even with <code>OLLAMA_NUM_PARALLEL</code> raised to 32.</p>
<p>The cost is that vLLM behaves as if it owns the card. It claims its share of GPU memory when it starts, fills whatever the weights don&rsquo;t need with KV cache, and holds all of it whether anyone is calling or not. It also wants full Hugging Face weights or a GPU-friendly quantisation like FP8 or AWQ. It will load a GGUF file, but vLLM&rsquo;s own docs call that support highly experimental and under-optimised, so don&rsquo;t plan on reusing Ollama&rsquo;s downloads.</p>

<h2 class="relative group">How much GPU memory you need
    <div id="how-much-gpu-memory-you-need" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#how-much-gpu-memory-you-need" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>Rough sizes for the weights alone:</p>
<table>
	<thead>
			<tr>
					<th>Model size</th>
					<th>16-bit (BF16)</th>
					<th>8-bit (FP8)</th>
					<th>4-bit GGUF (Q4_K_M)</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>8B</td>
					<td>~16 GB</td>
					<td>~8 GB</td>
					<td>~5 GB</td>
			</tr>
			<tr>
					<td>32B</td>
					<td>~64 GB</td>
					<td>~32 GB</td>
					<td>~20 GB</td>
			</tr>
			<tr>
					<td>70B</td>
					<td>~140 GB</td>
					<td>~70 GB</td>
					<td>~43 GB</td>
			</tr>
	</tbody>
</table>
<p>That&rsquo;s about 2 bytes per parameter at 16-bit, 1 at 8-bit, and a little over half a byte at Q4_K_M. On top of the weights goes the KV cache, which grows with context length and with how many requests are in flight. That second part is why the same model needs much more memory on vLLM serving fifty users than on Ollama serving you.</p>

<h2 class="relative group">One API, two servers
    <div id="one-api-two-servers" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#one-api-two-servers" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>Because both expose the OpenAI chat completions API, my code doesn&rsquo;t know which one it&rsquo;s talking to. The URL and model name come from the environment:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="kn">import</span> <span class="nn">os</span>
</span></span><span class="line"><span class="cl"><span class="kn">from</span> <span class="nn">openai</span> <span class="kn">import</span> <span class="n">OpenAI</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Laptop:     LLM_URL=http://localhost:11434/v1  LLM_MODEL=qwen3:8b</span>
</span></span><span class="line"><span class="cl"><span class="c1"># GPU server: LLM_URL=http://localhost:8000/v1   LLM_MODEL=qwen3-8b</span>
</span></span><span class="line"><span class="cl"><span class="n">client</span> <span class="o">=</span> <span class="n">OpenAI</span><span class="p">(</span><span class="n">base_url</span><span class="o">=</span><span class="n">os</span><span class="o">.</span><span class="n">environ</span><span class="p">[</span><span class="s2">&#34;LLM_URL&#34;</span><span class="p">],</span> <span class="n">api_key</span><span class="o">=</span><span class="s2">&#34;not-needed&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">reply</span> <span class="o">=</span> <span class="n">client</span><span class="o">.</span><span class="n">chat</span><span class="o">.</span><span class="n">completions</span><span class="o">.</span><span class="n">create</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">    <span class="n">model</span><span class="o">=</span><span class="n">os</span><span class="o">.</span><span class="n">environ</span><span class="p">[</span><span class="s2">&#34;LLM_MODEL&#34;</span><span class="p">],</span>
</span></span><span class="line"><span class="cl">    <span class="n">messages</span><span class="o">=</span><span class="p">[{</span><span class="s2">&#34;role&#34;</span><span class="p">:</span> <span class="s2">&#34;user&#34;</span><span class="p">,</span> <span class="s2">&#34;content&#34;</span><span class="p">:</span> <span class="s2">&#34;Say hello in Korean.&#34;</span><span class="p">}],</span>
</span></span><span class="line"><span class="cl"><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="nb">print</span><span class="p">(</span><span class="n">reply</span><span class="o">.</span><span class="n">choices</span><span class="p">[</span><span class="mi">0</span><span class="p">]</span><span class="o">.</span><span class="n">message</span><span class="o">.</span><span class="n">content</span><span class="p">)</span></span></span></code></pre></div></div>
<p>On my laptop, which has no big GPU, the app points at Ollama. On the server it points at vLLM. Nothing else changes, and the app can be developed without the GPU box switched on at all. On the vLLM side, <code>--served-model-name</code> gives the model a short name, so clients don&rsquo;t have to send the full Hugging Face path.</p>
<p>One caution, because it&rsquo;s easy to forget: the same model on both doesn&rsquo;t give the same answers. Ollama&rsquo;s <code>qwen3:8b</code> is a 4-bit quantisation, while on vLLM you&rsquo;d usually run 16-bit or FP8 weights. A prompt tuned against one can behave differently on the other, so anything you&rsquo;re measuring, whether that&rsquo;s answer quality, refusals or whether the JSON parses, has to be checked against the server production will actually use.</p>

<h2 class="relative group">How to install Ollama
    <div id="how-to-install-ollama" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#how-to-install-ollama" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>On a Mac or Windows, download the app from ollama.com. On Linux:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">curl -fsSL https://ollama.com/install.sh <span class="p">|</span> sh
</span></span><span class="line"><span class="cl">ollama run qwen3:8b</span></span></code></pre></div></div>
<p>That&rsquo;s the whole job. The API is now at <code>http://localhost:11434</code>, and it only listens on localhost unless you set <code>OLLAMA_HOST</code>, which is what you want. Mine runs on the office workstation bound to <code>127.0.0.1</code>, and I reach it from anywhere through a tunnel (<a href="https://jared.lynskey.co.nz/en/posts/2026/2026-10-01-cloudflare-warp-dev-network/" >how I set that up with Cloudflare WARP</a>).</p>

<h2 class="relative group">How to install vLLM
    <div id="how-to-install-vllm" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#how-to-install-vllm" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>On Linux with an NVIDIA GPU and a recent driver:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">python -m venv .venv <span class="o">&amp;&amp;</span> <span class="nb">source</span> .venv/bin/activate
</span></span><span class="line"><span class="cl">pip install vllm
</span></span><span class="line"><span class="cl">vllm serve Qwen/Qwen3-8B --served-model-name qwen3-8b --max-model-len <span class="m">16384</span></span></span></code></pre></div></div>
<p>The first run downloads the weights from Hugging Face. Once the log says <code>Application startup complete</code>, <code>curl localhost:8000/v1/models</code> lists the model, and the Python above works against it.</p>

<h2 class="relative group">Running vLLM in Docker
    <div id="running-vllm-in-docker" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#running-vllm-in-docker" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>For anything that has to stay up, I use the official image rather than a pip install, mostly so CUDA, PyTorch and vLLM come pinned together. A generic Compose service:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">services</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">vllm</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">vllm/vllm-openai:v0.30.0  </span><span class="w"> </span><span class="c"># the tag I ran; pin whatever is current. About 30 GB</span><span class="w">
</span></span></span><span class="line"><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="cl"><span class="w">    </span><span class="nt">ipc</span><span class="p">:</span><span class="w"> </span><span class="l">host                         </span><span class="w"> </span><span class="c"># PyTorch needs shared memory</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">command</span><span class="p">:</span><span class="w"> </span><span class="p">&gt;</span><span class="sd">
</span></span></span><span class="line"><span class="cl"><span class="sd">      --model Qwen/Qwen3-8B
</span></span></span><span class="line"><span class="cl"><span class="sd">      --served-model-name qwen3-8b
</span></span></span><span class="line"><span class="cl"><span class="sd">      --gpu-memory-utilization 0.85
</span></span></span><span class="line"><span class="cl"><span class="sd">      --max-model-len 16384</span><span class="w">
</span></span></span><span class="line"><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="cl"><span class="w">      </span>- <span class="l">${HOME}/.cache/huggingface:/root/.cache/huggingface</span><span class="w">
</span></span></span><span class="line"><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="cl"><span class="w">      </span>- <span class="s2">&#34;127.0.0.1:8000:8000&#34;</span><span class="w">          </span><span class="c"># reach it over ssh -L, not the LAN</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">deploy</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">resources</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">reservations</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">devices</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">            </span>- <span class="nt">driver</span><span class="p">:</span><span class="w"> </span><span class="l">nvidia</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">              </span><span class="nt">count</span><span class="p">:</span><span class="w"> </span><span class="l">all</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">              </span><span class="nt">capabilities</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">gpu]</span><span class="w">
</span></span></span><span class="line"><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="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;python3&#34;</span><span class="p">,</span><span class="w"> </span><span class="s2">&#34;-c&#34;</span><span class="p">,</span><span class="w"> </span><span class="s2">&#34;import urllib.request; urllib.request.urlopen(&#39;http://localhost:8000/health&#39;)&#34;</span><span class="p">]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">interval</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="cl"><span class="w">      </span><span class="nt">timeout</span><span class="p">:</span><span class="w"> </span><span class="l">5s</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">retries</span><span class="p">:</span><span class="w"> </span><span class="m">90</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">start_period</span><span class="p">:</span><span class="w"> </span><span class="l">120s</span></span></span></code></pre></div></div>
<p>Pin the tag. vLLM moves quickly, flags and defaults change between releases, and you want to be the one who decides when that happens. <code>ipc: host</code> (or a generous <code>shm_size</code>) is in vLLM&rsquo;s own Docker instructions. The long <code>start_period</code> is the first of the things below.</p>

<h2 class="relative group">What bit me running vLLM
    <div id="what-bit-me-running-vllm" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#what-bit-me-running-vllm" aria-label="Anchor">#</a>
    </span>
    
</h2>

<h3 class="relative group">Startup takes minutes
    <div id="startup-takes-minutes" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#startup-takes-minutes" aria-label="Anchor">#</a>
    </span>
    
</h3>
<p>Loading the weights, compiling and capturing CUDA graphs took 190 seconds on my server, and three to five minutes is normal for a large model. A health check written for a web app will kill it before it&rsquo;s ready. In Compose that means a long <code>start_period</code>; in Kubernetes it means a startup probe. It also means every restart is a few minutes with no model, so restarts shouldn&rsquo;t be casual.</p>

<h3 class="relative group">It takes the GPU memory you give it, at startup
    <div id="it-takes-the-gpu-memory-you-give-it-at-startup" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#it-takes-the-gpu-memory-you-give-it-at-startup" aria-label="Anchor">#</a>
    </span>
    
</h3>
<p><code>--gpu-memory-utilization</code> is the share of the card vLLM may use, 0.92 by default in recent releases, and whatever the weights don&rsquo;t need becomes KV cache. That&rsquo;s fine when vLLM is alone on the card. Mine isn&rsquo;t, because smaller models run beside it, so I set 0.70 on the 96 GB card. The model, a 35B mixture-of-experts in FP8, loaded at 47.7 GiB and left 15 GiB for KV cache, which is plenty at a 16k context. If vLLM refuses to start because the maximum sequence length won&rsquo;t fit in the KV cache, lower <code>--max-model-len</code> before you go looking for a bigger GPU.</p>

<h3 class="relative group">LoRA slots cost memory up front
    <div id="lora-slots-cost-memory-up-front" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#lora-slots-cost-memory-up-front" aria-label="Anchor">#</a>
    </span>
    
</h3>
<p>vLLM can serve one base model with several LoRA adapters and switch between them per request, by model name, which Ollama can&rsquo;t really do. But it reserves room for <code>--max-loras</code> adapters at <code>--max-lora-rank</code> when it starts, and on a mixture-of-experts model that reservation covers the experts too, so it adds up fast. Four adapters at rank 64 didn&rsquo;t fit at 0.70. Two did.</p>

<h3 class="relative group">The default sequence limit didn&rsquo;t suit a hybrid model
    <div id="the-default-sequence-limit-didnt-suit-a-hybrid-model" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#the-default-sequence-limit-didnt-suit-a-hybrid-model" aria-label="Anchor">#</a>
    </span>
    
</h3>
<p>The model I serve mixes Mamba-style layers in with attention, and each sequence needs its own cache block for those layers. vLLM&rsquo;s default <code>--max-num-seqs</code> is 1024 on a card with 70 GB or more (256 below that), so on that card it failed to start; <code>--max-num-seqs 256</code> fixed it. If a newer architecture won&rsquo;t start, check this flag before anything more exotic.</p>

<h3 class="relative group">A Kubernetes Service named <code>vllm</code> crashes vLLM
    <div id="a-kubernetes-service-named-vllm-crashes-vllm" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#a-kubernetes-service-named-vllm-crashes-vllm" aria-label="Anchor">#</a>
    </span>
    
</h3>
<p>This one&rsquo;s my favourite. Kubernetes injects environment variables for every Service in the namespace, named after the Service, so a Service called <code>vllm</code> hands every pod <code>VLLM_PORT=tcp://&lt;cluster-ip&gt;:8000</code>. vLLM reads <code>VLLM_PORT</code> as one of its own settings, couldn&rsquo;t make sense of a URL there, and died. After loading the whole model, naturally, so every attempt cost three minutes. vLLM&rsquo;s docs warn about this, which I found afterwards. The fix is <code>enableServiceLinks: false</code> on the pod, or a Service with a different name.</p>
<p>Here&rsquo;s the shape of a Deployment that works, cut down to the parts that matter:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">apiVersion</span><span class="p">:</span><span class="w"> </span><span class="l">apps/v1</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">kind</span><span class="p">:</span><span class="w"> </span><span class="l">Deployment</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">metadata</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">vllm</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">spec</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">replicas</span><span class="p">:</span><span class="w"> </span><span class="m">1</span><span class="w">
</span></span></span><span class="line"><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="cl"><span class="w">    </span><span class="nt">type</span><span class="p">:</span><span class="w"> </span><span class="l">Recreate              </span><span class="w"> </span><span class="c"># the old pod has to free the GPU first</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">selector</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">matchLabels</span><span class="p">:</span><span class="w"> </span>{<span class="nt">app</span><span class="p">:</span><span class="w"> </span><span class="l">vllm}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">template</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">metadata</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">labels</span><span class="p">:</span><span class="w"> </span>{<span class="nt">app</span><span class="p">:</span><span class="w"> </span><span class="l">vllm}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">spec</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">enableServiceLinks</span><span class="p">:</span><span class="w"> </span><span class="kc">false</span><span class="w">  </span><span class="c"># no VLLM_PORT=tcp://... from the Service</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">containers</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">vllm</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">vllm/vllm-openai:v0.30.0</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">args</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">&#34;--model=Qwen/Qwen3-8B&#34;</span><span class="p">,</span><span class="w"> </span><span class="s2">&#34;--served-model-name=qwen3-8b&#34;</span><span class="p">]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">resources</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">            </span><span class="nt">limits</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">              </span><span class="nt">nvidia.com/gpu</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;1&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">              </span><span class="nt">memory</span><span class="p">:</span><span class="w"> </span><span class="l">32Gi      </span><span class="w"> </span><span class="c"># loading needs far more host RAM than serving</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">volumeMounts</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">            </span>- {<span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="nt">shm, mountPath</span><span class="p">:</span><span class="w"> </span><span class="l">/dev/shm}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">startupProbe</span><span class="p">:</span><span class="w">          </span><span class="c"># don&#39;t judge it until the model is loaded</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">            </span><span class="nt">httpGet</span><span class="p">:</span><span class="w"> </span>{<span class="nt">path</span><span class="p">:</span><span class="w"> </span><span class="nt">/health, port</span><span class="p">:</span><span class="w"> </span><span class="m">8000</span>}<span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">            </span><span class="nt">initialDelaySeconds</span><span class="p">:</span><span class="w"> </span><span class="m">120</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">            </span><span class="nt">periodSeconds</span><span class="p">:</span><span class="w"> </span><span class="m">10</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">            </span><span class="nt">failureThreshold</span><span class="p">:</span><span class="w"> </span><span class="m">90</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">livenessProbe</span><span class="p">:</span><span class="w">         </span><span class="c"># a restart costs a full reload, so be patient</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">            </span><span class="nt">httpGet</span><span class="p">:</span><span class="w"> </span>{<span class="nt">path</span><span class="p">:</span><span class="w"> </span><span class="nt">/health, port</span><span class="p">:</span><span class="w"> </span><span class="m">8000</span>}<span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">            </span><span class="nt">periodSeconds</span><span class="p">:</span><span class="w"> </span><span class="m">30</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">            </span><span class="nt">timeoutSeconds</span><span class="p">:</span><span class="w"> </span><span class="m">5</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">            </span><span class="nt">failureThreshold</span><span class="p">:</span><span class="w"> </span><span class="m">10</span><span class="w">
</span></span></span><span class="line"><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="cl"><span class="w">        </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">shm</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">emptyDir</span><span class="p">:</span><span class="w"> </span>{<span class="nt">medium</span><span class="p">:</span><span class="w"> </span><span class="nt">Memory, sizeLimit</span><span class="p">:</span><span class="w"> </span><span class="l">16Gi}</span></span></span></code></pre></div></div>
<p>The probes are the part worth copying. The startup probe waits two minutes, then gives the model another fifteen to come up before Kubernetes calls it dead. The liveness probe only restarts it after about five minutes of failures, because <code>/health</code> can be slow on a busy engine and a restart costs a full reload anyway. <code>Recreate</code> matters on a single GPU: a rolling update starts the new pod first, and it sits there waiting for a GPU the old pod still holds. The memory limit looks generous because it is; mine settles at about 7 GiB of host RAM once the weights are on the GPU, but the load itself needs a lot more.</p>

<h2 class="relative group">What to know about Ollama
    <div id="what-to-know-about-ollama" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#what-to-know-about-ollama" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>Fewer surprises, mostly because it does less.</p>
<p>The first request after a quiet spell is slow, because the model was unloaded and has to load again. <code>OLLAMA_KEEP_ALIVE</code> sets how long a model stays in memory after its last request; raise it if that pause annoys you, or set it to <code>-1</code> on a machine that does nothing else.</p>
<p>The context window depends on how much GPU memory you have. Under 24 GiB, Ollama defaults to 4k tokens, far less than most models support, and a prompt that doesn&rsquo;t fit is trimmed quietly rather than refused. If answers seem to ignore the start of a long prompt, set <code>OLLAMA_CONTEXT_LENGTH</code> (or <code>num_ctx</code> per request) to what you actually need.</p>
<p>If you forward a remote Ollama to your laptop, don&rsquo;t put it on 11434. That&rsquo;s Ollama&rsquo;s default port, and if a local Ollama ever starts, the two race for it and you can end up talking to the wrong one without noticing. I forward mine to 11435.</p>
<p>And it isn&rsquo;t a multi-user server. It&rsquo;s fine for an internal tool a handful of people use; once real concurrency turns up, that&rsquo;s the point to move to vLLM.</p>

<h2 class="relative group">Which one to use
    <div id="which-one-to-use" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#which-one-to-use" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>If you want a model on your own machine, install Ollama and stop there; you&rsquo;ll have something answering in ten minutes. If you have a GPU server and users, or an app, that will call it all day, use vLLM, accept the slow start and the memory it takes, and get the probes right. Plenty of setups end up with both, mine included: Ollama where the code gets written, vLLM where the requests land.</p>
<p>There are other options. SGLang is the usual alternative to vLLM, but I haven&rsquo;t run it in production, so I&rsquo;ve left it out rather than guess. And if the model has to run on a phone, that&rsquo;s llama.cpp again, which I wrote about in <a href="https://jared.lynskey.co.nz/en/posts/2026/2026-01-30-on-device-ai/" >running an LLM on-device in React Native</a>.</p>
]]></content:encoded>
      <media:content url="https://jared.lynskey.co.nz/en/posts/2026/2026-10-30-vllm-vs-ollama/featured.jpg" medium="image" type="image/jpeg" />
    </item>
    <item>
      <title>Docker Compose in GitHub Actions CI: What I Replaced It With, and the Pipelines I Run Now</title>
      <link>https://jared.lynskey.co.nz/en/posts/2026/2026-10-22-docker-compose-ci-github-actions/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/en/posts/2026/2026-10-22-docker-compose-ci-github-actions/</guid>
      <pubDate>Thu, 22 Oct 2026 00:00:00 +1300</pubDate>
      <dc:creator>Jared Lynskey</dc:creator>
      <category>CI/CD</category>
      <category>GitHub Actions</category>
      <category>Docker</category>
      <category>DevOps</category>
      <category>Deployment</category>
      <category>Fly.io</category>
      <category>Cloudflare</category>
      <category>Django</category>
      <category>PostgreSQL</category>
      <category>Hugo</category>
      <category>Python</category>
      <description>The GitHub Actions pipelines I run in 2026: service containers instead of Docker Compose in CI, caching chosen from step timings, and deploys that are scripts.</description>
      <content:encoded><![CDATA[<p>A CI/CD pipeline is the automated route a change takes from <code>git push</code> to production: CI builds and tests every push on a clean machine, and CD releases whatever passed through one repeatable step. On GitHub Actions that&rsquo;s a YAML file under <code>.github/workflows/</code>, and mine are deliberately thin: the checks run the commands I run on my laptop, and every deploy is a script or a CLI that would work the same without GitHub.</p>
<p>If you came here to run Docker Compose in GitHub Actions: I did that for two years and stopped. CI now declares the same Postgres and Valkey images as <code>services:</code> and runs the same <code>uv run …</code> and <code>npm run …</code> commands straight on the runner; Compose stays what a developer runs. The rest is why, with the files.</p>
<p>In 2024 I wrote <a href="https://jared.lynskey.co.nz/en/posts/2024/2024-06-02-simplifyingcicd/" >Use Docker Compose for CI/CD Instead of Platform-Specific YAML</a>, which said to keep the build logic in Compose and let the CI tool do nothing but call it. Two years on, I went back through every repository I ship from. Four have workflows, each with one committer, me, and none of those workflows calls <code>docker compose</code> any more. The principle held; the mechanism didn&rsquo;t. Here&rsquo;s what replaced it, with cut-down example versions of the files, the step timings GitHub recorded, and the parts that are less automated than a CI/CD diagram would like.</p>

<h2 class="relative group">Every pipeline I run, in one table
    <div id="every-pipeline-i-run-in-one-table" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#every-pipeline-i-run-in-one-table" aria-label="Anchor">#</a>
    </span>
    
</h2>
<table>
	<thead>
			<tr>
					<th>Pipeline</th>
					<th>Started by</th>
					<th>What CI checks</th>
					<th>Where it goes</th>
					<th>Who releases</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>This blog, and a client&rsquo;s Hugo site</td>
					<td>Every push, on any branch</td>
					<td>That <code>hugo --gc</code> builds</td>
					<td>Cloudflare Pages: <code>main</code> is production, other branches get a preview URL</td>
					<td>Nobody; the push is the release</td>
			</tr>
			<tr>
					<td>My marketplace app&rsquo;s API</td>
					<td>A deploy script, run by hand</td>
					<td>Lint, type checks and the Django tests, on every push to <code>main</code></td>
					<td>Fly.io, as a rolling deploy</td>
					<td>Me</td>
			</tr>
			<tr>
					<td>The same app&rsquo;s front end: web, OTA updates, store builds</td>
					<td>Release scripts and local store builds, by hand</td>
					<td>TypeScript and Jest, on every push to <code>main</code></td>
					<td>Object storage, an OTA update service and the two app stores</td>
					<td>Me</td>
			</tr>
			<tr>
					<td>pyhtml2text, my open-source Python package</td>
					<td>Every push, pull request and tag</td>
					<td>Wheels for 15 build targets, tested on most of them</td>
					<td>PyPI, on a tag</td>
					<td>The tag</td>
			</tr>
	</tbody>
</table>
<p>In textbook terms the Hugo sites have continuous deployment, and the app (a marketplace and community app I build) has continuous integration plus one-command releases, which is continuous delivery: the checks tell me something is wrong, but they don&rsquo;t stop me deploying. That&rsquo;s deliberate. An OTA update reaches every phone on the binary version it targets, so publishing one should be a decision rather than a side effect of a merge, and the API is where the database migrations run. My other two Django apps have no workflows at all.</p>

<h2 class="relative group">The CI half: one workflow, two jobs
    <div id="the-ci-half-one-workflow-two-jobs" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#the-ci-half-one-workflow-two-jobs" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>The app&rsquo;s CI is one file, <code>.github/workflows/checks.yml</code>. Until 11 October there wasn&rsquo;t any. This is a trimmed example of its shape:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="c"># Lint, types and tests on every push and pull request to main.</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="c"># Nothing here deploys.</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">Checks</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">on</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">push</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">branches</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">main]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">pull_request</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">branches</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">main]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">concurrency</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">group</span><span class="p">:</span><span class="w"> </span><span class="l">checks-${{ github.ref }}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">cancel-in-progress</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="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">jobs</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">python</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><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="cl"><span class="w">    </span><span class="nt">timeout-minutes</span><span class="p">:</span><span class="w"> </span><span class="m">30</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">services</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">postgres</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">postgres:17</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">env</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">POSTGRES_USER</span><span class="p">:</span><span class="w"> </span><span class="l">myapp</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">POSTGRES_PASSWORD</span><span class="p">:</span><span class="w"> </span><span class="l">myapp</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">POSTGRES_DB</span><span class="p">:</span><span class="w"> </span><span class="l">myapp</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">ports</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">&#34;5432:5432&#34;</span><span class="p">]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">options</span><span class="p">:</span><span class="w"> </span><span class="p">&gt;-</span><span class="sd">
</span></span></span><span class="line"><span class="cl"><span class="sd">          --health-cmd &#34;pg_isready -U myapp&#34;
</span></span></span><span class="line"><span class="cl"><span class="sd">          --health-interval 5s --health-timeout 5s --health-retries 20</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">valkey</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">valkey/valkey:8-alpine</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">ports</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">&#34;6379:6379&#34;</span><span class="p">]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">env</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">DEBUG</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="cl"><span class="w">      </span><span class="nt">DATABASE_URL</span><span class="p">:</span><span class="w"> </span><span class="l">postgresql://myapp:myapp@localhost:5432/myapp</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">REDIS_URL</span><span class="p">:</span><span class="w"> </span><span class="l">redis://localhost:6379</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">PYTHON_JIT</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;0&#34;</span><span class="w">   </span><span class="c"># match production</span><span class="w">
</span></span></span><span class="line"><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="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">actions/checkout@v7</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">astral-sh/setup-uv@v10</span><span class="w">
</span></span></span><span class="line"><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="cl"><span class="w">          </span><span class="nt">enable-cache</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="cl"><span class="w">          </span><span class="nt">prune-cache</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="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">uv sync --locked</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">uv run ruff check .</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">uv run mypy myapp/</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">uv run python manage.py test --parallel 4 --noinput</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">app</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><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="cl"><span class="w">    </span><span class="nt">timeout-minutes</span><span class="p">:</span><span class="w"> </span><span class="m">20</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">defaults</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">run</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">working-directory</span><span class="p">:</span><span class="w"> </span><span class="l">frontend</span><span class="w">
</span></span></span><span class="line"><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="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">actions/checkout@v7</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">actions/setup-node@v7</span><span class="w">
</span></span></span><span class="line"><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="cl"><span class="w">          </span><span class="nt">node-version</span><span class="p">:</span><span class="w"> </span><span class="m">24</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">cache</span><span class="p">:</span><span class="w"> </span><span class="l">npm</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">cache-dependency-path</span><span class="p">:</span><span class="w"> </span><span class="l">frontend/package-lock.json</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">npm ci</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="c"># Expo writes this on `expo start` and it is gitignored,</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="c"># so a fresh checkout doesn&#39;t have it.</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">printf &#39;/// &lt;reference types=&#34;expo/types&#34; /&gt;\n&#39; &gt; expo-env.d.ts</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">npm run typecheck</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">npx jest --ci</span></span></span></code></pre></div></div>
<p>The two jobs run side by side. Across the 35 green runs so far, the app job&rsquo;s median is about seven minutes and the Python job&rsquo;s about twelve and a half.</p>
<p>The <code>services:</code> block is where the Compose file went, which is the next section. The environment is a few variables and a flag; everything else falls back to the development default <code>docker compose up</code> gives a developer, so the tests need no secrets. I checked by running part of the suite with exactly that environment and nothing else.</p>
<p>CI runs the same interpreter as my laptop and the production image, uv&rsquo;s own build of Python 3.15.0, because <code>uv sync</code> reads <code>.python-version</code> and downloads exactly that build. (My real file also passes setup-uv a <code>python-version-file</code> input, which does nothing: setup-uv v6 has no such input, and every run carries a warning saying so. I only noticed while writing this.)</p>
<p>Matching interpreters earned their keep eight days after the workflow went in. On 19 October I switched on 3.15&rsquo;s JIT compiler, and the first run with it failed a test: a plain <code>yield from</code>, on a code path the app takes on every save, raised <code>AttributeError: 'list' object has no attribute 'send'</code>. The same commit with the JIT off passed. My Mac is arm64 and never hit it; the runner is x86_64, like the production machines. The JIT is off again, in CI and in the Dockerfile, and the bug never reached production. It&rsquo;s python/cpython#155823, fixed upstream on 13 October, after 3.15.0 shipped.</p>
<p>The very first run failed too, for a duller reason. Expo writes <code>expo-env.d.ts</code> when you run <code>expo start</code>, and the file is gitignored, so every working copy I&rsquo;d used had one and a fresh checkout didn&rsquo;t. TypeScript reported five errors that had never appeared locally. The step that writes the file is the fix.</p>
<p><code>concurrency</code> cancels a run when a newer push to the same branch arrives, which is how all twelve cancelled runs so far ended. A pre-commit hook runs the linter, plus the type checker when Python or TypeScript is staged, so most mistakes never reach a push.</p>

<h2 class="relative group">Where the Docker Compose file went
    <div id="where-the-docker-compose-file-went" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#where-the-docker-compose-file-went" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>Here is the part of the app&rsquo;s <code>docker-compose.yml</code> that CI cares about, as an example. The real one has more services and a lot more environment.</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">services</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">valkey</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">valkey/valkey:8-alpine</span><span class="w">
</span></span></span><span class="line"><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="cl"><span class="w">      </span>- <span class="s2">&#34;6379:6379&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="c"># Host port 55432, not 5432, so it can&#39;t collide with a local Postgres.</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">postgres</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">postgres:17-alpine</span><span class="w">
</span></span></span><span class="line"><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="cl"><span class="w">      </span>- <span class="l">POSTGRES_USER=myapp</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">POSTGRES_PASSWORD=myapp</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">POSTGRES_DB=myapp</span><span class="w">
</span></span></span><span class="line"><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="cl"><span class="w">      </span>- <span class="s2">&#34;55432:5432&#34;</span><span class="w">
</span></span></span><span class="line"><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="cl"><span class="w">      </span>- <span class="l">postgres_data:/var/lib/postgresql/data</span><span class="w">
</span></span></span><span class="line"><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="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-SHELL&#34;</span><span class="p">,</span><span class="w"> </span><span class="s2">&#34;pg_isready -U myapp -d myapp&#34;</span><span class="p">]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">interval</span><span class="p">:</span><span class="w"> </span><span class="l">5s</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">retries</span><span class="p">:</span><span class="w"> </span><span class="m">10</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">api</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">build</span><span class="p">:</span><span class="w"> </span><span class="l">.</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">command</span><span class="p">:</span><span class="w"> </span><span class="l">python manage.py runserver 0.0.0.0:8000</span><span class="w">
</span></span></span><span class="line"><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="cl"><span class="w">      </span>- <span class="l">.:/app</span><span class="w">
</span></span></span><span class="line"><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="cl"><span class="w">      </span>- <span class="s2">&#34;8000:8000&#34;</span><span class="w">
</span></span></span><span class="line"><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="cl"><span class="w">      </span>- <span class="l">DATABASE_URL=postgresql://myapp:myapp@postgres:5432/myapp</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">REDIS_URL=redis://valkey:6379</span><span class="w">
</span></span></span><span class="line"><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="cl"><span class="w">      </span><span class="nt">postgres</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">condition</span><span class="p">:</span><span class="w"> </span><span class="l">service_healthy</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">valkey</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">condition</span><span class="p">:</span><span class="w"> </span><span class="l">service_started</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="l">postgres_data:</span></span></span></code></pre></div></div>
<p>Put it next to the <code>services:</code> block in <code>checks.yml</code> and it&rsquo;s the same two images: Postgres 17 (the Alpine build here, the Debian one in CI) and <code>valkey/valkey:8-alpine</code>. That&rsquo;s the relationship now. The Compose file is what a developer runs; CI declares the same images as service containers and runs the same commands a developer runs, <code>uv run …</code> and <code>npm run …</code>, straight on the runner.</p>
<p>Against the 2024 post, the part I cared about most held: the CI tool is thin, and the checks and deploys are commands I can run myself. What didn&rsquo;t survive is CI calling Compose.</p>
<p>The app&rsquo;s CI doesn&rsquo;t build the Docker image at all; Fly&rsquo;s builder does that once, at deploy time, and CI only has to say whether the code is good, which with a cached toolchain takes a six-second install. The Compose file also bind-mounts the working copy and seeds data, which a test run shouldn&rsquo;t, so CI would have needed a Compose file of its own: a second description of the same services, kept in step by hand.</p>
<p>And the 2024 example had a bug I didn&rsquo;t notice at the time. <code>docker compose build</code> builds the image and never runs the service&rsquo;s <code>command</code>, so the <code>npm test</code> in that file never ran in CI. The workflow went green whether the tests passed or not.</p>
<p>The risk that post worried about most, switching CI vendors, never came up. The move that did happen was at the other end: this blog and the client&rsquo;s site moved hosts to Cloudflare Pages, and for the blog that took one new 45-line workflow, because the build was already one command. And the three-hundred-line workflow I warned about? I have one, pyhtml2text&rsquo;s. It&rsquo;s long because the wheels have to be built on Linux, macOS and Windows runners, for several architectures each, which makes it the one job where platform-specific YAML is the right tool.</p>

<h2 class="relative group">Caching: read the step timings first
    <div id="caching-read-the-step-timings-first" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#caching-read-the-step-timings-first" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>GitHub keeps the duration of every step, and <code>gh api repos/&lt;owner&gt;/&lt;repo&gt;/actions/runs/&lt;run-id&gt;/jobs</code> hands it over. These are from the app&rsquo;s latest green run, on 20 October:</p>
<table>
	<thead>
			<tr>
					<th>Job</th>
					<th>Step</th>
					<th>Seconds</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Python</td>
					<td>Start Postgres and Valkey</td>
					<td>18</td>
			</tr>
			<tr>
					<td>Python</td>
					<td><code>uv sync --locked</code>, cache warm</td>
					<td>6</td>
			</tr>
			<tr>
					<td>Python</td>
					<td>mypy</td>
					<td>27</td>
			</tr>
			<tr>
					<td>Python</td>
					<td>Django tests</td>
					<td>761</td>
			</tr>
			<tr>
					<td>App</td>
					<td><code>npm ci</code>, cache warm</td>
					<td>60</td>
			</tr>
			<tr>
					<td>App</td>
					<td>Jest</td>
					<td>333</td>
			</tr>
	</tbody>
</table>
<p>Both dependency caches are on, and both are the cheap part. The uv cache GitHub keeps for the repository is about 200 KB, because setup-uv prunes whatever uv can simply download again; the npm one is nearly 400 MB. The time is in the tests, which already run four ways in parallel. If I wanted the run faster, I&rsquo;d change the suite, not the cache.</p>
<p>The blog is the opposite case. Hugo&rsquo;s processed images live in the gitignored <code>resources/_gen</code>, so every fresh runner re-encoded every cover, body image and placeholder. The workflow further down restores them with <code>actions/cache</code>, keyed on a hash of the pictures and falling back to the newest older cache, since Hugo only processes what&rsquo;s missing. In the last eight runs before the cache, the build step took 33 to 76 seconds; in the 26 since, 6 to 20, with one exception at 94, even though the same commit added placeholders and a second card size.</p>
<p>The app&rsquo;s image builds on Fly&rsquo;s builder, with the dependency files copied before the code and uv&rsquo;s download cache in a BuildKit cache mount (<code>RUN --mount=type=cache,target=/root/.cache/uv</code>). The bigger fix wasn&rsquo;t a cache. The old <code>.dockerignore</code> was a blocklist, and it missed a folder of extra git worktrees, each with its own dependencies and build output, so 7.7 GB across 277,000 files went to the builder on every deploy. Rewritten as an allowlist, the context is about 40 MB. An example of the pattern:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl"># Exclude everything, then let back in only what the Dockerfile COPYs.
</span></span><span class="line"><span class="cl">*
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">!pyproject.toml
</span></span><span class="line"><span class="cl">!uv.lock
</span></span><span class="line"><span class="cl">!manage.py
</span></span><span class="line"><span class="cl">!myapp
</span></span><span class="line"><span class="cl">!static
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"># Rules match the whole relative path and the last match wins,
</span></span><span class="line"><span class="cl"># so these re-excludes apply inside the allowed directories too.
</span></span><span class="line"><span class="cl">**/__pycache__/
</span></span><span class="line"><span class="cl">**/*.pyc
</span></span><span class="line"><span class="cl">**/.DS_Store
</span></span><span class="line"><span class="cl">**/*.log</span></span></code></pre></div></div>

<h2 class="relative group">Secrets, in as few places as possible
    <div id="secrets-in-as-few-places-as-possible" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#secrets-in-as-few-places-as-possible" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>Secrets live in three places. GitHub Actions secrets are for what workflows do, and the two Hugo repositories have exactly one each, <code>CLOUDFLARE_API_TOKEN</code>. Fly secrets are the app&rsquo;s runtime configuration, set with <code>flyctl secrets set</code>, and they never pass through Actions. And a gitignored <code>.env</code> on my laptop holds the token the deploy script uses to purge Cloudflare&rsquo;s cache.</p>
<p>PyPI needs no secret at all. pyhtml2text uses trusted publishing: PyPI accepts the short-lived OIDC token GitHub issues to the workflow, so the release job asks for <code>id-token: write</code> and the repository&rsquo;s secrets page is empty. There&rsquo;s nothing to leak and nothing to rotate.</p>
<p>Tokens get the least they need. The app&rsquo;s Fly token in GitHub serves a scheduled check that only lists machines, so it&rsquo;s created with <code>fly tokens create readonly</code>. The Pages deploys ask for <code>contents: read</code> and <code>deployments: write</code>, and nothing more.</p>
<p>Inputs go through <code>env:</code>, never straight into a script. The OTA workflow takes free-text release notes. Written as <code>${{ inputs.notes }}</code> inside <code>run:</code>, the text would be pasted into the script before bash reads it, so a note with a quote and a semicolon in it would become code. Passed as an environment variable, it stays data. An example of the pattern:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">Publish</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">env</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">OTA_API_TOKEN</span><span class="p">:</span><span class="w"> </span><span class="l">${{ secrets.OTA_API_TOKEN }}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">INPUT_PLATFORM</span><span class="p">:</span><span class="w"> </span><span class="l">${{ inputs.platform }}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">INPUT_ROLLOUT</span><span class="p">:</span><span class="w"> </span><span class="l">${{ inputs.rollout }}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">INPUT_NOTES</span><span class="p">:</span><span class="w"> </span><span class="l">${{ inputs.notes }}</span><span class="w">
</span></span></span><span class="line"><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="cl"><span class="sd">          args=(&#34;$INPUT_PLATFORM&#34;)
</span></span></span><span class="line"><span class="cl"><span class="sd">          [ -n &#34;$INPUT_ROLLOUT&#34; ] &amp;&amp; args+=(--rollout &#34;$INPUT_ROLLOUT&#34;)
</span></span></span><span class="line"><span class="cl"><span class="sd">          [ -n &#34;$INPUT_NOTES&#34; ] &amp;&amp; args+=(--notes &#34;$INPUT_NOTES&#34;)
</span></span></span><span class="line"><span class="cl"><span class="sd">          ./scripts/publish-ota.sh &#34;${args[@]}&#34;</span></span></span></code></pre></div></div>
<p>Some keys stay off GitHub altogether. One scheduled report needs a key with admin rights over the app&rsquo;s sign-in backend, so it moved from a scheduled workflow to a scheduled job on my Mac. The workflow is still there as a manual fallback, and leaves out the sections that need that key.</p>

<h2 class="relative group">Deploys: on every push, by script, and on a tag
    <div id="deploys-on-every-push-by-script-and-on-a-tag" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#deploys-on-every-push-by-script-and-on-a-tag" aria-label="Anchor">#</a>
    </span>
    
</h2>

<h3 class="relative group">The Hugo sites deploy on every push
    <div id="the-hugo-sites-deploy-on-every-push" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#the-hugo-sites-deploy-on-every-push" aria-label="Anchor">#</a>
    </span>
    
</h3>
<p>An example version of the blog&rsquo;s workflow, with the first-run step that creates the Pages project left out:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">Deploy to Cloudflare Pages</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">on</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">push</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">workflow_dispatch</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">env</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">HUGO_VERSION</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;0.165.0&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">PAGES_PROJECT</span><span class="p">:</span><span class="w"> </span><span class="l">my-site</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">concurrency</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">group</span><span class="p">:</span><span class="w"> </span><span class="l">deploy-${{ github.ref }}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">cancel-in-progress</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="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">jobs</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">deploy</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><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="cl"><span class="w">    </span><span class="nt">permissions</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">contents</span><span class="p">:</span><span class="w"> </span><span class="l">read</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">deployments</span><span class="p">:</span><span class="w"> </span><span class="l">write</span><span class="w">
</span></span></span><span class="line"><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="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">actions/checkout@v7</span><span class="w">
</span></span></span><span class="line"><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="cl"><span class="w">          </span><span class="nt">submodules</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="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">peaceiris/actions-hugo@v3</span><span class="w">
</span></span></span><span class="line"><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="cl"><span class="w">          </span><span class="nt">hugo-version</span><span class="p">:</span><span class="w"> </span><span class="l">${{ env.HUGO_VERSION }}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">extended</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="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="c"># Processed images are gitignored; restore them so Hugo only</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="c"># re-encodes what is new. An older cache still helps.</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">actions/cache@v6</span><span class="w">
</span></span></span><span class="line"><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="cl"><span class="w">          </span><span class="nt">path</span><span class="p">:</span><span class="w"> </span><span class="l">resources/_gen</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">key</span><span class="p">:</span><span class="w"> </span><span class="l">hugo-resources-${{ hashFiles(&#39;content/**/*.jpg&#39;, &#39;content/**/*.png&#39;, &#39;assets/img/**&#39;) }}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">restore-keys</span><span class="p">:</span><span class="w"> </span><span class="p">|</span><span class="sd">
</span></span></span><span class="line"><span class="cl"><span class="sd">            hugo-resources-</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="c"># A multilingual site has no root 404.html; without one, Pages</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="c"># answers unknown paths with index.html and a 200.</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">Build</span><span class="w">
</span></span></span><span class="line"><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="cl"><span class="sd">          hugo --gc
</span></span></span><span class="line"><span class="cl"><span class="sd">          cp public/en/404.html public/404.html</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="c"># ...</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">Deploy</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">cloudflare/wrangler-action@v4</span><span class="w">
</span></span></span><span class="line"><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="cl"><span class="w">          </span><span class="nt">apiToken</span><span class="p">:</span><span class="w"> </span><span class="l">${{ secrets.CLOUDFLARE_API_TOKEN }}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">accountId</span><span class="p">:</span><span class="w"> </span><span class="l">${{ vars.CLOUDFLARE_ACCOUNT_ID }}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">command</span><span class="p">:</span><span class="w"> </span><span class="l">pages deploy public --project-name=${{ env.PAGES_PROJECT }} --branch=${{ github.ref_name }}</span></span></span></code></pre></div></div>
<p><code>--branch=${{ github.ref_name }}</code> makes <code>main</code> production and gives every other branch a preview URL. The client site&rsquo;s workflow mirrors this one, plus an <code>rm -rf public</code> before the build, because its <code>public/</code> is committed and removed pages would otherwise linger in the upload.</p>
<p>Hugo is pinned here and nowhere else. In October a package-manager upgrade moved my laptop to 0.168, one past the newest version the theme supports. The deploys stayed on 0.165 and never noticed.</p>
<p>And the build is the test. On 14 September the preview deploy for one branch failed three times running: a post on it linked, through Hugo&rsquo;s <code>ref</code>, to one that was still a draft. CI builds what production builds, drafts excluded, so the link pointed at nothing and Hugo stopped with <code>REF_NOT_FOUND</code>. <code>main</code> carried on deploying in the meantime.</p>

<h3 class="relative group">The app&rsquo;s API deploys from a script
    <div id="the-apps-api-deploys-from-a-script" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#the-apps-api-deploys-from-a-script" aria-label="Anchor">#</a>
    </span>
    
</h3>
<p>The core of the deploy script, as an example:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="nv">IMAGE_LABEL</span><span class="o">=</span><span class="s2">&#34;deploy-</span><span class="k">$(</span>git rev-parse --short HEAD<span class="k">)</span><span class="s2">-</span><span class="k">$(</span>date +%Y%m%d%H%M%S<span class="k">)</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Build first, then wake a spare machine, then roll out exactly that image.</span>
</span></span><span class="line"><span class="cl">flyctl deploy --config fly.toml --build-only --push --image-label <span class="s2">&#34;</span><span class="nv">$IMAGE_LABEL</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">curl -fsS --retry <span class="m">5</span> https://myapp.fly.dev/health &gt; /dev/null
</span></span><span class="line"><span class="cl">flyctl deploy --config fly.toml --image <span class="s2">&#34;registry.fly.io/myapp:</span><span class="nv">$IMAGE_LABEL</span><span class="s2">&#34;</span></span></span></code></pre></div></div>
<p>And the matching lines in <code>fly.toml</code>:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-toml" data-lang="toml"><span class="line"><span class="cl"><span class="p">[</span><span class="nx">deploy</span><span class="p">]</span>
</span></span><span class="line"><span class="cl">  <span class="c"># max_unavailable only counts machines that are running,</span>
</span></span><span class="line"><span class="cl">  <span class="c"># which is why the script wakes one first.</span>
</span></span><span class="line"><span class="cl">  <span class="nx">strategy</span> <span class="p">=</span> <span class="s2">&#34;rolling&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="nx">max_unavailable</span> <span class="p">=</span> <span class="mi">1</span></span></span></code></pre></div></div>
<p>The image is built once, pushed to Fly&rsquo;s registry under a label made of the commit and the time, and deployed by that label, so what rolls out is exactly what was built.</p>
<p>Waking a spare first is what makes it zero-downtime. <code>max_unavailable = 1</code> only counts running machines, and the app keeps none running when it&rsquo;s idle (a suspended machine resumes in about a second), so a lone awake machine used to be replaced in the same batch as the sleeping ones, with nothing warm behind it. On 28 September a probe against the origin saw 7 timeouts and a 7.3-second gap during a plain <code>flyctl deploy</code>, and no failures in 2,514 requests during the scripted deploy, which builds first and has a machine awake before the rollout starts.</p>
<p>The rest of the script marks the deploy in the team chat channel the error alerts go to, so a burst of errors can be read against what just changed, purges Cloudflare&rsquo;s cache and warms the machines. The web front end skips the image entirely; that&rsquo;s in <a href="https://jared.lynskey.co.nz/en/posts/2026/2026-09-14-web-releases-without-redeploying/" >Deploy Frontend and Backend Separately, Without Redeploying the API</a>.</p>
<p>Migrations aren&rsquo;t a pipeline step. Fly&rsquo;s <code>release_command</code> hung for me, so the first machine to boot on a new image takes a lock and migrates while the others wait. Every migration therefore has to work with the code that&rsquo;s still running: add a column in one deploy, stop using it in the next, drop it in a third.</p>
<p>Nothing in the script needs my laptop as such, but a couple of local-only files keep it there: the Cloudflare credentials, and a record of what was last deployed, which a fresh runner wouldn&rsquo;t have. It will also deploy uncommitted changes, and says so in the chat channel, where the web release script refuses a dirty tree.</p>
<p>In <a href="https://jared.lynskey.co.nz/en/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/" >Preview Environments per Branch on Fly.io</a> I wrote that doing this by hand is fine while you&rsquo;re the only one doing it, and that from the second developer on it has to happen because something happened in git. The app is still at one developer; the branch-environment workflows from that post live on the branch I built them on.</p>
<p>The OTA workflow is manual-only because publishing should be a decision, and because the update service refuses a release while a partial rollout is open, which an on-push trigger would hit during every canary. It has never run. Every OTA update so far has gone out from my laptop through the same script the workflow calls, which is the case for thin workflows in one line: however a release starts, it&rsquo;s the same script.</p>

<h3 class="relative group">pyhtml2text releases on a tag
    <div id="pyhtml2text-releases-on-a-tag" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#pyhtml2text-releases-on-a-tag" aria-label="Anchor">#</a>
    </span>
    
</h3>
<p>The release job, with the package name made generic:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="w">  </span><span class="nt">release</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><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="cl"><span class="w">    </span><span class="nt">if</span><span class="p">:</span><span class="w"> </span><span class="l">${{ startsWith(github.ref, &#39;refs/tags/&#39;) || github.event_name == &#39;workflow_dispatch&#39; }}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">needs</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">linux, macos, windows, sdist]</span><span class="w">
</span></span></span><span class="line"><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="cl"><span class="w">      </span><span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">pypi</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">url</span><span class="p">:</span><span class="w"> </span><span class="l">https://pypi.org/p/mypackage</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">permissions</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">id-token</span><span class="p">:</span><span class="w"> </span><span class="l">write      </span><span class="w"> </span><span class="c"># trusted publishing and signing</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">contents</span><span class="p">:</span><span class="w"> </span><span class="l">write</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">attestations</span><span class="p">:</span><span class="w"> </span><span class="l">write</span><span class="w">
</span></span></span><span class="line"><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="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">actions/download-artifact@v8</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">actions/attest@v4</span><span class="w">
</span></span></span><span class="line"><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="cl"><span class="w">          </span><span class="nt">subject-path</span><span class="p">:</span><span class="w"> </span><span class="s1">&#39;wheels-*/*&#39;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">astral-sh/setup-uv@v10</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">if</span><span class="p">:</span><span class="w"> </span><span class="l">${{ startsWith(github.ref, &#39;refs/tags/&#39;) }}</span><span class="w">
</span></span></span><span class="line"><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="cl"><span class="w">          </span><span class="nt">prune-cache</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="cl"><span class="w">      </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">Publish to PyPI</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">if</span><span class="p">:</span><span class="w"> </span><span class="l">${{ startsWith(github.ref, &#39;refs/tags/&#39;) }}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">uv publish --trusted-publishing always &#39;wheels-*/*&#39;</span></span></span></code></pre></div></div>
<p>The release job waits for every build job, and only a tag publishes; a manual run builds and attests but uploads nothing. <code>actions/attest</code> records which workflow built each wheel, and <code>uv publish --trusted-publishing always</code> uploads them with the OIDC token rather than a password. The package itself is in <a href="https://jared.lynskey.co.nz/en/posts/2026/2026-10-18-pyhtml2text/" >A Closed Pull Request, a Fork and a PyPI Package</a>.</p>

<h2 class="relative group">Scheduled workflows are best effort
    <div id="scheduled-workflows-are-best-effort" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#scheduled-workflows-are-best-effort" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>The app has one scheduled workflow, a check that Fly hasn&rsquo;t started more machines than the baseline, which would otherwise first show up on the bill. It alerts by failing, since GitHub emails the owner about a failed scheduled run.</p>
<p>It&rsquo;s set to <code>cron: '*/15 * * * *'</code>, 96 runs a day. Over the past two weeks it ran between three and eight times a day, with a median gap of about three hours forty. The only explanation GitHub&rsquo;s documentation offers is load: scheduled runs can be delayed when Actions is busy, and dropped when it&rsquo;s busy enough. For a sustained scale-out, a few hours is fine. For anything that has to fire within minutes, a GitHub cron isn&rsquo;t a monitor.</p>

<h2 class="relative group">What I&rsquo;d ask of a pipeline
    <div id="what-id-ask-of-a-pipeline" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#what-id-ask-of-a-pipeline" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>If you&rsquo;re building one, or looking at one you already have:</p>
<ol>
<li>If GitHub Actions were down for a day, could you still release? If not, your release logic lives in YAML, and the 2024 post still applies.</li>
<li>Does CI run the interpreter, the CPU architecture and the database major version that production runs? Mine caught a compiler bug only because the runner and production are both x86_64 and my laptop isn&rsquo;t.</li>
<li>Does a fresh checkout pass? Generated, gitignored files are the usual reason it doesn&rsquo;t.</li>
<li>Where does a run&rsquo;s time actually go? Read the step timings before you add a cache.</li>
<li>For each secret, how many places does it live, and does it need to be in GitHub at all?</li>
</ol>
]]></content:encoded>
      <media:content url="https://jared.lynskey.co.nz/en/posts/2026/2026-10-22-docker-compose-ci-github-actions/featured.jpg" medium="image" type="image/jpeg" />
    </item>
    <item>
      <title>Use Docker Compose for CI/CD Instead of Platform-Specific YAML</title>
      <link>https://jared.lynskey.co.nz/en/posts/2024/2024-06-02-simplifyingcicd/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/en/posts/2024/2024-06-02-simplifyingcicd/</guid>
      <pubDate>Sun, 02 Jun 2024 00:30:03 +0000</pubDate>
      <dc:creator>Jared Lynskey</dc:creator>
      <category>CI/CD</category>
      <category>Docker</category>
      <category>GitHub Actions</category>
      <category>DevOps</category>
      <description>Put your build and test logic in Docker Compose files instead of GitHub Actions YAML. It&#39;s portable, testable locally, and simpler to maintain.</description>
      <content:encoded><![CDATA[<p><em>Update, October 2026: two years on I went back through every repository I ship from and wrote up what survived of this advice in <a href="https://jared.lynskey.co.nz/en/posts/2026/2026-10-22-docker-compose-ci-github-actions/" >Building a CI/CD Pipeline on GitHub Actions</a>. The principle held; the Compose-in-CI mechanism mostly didn&rsquo;t.</em></p>
<p>Most teams write their build and test steps straight into GitHub Actions YAML. It works, right up until you want to run the same build on your laptop, or someone suggests moving to GitLab and you realise the whole pipeline is welded to one vendor&rsquo;s config format. My preference is fairly boring: keep the real logic in Docker Compose, and let the CI tool do nothing except call it.</p>

<h4 class="relative group">Why I keep build logic out of Actions YAML
    <div id="why-i-keep-build-logic-out-of-actions-yaml" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#why-i-keep-build-logic-out-of-actions-yaml" aria-label="Anchor">#</a>
    </span>
    
</h4>
<p>The obvious problem is lock-in. An Actions workflow means nothing to GitLab, Bitbucket or Jenkins, so if your build steps live in the workflow file, changing providers means rewriting all of them. Nobody ever budgets time for that.</p>
<p>The quieter problem is that you can&rsquo;t run the pipeline locally. When the only way to test a change to the build is pushing a commit and watching the Actions tab, you end up with a git history full of &ldquo;fix ci&rdquo;, &ldquo;fix ci again&rdquo;, &ldquo;please&rdquo;. A Compose file runs the same on my laptop as on the runner, which also kills most &ldquo;works on my machine&rdquo; arguments before they start.</p>
<p>And the YAML grows. Twenty tidy lines turn into three hundred once caching tricks and conditionals show up, and now every developer has to learn a CI dialect on top of Git and everything else. A script should do one thing; a big workflow file ends up doing all of them at once.</p>
<p>Compose splits the responsibilities cleanly: the Compose file owns building and testing, and the CI platform is just the thing that runs it on every push.</p>

<h4 class="relative group">The setup
    <div id="the-setup" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#the-setup" aria-label="Anchor">#</a>
    </span>
    
</h4>
<ol>
<li>Start with a <code>Dockerfile</code>, so building the image has exactly one home:</li>
</ol>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="c"># Use an official Node.js runtime as the base image</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="l">FROM node:14</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="c"># Set the working directory in the container</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="l">WORKDIR /app</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="c"># Copy the package.json and package-lock.json files to the container</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="l">COPY package*.json ./</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="c"># Install the dependencies</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="l">RUN npm install</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="c"># Copy the rest of the application code to the container</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="l">COPY . .</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="c"># Expose the port the app runs on</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="l">EXPOSE 3000</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="c"># Define the command to run the application</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="l">CMD [&#34;npm&#34;, &#34;start&#34;]</span></span></span></code></pre></div></div>
<ol start="2">
<li>
<p>Then a <code>docker-compose.build.yaml</code> that defines the services needed to build and test:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">services</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">app</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">myprivaterepo.com/image:latest</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">build</span><span class="p">:</span><span class="w"> </span><span class="l">.</span><span class="w">
</span></span></span><span class="line"><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="cl"><span class="w">      </span>- <span class="l">.:/app</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">command</span><span class="p">:</span><span class="w"> </span><span class="l">sh -c &#34;npm install &amp;&amp; npm test&#34;</span></span></span></code></pre></div></div>
</li>
<li>
<p>Run it locally first. This is the whole point of the exercise:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="cl">docker compose -f docker-compose.build.yaml build</span></span></code></pre></div></div>
</li>
<li>
<p>With that working, the GitHub Actions workflow (or its equivalent on any other platform) shrinks down to an orchestrator:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">CI/CD Pipeline</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">on</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">push, pull_request]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">jobs</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><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="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="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="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">actions/checkout@v2</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <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="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@v1</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">Build and Test with Docker Compose</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">docker compose -f docker-compose.build.yaml build</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">Push the image referenced in the docker compose file</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">docker compose -f docker-compose.build.yaml push</span></span></span></code></pre></div></div>
</li>
</ol>
<p>The CI tool now does two things, build and push, and everything worth understanding lives in files that any platform (or any new team member) can run directly.</p>

<h4 class="relative group">Pushing the image
    <div id="pushing-the-image" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#pushing-the-image" aria-label="Anchor">#</a>
    </span>
    
</h4>
<p>A nice side effect of the Compose file is that pushing is trivial. Docker works out where the image goes from its name: <code>image: myprivaterepo.com/image:latest</code> means it gets pushed to <code>myprivaterepo.com</code>. For a private registry you&rsquo;ll need an access token — check your registry&rsquo;s docs for that part.</p>
<p>If you&rsquo;re on GitHub Actions and happy, none of this is urgent. But the day you switch providers, the migration is copying two commands instead of porting three hundred lines of YAML. And until then, you get to debug your builds locally like a civilised person.</p>
]]></content:encoded>
      <media:content url="https://jared.lynskey.co.nz/en/posts/2024/2024-06-02-simplifyingcicd/featured.jpg" medium="image" type="image/jpeg" />
    </item>
  </channel>
</rss>
