<?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: Eduardo Pittol</title>
    <description>The latest articles on DEV Community by Eduardo Pittol (@edpittol).</description>
    <link>https://dev.to/edpittol</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%2F3881058%2F17f670b3-e8eb-4e4e-b7ad-128c71b6e970.jpeg</url>
      <title>DEV Community: Eduardo Pittol</title>
      <link>https://dev.to/edpittol</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/edpittol"/>
    <language>en</language>
    <item>
      <title>Your Stopped GCP VM Is Not Guaranteed to Start Again</title>
      <dc:creator>Eduardo Pittol</dc:creator>
      <pubDate>Wed, 22 Jul 2026 19:46:37 +0000</pubDate>
      <link>https://dev.to/edpittol/your-stopped-gcp-vm-is-not-guaranteed-to-start-again-3159</link>
      <guid>https://dev.to/edpittol/your-stopped-gcp-vm-is-not-guaranteed-to-start-again-3159</guid>
      <description>&lt;p&gt;The setup: an &lt;strong&gt;ephemeral staging box&lt;/strong&gt;. Nobody reviews releases at 2 AM, so a scheduler auto-stops it every evening after business hours — you don't pay for a machine that's asleep. It's a deliberately disposable environment, which is exactly why the next part stung.&lt;/p&gt;

&lt;p&gt;One morning you press start and get this:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The zone &lt;code&gt;us-central1-f&lt;/code&gt; does not have enough resources available to fulfill the request.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Under the hood that's &lt;code&gt;ZONE_RESOURCE_POOL_EXHAUSTED&lt;/code&gt;. Not quota. Not billing. Not IAM. The instance is right there in the console, stopped, exactly as you left it — and it won't turn on. Google Cloud has simply &lt;strong&gt;run out of your machine type in that zone&lt;/strong&gt;. It's called a stockout, and "just start it in another zone" turns out to be impossible. This post is the recovery script I keep on hand, the one concept that makes it work, and what I learned about how GCP hands out capacity.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a stopped VM can even fail to start
&lt;/h2&gt;

&lt;p&gt;It ran fine yesterday. Why can't it start today?&lt;/p&gt;

&lt;p&gt;Because &lt;strong&gt;capacity errors apply only to &lt;em&gt;new&lt;/em&gt; requests&lt;/strong&gt;, and a stopped VM has already handed its compute back. GCP tracks capacity &lt;strong&gt;per machine type, per zone&lt;/strong&gt; — there is no single generic pool of servers. When your VM stopped, that capacity returned to the &lt;code&gt;e2-medium&lt;/code&gt; pool in that zone. Pressing start is a brand-new request against whatever's free &lt;em&gt;right now&lt;/em&gt;. If the zone filled up with other people's &lt;code&gt;e2-medium&lt;/code&gt; instances overnight, your start loses the race. Stopping to save money quietly means giving up your seat and hoping one's free when you come back.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix in one idea: make the disk portable
&lt;/h2&gt;

&lt;p&gt;A stopped VM's boot disk is a &lt;strong&gt;zonal&lt;/strong&gt; resource. It lives in exactly one zone and it cannot move. When you start the VM, GCP has to find capacity for that machine type &lt;em&gt;in that specific zone&lt;/em&gt;, because that's where the disk is nailed down. If the zone is stocked out for your shape, you're holding a disk you can't boot and can't relocate. Retrying &lt;code&gt;start&lt;/code&gt; just hammers the one zone that already said no.&lt;/p&gt;

&lt;p&gt;The escape hatch: &lt;strong&gt;a zonal disk can't cross zones, but an image can.&lt;/strong&gt; So you snapshot the stuck disk into an image, delete the instance, and recreate it from that image in whichever zone (and machine type) actually has room.&lt;/p&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%2Fp9868lo4pwfylv9fkulw.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%2Fp9868lo4pwfylv9fkulw.png" alt="A zonal boot disk is pinned to one zone; snapshotting it into a global recovery image lets the VM be recreated in any zone that has capacity." width="800" height="267"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The script
&lt;/h2&gt;

&lt;p&gt;Set the four variables at the top and run it.&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="c"&gt;#!/usr/bin/env bash&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-euo&lt;/span&gt; pipefail

&lt;span class="nv"&gt;PROJECT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"my-project"&lt;/span&gt;
&lt;span class="nv"&gt;INSTANCE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"stg-my-app"&lt;/span&gt;
&lt;span class="nv"&gt;IMAGE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;INSTANCE&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;-recovery-image"&lt;/span&gt;

&lt;span class="c"&gt;# Standard-tier fallbacks, cheapest first. All x86 + pd-balanced compatible,&lt;/span&gt;
&lt;span class="c"&gt;# so no Arm families (T2A/C4A/N4A) and no Hyperdisk-only ones (C4/C3D) —&lt;/span&gt;
&lt;span class="c"&gt;# the recovery image simply can't boot on those.&lt;/span&gt;
&lt;span class="nv"&gt;MACHINE_TYPES&lt;/span&gt;&lt;span class="o"&gt;=(&lt;/span&gt;e2-medium e2-standard-2 n2d-standard-2 t2d-standard-2 n1-standard-2&lt;span class="o"&gt;)&lt;/span&gt;

&lt;span class="c"&gt;# 1. Find where the stopped instance currently lives.&lt;/span&gt;
&lt;span class="nv"&gt;ZONE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;gcloud compute instances list &lt;span class="nt"&gt;--project&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PROJECT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--filter&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"name=&lt;/span&gt;&lt;span class="nv"&gt;$INSTANCE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--format&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"value(zone.basename())"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="o"&gt;[[&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$ZONE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]]&lt;/span&gt; &lt;span class="o"&gt;||&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;"Couldn't find &lt;/span&gt;&lt;span class="nv"&gt;$INSTANCE&lt;/span&gt;&lt;span class="s2"&gt;."&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&amp;amp;2&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;exit &lt;/span&gt;1&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;

