<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Pedro Garduno</title>
    <description>The latest articles on DEV Community by Pedro Garduno (@pgardunoc).</description>
    <link>https://dev.to/pgardunoc</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4040870%2Fb59c0094-433a-41c3-8377-fddaf1c1973e.png</url>
      <title>DEV Community: Pedro Garduno</title>
      <link>https://dev.to/pgardunoc</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/pgardunoc"/>
    <language>en</language>
    <item>
      <title>I moved StoryKept from a $0 host to a $19/month one on purpose</title>
      <dc:creator>Pedro Garduno</dc:creator>
      <pubDate>Sun, 16 Aug 2026 05:25:51 +0000</pubDate>
      <link>https://dev.to/pgardunoc/i-moved-storykept-from-a-0-host-to-a-19month-one-on-purpose-1n10</link>
      <guid>https://dev.to/pgardunoc/i-moved-storykept-from-a-0-host-to-a-19month-one-on-purpose-1n10</guid>
      <description>&lt;p&gt;Vercel is the easiest way to ship a Next.js app. I moved StoryKept off it anyway from a bill of exactly $0 to $19 a month.&lt;/p&gt;

&lt;p&gt;That sounds backwards. Free is cheaper than $19. But "free" was only free for the&lt;br&gt;
part of StoryKept that Vercel does beautifully: the frontend. The moment the app&lt;br&gt;
needed a database, persistent storage for user media, and Docker, "free" was about&lt;br&gt;
to stop being the number — and the number it was about to become was not $19.&lt;/p&gt;

&lt;p&gt;This is the story of that move: why I made it, what I set up by hand, the two things&lt;br&gt;
that didn't just work, and why $19 flat turned out to be the cheaper decision.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why leave a free host at all
&lt;/h2&gt;

&lt;p&gt;StoryKept isn't a frontend. People send in voice notes and videos over WhatsApp,&lt;br&gt;
and those files have to be stored safely and served back. That means, at minimum:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Next.js&lt;/strong&gt; — the part Vercel is genuinely best at&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A database&lt;/strong&gt; — Postgres&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Object storage&lt;/strong&gt; — for the media, potentially a lot of it&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Docker&lt;/strong&gt; — so the whole thing runs the same everywhere&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;On Vercel, the frontend is free and the rest is a menu. Each managed piece the database, the storage, the bandwidth once media traffic grows is its own line&lt;br&gt;
item, and each one climbs with usage. The free tier is a doorway, not a house. For&lt;br&gt;
a media-heavy app, the honest projection wasn't "$0 forever." It was "$0 until it&lt;br&gt;
isn't, then a bill that grows with every uploaded video."&lt;/p&gt;

&lt;p&gt;So I went shopping for a box that could run the whole stack for one predictable&lt;br&gt;
number. DigitalOcean, Linode, Hetzner. Hetzner won on price-for-performance, and&lt;br&gt;
$19/month covers &lt;strong&gt;both&lt;/strong&gt; the VPS and an S3-compatible storage bucket. One number,&lt;br&gt;
flat, for the entire stack.&lt;/p&gt;
&lt;h2&gt;
  
  
  What you actually get: a bare Linux box
&lt;/h2&gt;

&lt;p&gt;Hetzner hands you something deliberately minimal: a Linux VPS with a public IP. You&lt;br&gt;
pick the type, the RAM, the location, the OS and that's the end of the hand-holding.&lt;br&gt;
Everything else is yours to stand up:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Docker and the container runtime&lt;/li&gt;
&lt;li&gt;Postgres&lt;/li&gt;
&lt;li&gt;The storage bucket&lt;/li&gt;
&lt;li&gt;The SSL certificate&lt;/li&gt;
&lt;li&gt;An SSH-based deploy path&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is the "old way," and it's exactly the part that scares people off Hetzner.&lt;br&gt;
It shouldn't. None of it is exotic it's a sequence of well-documented steps, and&lt;br&gt;
the whole thing was easier than the reputation suggests. The setup, roughly:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Spin up the VPS, lock down SSH (key-only, no password login).&lt;/li&gt;
&lt;li&gt;Install Docker and bring up the app + Postgres as containers.&lt;/li&gt;
&lt;li&gt;Point a domain at the public IP.&lt;/li&gt;
&lt;li&gt;Put the app behind a reverse proxy and get a TLS cert.&lt;/li&gt;
&lt;li&gt;Wire up the storage bucket via the S3 API.&lt;/li&gt;
&lt;li&gt;Set up a simple SSH deploy so shipping a new version is one command.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Two of those six steps are where I actually spent my time. The rest was typing.&lt;/p&gt;
&lt;h2&gt;
  
  
  The storage bucket: same SDK, different endpoint
