<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Ci on Guillaume Delré</title><link>https://guillaumedelre.github.io/tags/ci/</link><description>Recent content in Ci on Guillaume Delré</description><generator>Hugo</generator><language>en</language><lastBuildDate>Sat, 16 May 2026 10:00:00 +0000</lastBuildDate><atom:link href="https://guillaumedelre.github.io/tags/ci/index.xml" rel="self" type="application/rss+xml"/><item><title>Fifteen Minutes Before the First Test</title><link>https://guillaumedelre.github.io/2026/05/16/fifteen-minutes-before-the-first-test/</link><pubDate>Sat, 16 May 2026 10:00:00 +0000</pubDate><guid>https://guillaumedelre.github.io/2026/05/16/fifteen-minutes-before-the-first-test/</guid><description>Part 5 of 8 in &amp;quot;Symfony to the Cloud: Twelve Factors, Thirteen Services&amp;quot;: How a CI pipeline that provisioned an Azure VM per run — missing RabbitMQ, MinIO, and Varnish — became one that assembles the production environment from the same images it ships.</description><category>symfony-to-the-cloud</category><content:encoded><![CDATA[<p>The pipeline had two stages that had nothing to do with code: <code>provision</code> and <code>deprovision</code>. Between them, in sequence, came <code>phpunit</code>, <code>phpmetrics</code>, and <code>behat</code>.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#f92672">stages</span>:
</span></span><span style="display:flex;"><span>  - <span style="color:#ae81ff">build</span>
</span></span><span style="display:flex;"><span>  - <span style="color:#ae81ff">provision</span>
</span></span><span style="display:flex;"><span>  - <span style="color:#ae81ff">phpunit</span>
</span></span><span style="display:flex;"><span>  - <span style="color:#ae81ff">phpmetrics</span>
</span></span><span style="display:flex;"><span>  - <span style="color:#ae81ff">behat</span>
</span></span><span style="display:flex;"><span>  - <span style="color:#ae81ff">deprovision</span>
</span></span><span style="display:flex;"><span>  - <span style="color:#ae81ff">deploy</span>
</span></span></code></pre></div><p>Before the first assertion ran, fifteen minutes had passed. Terraform had cloned an infrastructure repository, authenticated to Azure, and applied a VM configuration. Ansible had connected to the new VM, installed PHP, configured the application, wired up a database and a Redis instance. Then the tests ran. Then Terraform destroyed what Ansible had built.</p>
<p>For every pipeline. From every branch. For every pull request, from open to merge.</p>
<h2 id="what-those-fifteen-minutes-were-missing">What those fifteen minutes were missing</h2>
<p>The <code>provision</code> stage set up two services: PostgreSQL and Redis. Three services that the application depended on in production were absent: RabbitMQ, MinIO, and Varnish.</p>
<p>RabbitMQ processed all asynchronous work — 56 consumers across 14 microservices. MinIO handled media storage. Varnish fronted the HTTP cache. In CI, none of them existed. Tests that exercised message queuing or file storage had two options: skip these paths, or leave them untested until staging. Varnish is a different case: tests hit the application directly and intentionally bypass the cache layer, so its absence in CI is a deliberate choice rather than a gap.</p>
<p>This is the problem <a href="https://12factor.net/dev-prod-parity" target="_blank" rel="noopener noreferrer">Factor X</a>
 describes as the environment gap. The gap here wasn&rsquo;t a matter of configuration — it was structural. The VM was built by Ansible from a script in a separate repository. It wasn&rsquo;t a container image. It wasn&rsquo;t versioned alongside the application. If a branch modified the RabbitMQ message topology, there was no way to test that modification in CI. The topology change and the code that relied on it would only meet in staging.</p>