&lt;span class="c"&gt;# 2. Discover every UP zone in the instance's region — no hardcoded zone&lt;/span&gt;
&lt;span class="c"&gt;#    list to maintain as GCP adds or retires zones.&lt;/span&gt;
&lt;span class="nv"&gt;REGION&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;ZONE&lt;/span&gt;&lt;span class="p"&gt;%-*&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;                       &lt;span class="c"&gt;# us-central1-f -&amp;gt; us-central1&lt;/span&gt;
&lt;span class="nv"&gt;ZONES&lt;/span&gt;&lt;span class="o"&gt;=(&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;gcloud compute zones list &lt;span class="nt"&gt;--project&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PROJECT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--filter&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"region:&lt;/span&gt;&lt;span class="nv"&gt;$REGION&lt;/span&gt;&lt;span class="s2"&gt; AND status=UP"&lt;/span&gt; &lt;span class="nt"&gt;--format&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"value(name)"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="o"&gt;[[&lt;/span&gt; &lt;span class="k"&gt;${#&lt;/span&gt;&lt;span class="nv"&gt;ZONES&lt;/span&gt;&lt;span class="p"&gt;[@]&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt; &lt;span class="nt"&gt;-gt&lt;/span&gt; 0 &lt;span class="o"&gt;]]&lt;/span&gt; &lt;span class="o"&gt;||&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 UP zones in &lt;/span&gt;&lt;span class="nv"&gt;$REGION&lt;/span&gt;&lt;span class="s2"&gt;."&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&amp;amp;2&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;exit &lt;/span&gt;1&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;

&lt;span class="c"&gt;# 3. Read the config we need to recreate the instance faithfully.&lt;/span&gt;
field&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; gcloud compute instances describe &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$INSTANCE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--project&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PROJECT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--zone&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$ZONE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--format&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"value(&lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt;&lt;span class="s2"&gt;)"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="nv"&gt;NETWORK&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;field &lt;span class="s2"&gt;"networkInterfaces[0].network.basename()"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;SUBNET&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;field &lt;span class="s2"&gt;"networkInterfaces[0].subnetwork.basename()"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;DISK_SIZE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;field &lt;span class="s2"&gt;"disks[0].diskSizeGb"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;

&lt;span class="c"&gt;# 4. Freeze the boot disk into a GLOBAL image. This is the key move:&lt;/span&gt;
&lt;span class="c"&gt;#    a zonal disk can't change zones, but an image can.&lt;/span&gt;
gcloud compute images create &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$IMAGE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--project&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PROJECT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--source-disk&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$INSTANCE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--source-disk-zone&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$ZONE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="c"&gt;# 5. Delete the stuck instance so its name is free to reuse.&lt;/span&gt;
gcloud compute instances delete &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$INSTANCE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--project&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PROJECT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--zone&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$ZONE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--quiet&lt;/span&gt;

&lt;span class="c"&gt;# 6. Cheapest type first: hold each machine type and sweep every zone before&lt;/span&gt;
&lt;span class="c"&gt;#    escalating to a costlier one. Lands on the cheapest shape that has&lt;/span&gt;
&lt;span class="c"&gt;#    capacity anywhere, and only pays more when a type is stocked out&lt;/span&gt;
&lt;span class="c"&gt;#    region-wide. (MACHINE_TYPES is already ordered cheapest -&amp;gt; priciest.)&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;mtype &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;MACHINE_TYPES&lt;/span&gt;&lt;span class="p"&gt;[@]&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  for &lt;/span&gt;zone &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;ZONES&lt;/span&gt;&lt;span class="p"&gt;[@]&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
    &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Trying &lt;/span&gt;&lt;span class="nv"&gt;$mtype&lt;/span&gt;&lt;span class="s2"&gt; in &lt;/span&gt;&lt;span class="nv"&gt;$zone&lt;/span&gt;&lt;span class="s2"&gt;..."&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;gcloud compute instances create &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$INSTANCE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
      &lt;span class="nt"&gt;--project&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PROJECT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--zone&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$zone&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--machine-type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$mtype&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
      &lt;span class="nt"&gt;--image&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$IMAGE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--network&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$NETWORK&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--subnet&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SUBNET&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
      &lt;span class="nt"&gt;--boot-disk-size&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;DISK_SIZE&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;GB"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
      &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Back up in &lt;/span&gt;&lt;span class="nv"&gt;$zone&lt;/span&gt;&lt;span class="s2"&gt; as &lt;/span&gt;&lt;span class="nv"&gt;$mtype&lt;/span&gt;&lt;span class="s2"&gt;."&lt;/span&gt;
      gcloud compute images delete &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$IMAGE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--project&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PROJECT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--quiet&lt;/span&gt;
      &lt;span class="nb"&gt;exit &lt;/span&gt;0
    &lt;span class="k"&gt;fi
  done
done

&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"No capacity anywhere. Recovery image '&lt;/span&gt;&lt;span class="nv"&gt;$IMAGE&lt;/span&gt;&lt;span class="s2"&gt;' kept — recreate by hand."&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&amp;amp;2
&lt;span class="nb"&gt;exit &lt;/span&gt;1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Step 6 is the heart of it: a nested loop that holds the &lt;strong&gt;cheapest machine type&lt;/strong&gt; and sweeps every zone before moving up to a costlier type — so you land on the cheapest shape that has capacity anywhere, and only pay more when the cheap one is stocked out region-wide. The zone list isn't hardcoded — it's pulled from the instance's own region at runtime, so the script keeps working as Google adds or retires zones.&lt;/p&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%2Fk11aq8r3q657kh139fzd.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%2Fk11aq8r3q657kh139fzd.png" alt="A grid of machine types (rows, cheapest on top) by zones (columns); the cheapest type is out of capacity in every zone, and the run succeeds on the next type up in zone b." width="800" height="337"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Cheapest type first: sweep it across every zone before paying for a bigger one, and stop at the first hit.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The gotchas that cost me retries
&lt;/h2&gt;

&lt;p&gt;The machine-type list isn't arbitrary. Two constraints are baked in, both learned the hard way:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No Arm.&lt;/strong&gt; The image is built from an x86 box. T2A, C4A, and N4A are Arm — the image won't boot on them. That's not a capacity failure, it's a hard incompatibility, and each one wastes a retry.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No Hyperdisk-only families.&lt;/strong&gt; C4/C4D/C3D want Hyperdisk boot disks, not the &lt;code&gt;pd-balanced&lt;/code&gt; this instance uses. Same story: they fail for the wrong reason.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And the rule I'd tattoo on my hand: &lt;strong&gt;the recovery image is only deleted after a new instance is confirmed up.&lt;/strong&gt; You are deliberately deleting your only instance in step 5. If the script also deleted the image on failure, a total stockout would leave you with &lt;em&gt;nothing&lt;/em&gt;. On failure it keeps the image — that's your recovery point.&lt;/p&gt;

&lt;h2&gt;
  
  
  How GCP hands out capacity (so you can bias the odds)
&lt;/h2&gt;

&lt;p&gt;The fallback list looks cheapest-first, but it's really &lt;em&gt;availability&lt;/em&gt;-first — it climbs GCP's capacity gradient:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;You can't check first.&lt;/strong&gt; There's no API that tells you whether a type has free capacity in a zone. On-demand capacity is opaque; the only signal is attempting the create and catching the stockout. Every strategy is fundamentally attempt-and-fall-back. (I did try &lt;code&gt;gcloud alpha compute advice capacity&lt;/code&gt; to get ahead of it — it's gated behind an Alpha allowlist my account isn't on, so: &lt;code&gt;403&lt;/code&gt;.)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Commodity beats cutting-edge.&lt;/strong&gt; The newest series (C3, C4, GPUs) are the scarcest; E2 and N2 are the safe fallbacks. A ladder like C3 → N2 → E2 usually finds room.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Smaller beats bigger.&lt;/strong&gt; Large shapes need contiguous capacity on a single host, so they stock out more. A smaller shape in the same series is likelier to fit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Zone beats type.&lt;/strong&gt; A stockout is per type &lt;em&gt;and&lt;/em&gt; per zone, so sweeping zones is often a bigger lever than swapping types. The loop leans on this: it holds the cheapest type and tries &lt;em&gt;every&lt;/em&gt; zone before escalating — diversifying zones first, and paying more only when a type is out region-wide.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Spot and on-demand are separate pools.&lt;/strong&gt; Spot has its own capacity, but it can be preempted — no good when you need the box reliably up for a test session.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Lock down who can run it
&lt;/h2&gt;

&lt;p&gt;"Let a teammate restart the staging box" sounds harmless, but this script &lt;strong&gt;deletes an instance&lt;/strong&gt;. You do not want to hand out &lt;code&gt;compute.admin&lt;/code&gt; for that.&lt;/p&gt;

&lt;p&gt;I scoped a custom role to exactly the verbs the script needs — create/delete instance, create/delete image, read the config, and (for the dynamic zone lookup) &lt;code&gt;compute.zones.list&lt;/code&gt; — and pinned it with an IAM condition so it only applies to &lt;em&gt;this one instance, its disk, and its recovery image&lt;/em&gt;. The blast radius of the restart button should be one VM.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this happens — and why it's not just you
&lt;/h2&gt;

&lt;p&gt;It's tempting to assume you misconfigured something. You didn't. A zone is a finite pile of physical machines, and sometimes the shape you want isn't in the pile right now. Google's own docs are blunt about it: resource errors are unrelated to your quota and apply only to the exact resource you asked for, at the moment you asked — &lt;a href="https://docs.cloud.google.com/compute/docs/troubleshooting/troubleshooting-resource-availability" rel="noopener noreferrer"&gt;try a different zone, a different machine type, or again later&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;And it's a well-worn rake. Google's &lt;a href="https://groups.google.com/g/gce-discussion/c/Lfyk38giqK8" rel="noopener noreferrer"&gt;gce-discussion group&lt;/a&gt; has threads going back years — one stockout stretched close to 24 hours across multiple zones, with a user flatly saying it "affects us to the point we cant use GCP."&lt;/p&gt;

&lt;p&gt;If uptime genuinely matters, this script is the wrong tool, and it's worth knowing what the right ones are:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Reservations&lt;/strong&gt; hold capacity for a specific machine type in a specific zone. You pay whether you use it or not, but the capacity is &lt;em&gt;yours&lt;/em&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Committed use discounts (CUDs)&lt;/strong&gt; are easy to confuse with reservations, but they're a &lt;em&gt;billing&lt;/em&gt; discount across a series in a region — not a capacity guarantee.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;MIG instance flexibility&lt;/strong&gt; is GCP's native "try types until one is available" primitive, with a ranked list of machine types. It's built for stateless, interchangeable VMs, though — wrapping a single stateful staging box in a managed instance group is far more machinery than the problem deserves. The &lt;a href="https://medium.com/google-cloud/beyond-try-again-later-a-strategic-guide-to-obtaining-high-demand-resources-in-google-cloud-0f7a2f152b7f" rel="noopener noreferrer"&gt;&lt;em&gt;Beyond "Try Again Later"&lt;/em&gt;&lt;/a&gt; writeup is a good map of these options.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The cheapest fix is prevention
&lt;/h2&gt;

&lt;p&gt;The highest-leverage, lowest-effort move isn't the script at all: &lt;strong&gt;don't run staging on scarce hardware.&lt;/strong&gt; If the box is on C3/C4/GPU, that's very likely why it won't start. Staging rarely needs the newest silicon — move it to E2 or N2 and the stockouts mostly disappear. The script is the seatbelt for the day it happens anyway.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A stopped VM is not a reservation.&lt;/strong&gt; Stopping it releases capacity back to the pool; starting it is a fresh request that can fail.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Capacity is per type, per zone, and opaque.&lt;/strong&gt; There's no API to check it in advance — you attempt and fall back.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Zonal disks pin you to a zone.&lt;/strong&gt; Crossing zones means routing through a global artifact — an image or a snapshot.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Commodity types stock out far less.&lt;/strong&gt; The cheapest fix is not being on scarce hardware in the first place.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;When a script deletes your only copy, protect the recovery point above everything else.&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The cloud markets itself as infinite. It isn't — it's a very large, very finite pile of other people's computers, and once in a while the pile you want is empty. Plan for the empty pile.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Sources: &lt;a href="https://docs.cloud.google.com/compute/docs/troubleshooting/troubleshooting-resource-availability" rel="noopener noreferrer"&gt;GCP resource-availability troubleshooting&lt;/a&gt; · &lt;a href="https://groups.google.com/g/gce-discussion/c/Lfyk38giqK8" rel="noopener noreferrer"&gt;gce-discussion stockout thread&lt;/a&gt; · &lt;a href="https://medium.com/google-cloud/beyond-try-again-later-a-strategic-guide-to-obtaining-high-demand-resources-in-google-cloud-0f7a2f152b7f" rel="noopener noreferrer"&gt;Beyond "Try Again Later" (Google Cloud Community)&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>gcp</category>
      <category>googlecloud</category>
      <category>devops</category>
      <category>bash</category>
    </item>
    <item>
      <title>I had an application taking 10 seconds to load. Turned out file ops functions on aren't real filesystem calls due S3 stream wrapper. This post brings the results of a POC that I built to demonstrate the impact of these invisible of these network calls.</title>
      <dc:creator>Eduardo Pittol</dc:creator>
      <pubDate>Thu, 16 Jul 2026 18:26:55 +0000</pubDate>
      <link>https://dev.to/edpittol/i-had-an-application-taking-10-seconds-to-load-turned-out-file-ops-functions-on-arent-real-38j5</link>
      <guid>https://dev.to/edpittol/i-had-an-application-taking-10-seconds-to-load-turned-out-file-ops-functions-on-arent-real-38j5</guid>
      <description>&lt;div class="ltag__link--embedded"&gt;
  &lt;div class="crayons-story "&gt;
  &lt;a href="https://dev.to/edpittol/your-fileexists-is-secretly-a-network-call-4236" class="crayons-story__hidden-navigation-link"&gt;Your file_exists() Is Secretly a Network Call&lt;/a&gt;


  &lt;div class="crayons-story__body crayons-story__body-full_post"&gt;
    &lt;div class="crayons-story__top"&gt;
      &lt;div class="crayons-story__meta"&gt;
        &lt;div class="crayons-story__author-pic"&gt;

          &lt;a href="/edpittol" class="crayons-avatar  crayons-avatar--l  "&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%2Fuser%2Fprofile_image%2F3881058%2F17f670b3-e8eb-4e4e-b7ad-128c71b6e970.jpeg" alt="edpittol profile" class="crayons-avatar__image" width="460" height="460"&gt;
          &lt;/a&gt;
        &lt;/div&gt;
        &lt;div&gt;
          &lt;div&gt;
            &lt;a href="/edpittol" class="crayons-story__secondary fw-medium m:hidden"&gt;
              Eduardo Pittol
            &lt;/a&gt;
            &lt;div class="profile-preview-card relative mb-4 s:mb-0 fw-medium hidden m:inline-block"&gt;
              
                Eduardo Pittol
                
              
              &lt;div id="story-author-preview-content-4160257" class="profile-preview-card__content crayons-dropdown branded-7 p-4 pt-0"&gt;
                &lt;div class="gap-4 grid"&gt;
                  &lt;div class="-mt-4"&gt;
                    &lt;a href="/edpittol" class="flex"&gt;
                      &lt;span class="crayons-avatar crayons-avatar--xl mr-2 shrink-0"&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%2Fuser%2Fprofile_image%2F3881058%2F17f670b3-e8eb-4e4e-b7ad-128c71b6e970.jpeg" class="crayons-avatar__image" alt="" width="460" height="460"&gt;
                      &lt;/span&gt;
                      &lt;span class="crayons-link crayons-subtitle-2 mt-5"&gt;Eduardo Pittol&lt;/span&gt;
                    &lt;/a&gt;
                  &lt;/div&gt;
                  &lt;div class="print-hidden"&gt;
                    
                      Follow
                    
                  &lt;/div&gt;
                  &lt;div class="author-preview-metadata-container"&gt;&lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
            &lt;/div&gt;

          &lt;/div&gt;
          &lt;a href="https://dev.to/edpittol/your-fileexists-is-secretly-a-network-call-4236" class="crayons-story__tertiary fs-xs"&gt;&lt;time&gt;Jul 16&lt;/time&gt;&lt;span class="time-ago-indicator-initial-placeholder"&gt;&lt;/span&gt;&lt;/a&gt;
        &lt;/div&gt;
      &lt;/div&gt;

    &lt;/div&gt;

    &lt;div class="crayons-story__indention"&gt;
      &lt;h2 class="crayons-story__title crayons-story__title-full_post"&gt;
        &lt;a href="https://dev.to/edpittol/your-fileexists-is-secretly-a-network-call-4236" id="article-link-4160257"&gt;
          Your file_exists() Is Secretly a Network Call
        &lt;/a&gt;
      &lt;/h2&gt;
        &lt;div class="crayons-story__tags"&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/php"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;php&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/performance"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;performance&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/s3"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;s3&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/aws"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;aws&lt;/a&gt;
        &lt;/div&gt;
      &lt;div class="crayons-story__bottom"&gt;
        &lt;div class="crayons-story__details"&gt;
            &lt;a href="https://dev.to/edpittol/your-fileexists-is-secretly-a-network-call-4236#comments" class="crayons-btn crayons-btn--s crayons-btn--ghost crayons-btn--icon-left flex items-center"&gt;
              

              &lt;span class="hidden s:inline"&gt;Add&amp;nbsp;Comment&lt;/span&gt;
            &lt;/a&gt;
        &lt;/div&gt;
        &lt;div class="crayons-story__save"&gt;
          &lt;small class="crayons-story__tertiary fs-xs mr-2"&gt;
            6 min read
          &lt;/small&gt;
            
              &lt;span class="bm-initial crayons-icon c-btn__icon"&gt;
                

              &lt;/span&gt;
              &lt;span class="bm-success crayons-icon c-btn__icon"&gt;
                

              &lt;/span&gt;
            
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;/div&gt;


</description>
      <category>aws</category>
      <category>backend</category>
      <category>performance</category>
      <category>php</category>
    </item>
    <item>
      <title>Your file_exists() Is Secretly a Network Call</title>
      <dc:creator>Eduardo Pittol</dc:creator>
      <pubDate>Thu, 16 Jul 2026 18:14:10 +0000</pubDate>
      <link>https://dev.to/edpittol/your-fileexists-is-secretly-a-network-call-4236</link>
      <guid>https://dev.to/edpittol/your-fileexists-is-secretly-a-network-call-4236</guid>
      <description>&lt;h2&gt;
  
  
  The one-line horror
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nb"&gt;file_exists&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$path&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// ... use the cached file&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nothing about that line looks dangerous. It's the kind of guard clause you've written a thousand times. But suppose &lt;code&gt;$path&lt;/code&gt; is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;s3://my-bucket/cache/style-4f2a.css
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now that single &lt;code&gt;file_exists()&lt;/code&gt; is not a syscall. It's a &lt;code&gt;HeadObject&lt;/code&gt; request to Amazon S3 — a network round trip that leaves your server, waits for S3, and comes back. Against distant S3, that round trip costs tens of milliseconds.&lt;/p&gt;

&lt;p&gt;Here's the twist that makes it dangerous: the AWS SDK caches that result in memory, so a &lt;em&gt;second&lt;/em&gt; check of the same path is instant. The cost hides behind the cache. But every web request is a fresh PHP process with an empty cache, so the first touch of every path pays full price — every request. And writes never get even that reprieve: nothing caches a &lt;code&gt;PutObject&lt;/code&gt;, so every &lt;code&gt;file_put_contents()&lt;/code&gt; over S3 is a full round trip, every time. Reads can ride the cache; writes always cross the wire.&lt;/p&gt;

&lt;p&gt;Local &lt;code&gt;stat()&lt;/code&gt; costs microseconds. The code doesn't change — only the string in &lt;code&gt;$path&lt;/code&gt; does. PHP stream wrappers make remote storage look like a local disk, and code written for local-disk economics keeps compiling, keeps passing tests, and silently falls off a performance cliff the moment the path points at a bucket.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why an innocent function makes a network call
&lt;/h2&gt;

&lt;p&gt;A stream wrapper is a class registered against a URL scheme:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nb"&gt;stream_wrapper_register&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'s3'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;S3\StreamWrapper&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Once &lt;code&gt;s3://&lt;/code&gt; is registered, PHP routes every filesystem call for that scheme through the wrapper: &lt;code&gt;fopen()&lt;/code&gt;, &lt;code&gt;fread()&lt;/code&gt;, &lt;code&gt;file_get_contents()&lt;/code&gt;, &lt;code&gt;stat()&lt;/code&gt;, &lt;code&gt;is_dir()&lt;/code&gt;, &lt;code&gt;unlink()&lt;/code&gt;. The whole point is that your code doesn't have to know or care whether it's talking to a disk or a bucket — the API surface is identical.&lt;/p&gt;

&lt;p&gt;That's exactly the trap. Each operation quietly maps to an S3 API request:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;PHP call&lt;/th&gt;
&lt;th&gt;S3 request&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;file_exists()&lt;/code&gt; / &lt;code&gt;stat()&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;HeadObject&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;is_dir()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ListObjects&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;unlink()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;DeleteObject&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;file_put_contents()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;PutObject&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If you've ever hunted down an &lt;strong&gt;N+1 query&lt;/strong&gt; in an ORM, you already understand the failure mode. N+1 is a loop that fires one database round trip per item instead of batching them. This is the same shape — one network round trip per filesystem call — except the round trip hides behind a function whose name says "filesystem." Nobody profiles &lt;code&gt;file_exists()&lt;/code&gt;. Static analysis won't flag it. And in local development, where the wrapper points at a fast disk or a nearby S3-compatible service, it's instant. The cost only shows up as latency against real, distant S3 in production — the worst possible place to discover it.&lt;/p&gt;

&lt;p&gt;The numbers below come from a controlled rig: a local MinIO standing in for S3, with &lt;a href="https://github.com/Shopify/toxiproxy" rel="noopener noreferrer"&gt;Toxiproxy&lt;/a&gt; injecting a fixed round-trip time (RTT) of 0, 10, 20, or 40 ms — roughly the spread from same-AZ to cross-region.&lt;/p&gt;

&lt;h2&gt;
  
  
  The benchmark: cost scales with the network
&lt;/h2&gt;

&lt;p&gt;Median latency per call, S3 backend, as RTT climbs (local disk stays ≤ 0.01 ms for all operations at every RTT):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;S3 operation&lt;/th&gt;
&lt;th&gt;RTT 0 ms&lt;/th&gt;
&lt;th&gt;10 ms&lt;/th&gt;
&lt;th&gt;20 ms&lt;/th&gt;
&lt;th&gt;40 ms&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;file_exists&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0.002 ms&lt;/td&gt;
&lt;td&gt;0.022 ms&lt;/td&gt;
&lt;td&gt;0.025 ms&lt;/td&gt;
&lt;td&gt;0.026 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;stat&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0.002 ms&lt;/td&gt;
&lt;td&gt;0.022 ms&lt;/td&gt;
&lt;td&gt;0.029 ms&lt;/td&gt;
&lt;td&gt;0.032 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;file_put_contents&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1.460 ms&lt;/td&gt;
&lt;td&gt;17.961 ms&lt;/td&gt;
&lt;td&gt;29.626 ms&lt;/td&gt;
&lt;td&gt;49.551 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two things jump out. &lt;strong&gt;Reads barely move with latency.&lt;/strong&gt; Checking the same path in a loop, &lt;code&gt;file_exists()&lt;/code&gt; stays around 0.02 ms no matter the RTT — the SDK's in-memory stat cache absorbs the repeats, so they never cross the wire twice. &lt;strong&gt;Writes track RTT almost linearly&lt;/strong&gt;, because nothing caches a &lt;code&gt;PutObject&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Now scale to a page. A single real page in the incident below fired &lt;strong&gt;88&lt;/strong&gt; filesystem ops, so reconstruct the page cost as median × 88:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;S3 op × 88&lt;/th&gt;
&lt;th&gt;RTT 0 ms&lt;/th&gt;
&lt;th&gt;10 ms&lt;/th&gt;
&lt;th&gt;20 ms&lt;/th&gt;
&lt;th&gt;40 ms&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;file_exists&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0.14 ms&lt;/td&gt;
&lt;td&gt;1.95 ms&lt;/td&gt;
&lt;td&gt;2.19 ms&lt;/td&gt;
&lt;td&gt;2.25 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;stat&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0.17 ms&lt;/td&gt;
&lt;td&gt;1.94 ms&lt;/td&gt;
&lt;td&gt;2.55 ms&lt;/td&gt;
&lt;td&gt;2.81 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;file_put_contents&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;128.5 ms&lt;/td&gt;
&lt;td&gt;1,580.6 ms&lt;/td&gt;
&lt;td&gt;2,607.1 ms&lt;/td&gt;
&lt;td&gt;4,360.5 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;At 40 ms RTT — a cross-region hop — 88 writes cost &lt;strong&gt;~4.4 seconds&lt;/strong&gt;. That's the cliff, and every extra millisecond between you and S3 makes it steeper.&lt;/p&gt;

&lt;p&gt;But look at the read rows: 2 ms for 88 checks. That looks harmless — and it's the most misleading number in the table. It's low only because the benchmark checks the same paths repeatedly inside one long-lived process, so the SDK's in-memory stat cache absorbs the repeats. Nothing crosses the wire twice.&lt;/p&gt;

&lt;p&gt;PHP in production doesn't work that way. Every web request is served by a fresh process with an empty cache, and that cache is gone the moment the request ends — the &lt;code&gt;LruArrayCache&lt;/code&gt; lives in process memory, not in Redis or on disk. So the first touch of each &lt;em&gt;distinct&lt;/em&gt; path is always a full round trip, and nothing carries over to the next request. If a page checks 73 distinct files, that's 73 cold round trips — every request. Which is exactly what happened next.&lt;/p&gt;

&lt;h2&gt;
  
  
  The war story: a 10-second homepage
&lt;/h2&gt;

&lt;p&gt;This isn't hypothetical. A high-traffic WordPress site, with its media library backed by S3, had a homepage that took &lt;strong&gt;10 seconds&lt;/strong&gt; to return.&lt;/p&gt;

&lt;p&gt;New Relic told the story immediately. &lt;code&gt;GET /&lt;/code&gt; returned HTTP 200 in 10.07 s — with an &lt;strong&gt;empty database-queries tab&lt;/strong&gt;. No slow SQL. Roughly 90% of the time was synchronous S3 traffic, all inside a single page view:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Segment&lt;/th&gt;
&lt;th&gt;Calls&lt;/th&gt;
&lt;th&gt;Time&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;s3.amazonaws.com&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;73&lt;/td&gt;
&lt;td&gt;3,281 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Guzzle &lt;code&gt;CurlMultiHandler::tick&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;59&lt;/td&gt;
&lt;td&gt;2,623 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Stream wrapper closure&lt;/td&gt;
&lt;td&gt;88&lt;/td&gt;
&lt;td&gt;1,702 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;AwsClient::execute&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;124&lt;/td&gt;
&lt;td&gt;1,582 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Guzzle &lt;code&gt;CurlMultiHandler::execute&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;78&lt;/td&gt;
&lt;td&gt;873 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;73 real S3 calls to render one homepage.&lt;/strong&gt; The theme's CSS layer was the culprit: for every style handle, on every breakpoint, on every request, it called &lt;code&gt;file_exists()&lt;/code&gt; on the generated stylesheet to decide whether to regenerate — dozens of &lt;code&gt;HeadObject&lt;/code&gt;s per page — &lt;em&gt;even though it already held a persisted flag saying the cache was valid.&lt;/em&gt; Those were 73 &lt;strong&gt;distinct&lt;/strong&gt; paths, so the in-process cache never helped; and because the SDK's stat cache was request-scoped — an in-memory cache that dies with the PHP process — nothing survived to the next request either. Every check was a cold round trip.&lt;/p&gt;

&lt;p&gt;The fix was a tour of &lt;strong&gt;where a cache can live&lt;/strong&gt; — each layer trading something different:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Persistent stat cache.&lt;/strong&gt; Replace the request-scoped &lt;code&gt;LruArrayCache&lt;/code&gt; with an adapter over the WordPress object cache (Redis). It implements the SDK's &lt;code&gt;CacheInterface&lt;/code&gt; and is injected when the wrapper is registered — no plugin patching required. Now a stat survives across requests. The cost: a Redis hop instead of memory, still far cheaper than S3.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Local-disk-first.&lt;/strong&gt; For generated CSS, write to local disk, mirror to S3 asynchronously after the write, and restore from S3 only if the local copy goes missing. Reads become genuine microsecond &lt;code&gt;stat()&lt;/code&gt;s again, and — crucially — the writes stop hitting S3 on the request path. The cost: you trade strong read-after-write consistency across nodes for latency, a deliberate, documented trade-off.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The page dropped from ~10 seconds to well under a second. Same features, same S3 bucket — the only thing that changed was refusing to let a network call keep masquerading as a syscall.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to spot this in your own code
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Audit filesystem calls near wrapped paths.&lt;/strong&gt; Grep for &lt;code&gt;file_exists&lt;/code&gt;, &lt;code&gt;stat&lt;/code&gt;, &lt;code&gt;is_dir&lt;/code&gt;, &lt;code&gt;unlink&lt;/code&gt;, and &lt;code&gt;file_put_contents&lt;/code&gt;, and ask, for each, whether the path could ever be &lt;code&gt;s3://&lt;/code&gt; (or &lt;code&gt;gs://&lt;/code&gt;, or any wrapper). Pay special attention to anything inside a loop and to &lt;strong&gt;distinct&lt;/strong&gt; paths — those never hit the cache.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Read APM segment breakdowns, not just the total.&lt;/strong&gt; A slow request with an &lt;em&gt;empty&lt;/em&gt; SQL tab is the tell. Look for time in the storage SDK and the HTTP handler.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Distrust request-scoped caches.&lt;/strong&gt; A cache that isn't shared across processes does nothing for a per-request PHP model. Confirm where your cache actually lives.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Remember that a cache can't save a write.&lt;/strong&gt; Reads can be cached; every &lt;code&gt;PutObject&lt;/code&gt; is a real round trip. Batch writes, defer them off the request path, or keep them local and mirror asynchronously.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And the principle underneath all of it: &lt;strong&gt;when an abstraction changes the cost model by orders of magnitude, it has to leak that cost somewhere.&lt;/strong&gt; An abstraction that hides a 50 ms network call behind a microsecond-shaped function isn't a convenience — it's a latency bug waiting for production traffic. Put the cost back where you can see it: a persistent cache, a batched call, or a local-first layer. Don't let &lt;code&gt;file_exists()&lt;/code&gt; keep wearing a syscall's clothes.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;All the measurements in this post come from a small, self-contained benchmark rig — MinIO standing in for S3, Toxiproxy injecting the round-trip latency, and the PHP scripts that produced every table above. It's on GitHub: &lt;a href="https://github.com/edpittol/s3-stream-multiple-operations" rel="noopener noreferrer"&gt;edpittol/s3-stream-multiple-operations&lt;/a&gt;. Clone it and reproduce the numbers.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>php</category>
      <category>performance</category>
      <category>s3</category>
      <category>aws</category>
    </item>
    <item>
      <title>Stop your team from rebuilding the same AI skills: a shared catalog that maintains itself</title>
      <dc:creator>Eduardo Pittol</dc:creator>
      <pubDate>Sun, 14 Jun 2026 18:39:06 +0000</pubDate>
      <link>https://dev.to/edpittol/stop-your-team-from-rebuilding-the-same-ai-skills-a-shared-catalog-that-maintains-itself-35l5</link>
      <guid>https://dev.to/edpittol/stop-your-team-from-rebuilding-the-same-ai-skills-a-shared-catalog-that-maintains-itself-35l5</guid>
      <description>&lt;p&gt;If your team has started using skills for AI coding agents, you've probably hit this already: someone uses an existing skill or builds one to run the test suite a certain way. Two weeks later, someone else does it &lt;em&gt;their own&lt;/em&gt; way for the same job — different steps, different conventions, a different result — because they had no idea the first one existed. Now the agent does the "same" task one way on your machine and another way on theirs.&lt;/p&gt;

&lt;p&gt;That's the part that actually hurts. It isn't just the wasted hour rebuilding something that already existed (though that adds up). It's that the team quietly drifts into a dozen slightly different ways of solving the &lt;em&gt;same&lt;/em&gt; problem — inconsistent results, no shared standard, and no single place to fix anything when it breaks. Multiply it across a few people and a few months, and "skills" stops being leverage and starts being entropy.&lt;/p&gt;

&lt;p&gt;I ran into exactly this. The fix wasn't more discipline — it was a shared catalog that's so easy to contribute to that nobody has an excuse not to — and the whole team reaches for &lt;em&gt;one&lt;/em&gt; skill instead of each rolling their own. The trick that made it stick: a skill whose only job is to add other skills to the catalog. This post is how that works, and how to build your own.&lt;/p&gt;

&lt;h2&gt;
  
  
  The idea: one catalog, one source of truth
&lt;/h2&gt;

&lt;p&gt;The catalog is a single repo. Skills live under &lt;code&gt;skills/&amp;lt;category&amp;gt;/&lt;/code&gt;, where the categories are deliberately boring:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;engineering&lt;/strong&gt; — development, testing, code review, automation&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;productivity&lt;/strong&gt; — organization, writing, planning, day-to-day workflows&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;misc&lt;/strong&gt; — everything else&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;At the root there's a master table — one row per skill, with its category and a link back to the source it came from. That table is the single source of truth for what the team has. New teammate? Point them at the table. Wondering if a skill already exists? Check the table before you build.&lt;/p&gt;

&lt;p&gt;And here's the payoff that's easy to miss: when everyone reaches for the &lt;em&gt;same&lt;/em&gt; skill, the catalog becomes a feedback loop. Someone hits a rough edge — a flaky step, a missing case, a prompt the agent keeps misreading — and they fix it in that one canonical skill. The fix lands for the whole team at once. Troubleshooting stops being something each person quietly redoes in private and starts &lt;em&gt;accumulating&lt;/em&gt;: a shared skill gets sharper every time anyone uses it. Twelve private copies just stay broken in twelve different ways.&lt;/p&gt;

&lt;p&gt;It's a nice idea. It also rots the instant adding a skill becomes a chore.&lt;/p&gt;

&lt;h2&gt;
  
  
  The catch: cataloging by hand is exactly the friction that kills it
&lt;/h2&gt;

&lt;p&gt;Look at what "just add it to the catalog" actually means by hand:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Pick the right category and copy the folder into &lt;code&gt;skills/&amp;lt;category&amp;gt;/&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Write an entry in that category's README — in the right alphabetical spot.&lt;/li&gt;
&lt;li&gt;Resolve a permalink back to the source: the upstream repo URL, the &lt;code&gt;org/repo&lt;/code&gt;, and the exact commit hash.&lt;/li&gt;
&lt;li&gt;Regenerate the master table at the root, re-sorted alphabetically, without breaking the existing rows.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Nobody wants to do that after every skill. So they don't — and the catalog drifts out of date until it's useless. The friction is the failure mode.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: a skill that catalogs skills
&lt;/h2&gt;

&lt;p&gt;So I made the bookkeeping itself a skill: &lt;code&gt;catalog-a-skill&lt;/code&gt;. You point it at a skill folder and it does all four steps. Here's the abridged &lt;code&gt;SKILL.md&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;catalog-a-skill&lt;/span&gt;
&lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Catalogs&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;an&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;existing&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;skill&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;into&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;this&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;repo's&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;category&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;structure.&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Use&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;when&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;a&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;skill&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;folder&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;with&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;a&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;valid&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;SKILL.md&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;already&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;exists&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;and&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;needs&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;to&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;be&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;added&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;to&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;the&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;catalog."&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;

&lt;span class="gh"&gt;# Catalog a Skill&lt;/span&gt;

&lt;span class="gu"&gt;## Workflow&lt;/span&gt;

&lt;span class="gu"&gt;### 1. Read the skill&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; Read &lt;span class="sb"&gt;`&amp;lt;skill-dir&amp;gt;/SKILL.md`&lt;/span&gt; and extract &lt;span class="sb"&gt;`name`&lt;/span&gt; and &lt;span class="sb"&gt;`description`&lt;/span&gt;.
&lt;span class="p"&gt;-&lt;/span&gt; Gate check 1 — missing frontmatter.
&lt;span class="p"&gt;-&lt;/span&gt; Gate check 2 — folder name ≠ frontmatter &lt;span class="sb"&gt;`name`&lt;/span&gt;.

&lt;span class="gu"&gt;### 2. Choose the category&lt;/span&gt;
Pick engineering / productivity / misc from the description.

&lt;span class="gu"&gt;### 3. Position the skill&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; Copy to &lt;span class="sb"&gt;`skills/&amp;lt;category&amp;gt;/&amp;lt;skill-name&amp;gt;/`&lt;/span&gt;.
&lt;span class="p"&gt;-&lt;/span&gt; Resolve the upstream reference from the SOURCE repo:
  commit hash (&lt;span class="sb"&gt;`git rev-parse HEAD`&lt;/span&gt;), remote URL, and &lt;span class="sb"&gt;`org/repo`&lt;/span&gt;.

&lt;span class="gu"&gt;### 4. Update the category README&lt;/span&gt;
Insert a section at its alphabetical position.

&lt;span class="gu"&gt;### 5. Regenerate the master table&lt;/span&gt;
One row per skill, sorted by name, with a permalink to the source commit.

&lt;span class="gu"&gt;### 6. Verify&lt;/span&gt;
Run &lt;span class="sb"&gt;`npx skills list`&lt;/span&gt; and confirm the skill appears.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The whole point: the contributor does the &lt;em&gt;fun&lt;/em&gt; part (writing a useful skill) and hands the &lt;em&gt;tedious, rule-bound&lt;/em&gt; part to the agent. That's the kind of work agents are genuinely good at — deterministic, fiddly, easy to get subtly wrong by hand.&lt;/p&gt;

&lt;h3&gt;
  
  
  The part that makes it trustworthy: it refuses to do the wrong thing
&lt;/h3&gt;

&lt;p&gt;A naive version would happily corrupt the catalog. The real value is in the gates — the cases where it &lt;em&gt;stops&lt;/em&gt; instead of cataloging:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Missing frontmatter.&lt;/strong&gt; If &lt;code&gt;name&lt;/code&gt; or &lt;code&gt;description&lt;/code&gt; is absent, it copies the skill into &lt;code&gt;misc&lt;/code&gt; but does &lt;strong&gt;not&lt;/strong&gt; touch the README or the master table. Then it tells you exactly which field is missing. A broken skill never silently pollutes the source of truth.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Name divergence.&lt;/strong&gt; If the folder is named &lt;code&gt;foo&lt;/code&gt; but the frontmatter says &lt;code&gt;name: bar&lt;/code&gt;, that's a red flag — it copies under the original folder name, refuses to catalog, and reports the mismatch.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That "refuse and report" behavior is what turns a convenient script into something a team can trust to run on its catalog.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to build your own
&lt;/h2&gt;

&lt;p&gt;You don't need my repo — you need the pattern. To replicate it:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Make the catalog repo.&lt;/strong&gt; &lt;code&gt;skills/&amp;lt;category&amp;gt;/&lt;/code&gt; directories, a root README with a master table, one README per category. Keep the categories few and obvious.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Decide your source of truth.&lt;/strong&gt; I use the root table; the rule is "if it's not in the table, it doesn't exist." Pick yours and make it explicit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Write the meta-skill.&lt;/strong&gt; Encode the four steps above as a &lt;code&gt;SKILL.md&lt;/code&gt;. The non-obvious work is the bookkeeping rules: alphabetical insertion, permalink resolution from the &lt;em&gt;source&lt;/em&gt; repo's git remote and commit, and consistent table regeneration.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add the gates.&lt;/strong&gt; Decide what "invalid" means for you (missing fields, name mismatch, wrong structure) and make the skill &lt;em&gt;refuse and report&lt;/em&gt; instead of pushing bad entries.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lower the barrier to zero.&lt;/strong&gt; The whole bet is that a catalog survives only when contributing costs nothing. The meta-skill is what buys that.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That's it. The catalog stays current because keeping it current is now a single command, not a chore — and the team stops rebuilding skills it already has.&lt;/p&gt;

&lt;p&gt;The full working version, gate cases and all, is here: &lt;strong&gt;&lt;a href="https://github.com/edpittol/skills" rel="noopener noreferrer"&gt;github.com/edpittol/skills&lt;/a&gt;&lt;/strong&gt;. Take it apart, adapt the categories and rules to your team, and let your agent keep the books.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>productivity</category>
      <category>claude</category>
      <category>devtools</category>
    </item>
  </channel>
</rss>