&lt;/h2&gt;

&lt;p&gt;Here's the piece that makes lock-in a non-issue, and it's the most reusable idea in&lt;br&gt;
this whole post. Amazon's S3 became so ubiquitous that other providers built their&lt;br&gt;
storage to speak the exact same API. Hetzner's bucket is S3-compatible, which means&lt;br&gt;
my application code doesn't know or care that it isn't AWS.&lt;/p&gt;

&lt;p&gt;You don't rewrite anything. You keep the same S3 SDK and change three values — the&lt;br&gt;
endpoint, the credentials, and (for some providers) the addressing style:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;S3Client&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@aws-sdk/client-s3&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;s3&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;S3Client&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;region&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;auto&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;endpoint&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;S3_ENDPOINT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;        &lt;span class="c1"&gt;// Hetzner bucket URL, not AWS&lt;/span&gt;
  &lt;span class="na"&gt;credentials&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;accessKeyId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;S3_ACCESS_KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;secretAccessKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;S3_SECRET_KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;forcePathStyle&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;                       &lt;span class="c1"&gt;// matters more than you'd think&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the whole switch. The same code that talked to AWS now talks to Hetzner by&lt;br&gt;
changing environment variables. And the reverse is just as cheap: if Hetzner ever&lt;br&gt;
slips, moving back to AWS — or to any other S3-compatible provider is a config&lt;br&gt;
change, not a migration project. The point of avoiding lock-in was never to refuse&lt;br&gt;
to pick a vendor. It's to build so the choice never becomes a cage.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two things that didn't just work
&lt;/h2&gt;

&lt;p&gt;A migration where everything works on the first try isn't a real migration. Two&lt;br&gt;
things fought me.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Presigned URLs needed configuration.&lt;/strong&gt; StoryKept serves user media through&lt;br&gt;
presigned URLs — time-limited links that let a browser fetch a file directly from&lt;br&gt;
the bucket without routing bytes through my server. Against AWS these are close to&lt;br&gt;
zero-config. Against an S3-compatible provider, they didn't work out of the box; the&lt;br&gt;
signing had to be configured to match how the provider expects the request to be&lt;br&gt;
addressed before the generated URLs would validate. Once that was aligned, they&lt;br&gt;
worked exactly like the AWS ones — but it was the opposite of plug-and-play, and&lt;br&gt;
it's the kind of thing that passes in local testing and fails the first time a real&lt;br&gt;
file is requested.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. HTTPS: the config was manual, the cert was not.&lt;/strong&gt; On a managed platform, HTTPS&lt;br&gt;
is a checkbox. On a bare VPS, it's yours to handle — and here the right tool choice&lt;br&gt;
did most of the work for me. I put the app behind Caddy as the reverse proxy, and&lt;br&gt;
Caddy does automatic HTTPS: it provisions a free Let's Encrypt certificate on its&lt;br&gt;
own and renews it before it expires, with no cron job and no certbot ritual to&lt;br&gt;
remember. That's a deliberate senior call picking the tool whose default is the&lt;br&gt;
thing you don't want to babysit.&lt;/p&gt;

&lt;p&gt;It still wasn't zero-effort. You write the Caddyfile, point DNS at the box, and make&lt;br&gt;
sure the ACME challenge can actually reach the server — get any of those wrong and&lt;br&gt;
the cert silently never issues. But that's setup you do once, not a renewal you have&lt;br&gt;
to nurse forever. Compared to hand-rolling certbot on a bare box, choosing Caddy&lt;br&gt;
turned "SSL is a chore I'll forget to renew" into "SSL is a line of config."&lt;/p&gt;