<p>The Ansible provisioning script itself is part of the problem:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#f92672">launch_vm</span>:
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">stage</span>: <span style="color:#ae81ff">provision</span>
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">script</span>:
</span></span><span style="display:flex;"><span>    - <span style="color:#ae81ff">git clone git@gitlab.internal/infra/ci-vm.git</span>
</span></span><span style="display:flex;"><span>    - <span style="color:#ae81ff">cd ci-vm</span>
</span></span><span style="display:flex;"><span>    - <span style="color:#ae81ff">az login --service-principal -u $ARM_CLIENT_ID ...</span>
</span></span><span style="display:flex;"><span>    - <span style="color:#ae81ff">terraform apply -var &#34;prefix=${CI_PIPELINE_ID}-vm&#34; ...</span>
</span></span><span style="display:flex;"><span>    - <span style="color:#ae81ff">sleep 45</span>
</span></span><span style="display:flex;"><span>    - <span style="color:#ae81ff">ansible-playbook behat/test-env.yml ...</span>
</span></span></code></pre></div><p>The <code>sleep 45</code> is there because Ansible needs the VM to finish booting before it can connect. It&rsquo;s not an oversight — it&rsquo;s the minimum time a freshly provisioned VM needs before SSH works. It&rsquo;s baked into the process.</p>
<h2 id="what-replaced-it">What replaced it</h2>
<p>The new pipeline has no <code>provision</code> stage. It has no <code>deprovision</code> stage. The environment is the images, and the images exist before the tests begin.</p>
<p>Each test job declares its dependencies as Docker services:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#f92672">services</span>:
</span></span><span style="display:flex;"><span>  - <span style="color:#f92672">name</span>: <span style="color:#ae81ff">$REGISTRY_URL/platform/rabbitmq:$CI_COMMIT_REF_SLUG</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">alias</span>: <span style="color:#ae81ff">rabbitmq</span>
</span></span><span style="display:flex;"><span>  - <span style="color:#f92672">name</span>: <span style="color:#ae81ff">$REGISTRY_URL/platform/minio:$CI_COMMIT_REF_SLUG</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">alias</span>: <span style="color:#ae81ff">minio</span>
</span></span><span style="display:flex;"><span>  - <span style="color:#f92672">name</span>: <span style="color:#ae81ff">redis:7.4.1</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">alias</span>: <span style="color:#ae81ff">redis</span>
</span></span><span style="display:flex;"><span>  - <span style="color:#f92672">name</span>: <span style="color:#ae81ff">$ARTIFACTORY_URL/postgresql:13</span>
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">alias</span>: <span style="color:#ae81ff">postgresql</span>
</span></span></code></pre></div><p>The services start in parallel when the job begins. Before the test script runs, a <code>before_script</code> waits for all of them to be ready:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#f92672">before_script</span>:
</span></span><span style="display:flex;"><span>  - <span style="color:#ae81ff">$CI_PROJECT_DIR/dockerize</span>
</span></span><span style="display:flex;"><span>      -<span style="color:#ae81ff">wait tcp://postgresql:5432</span>
</span></span><span style="display:flex;"><span>      -<span style="color:#ae81ff">wait tcp://rabbitmq:5672</span>
</span></span><span style="display:flex;"><span>      -<span style="color:#ae81ff">wait tcp://minio:9000</span>
</span></span><span style="display:flex;"><span>      -<span style="color:#ae81ff">wait tcp://redis:6379</span>
</span></span><span style="display:flex;"><span>      -<span style="color:#ae81ff">timeout 120s</span>
</span></span></code></pre></div><p>From pipeline start to first assertion: ninety seconds — assuming images are already cached on the runner; a cold pull adds time, but becomes negligible once the pipeline has run once on a given branch.</p>
<h2 id="what-ci_commit_ref_slug-means">What <code>$CI_COMMIT_REF_SLUG</code> means</h2>
<p>The timing is the visible result. What produces it is more interesting: the image names.</p>
<p><code>$REGISTRY_URL/platform/rabbitmq:$CI_COMMIT_REF_SLUG</code> is not the official RabbitMQ image from Docker Hub. It&rsquo;s an image built by the same pipeline, from the same branch, at the same commit as the code being tested. The RabbitMQ image carries the topology: a <code>definitions.json</code> with every exchange, every queue, every binding, every dead-letter configuration — versioned in git alongside the application that depends on them.</p>
<p>If a branch modifies the messaging topology, the CI pipeline builds a new RabbitMQ image that includes those modifications, then runs the tests against it. The topology change and the code that relies on it are tested together, at the same commit, before anything reaches staging.</p>
<p>The same logic applies to MinIO, as described in the <a href="/2026/05/14/the-ghost-of-the-ci-runner/">first article in this series</a>
: the MinIO image carries preloaded test fixtures. The CI environment doesn&rsquo;t need a setup step to populate storage. The state is built in.</p>
<p>The test runner itself follows the same pattern. Each job uses a debug variant of the application image — built from the same branch, same commit — with the test dependencies included:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#f92672">image</span>: <span style="color:#ae81ff">$REGISTRY_URL/platform/$service:$CI_COMMIT_REF_SLUG-debug</span>
</span></span></code></pre></div><p>The whole environment assembles from artifacts built at the same point in the git history.</p>
<h2 id="what-this-required-dropping">What this required dropping</h2>
<p>Behat and the provisioned VM were coupled. The Behat test suite ran against an HTTP server on the VM; removing the VM meant removing Behat.</p>
<p>That turned out not to be the obstacle it looked like. The Behat suite lived in a separate repository, required the VM to run, and had accumulated significant maintenance overhead. PHPUnit, running inside the application container with Docker services, covered the same scenarios through a more direct path: functional tests exercising the HTTP layer, unit tests for individual components, suites organized per feature area and generated dynamically into parallel CI jobs.</p>
<p>The BDD layer went away. The test coverage stayed — and could now run against the actual services.</p>
<h2 id="factor-x-applied">Factor X, applied</h2>
<p><a href="https://12factor.net/dev-prod-parity" target="_blank" rel="noopener noreferrer">Factor X</a>
 is often read as &ldquo;use the same database locally as in production.&rdquo; That&rsquo;s the simplest version. The deeper version is about the gap between what you test and what you ship.</p>
