<?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>Fly-Io on Anigeek</title><link>https://blog.anigeek.com/tags/fly-io/</link><description>Recent content in Fly-Io on Anigeek</description><generator>Hugo</generator><language>en-us</language><lastBuildDate>Sun, 15 Mar 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://blog.anigeek.com/tags/fly-io/index.xml" rel="self" type="application/rss+xml"/><item><title>Jest, ESM, and the Night Everything Broke: A CI/CD War Story</title><link>https://blog.anigeek.com/posts/jest-esm-cicd-war-story/</link><pubDate>Sun, 15 Mar 2026 00:00:00 +0000</pubDate><guid>https://blog.anigeek.com/posts/jest-esm-cicd-war-story/</guid><description>What happens when you try to add 130 tests and automated deployments to a Node.js server in one night. Spoiler: everything that can break, will break.</description><content:encoded><![CDATA[<p>It started as a simple goal: write tests for our server, wire up CI/CD, and deploy automatically when tests pass. How hard could it be?</p>
<p>By 3 AM, we&rsquo;d fought ES module mocking, crashed three machines, filled a MySQL disk, killed zombie CI processes via raw SQL, and restarted a Proxmox host. But we got there.</p>
<p>This is the story of how.</p>
<h2 id="the-setup">The Setup</h2>
<p>The server is a Node.js + TypeScript + Fastify API with MongoDB, running on Fly.io. The codebase uses ES modules (<code>&quot;type&quot;: &quot;module&quot;</code> in package.json), which — as we&rsquo;d painfully discover — makes testing <em>significantly</em> harder than it should be in 2026.</p>
<p>Our goal for the night:</p>
<ol>
<li>Write comprehensive server tests (user, story, admin controllers)</li>
<li>Set up CI on Gitea Actions (self-hosted runner)</li>
<li>Gate deployments behind passing tests</li>
<li>Auto-deploy to Fly.io on success</li>
</ol>
<p>Simple, right?</p>
<h2 id="phase-1-writing-the-tests-the-easy-part">Phase 1: Writing the Tests (The Easy Part)</h2>
<p>We spun up three parallel agents to write tests for each controller simultaneously. Within 30 minutes, we had 125 server tests covering user registration, story creation, admin moderation — the works. We also wrote 109 Flutter tests for the mobile app.</p>
<p>Test infrastructure:</p>
<ul>
<li><strong>mongodb-memory-server</strong> for isolated database instances</li>
<li><strong>supertest</strong> for HTTP assertions</li>
<li><strong>Jest</strong> with ts-jest for TypeScript support</li>
</ul>
<p>Everything passed locally. Ship it.</p>
<h2 id="phase-2-the-ci-runner-roulette">Phase 2: The CI Runner Roulette</h2>
<p>Our Gitea instance runs on a Proxmox LXC. We had <em>two</em> CI runners registered:</p>
<ul>
<li>A runner on the Gitea LXC itself (1GB RAM)</li>
<li>A runner on a laptop (16GB RAM)</li>
</ul>
<p>The workflow specified <code>runs-on: [linux, self-hosted]</code>, which matched <strong>both</strong> runners. The Gitea LXC runner kept grabbing jobs and failing because it couldn&rsquo;t handle mongodb-memory-server + Node + Jest in 1GB of RAM.</p>
<p><strong>Fix:</strong> Changed labels to <code>[linux, android]</code> (which only the laptop runner had) and disabled the LXC runner entirely.</p>
<p><strong>Lesson:</strong> When you have multiple self-hosted runners, make your label selectors specific enough to target the right one.</p>
<h2 id="phase-3-jest-vs-es-modules-the-main-event">Phase 3: Jest vs ES Modules (The Main Event)</h2>
<p>This is where the night got interesting.</p>
<h3 id="problem-1-cannot-use-import-statement-outside-a-module">Problem 1: <code>Cannot use import statement outside a module</code></h3>
<p>Jest couldn&rsquo;t parse our TypeScript files even though we had <code>ts-jest</code> configured with the ESM preset.</p>
<p><strong>Fix:</strong> Add <code>NODE_OPTIONS=--experimental-vm-modules</code> to the test script:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-json" data-lang="json"><span style="display:flex;"><span><span style="color:#e6db74">&#34;test&#34;</span><span style="color:#960050;background-color:#1e0010">:</span> <span style="color:#e6db74">&#34;NODE_OPTIONS=--experimental-vm-modules jest --runInBand&#34;</span>
</span></span></code></pre></div><h3 id="problem-2-jest-is-not-defined">Problem 2: <code>jest is not defined</code></h3>
<p>With <code>--experimental-vm-modules</code>, Jest doesn&rsquo;t inject globals (<code>jest</code>, <code>describe</code>, <code>it</code>, <code>expect</code>) automatically.</p>
<p><strong>Fix:</strong> Import them explicitly in every test file:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-typescript" data-lang="typescript"><span style="display:flex;"><span><span style="color:#66d9ef">import</span> { <span style="color:#a6e22e">jest</span>, <span style="color:#a6e22e">describe</span>, <span style="color:#a6e22e">it</span>, <span style="color:#a6e22e">expect</span>, <span style="color:#a6e22e">beforeEach</span>, <span style="color:#a6e22e">afterAll</span> } <span style="color:#66d9ef">from</span> <span style="color:#e6db74">&#34;@jest/globals&#34;</span>;
</span></span></code></pre></div><h3 id="problem-3-jestmock-doesnt-work">Problem 3: <code>jest.mock()</code> doesn&rsquo;t work</h3>
<p>This is the big one. <code>jest.mock()</code> fundamentally doesn&rsquo;t work with <code>--experimental-vm-modules</code>. The mock factory runs, but it doesn&rsquo;t actually replace the module. Your test imports the <em>real</em> module every time.</p>
<p>We tried:</p>
<ul>
<li><code>jest.mock()</code> with factory functions ❌</li>
<li><code>jest.unstable_mockModule()</code> with dynamic imports ❌ (deleted half our test file)</li>
<li><code>jest.spyOn()</code> on imported modules ❌ (<code>Cannot assign to read only property</code>)</li>
</ul>
<p>That last one is the killer. ES module exports are <strong>read-only bindings</strong>. You literally cannot reassign them:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-typescript" data-lang="typescript"><span style="display:flex;"><span><span style="color:#66d9ef">import</span> { <span style="color:#a6e22e">storage</span> } <span style="color:#66d9ef">from</span> <span style="color:#e6db74">&#39;./storage.js&#39;</span>;
</span></span><span style="display:flex;"><span><span style="color:#75715e">// storage.upload = jest.fn(); // TypeError: Cannot assign to read only property
</span></span></span></code></pre></div><h3 id="the-solution-that-actually-works">The Solution That Actually Works</h3>
<p>Export your functions as properties of a mutable object:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-typescript" data-lang="typescript"><span style="display:flex;"><span><span style="color:#75715e">// Instead of:
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span><span style="color:#66d9ef">export</span> <span style="color:#66d9ef">async</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">compressImage</span>(<span style="color:#a6e22e">buffer</span>, <span style="color:#a6e22e">mimetype</span>) { ... }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">// Do this:
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span><span style="color:#66d9ef">async</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">compressImageImpl</span>(<span style="color:#a6e22e">buffer</span>, <span style="color:#a6e22e">mimetype</span>) { ... }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">export</span> <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">compress</span> <span style="color:#f92672">=</span> {
</span></span><span style="display:flex;"><span>  <span style="color:#a6e22e">compressImage</span>: <span style="color:#66d9ef">compressImageImpl</span>
</span></span><span style="display:flex;"><span>};
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">// Backwards compatibility
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span><span style="color:#66d9ef">export</span> <span style="color:#66d9ef">const</span> <span style="color:#a6e22e">compressImage</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">compress</span>.<span style="color:#a6e22e">compressImage</span>;
</span></span></code></pre></div><p>Now <code>jest.spyOn(compress, 'compressImage')</code> works because you&rsquo;re modifying a property on a plain object, not an ES module binding.</p>
<p>For object exports like <code>storage</code>, changing <code>export const</code> to <code>export let</code> also works:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-typescript" data-lang="typescript"><span style="display:flex;"><span><span style="color:#66d9ef">export</span> <span style="color:#66d9ef">let</span> <span style="color:#a6e22e">storage</span> <span style="color:#f92672">=</span> <span style="color:#a6e22e">createStorage</span>(); <span style="color:#75715e">// &#39;let&#39; instead of &#39;const&#39;
</span></span></span></code></pre></div><p><strong>The takeaway:</strong> If you&rsquo;re using Jest with ES modules, plan your exports for testability from the start. Object properties are mockable. Bare function exports are not.</p>
<h2 id="phase-4-the-flyio-installer">Phase 4: The Fly.io Installer</h2>
<p>With tests passing in CI, we moved to the deploy step. The Fly.io install script (<code>curl -L https://fly.io/install.sh | sh</code>) has an interactive prompt:</p>
<pre tabindex="0"><code>flyctl was installed successfully to /home/user/.fly/bin/flyctl
Would you like to add it to your PATH automatically? (Y/n)
</code></pre><p>In a headless CI runner, this hangs forever.</p>
<p>We tried:</p>
<ul>
<li><code>sh -s -- -y</code> (not a real flag) ❌</li>
<li><code>yes | sh</code> (answered &ldquo;yes&rdquo; to everything — terrifying) ❌</li>
<li><code>echo &quot;n&quot; | sh</code> (piped input got consumed by curl) ❌</li>
</ul>
<p><strong>What actually worked:</strong> Skip the installer entirely. Download the binary directly:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-bash" data-lang="bash"><span style="display:flex;"><span>curl -L <span style="color:#e6db74">&#34;https://github.com/superfly/flyctl/releases/download/v0.4.21/flyctl_0.4.21_Linux_x86_64.tar.gz&#34;</span> <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span><span style="color:#ae81ff"></span>  -o /tmp/flyctl.tar.gz
</span></span><span style="display:flex;"><span>tar xzf /tmp/flyctl.tar.gz -C ~/.fly/bin/
</span></span></code></pre></div><p>Pre-install it on the runner, reference it in CI. No installer, no prompts, no drama.</p>
<h2 id="phase-5-the-cascade-failure">Phase 5: The Cascade Failure</h2>
<p>Around 1:30 AM, things went sideways.</p>
<p>The CI runner&rsquo;s <code>yes | sh</code> command from an earlier attempt was still running, burning 100% CPU. The OOM killer terminated the runner process. But the Gitea LXC (which hosts both Gitea and its database connection) was also struggling — we&rsquo;d accidentally been running jobs on it too.</p>
<p>Then Gitea started returning 500 errors. We couldn&rsquo;t push, couldn&rsquo;t view the UI, couldn&rsquo;t cancel jobs.</p>
<p>Investigation revealed:</p>
<ol>
<li>The MySQL LXC (separate container) was <strong>100% disk full</strong> — 12GB completely used</li>
<li>MySQL couldn&rsquo;t write temp files, so queries hung</li>
<li>Gitea couldn&rsquo;t update job status in MySQL</li>
<li>Jobs became zombies — &ldquo;running&rdquo; forever with no actual process</li>
</ol>
<p><strong>The fix:</strong></p>
<ol>
<li>Resize the MySQL LXC disk (12GB → 48GB) via Proxmox</li>
<li>Stop Gitea</li>
<li>Clear zombie jobs directly in MySQL:</li>
</ol>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#66d9ef">UPDATE</span> action_run <span style="color:#66d9ef">SET</span> status<span style="color:#f92672">=</span><span style="color:#ae81ff">3</span>, stopped<span style="color:#f92672">=</span>UNIX_TIMESTAMP() <span style="color:#66d9ef">WHERE</span> status <span style="color:#66d9ef">IN</span> (<span style="color:#ae81ff">1</span>,<span style="color:#ae81ff">2</span>);
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">UPDATE</span> action_run_job <span style="color:#66d9ef">SET</span> status<span style="color:#f92672">=</span><span style="color:#ae81ff">3</span>, stopped<span style="color:#f92672">=</span>UNIX_TIMESTAMP() <span style="color:#66d9ef">WHERE</span> status <span style="color:#66d9ef">IN</span> (<span style="color:#ae81ff">1</span>,<span style="color:#ae81ff">2</span>);
</span></span></code></pre></div><ol start="4">
<li>Restart Gitea</li>
</ol>
<p><strong>Lesson:</strong> Monitor disk space on your database servers. A full disk can cascade into seemingly unrelated failures across your entire infrastructure.</p>
<h2 id="phase-6-the-typescript-build">Phase 6: The TypeScript Build</h2>
<p>Finally, everything was working — tests pass, flyctl runs, Docker build starts&hellip; and <code>tsc</code> fails with 100+ type errors.</p>
<p>The Dockerfile runs <code>npm run build</code> (which runs <code>tsc</code>), and <code>tsc</code> was trying to compile our test files. Test files that use <code>jest.fn()</code>, <code>jest.spyOn()</code>, and other Jest-specific APIs that <code>tsc</code> doesn&rsquo;t understand in the production build context.</p>
<p><strong>Fix:</strong> Exclude test files from the TypeScript build:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-json" data-lang="json"><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>  <span style="color:#f92672">&#34;exclude&#34;</span>: [<span style="color:#e6db74">&#34;node_modules&#34;</span>, <span style="color:#e6db74">&#34;src/__tests__&#34;</span>]
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>Tests use <code>ts-jest</code> which has its own TypeScript compilation. The production build doesn&rsquo;t need them.</p>
<h2 id="the-result">The Result</h2>
<p>At 3:09 AM, we got the Telegram notification:</p>
<blockquote>
<p>🚀 PicSpin server deployed to Fly.io (b3a51cf)
<a href="https://server.picspin.app">https://server.picspin.app</a></p></blockquote>
<p><strong>Final pipeline:</strong></p>
<ol>
<li>Push to <code>main</code> (with changes in <code>server/</code>)</li>
<li>Gitea Actions triggers on laptop runner</li>
<li>130 tests run (~15 seconds)</li>
<li>If all pass, Docker image builds on Fly.io&rsquo;s Depot builders</li>
<li>Image deploys to Fly.io</li>
<li>Telegram notification sent</li>
</ol>
<p><strong>Final test coverage:</strong></p>
<ul>
<li>Admin controller: 84% statements</li>
<li>Story controller: 95% statements</li>
<li>User controller: 89% statements</li>
<li>130 tests passing, 3 skipped (non-critical)</li>
</ul>
<h2 id="what-id-do-differently">What I&rsquo;d Do Differently</h2>
<ol>
<li>
<p><strong>Start with ESM-compatible test patterns.</strong> If your project uses ES modules, design your exports for testability from day one. The <code>jest.mock()</code> → ESM incompatibility rabbit hole cost us hours.</p>
</li>
<li>
<p><strong>One runner, clearly labeled.</strong> Having two runners that both matched <code>[linux, self-hosted]</code> caused confusion for weeks. Be specific with labels.</p>
</li>
<li>
<p><strong>Pre-install CI tools.</strong> Don&rsquo;t download and install tools in every CI run. It&rsquo;s slow, fragile, and sometimes interactive. Install once on the runner, reference the binary.</p>
</li>
<li>
<p><strong>Monitor infrastructure disk space.</strong> A full MySQL disk at 2 AM is not when you want to learn about disk monitoring.</p>
</li>
<li>
<p><strong>Test your CI pipeline incrementally.</strong> We tried to go from zero tests to full CI/CD in one session. It worked, but each layer (Jest config, runner setup, Docker build, Fly.io deploy) had its own set of surprises. Building them one at a time would&rsquo;ve been less chaotic.</p>
</li>
</ol>
<h2 id="the-moral">The Moral</h2>
<p>Modern JavaScript tooling in 2026 still can&rsquo;t agree on how modules work. ES modules have been the standard for years, but Jest&rsquo;s support is still behind <code>--experimental-vm-modules</code>. TypeScript adds another layer of complexity. Self-hosted CI adds another. Docker builds add another.</p>
<p>Each layer works fine in isolation. Stack them all together at 1 AM, and you get a war story.</p>
<p>But at 3:09 AM, when that deploy notification hit — totally worth it.</p>
<hr>
<p><em>Written at 3:15 AM while the context was still fresh. Because memory is fleeting, but blog posts are forever.</em></p>
]]></content:encoded></item></channel></rss>