&lt;h2&gt;
  
  
  The number, and the actual senior call
&lt;/h2&gt;

&lt;p&gt;So: $0 to $19/month. Framed as a raw bill, that's a $19 increase. Framed honestly,&lt;br&gt;
it's the cheaper decision — because the $0 was temporary and load-bearing on a&lt;br&gt;
frontend-only definition of the app. $19 flat buys the database, the storage, the&lt;br&gt;
compute, and the bandwidth headroom in one predictable line that doesn't spike when&lt;br&gt;
a user uploads a 200MB video. The bill I avoided isn't $0; it's the per-service&lt;br&gt;
climb the $0 was quietly deferring.&lt;/p&gt;

&lt;p&gt;I want to be careful not to oversell it. Hetzner isn't AWS. There are no fifty&lt;br&gt;
managed services on tap, no autoscaling magic, no one to page when the box misbehaves&lt;br&gt;
at 3am that's you. If your time is worth more than the money a managed platform&lt;br&gt;
saves you, pay for the managed platform. That's a real and correct call for a lot of&lt;br&gt;
teams.&lt;/p&gt;

&lt;p&gt;But that &lt;em&gt;is&lt;/em&gt; the call and making it well is the actual senior skill here. Not&lt;br&gt;
"can you run a Linux box," but knowing when a managed platform's convenience is worth&lt;br&gt;
paying for and when it's just a tax on things you're perfectly capable of running&lt;br&gt;
yourself. For a solo founder watching a budget, who knows his way around a server,&lt;br&gt;
$19/month for the whole stack — with the freedom to walk away from any piece of it&lt;br&gt;
by changing an environment variable — was the right trade.&lt;/p&gt;

&lt;p&gt;The hard part was never knowing the tools. It was knowing which brand-name defaults&lt;br&gt;
were worth paying for, and building so that every one of those choices could be&lt;br&gt;
undone.&lt;/p&gt;

</description>
      <category>devops</category>
      <category>aws</category>
      <category>webdev</category>
      <category>selfhosting</category>
    </item>
    <item>
      <title>The night my World Cup model started fighting itself</title>
      <dc:creator>Pedro Garduno</dc:creator>
      <pubDate>Fri, 07 Aug 2026 15:44:54 +0000</pubDate>
      <link>https://dev.to/pgardunoc/the-night-my-world-cup-model-started-fighting-itself-19f0</link>
      <guid>https://dev.to/pgardunoc/the-night-my-world-cup-model-started-fighting-itself-19f0</guid>
      <description>&lt;p&gt;Back in July I &lt;a href="https://pedrogarduno.com/blog/jetlagxi-world-cup-model/" rel="noopener noreferrer"&gt;wrote up my World Cup model&lt;/a&gt; — Elo, jet-lag penalties, and 10,000 Monte Carlo tournaments, refreshing itself on a schedule all through the tournament. That "refreshing itself on a schedule" part is where this story lives.&lt;/p&gt;

&lt;p&gt;A GitHub Action wakes up, pulls the latest scores from the football API, re-runs the simulation, writes a snapshot of the day's predictions, and pushes the result back to the repo. Nothing exotic. It ran fine for weeks.&lt;/p&gt;

&lt;p&gt;Then one night in early July it just... failed. And kept failing.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I saw
&lt;/h2&gt;

&lt;p&gt;The Action would go red with a merge conflict, of all things — on a file that no human had touched. The conflict was always on the daily snapshot:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;&lt;span class="p"&gt;CONFLICT (add/add): Merge conflict in data/snapshots/2026-07-03.json
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An &lt;em&gt;add/add&lt;/em&gt; conflict means two commits each created the same file, from scratch, with different contents. Git has no idea which one you meant, so it gives up and the job exits 1.&lt;/p&gt;

&lt;p&gt;But there's only one cron job. How do two commits create the same file at the same time?&lt;/p&gt;

&lt;h2&gt;
  
  
  The actual bug
&lt;/h2&gt;