<p>The gap in the old pipeline was wide: a manually configured VM, missing key services, rebuilt from scratch on every run. The gap in the new pipeline is narrow: the CI assembles the environment from the same images as production, built from the same commit as the code under test.</p>
<p>The fifteen minutes of Terraform and Ansible were not just slow. They were building something that wasn&rsquo;t what production ran, every time, before any test could begin. The ninety seconds of <code>docker pull</code> build exactly what production runs — and the tests that follow are testing that, not an approximation of it.</p>
]]></content:encoded></item><item><title>The Ghost of the CI Runner</title><link>https://guillaumedelre.github.io/2026/05/14/the-ghost-of-the-ci-runner/</link><pubDate>Thu, 14 May 2026 10:00:00 +0000</pubDate><guid>https://guillaumedelre.github.io/2026/05/14/the-ghost-of-the-ci-runner/</guid><description>Part 1 of 8 in &amp;quot;Symfony to the Cloud: Twelve Factors, Thirteen Services&amp;quot;: How a CI runner&amp;#39;s home directory in production config revealed a storage architecture problem — and how Flysystem lazy adapters fixed it.</description><category>symfony-to-the-cloud</category><content:encoded><![CDATA[<pre tabindex="0"><code class="language-dotenv" data-lang="dotenv">APP__COLD_STORAGE__FILESYSTEM_PATH=&#34;/home/jenkins-slave/share_media/media&#34;
APP__COLD_STORAGE__FILESYSTEM_PATH_CACHE=&#34;/home/jenkins-slave/share_media/media/cache&#34;
APP__COLD_STORAGE__RAW_IMAGE_PATH=&#34;/home/jenkins-slave/share_media/media_raw&#34;
APP__SHARE_STORAGE__FILESYSTEM_PATH=&#34;/home/jenkins-slave/share_storage&#34;
</code></pre><p>These lines were in the production <code>.env</code> of the media service. Not staging. Not a local override. Production, committed to the repository, read on every startup.</p>
<p>The paths end where you&rsquo;d expect: <code>/media</code>, <code>/share_storage</code>. They start somewhere more surprising: <code>/home/jenkins-slave</code>, the home directory of a CI runner from an old Jenkins setup.</p>
<h2 id="how-a-runners-home-directory-ends-up-in-production-config">How a runner&rsquo;s home directory ends up in production config</h2>
<p>The platform had grown from a single machine. One server ran everything — the application, the CI runner, the database, the file storage. Files moved between the app and the CI system via NFS: a directory mounted on the same host, accessible to both the containers and the runner.</p>
<p>The path <code>/home/jenkins-slave/share_media</code> was where the NFS share landed on that machine. When the team migrated to Docker Compose, the containers inherited the NFS mount. The path made it into the <code>.env</code> because the application needed to know where to find files. Nobody changed it because it worked. The mount was still there. The path was valid. The application started. Files appeared where they should.</p>
<p>Three years later, nobody thought about it at all. It was just how the media path was configured.</p>
<h2 id="what-kubectl-apply-found">What kubectl apply found</h2>
<p>The first <code>kubectl apply</code> for the media service ended with a pod stuck in CrashLoopBackOff. The container started. The entrypoint ran. The application tried to access <code>/home/jenkins-slave/share_media/media</code>. No such file or directory. No NFS mount. No runner.</p>
<p>The path didn&rsquo;t document a design decision. It documented the machine that happened to be running at the time the <code>.env</code> was written.</p>
<p>This is what <a href="https://12factor.net/backing-services" target="_blank" rel="noopener noreferrer">Factor IV</a>
 of the twelve-factor app is warning against. Backing services — storage, queues, databases — should be attached resources, configured via URL or connection string, interchangeable between environments without code changes. A filesystem path on a shared host is not a backing service. It&rsquo;s a physical assumption about the machine. When the machine changes, the assumption fails.</p>