&lt;p&gt;The answer was in the schedule. The cron runs every 30 minutes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;cron&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;10,40&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Except GitHub Actions cron isn't punctual. Under load it lags — 15 to 60 minutes is normal, and I'd deliberately set it to fire twice an hour to catch matches quickly. So on a busy evening, the :10 run would still be grinding away when the :40 run started. &lt;strong&gt;Two copies of the same job, running at once.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That overlap on its own would've been harmless if the job produced the same bytes every time. It didn't. Two things in the pipeline are non-deterministic:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;A timestamp.&lt;/strong&gt; Every build stamps itself: &lt;code&gt;generatedAt: new Date().toISOString()&lt;/code&gt;. Two runs, two different clocks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Monte Carlo noise.&lt;/strong&gt; The whole model is 10,000 randomized tournament simulations — &lt;code&gt;Math.random()&lt;/code&gt; at its core. Run it twice and Spain's title odds come back 24.1% one time, 24.3% the next. Close, but not byte-identical.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;So both overlapping runs generated &lt;code&gt;data/snapshots/2026-07-03.json&lt;/code&gt; — a brand-new file that day — with slightly different numbers inside. Whoever pushed first, won. The second run came back, tried to rebase its commit on top of the first, and hit two commits both &lt;em&gt;adding&lt;/em&gt; the same path with different contents. Add/add conflict. Exit 1. Red X.&lt;/p&gt;

&lt;p&gt;The old reconciliation step was trying to be careful, and that was exactly the problem:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git add data/
git commit &lt;span class="nt"&gt;-m&lt;/span&gt; &lt;span class="s2"&gt;"data: refresh scores (...)"&lt;/span&gt;
git pull &lt;span class="nt"&gt;--rebase&lt;/span&gt; &lt;span class="nt"&gt;--autostash&lt;/span&gt; origin main   &lt;span class="c"&gt;# &amp;lt;- tries to MERGE two generated files&lt;/span&gt;
git push &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;git pull &lt;span class="nt"&gt;--rebase&lt;/span&gt; &lt;span class="nt"&gt;--autostash&lt;/span&gt; origin main &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; git push&lt;span class="o"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It was attempting to &lt;em&gt;merge&lt;/em&gt; two machine-generated files that were never meant to be merged. There's no sensible resolution — you don't want half of one simulation and half of another.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;The insight: this data isn't something you &lt;em&gt;merge&lt;/em&gt;. It's something you &lt;em&gt;rebuild&lt;/em&gt;. The scores live in the API, not in the repo — the repo is just a cache. So instead of reconciling two divergent versions, throw the loser away and regenerate on top of the winner:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;for &lt;/span&gt;attempt &lt;span class="k"&gt;in &lt;/span&gt;1 2 3 4 5&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;git fetch origin main
  git reset &lt;span class="nt"&gt;--hard&lt;/span&gt; origin/main      &lt;span class="c"&gt;# start from whatever main is NOW&lt;/span&gt;

  npm run scores                    &lt;span class="c"&gt;# re-fetch from the API (source of truth)&lt;/span&gt;
  npm run lock:knockouts            &lt;span class="c"&gt;# cascade the bracket (idempotent)&lt;/span&gt;
  npm run build:data                &lt;span class="c"&gt;# regenerate the derived data&lt;/span&gt;
  npm run snapshot                  &lt;span class="c"&gt;# write today's snapshot if absent&lt;/span&gt;

  &lt;span class="o"&gt;[[&lt;/span&gt; &lt;span class="nt"&gt;-z&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;git status &lt;span class="nt"&gt;--porcelain&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"No changes."&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;exit &lt;/span&gt;0&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;

  git add data/
  git commit &lt;span class="nt"&gt;-m&lt;/span&gt; &lt;span class="s2"&gt;"data: refresh scores (&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; +%Y-%m-%dT%H:%MZ&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;)"&lt;/span&gt;
  git push &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;exit &lt;/span&gt;0                &lt;span class="c"&gt;# won the race? done.&lt;/span&gt;

  &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Push lost a race; rebuilding on new main…"&lt;/span&gt;
  &lt;span class="nb"&gt;sleep&lt;/span&gt; &lt;span class="k"&gt;$((&lt;/span&gt;RANDOM &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="m"&gt;5&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;&lt;span class="k"&gt;))&lt;/span&gt;         &lt;span class="c"&gt;# jittered backoff, then try again&lt;/span&gt;
&lt;span class="k"&gt;done
&lt;/span&gt;&lt;span class="nb"&gt;exit &lt;/span&gt;1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The whole shape changes. If a push loses the race, the job doesn't try to force its stale work through — it resets to the new &lt;code&gt;main&lt;/code&gt; and rebuilds from there. Because every step is idempotent and re-reads scores from the API, rebuilding on fresh main can &lt;em&gt;never&lt;/em&gt; conflict and can &lt;em&gt;never&lt;/em&gt; drop a result. The random &lt;code&gt;sleep&lt;/code&gt; keeps two retriers from lockstepping into each other again.&lt;/p&gt;

&lt;p&gt;It's been green ever since — all 104 matches captured, right through to Spain lifting the trophy.&lt;/p&gt;

&lt;h2&gt;
  
  
  The lesson I keep relearning
&lt;/h2&gt;

&lt;p&gt;The bug wasn't really in the git commands. It was in treating generated, non-deterministic output like hand-written source you can merge. Two overlapping jobs, a timestamp, and a random seed were all it took.&lt;/p&gt;

&lt;p&gt;Once I stopped asking git to reconcile two simulations and instead made the job &lt;em&gt;regenerate from the source of truth&lt;/em&gt;, the race condition didn't need clever handling — it just stopped being able to happen. The best fix for a race is usually to remove the thing being raced over, not to guard it more carefully.&lt;/p&gt;

</description>
      <category>automation</category>
      <category>devops</category>
      <category>git</category>
      <category>github</category>
    </item>
    <item>
      <title>I built a World Cup model around jet lag, then graded it in public</title>
      <dc:creator>Pedro Garduno</dc:creator>
      <pubDate>Tue, 21 Jul 2026 23:51:22 +0000</pubDate>
      <link>https://dev.to/pgardunoc/i-built-a-world-cup-model-around-jet-lag-then-graded-it-in-public-dp1</link>
      <guid>https://dev.to/pgardunoc/i-built-a-world-cup-model-around-jet-lag-then-graded-it-in-public-dp1</guid>
      <description>&lt;p&gt;Most World Cup prediction models rank teams by strength and leave it there. The 2026 tournament was spread across the US, Canada, and México — four time zones, cities at sea level and cities a mile up, June heat in some places and air conditioning in others, and kickoff times that can drag a European team's body clock to four in the morning. Some teams got an easy run of it. Others got a schedule that would flatten a touring band.&lt;/p&gt;

&lt;p&gt;So I built &lt;a href="https://www.jetlagxi.com" rel="noopener noreferrer"&gt;JetLag XI&lt;/a&gt; to measure that, simulate the whole tournament on top of it, and save every prediction so I could see afterward how wrong it was.&lt;/p&gt;

&lt;p&gt;The tournament's done now. Spain beat Argentina 1–0 in the final. What the model got right, what it didn't, and the one thing it was never going to get right given how I built it.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it works
&lt;/h2&gt;

&lt;p&gt;Two things go into every match prediction.&lt;/p&gt;

&lt;p&gt;The first is &lt;strong&gt;strength&lt;/strong&gt; — plain Elo ratings, sanity-checked against squad market values. Elo is just a number per team that goes up when they win and down when they lose, and the gap between two teams turns "who's better" into "by how much," which is what you need to get a scoreline out.&lt;/p&gt;

&lt;p&gt;The second is &lt;strong&gt;fatigue&lt;/strong&gt;, and this is the part I hadn't seen anyone actually put on the board. It's all computable from the fixture list: how far each team flies (the trip in, plus every hop between cities), how many time zones they cross and re-cross, how much the altitude jumps around, the heat, the rest days between games, and a little circadian penalty for how far each kickoff sits from a comfortable mid-afternoon on the team's home clock. A 9pm local kickoff is a different game if your body still thinks it's in Buenos Aires. I rolled all of that into one "Edge Index," with a bonus for the host nations who got to sleep in their own beds.&lt;/p&gt;

&lt;p&gt;Then it all runs through a &lt;strong&gt;Monte Carlo simulation&lt;/strong&gt; — 10,000 full tournaments every time it refreshes. Each group match draws goals from the Elo gap, the real tiebreakers sort the tables, and the bracket plays out through the knockouts. I went with simulation instead of just using Elo directly because Elo can only tell you the odds of one team beating another. It can't tell you a team's chance of &lt;em&gt;winning the group&lt;/em&gt; or &lt;em&gt;reaching the final&lt;/em&gt; through a 48-team bracket with third-place qualifiers — for that you have to actually play the thing out, thousands of times, and count.&lt;/p&gt;

&lt;p&gt;The whole pipeline refreshes itself hourly during the tournament off a GitHub Action that pulls the latest scores, rebuilds, and re-runs the sim. I mostly just watched.&lt;/p&gt;

&lt;h2&gt;
  
  
  The scorecard
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fin4kqw35ffrrdvxurlhn.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fin4kqw35ffrrdvxurlhn.png" alt="Calibration chart: predicted win probability vs. actual outcome across 104 matches" width="800" height="916"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Over 104 matches it went &lt;strong&gt;65% straight up, with a Brier score of 0.220.&lt;/strong&gt; Every prediction got written down next to the real result the moment the game finished, so none of this is me remembering the good calls and forgetting the bad ones.&lt;/p&gt;

&lt;p&gt;The chart is the bit I'd actually stand behind. Each dot is a confidence band — above the line the model was too cautious, below it too sure of itself. Four of the five bands sit close enough to the line. It's the red one that's interesting, and it points straight at the thing I got wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it got right
&lt;/h2&gt;

&lt;p&gt;The travel idea paid off where you'd most expect it to: the hosts. The US, México, and Canada flew basically nowhere and slept at home the whole time — exactly what the Edge Index rewards — and all three did better than their raw ratings said they should. The US was an underdog on paper against Paraguay and won 4–1. A strength-only model calls that game the other way; the one that knew about the travel didn't. That single result is the whole premise working.&lt;/p&gt;

&lt;p&gt;The favorites also held up top. The four teams the sim liked most — France, Spain, Argentina, England — all made the semis, the final was Spain against Argentina, and its pick won. It had the final itself as pretty much a coin flip (52% Spain), which, for a 1–0, feels about right.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it got wrong
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnqf9dpw8lf2s6xdo2v2p.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnqf9dpw8lf2s6xdo2v2p.png" alt="The model's most confident misses — seven of the top eight were draws" width="800" height="819"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;It only ever outputs a winner, so a 0–0 is a miss no matter what. And its most confident calls were the worst offenders — Spain at 94% over Cape Verde, 0–0; England 92% over Ghana, 0–0. Seven of its eight biggest misses were draws.&lt;/p&gt;

&lt;p&gt;In games it called roughly 90% locks, the favorite actually won only about two-thirds of the time, and that whole gap is draws. It's baked into how I built it: the sim turns an Elo gap into goals and then just picks whoever scored more, tossing the draw out. But a draw is the most common result in a tight group game — especially a strong side against one happy to sit ten men behind the ball, which is exactly the matchup the model was most confident about. Twenty draws across the tournament, every one an automatic miss. A proper win/draw/loss output is the first thing I'd change.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why grade it at all
&lt;/h2&gt;

&lt;p&gt;If you want the methodology, the live pages, or every prediction it ever made, it's all here: &lt;a href="https://www.jetlagxi.com" rel="noopener noreferrer"&gt;jetlagxi.com&lt;/a&gt;. The minimal engine behind it — Elo + Poisson + Monte Carlo, ~200 lines of TypeScript — is on &lt;a href="https://github.com/pgardunoc/code-samples/tree/main/monte-carlo-tournament-sim" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>statistics</category>
      <category>typescript</category>
      <category>datascience</category>
      <category>showdev</category>
    </item>
  </channel>
</rss>