<h2 id="the-path-was-the-symptom">The path was the symptom</h2>
<p>The obvious first step was removing the runner reference:</p>
<pre tabindex="0"><code class="language-dotenv" data-lang="dotenv">APP__COLD_STORAGE__FILESYSTEM_PATH=&#34;/share_media/media&#34;
APP__SHARE_STORAGE__FILESYSTEM_PATH=&#34;/share_storage&#34;
</code></pre><p>Cleaner. No more CI references in a production config. Still not right. The application still assumed a POSIX filesystem — either a volume mount or a directory on the node. In Kubernetes, a volume shared between multiple pods requires a <code>ReadWriteMany</code> PersistentVolumeClaim. Most storage providers don&rsquo;t support it. Those that do tend to be slow and expensive. And even where it works, you&rsquo;ve replaced one shared filesystem assumption with another.</p>
<p>Renaming the path bought time. It didn&rsquo;t fix the problem.</p>
<p>The problem was that roughly twelve terabytes of images — originals and pre-generated derivatives in multiple formats — from multiple editorial brands — were treated as a directory. A directory can&rsquo;t be mounted cleanly across pods. A backing service can.</p>
<h2 id="flysystem-as-the-shape-of-the-solution">Flysystem as the shape of the solution</h2>
<p>The media service already had a Flysystem dependency. Three concrete adapters — local filesystem, AWS S3, Azure Blob — and one lazy adapter sitting on top:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#75715e"># config/packages/flysystem.yaml</span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">flysystem</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">storages</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">media.storage.local</span>:
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">adapter</span>: <span style="color:#e6db74">&#39;local&#39;</span>
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">options</span>:
</span></span><span style="display:flex;"><span>                <span style="color:#f92672">directory</span>: <span style="color:#e6db74">&#34;/&#34;</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">media.storage.aws</span>:
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">adapter</span>: <span style="color:#e6db74">&#39;aws&#39;</span>
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">options</span>:
</span></span><span style="display:flex;"><span>                <span style="color:#f92672">client</span>: <span style="color:#e6db74">&#39;aws_client_service&#39;</span>
</span></span><span style="display:flex;"><span>                <span style="color:#f92672">bucket</span>: <span style="color:#e6db74">&#39;media&#39;</span>
</span></span><span style="display:flex;"><span>                <span style="color:#f92672">streamReads</span>: <span style="color:#66d9ef">true</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">media.storage</span>:
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">adapter</span>: <span style="color:#e6db74">&#39;lazy&#39;</span>
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">options</span>:
</span></span><span style="display:flex;"><span>                <span style="color:#f92672">source</span>: <span style="color:#e6db74">&#39;%env(APP__FLYSYSTEM_MEDIA_STORAGE)%&#39;</span>
</span></span></code></pre></div><p>All application code depends on <code>media.storage</code>. It doesn&rsquo;t know whether files live on the filesystem or in a cloud bucket. One environment variable determines which backend is active:</p>
<pre tabindex="0"><code class="language-dotenv" data-lang="dotenv">APP__FLYSYSTEM_MEDIA_STORAGE=media.storage.aws   # production
APP__FLYSYSTEM_MEDIA_STORAGE=media.storage.local  # local fallback still available
</code></pre><p>The path is gone. The filesystem assumption is gone. What remains is a service name — an attached resource in the twelve-factor sense, configurable without rebuilding the image.</p>
<p>The same pattern extends to the thumbnail cache. <a href="https://github.com/liip/LiipImagineBundle" target="_blank" rel="noopener noreferrer">LiipImagine</a>
 generates resized images on demand; both the source originals and the generated cache go through separate Flysystem adapters:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#f92672">liip_imagine</span>:
</span></span><span style="display:flex;"><span>    <span style="color:#f92672">loaders</span>:
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">default</span>:
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">flysystem</span>:
</span></span><span style="display:flex;"><span>                <span style="color:#f92672">filesystem_service</span>: <span style="color:#e6db74">&#39;media.storage&#39;</span>
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">default_cache</span>:
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">flysystem</span>:
</span></span><span style="display:flex;"><span>                <span style="color:#f92672">filesystem_service</span>: <span style="color:#e6db74">&#39;media.cache.storage&#39;</span>
</span></span></code></pre></div><p>Two environment variables, two buckets. The full pipeline — receive upload, store original, generate thumbnail, cache it — is cloud-portable without touching a line of PHP.</p>
<p>What this doesn&rsquo;t cover is moving the data. The lazy adapter changes one environment variable. Getting twelve terabytes from an NFS mount into an S3 bucket is a different project — a migration window, double-write during cutover, verification that nothing was missed.</p>
<h2 id="what-minio-makes-possible-in-ci">What Minio makes possible in CI</h2>
<p>Production uses S3. Local development uses <a href="https://min.io/" target="_blank" rel="noopener noreferrer">Minio</a>
, an S3-compatible object store that runs in a Docker container. The AWS adapter talks to Minio locally and to S3 in production. The application doesn&rsquo;t notice the difference:</p>
<pre tabindex="0"><code class="language-dotenv" data-lang="dotenv"># local/CI
APP__FLYSYSTEM_MEDIA_STORAGE=media.storage.aws
APP__MINIO_ENDPOINT=http://minio:9000
APP__MINIO_ACCESS_KEY=minioadmin
APP__MINIO_SECRET_KEY=minioadmin
</code></pre><p>The same code, the same adapter, a different endpoint. No mocking, no special test paths, no environment-specific branches.</p>
<p>But the CI configuration goes one step further. The Minio image used in the pipeline isn&rsquo;t the standard upstream one — it&rsquo;s a custom image built with test fixtures preloaded:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-dockerfile" data-lang="dockerfile"><span style="display:flex;"><span><span style="color:#66d9ef">FROM</span> <span style="color:#e6db74">minio/minio:latest</span><span style="color:#960050;background-color:#1e0010">
</span></span></span><span style="display:flex;"><span><span style="color:#66d9ef">COPY</span> tests/fixtures/ /fixtures_media/<span style="color:#960050;background-color:#1e0010">
</span></span></span></code></pre></div><p>Every CI run starts with a Minio instance that already contains the data the test suite expects. No setup script, no seed command, no &ldquo;wait for fixtures to load&rdquo; step before tests begin. The initial state of the test environment is part of the build artifact.</p>
<p><a href="https://12factor.net/build-release-run" target="_blank" rel="noopener noreferrer">Factor V</a>
 applied to test infrastructure: the environment state is built, versioned, and immutable. The CI pipeline builds the Minio image from the same source and at the same commit as the application image. The test fixtures and the code that exercises them are always in sync.</p>
<h2 id="the-s3-tradeoff-honestly">The S3 tradeoff, honestly</h2>
<p>S3 introduces a latency cost that local storage doesn&rsquo;t have. The first bytes of a file take 10 to 30 milliseconds to arrive from S3 — that&rsquo;s the documented first-byte latency for the service, not a measurement on this specific workload.</p>
<p>At 300 requests per second, the reasoning for accepting it was this: most reads hit already-generated thumbnails in the S3-backed cache, not the original files. A freshly uploaded image pays the cold-miss penalty once, on the first thumbnail request. Everything after that is a cache hit. Whether the actual tail latency under load bore that reasoning out required performance testing that was tracked separately — the architecture decision and the validation were decoupled.</p>
<p>The tradeoff was accepted: predictable behavior across multiple pods, no shared-state problems, a storage layer that scales without coordination. The full measurement story belongs in the load test report, not here.</p>
<h2 id="the-ghost-leaves">The ghost leaves</h2>
<p>The path <code>/home/jenkins-slave</code> no longer appears in the configuration. But what it pointed to was a coupling that predated Docker, predated microservices, predated any conversation about cloud migration. The CI runner and the production application shared a filesystem because they lived on the same machine. Nobody designed it that way. It accumulated.</p>
<p>A <code>kubectl apply</code> error on a path that shouldn&rsquo;t have existed forced the question: why does this application assume a specific CI runner is present on the host? The answer was &ldquo;because it always has.&rdquo; That&rsquo;s not a reason. It&rsquo;s a history.</p>
<p>Renaming the path was a paper fix. Flysystem&rsquo;s lazy adapter was the actual answer — not because it&rsquo;s more elegant, but because it makes the storage backend a decision that belongs to the environment, not to the application. The container starts, reads one variable, connects to whatever is at the other end. It doesn&rsquo;t know whether that&rsquo;s a bucket in a data center or a container on a laptop.</p>
<p>The runner&rsquo;s home directory is gone from the config. What replaced it is a service name. That&rsquo;s the difference.</p>
]]></content:encoded></item></channel></rss>