<?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: Sunny JayaRaju</title>
    <description>The latest articles on DEV Community by Sunny JayaRaju (@sunnyjayaraju).</description>
    <link>https://dev.to/sunnyjayaraju</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%2F3772611%2F5bd08e1d-942d-4acd-8363-1efc038e1dc9.png</url>
      <title>DEV Community: Sunny JayaRaju</title>
      <link>https://dev.to/sunnyjayaraju</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/sunnyjayaraju"/>
    <language>en</language>
    <item>
      <title>Building a macOS Dev Workstation Toolkit in Bash: 187 Tests, Pinned CI, and Verified Backups</title>
      <dc:creator>Sunny JayaRaju</dc:creator>
      <pubDate>Sat, 03 Oct 2026 12:35:34 +0000</pubDate>
      <link>https://dev.to/sunnyjayaraju/building-a-macos-dev-workstation-toolkit-in-bash-187-tests-pinned-ci-and-verified-backups-28nl</link>
      <guid>https://dev.to/sunnyjayaraju/building-a-macos-dev-workstation-toolkit-in-bash-187-tests-pinned-ci-and-verified-backups-28nl</guid>
      <description>&lt;p&gt;Every workstation setup starts the same way: a &lt;code&gt;setup.sh&lt;/code&gt; that works. Then it accretes.&lt;/p&gt;

&lt;p&gt;Someone adds a second script. The logging block gets copy-pasted. Config lives in three places and nobody can say which one wins. The README describes a flag that was renamed four months ago. Validation is whatever you remember to run before you break something.&lt;/p&gt;

&lt;p&gt;The rot isn't dramatic. It's a repo nobody dares to touch, and no idea whether your last change broke anything.&lt;/p&gt;

&lt;p&gt;I wanted to fix that on my own machine, in Bash.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it is
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://github.com/SunnyJayaRaju/workstation-framework" rel="noopener noreferrer"&gt;Developer Workstation Framework&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;An engineering-first framework for building, managing, validating, and evolving a professional macOS development workstation using modern Bash engineering practices.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Ten utilities — &lt;code&gt;install&lt;/code&gt;, &lt;code&gt;update&lt;/code&gt;, &lt;code&gt;uninstall&lt;/code&gt;, &lt;code&gt;sync&lt;/code&gt;, &lt;code&gt;backup&lt;/code&gt;, &lt;code&gt;restore&lt;/code&gt;, &lt;code&gt;check-project&lt;/code&gt;, &lt;code&gt;doctor&lt;/code&gt;, &lt;code&gt;repo-clean&lt;/code&gt;, &lt;code&gt;shell-quality&lt;/code&gt; — built on seven shared Bash libraries. Current release is &lt;strong&gt;v2.2.0&lt;/strong&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/SunnyJayaRaju/workstation-framework.git
&lt;span class="nb"&gt;cd &lt;/span&gt;workstation-framework
./scripts/install.sh
./scripts/doctor.sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The problem it solves
&lt;/h2&gt;

&lt;p&gt;Workstation scripts rot for the same reason any codebase without tests rots. There's no compiler and no failing build, so nothing tells you that you broke something. You find out when the machine is subtly wrong.&lt;/p&gt;

&lt;p&gt;The usual failure pattern:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Scripts become duplicated.&lt;/strong&gt; The same date-formatting logic in five files, one of which is wrong.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Configuration is scattered.&lt;/strong&gt; No answer to "what is this actually set to on this machine?"&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Documentation drifts.&lt;/strong&gt; The docs describe the previous release, confidently.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Validation is manual.&lt;/strong&gt; Which means it doesn't happen.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Small changes cause regressions.&lt;/strong&gt; And you find out weeks later.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This project treats workstation automation as a software project: shared libraries instead of copy-paste, one documented config precedence order, behavioural tests, and CI on every change.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it's engineered, not just scripted
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Shared libraries, not copy-paste
&lt;/h3&gt;

&lt;p&gt;Seven libraries in &lt;code&gt;scripts/lib/&lt;/code&gt; — &lt;code&gt;logging.sh&lt;/code&gt;, &lt;code&gt;config.sh&lt;/code&gt;, &lt;code&gt;errors.sh&lt;/code&gt;, &lt;code&gt;filesystem.sh&lt;/code&gt;, &lt;code&gt;checks.sh&lt;/code&gt;, &lt;code&gt;backup_paths.sh&lt;/code&gt;, &lt;code&gt;secrets.sh&lt;/code&gt;. Common behaviour lives in one place, which means it's fixed in one place.&lt;/p&gt;

&lt;h3&gt;
  
  
  One documented configuration precedence
&lt;/h3&gt;

&lt;p&gt;Environment variables beat &lt;code&gt;config/user.conf&lt;/code&gt;, which beats &lt;code&gt;config/default.conf&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Environment Variables
        ↓
config/user.conf
        ↓
config/default.conf
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You can override anything for one command without editing a tracked file, which is also what makes CI able to test paths your laptop will never take.&lt;/p&gt;

&lt;h3&gt;
  
  
  CI on macOS &lt;em&gt;and&lt;/em&gt; Ubuntu, with pinned tools
&lt;/h3&gt;

&lt;p&gt;Every push and pull request runs Bash syntax validation, ShellCheck, shfmt verification, and the Bats suite — on both &lt;code&gt;macos-latest&lt;/code&gt; and &lt;code&gt;ubuntu-latest&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The pinning is the part worth copying. Ubuntu gets exact apt versions (&lt;code&gt;shellcheck=0.9.0-1&lt;/code&gt;, &lt;code&gt;shfmt=3.8.0-1&lt;/code&gt;, &lt;code&gt;bats=1.10.0-1&lt;/code&gt;). Homebrew has no per-version pin, so macOS can't do that — instead it asserts the versions after install and fails if they differ.&lt;/p&gt;

&lt;p&gt;Both approaches exist for the same reason: unpinned installs had drifted, so the Ubuntu runner ended up on shfmt 3.8.0 while macOS and developer machines were on 3.14.1. That mismatch caused two red pushes.&lt;/p&gt;

&lt;h3&gt;
  
  
  Bats tests, and a single command to run them
&lt;/h3&gt;

&lt;p&gt;v2.2.0 ships &lt;strong&gt;187 passing Bats tests&lt;/strong&gt; covering backup, restore, install, update, uninstall, doctor, cleanup, config, shell quality, and repo sync.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;make all      &lt;span class="c"&gt;# full quality gate — run this before every commit&lt;/span&gt;
make &lt;span class="nb"&gt;test&lt;/span&gt;     &lt;span class="c"&gt;# just the Bats suite&lt;/span&gt;
make lint     &lt;span class="c"&gt;# ShellCheck&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  v2.2.0: making backups actually verifiable
&lt;/h3&gt;

&lt;p&gt;The headline release was backup/restore integrity, and the theme of it was &lt;strong&gt;checks that could not fail&lt;/strong&gt;. The repo had an &lt;code&gt;assert&lt;/code&gt; helper with zero callers, a verification harness invoking a flag that never existed, and a standards document mandating a versioning scheme no script used. A test suite could be comprehensively green while a data-loss bug sat in &lt;code&gt;restore.sh&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;So the release went through making each safeguard actually do something:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Backups are recorded and verified.&lt;/strong&gt; Every &lt;code&gt;backup.sh&lt;/code&gt; run writes a &lt;code&gt;manifest_&amp;lt;timestamp&amp;gt;.json&lt;/code&gt; with the filename, source path, and a SHA-256 of the content. &lt;code&gt;restore.sh&lt;/code&gt; verifies a backup against its manifest &lt;em&gt;before&lt;/em&gt; overwriting anything — a mismatch names the file, refuses it, and carries on with the rest. &lt;code&gt;--dry-run&lt;/code&gt; reports the same verdict, so you can't use a dry run to discover a corrupt backup &lt;em&gt;would&lt;/em&gt; have been installed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A manifest can't redirect a write.&lt;/strong&gt; It's parsed as text, never evaluated; only the digest is read, and the destination always comes from &lt;code&gt;BACKUP_SOURCES&lt;/code&gt;. A tampered manifest can make the tool refuse — it cannot make it write somewhere else.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A 0-byte backup is refused outright.&lt;/strong&gt; Checked &lt;em&gt;before&lt;/em&gt; the manifest, deliberately: the SHA-256 of empty content is a fixed constant, so a manifest written for an empty backup matches perfectly and the hash check alone could never catch it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Safety copies stop overwriting each other.&lt;/strong&gt; The pre-restore safety copy is what makes a restore reversible, but its name had one-second resolution. Measured before the fix: one surviving copy in four runs out of five. After: two in five out of five.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Backup names are keyed on path, not basename.&lt;/strong&gt; &lt;code&gt;~/.ssh/config&lt;/code&gt; and a top-level &lt;code&gt;~/config&lt;/code&gt; no longer collide into a single backup file.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;uninstall.sh&lt;/code&gt; validates its target before deleting.&lt;/strong&gt; It fails closed on &lt;code&gt;/&lt;/code&gt;, &lt;code&gt;//&lt;/code&gt;, relative paths, and shallow paths like &lt;code&gt;/usr/local&lt;/code&gt;. The destructive-path tests resolve everything from their own temp directory, and a logging &lt;code&gt;rm&lt;/code&gt; stub asserts no removal was even &lt;em&gt;attempted&lt;/em&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last one is the lesson I'd keep: a test that runs against your real &lt;code&gt;~/.ssh&lt;/code&gt; is a test that will eventually delete your real &lt;code&gt;~/.ssh&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;doctor.sh&lt;/code&gt; as an honest reporter
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;doctor.sh&lt;/code&gt; reports an absent ShellCheck as an &lt;em&gt;incomplete environment&lt;/em&gt;, never as a lint failure — so a missing tool can't be mistaken for a code defect. It also checks that the installed &lt;code&gt;VERSION&lt;/code&gt; still matches the source. That check exists because the live machine had silently drifted several releases behind source, which nothing else noticed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this might interest you
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;If you set up macOS for a living, or for yourself, repeatedly.&lt;/strong&gt; &lt;code&gt;install.sh&lt;/code&gt; writes the utilities to &lt;code&gt;INSTALL_DIR&lt;/code&gt; (default &lt;code&gt;~/.local/bin&lt;/code&gt;), &lt;code&gt;update.sh&lt;/code&gt; reinstalls in place, &lt;code&gt;doctor.sh&lt;/code&gt; tells you what it finds. Your workstation becomes something you can rebuild and audit instead of something you remember.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If you're tired of "just shell scripts."&lt;/strong&gt; Everything here is Bash, but the discipline isn't language-specific. Tests, a lint gate, pinned CI, documented precedence, and a changelog that reflects reality are all transferable to a shell project you're maintaining right now.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If you enjoy the unglamorous middle.&lt;/strong&gt; v2.2.0 also removed an unused &lt;code&gt;eval&lt;/code&gt;-based &lt;code&gt;assert&lt;/code&gt; helper, two existence predicates with no call sites, and an entire &lt;code&gt;lib/colors.sh&lt;/code&gt; that duplicated what &lt;code&gt;logging.sh&lt;/code&gt; already provided. Deleting dead code that only &lt;em&gt;looked&lt;/em&gt; load-bearing is most of the job.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;The roadmap is more automation, broader backup support, cross-platform compatibility, better diagnostics, and more tests. If that sounds like your kind of problem:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Try it&lt;/strong&gt; — &lt;a href="https://github.com/SunnyJayaRaju/workstation-framework" rel="noopener noreferrer"&gt;clone it and run &lt;code&gt;./scripts/install.sh&lt;/code&gt;&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Star it&lt;/strong&gt; if it's useful&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Open an issue&lt;/strong&gt; — especially if a utility doesn't fit how you actually work&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Contribute&lt;/strong&gt; — &lt;code&gt;CONTRIBUTING.md&lt;/code&gt; has the workflow; the short version is &lt;code&gt;make all&lt;/code&gt; must pass&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Everything ships through the same gate as everything else: tests, ShellCheck, shfmt, and CI on two platforms. If it merges, it was verified.&lt;/p&gt;




&lt;h2&gt;
  
  
  Cover Image Description
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;This section is not part of the article — it is the prompt for generating the cover image.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;A clean, minimalist flat-vector illustration of a macOS developer workstation, viewed straight on. A single modern desktop monitor displays a dark terminal window with crisp green monospace text and a tidy vertical list of shell commands, accented by a small green checkmark badge suggesting a passing test run. Beside the monitor sit a laptop and a coffee cup on a light desk. Muted palette: charcoal, soft white, and a restrained green accent, with generous negative space. Flat geometric shapes, no gradients, no photorealism, no text other than abstract terminal glyphs.&lt;/p&gt;

</description>
      <category>bash</category>
      <category>macos</category>
      <category>devops</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Your OpenCode Model List Is Rotting: ocprobe</title>
      <dc:creator>Sunny JayaRaju</dc:creator>
      <pubDate>Thu, 01 Oct 2026 03:14:59 +0000</pubDate>
      <link>https://dev.to/sunnyjayaraju/your-opencode-model-list-is-rotting-ocprobe-22ap</link>
      <guid>https://dev.to/sunnyjayaraju/your-opencode-model-list-is-rotting-ocprobe-22ap</guid>
      <description>&lt;p&gt;&lt;em&gt;Your OpenCode whitelist is a list of promises made by other people's APIs. This is about the small bash tool I wrote to check them — and one bug in it that turned out to be far more interesting than the feature it came from.&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/SunnyJayaRaju/oc-model-manager" rel="noopener noreferrer"&gt;ocprobe&lt;/a&gt; is a command-line tool that manages OpenCode model catalogs. It diffs the upstream catalog against your whitelist, probes the models that changed, shows you what it found, and only applies changes after you confirm — with a set of guards that are more interesting than the diffing.&lt;/p&gt;

&lt;p&gt;The reason I care about it: running OpenCode against several model providers means your whitelist is a list of promises made by other people's infrastructure. Providers retire models, keys go stale, quotas run out, and a model that worked yesterday can 404 today. The failure is silent until something breaks mid-task, and the tool that reports the problem — the model picker — is not the thing that can tell you &lt;em&gt;which&lt;/em&gt; entry is now lying.&lt;/p&gt;

&lt;p&gt;Currently at &lt;strong&gt;v3.1.3&lt;/strong&gt;, MIT licensed, installable with Homebrew in two commands.&lt;/p&gt;

&lt;p&gt;The section I'd skip to if you only read one thing is the case study — it's about a security allowlist that passed every test on the machine it was written on and was quietly broken on every other platform.&lt;/p&gt;




&lt;h2&gt;
  
  
  The problem: your catalog rots quietly
&lt;/h2&gt;

&lt;p&gt;Here's the manual loop this replaces, which is straight from the README's own description of the problem:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;You diff catalogs by hand, probe models one by one, and risk wiping your whitelist with a bad apply.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Three separate failure modes hide in that sentence:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Models disappear.&lt;/strong&gt; Providers retire models. An entry that was valid when you added it becomes a 404 later, and it stays in your whitelist until you try to use it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Models fail without being gone.&lt;/strong&gt; A key expires, a quota is exhausted, a provider has a bad afternoon. The model exists; your access to it doesn't.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Models change billing behaviour.&lt;/strong&gt; This is the one that surprised me most, and it's why &lt;code&gt;ocprobe&lt;/code&gt; has a status you might not expect. A probe can fail with &lt;code&gt;BILLING_ERROR&lt;/code&gt;, which means &lt;em&gt;your account can't currently afford that model's default output size&lt;/em&gt; — the model is completely fine and will work the moment credits exist. Blacklisting that would permanently hide a working model on a free or credit-limited key. So &lt;code&gt;BILLING_ERROR&lt;/code&gt; is reported for review but never blacklisted, and it doesn't even count toward the failure gate.&lt;/p&gt;

&lt;p&gt;That distinction only matters because ocprobe is willing to hide models at all. Which is the part that needs to be dangerous on purpose.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it actually does
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;brew tap SunnyJayaRaju/ocprobe
brew &lt;span class="nb"&gt;install &lt;/span&gt;ocprobe
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The commands that matter day to day:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Command&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ocprobe doctor&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Health check: config, DB, auth, disk&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ocprobe audit&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Full cycle: diff → probe → confirm → apply (the default)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ocprobe check&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Dry-run only; exits 1 if changes are pending&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ocprobe validate&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Probes every model and manages the blacklist. Dry-run by default&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ocprobe probe &amp;lt;model&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Test one model right now&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ocprobe status&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Whitelisted models plus recent probe results&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ocprobe watch&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;check&lt;/code&gt; + alerts on an interval, never auto-applies&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ocprobe scheduler install&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Same thing as a launchd (macOS) or systemd (Linux) service&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ocprobe session&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;list&lt;/code&gt; / &lt;code&gt;backup&lt;/code&gt; / &lt;code&gt;restore&lt;/code&gt; / &lt;code&gt;cleanup&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A reasonable first run, which is also the sequence I'd suggest:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ocprobe doctor                 &lt;span class="c"&gt;# is anything even set up correctly?&lt;/span&gt;
ocprobe check                  &lt;span class="c"&gt;# what would change, without changing it?&lt;/span&gt;
ocprobe audit                  &lt;span class="c"&gt;# actually probe, then confirm the apply&lt;/span&gt;
ocprobe validate               &lt;span class="c"&gt;# what would be blacklisted, without doing it&lt;/span&gt;
ocprobe validate &lt;span class="nt"&gt;--apply&lt;/span&gt;       &lt;span class="c"&gt;# do it, with a backup and a verification pass&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The default probe prompt is &lt;code&gt;Reply with exactly: OK&lt;/code&gt;. That's not arbitrary — it's what makes the session-isolation guarantee below checkable.&lt;/p&gt;

&lt;h3&gt;
  
  
  Blacklisting is gated
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;ocprobe validate&lt;/code&gt; is the part that hides models from your picker, so it has rules about when it's allowed to do that.&lt;/p&gt;

&lt;p&gt;Non-terminal failures — &lt;code&gt;TIMEOUT&lt;/code&gt;, &lt;code&gt;AUTH_ERROR&lt;/code&gt;, &lt;code&gt;ERROR&lt;/code&gt;, &lt;code&gt;UNCLEAR&lt;/code&gt; — need &lt;strong&gt;two consecutive failures&lt;/strong&gt; before a model is added to the blacklist:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;First failure → &lt;code&gt;TENTATIVE&lt;/code&gt;. Surfaced for review, not blacklisted.&lt;/li&gt;
&lt;li&gt;Second consecutive failure → &lt;code&gt;CONFIRMED&lt;/code&gt;. Blacklisted.&lt;/li&gt;
&lt;li&gt;A &lt;code&gt;WORKS&lt;/code&gt; at any point resets the counter.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;EOL&lt;/code&gt; / &lt;code&gt;NOT_FOUND&lt;/code&gt; → &lt;code&gt;CONFIRMED&lt;/code&gt; immediately. Those are terminal, and waiting buys nothing.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;BILLING_ERROR&lt;/code&gt; → never blacklisted, and doesn't advance the counter.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There are two more brakes on the same path. A &lt;strong&gt;modality skip list&lt;/strong&gt; means embedding, reranking, image/audio/video and moderation models are never probed at all — you didn't configure them for chat, so failing them is meaningless. And an &lt;strong&gt;AUTH_ERROR provider-wide abort&lt;/strong&gt;: if more than 40% of a provider's models come back &lt;code&gt;AUTH_ERROR&lt;/code&gt;, the whole provider is skipped, because that pattern means your key or your quota is the problem, not the models.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lesson #1: the safe default for a tool that hides things is to require a second, independent piece of evidence.&lt;/strong&gt; One failure is a bad afternoon. Two in a row is a fact.&lt;/p&gt;

&lt;h2&gt;
  
  
  The guards that keep it from making things worse
&lt;/h2&gt;

&lt;p&gt;Because a tool that edits your config and deletes your sessions can absolutely cause the outage it was installed to prevent:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Session isolation.&lt;/strong&gt; Only sessions created during &lt;em&gt;this run&lt;/em&gt;, carrying the exact probe prompt in their first message, are ever deleted. Real conversations are never touched.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mass-removal guard.&lt;/strong&gt; If more than 50% of your whitelist would be removed in one run, ocprobe refuses to apply. Override is opt-in via &lt;code&gt;OCPROBE_ALLOW_MASS_REMOVE=1&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Backups on every apply.&lt;/strong&gt; Timestamped, next to the file: &lt;code&gt;opencode.json.ocprobe-backup-YYYYMMDD-HHMMSS&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Graveyard cooldown.&lt;/strong&gt; A model you deliberately removed won't reappear as a "new" model for 24 hours.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Restore is validated per statement.&lt;/strong&gt; Covered below, because the interesting bug is there.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  A case study: an allowlist whose correctness depended on your sqlite build
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;ocprobe session backup&lt;/code&gt; writes your OpenCode sessions to a SQL dump, and &lt;code&gt;ocprobe session restore &amp;lt;file.sql&amp;gt;&lt;/code&gt; reads one back. A SQL dump is not something you want to execute by hand — so the restore is enforced through SQLite's &lt;strong&gt;authorizer&lt;/strong&gt; callback, which lets you approve or deny each operation as the statement runs.&lt;/p&gt;

&lt;p&gt;The policy ended up small and explicit, living in &lt;code&gt;lib/session_restore.py&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;ALLOWED_TABLES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;frozenset&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;session&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;message&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;part&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;todo&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="n"&gt;ALLOWED_FUNCS&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;frozenset&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;unistr&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;replace&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;char&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Only inserts into the four tables &lt;code&gt;cmd_session_backup&lt;/code&gt; actually emits. &lt;code&gt;ATTACH&lt;/code&gt;, &lt;code&gt;DETACH&lt;/code&gt;, &lt;code&gt;DROP&lt;/code&gt;, &lt;code&gt;DELETE&lt;/code&gt;, &lt;code&gt;UPDATE&lt;/code&gt;, &lt;code&gt;ALTER&lt;/code&gt;, every &lt;code&gt;CREATE&lt;/code&gt; (which shows up as an insert into &lt;code&gt;sqlite_master&lt;/code&gt;, so the table allowlist catches it), every &lt;code&gt;PRAGMA&lt;/code&gt;, every other function, and all reads — so &lt;code&gt;INSERT ... SELECT&lt;/code&gt; can't quietly copy data out of somewhere. The whole file runs as one transaction, so a rejected dump leaves the database &lt;strong&gt;byte-identical&lt;/strong&gt;, and CI asserts that against the installed package rather than against a working tree where every file is present by definition.&lt;/p&gt;

&lt;p&gt;That reads like a solid design. Here's the hole in it.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;replace&lt;/code&gt; entry in &lt;code&gt;ALLOWED_FUNCS&lt;/code&gt; looks redundant. A real dump doesn't call a function called &lt;code&gt;replace&lt;/code&gt;. It's there because of this, quoted from the module's own header:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;SQLITE_FUNCTION&lt;/code&gt; callback for the conflict target of &lt;code&gt;INSERT OR REPLACE&lt;/code&gt; ("replace"), which sqlite 3.54 on macOS does not report at all, so an allowlist of just {"unistr"} passed every local test and the macOS CI leg while breaking restore for every Linux user.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Read that again, because it's the whole lesson. Whether &lt;code&gt;INSERT OR REPLACE&lt;/code&gt; causes a function callback &lt;strong&gt;at all is a property of your SQLite build, not of the program asking.&lt;/strong&gt; The allowlist was not just listing which functions were permitted; it was implicitly asserting which functions &lt;em&gt;would ever be offered to it&lt;/em&gt;. On macOS, &lt;code&gt;replace&lt;/code&gt; never arrives, so its absence from the allowlist was untestable. On the Ubuntu runner, it arrives, isn't on the list, and the restore is denied — for a completely legitimate dump, with an error that names the function rather than the mistake.&lt;/p&gt;

&lt;p&gt;There was a second hole of the same shape. &lt;code&gt;INSERT INTO &amp;lt;table&amp;gt; DEFAULT VALUES&lt;/code&gt; is a real &lt;code&gt;SQLITE_INSERT&lt;/code&gt; on a permitted table, so the authorizer approved it — and it wrote a row of NULLs over real data. It was refused only after a first-keyword check was added ahead of SQLite, which strips a BOM, Unicode whitespace and &lt;code&gt;--&lt;/code&gt; / &lt;code&gt;/* */&lt;/code&gt; comments before looking at the word. That change also stopped the code depending on another untested assumption: &lt;code&gt;REINDEX&lt;/code&gt;, &lt;code&gt;ANALYZE&lt;/code&gt; and &lt;code&gt;VACUUM&lt;/code&gt; were already denied on the builds that had been tested, and after the change that's no longer load-bearing.&lt;/p&gt;

&lt;p&gt;Two things make this worth writing down:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;An allowlist has two halves — what you permit, and what you expect to be asked about.&lt;/strong&gt; Only the first is visible in the code. The second is a property of the environment, and the environment is not covered by your tests unless you run your tests in it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A denier that fires on a legitimate input looks exactly like a denier working.&lt;/strong&gt; The Linux restore wasn't obviously broken; it was a strict-looking tool refusing a valid file. The only way to tell those apart is to have a test that asserts a &lt;em&gt;known-good&lt;/em&gt; input is accepted, on every platform you claim to support.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This is why the project now runs its unit and integration suites on macOS and Ubuntu, plus a separate leg that runs the entire suite under a compiled bash 4.3.30 — the minimum the README advertises. macOS ships bash 3.2, and expanding a declared-but-empty array is an error there before 4.4. Five such expansions aborted. That minimum used to be asserted in the README and never tested.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lesson #2: "passes on my machine" is not a portability story, it's a measurement you only took once.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Install and try it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;brew tap SunnyJayaRaju/ocprobe
brew &lt;span class="nb"&gt;install &lt;/span&gt;ocprobe

ocprobe doctor          &lt;span class="c"&gt;# config, DB, auth, disk&lt;/span&gt;
ocprobe check           &lt;span class="c"&gt;# dry-run: what would change&lt;/span&gt;
ocprobe validate        &lt;span class="c"&gt;# dry-run: what would be blacklisted&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both of the dry-runs exit 1 when they have something to tell you, so they drop straight into CI or a pre-commit hook if that's useful to you. &lt;code&gt;ocprobe watch&lt;/code&gt; is the low-maintenance option — it re-runs &lt;code&gt;check&lt;/code&gt; and raises alerts, and never applies anything on its own.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it lives
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Source, issues, CHANGELOG: &lt;a href="https://github.com/SunnyJayaRaju/oc-model-manager" rel="noopener noreferrer"&gt;github.com/SunnyJayaRaju/oc-model-manager&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Homebrew tap: &lt;a href="https://github.com/SunnyJayaRaju/homebrew-ocprobe" rel="noopener noreferrer"&gt;github.com/SunnyJayaRaju/homebrew-ocprobe&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;MIT licensed. Contributions are welcome — the repo has one rule worth knowing up front, which I think is the right one: &lt;strong&gt;any PR that adds a feature, changes a command's behaviour, or changes a flag must update &lt;code&gt;README.md&lt;/code&gt; and &lt;code&gt;CHANGELOG.md&lt;/code&gt; in the same PR.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  What it doesn't do
&lt;/h2&gt;

&lt;p&gt;A few honest limits, so you can decide whether it's for you:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It's a management tool, not a proxy.&lt;/strong&gt; It probes models on a schedule or on demand. It does not sit in the path of your requests and fail over when one breaks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The policy engine is experimental and off by default.&lt;/strong&gt; &lt;code&gt;ocprobe policy&lt;/code&gt; gives you declarative &lt;code&gt;never_add&lt;/code&gt; / &lt;code&gt;never_remove&lt;/code&gt; rules for &lt;code&gt;audit&lt;/code&gt; and &lt;code&gt;check&lt;/code&gt;, but a missing policy file is a true no-op. It never touches &lt;code&gt;validate&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Restores of older dumps still work&lt;/strong&gt;, but only because the round-trip test covers the escaping both current and older SQLite builds produce for values containing newlines. That's a compatibility promise with a test behind it, not a general one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Three config keys are accepted but do nothing&lt;/strong&gt;: &lt;code&gt;catalog.force_refresh&lt;/code&gt;, &lt;code&gt;scheduler.enabled&lt;/code&gt; and &lt;code&gt;scheduler.run_at_load&lt;/code&gt;. They were declared in the schema and shipped in generated configs, and nothing ever read them — so &lt;code&gt;scheduler.enabled: false&lt;/code&gt; did not disable the scheduler. They're still accepted so existing configs keep validating, they log a warning if set to a non-default value, and &lt;code&gt;ocprobe probe --force-refresh&lt;/code&gt; is the flag that actually works.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The &lt;code&gt;validate&lt;/code&gt; path is the risky one.&lt;/strong&gt; It's gated, backed up and verifiable, but it does hide models from your picker by design. Start with &lt;code&gt;--apply&lt;/code&gt; off and read the diff.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>opensource</category>
      <category>bash</category>
      <category>cli</category>
      <category>ai</category>
    </item>
    <item>
      <title>Apigee X Eval Org Stuck at 502? Check These 2 Things First</title>
      <dc:creator>Sunny JayaRaju</dc:creator>
      <pubDate>Sat, 05 Sep 2026 07:04:20 +0000</pubDate>
      <link>https://dev.to/sunnyjayaraju/apigee-x-eval-org-stuck-at-502-check-these-2-things-first-50mm</link>
      <guid>https://dev.to/sunnyjayaraju/apigee-x-eval-org-stuck-at-502-check-these-2-things-first-50mm</guid>
      <description>&lt;p&gt;&lt;em&gt;Quick-fix reference. For the full debugging story behind this, see: &lt;a href="https://dev.to/sunnyjayaraju/the-502-that-wouldnt-die-debugging-a-broken-apigee-eval-org-from-the-ground-up-4m2k"&gt;The 502 That Wouldn't Die&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Symptom
&lt;/h2&gt;

&lt;p&gt;Your Apigee X evaluation org shows fully provisioned:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Console wizard: all 4 setup steps green ✅&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;organizations.get&lt;/code&gt; API: &lt;code&gt;"state": "ACTIVE"&lt;/code&gt; ✅&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;instances.list&lt;/code&gt; API: &lt;code&gt;"state": "ACTIVE"&lt;/code&gt;, real host/port ✅&lt;/li&gt;
&lt;li&gt;Environment attached, proxy deployed, envgroup hostname bound — all clean ✅&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And yet every request — even to a brand-new proxy — returns:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="k"&gt;HTTP&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="m"&gt;2&lt;/span&gt; &lt;span class="m"&gt;502&lt;/span&gt;
&lt;span class="na"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Server Error&lt;/span&gt;
&lt;span class="s"&gt;The server encountered a temporary error and could not complete your request.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Waiting longer doesn't fix it. Deploying a different proxy doesn't fix it.&lt;/p&gt;

&lt;h2&gt;
  
  
  First: confirm this is actually your bug
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gcloud compute backend-services list
gcloud compute backend-services get-health apigee-proxy-backend &lt;span class="nt"&gt;--global&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If this shows &lt;code&gt;healthState: UNHEALTHY&lt;/code&gt; on the &lt;code&gt;apigee-proxy-*&lt;/code&gt; instances, keep reading — this is the load-balancer layer that sits between the external HTTPS LB and your actual Apigee runtime, and it's separate from Apigee's own control plane. That's why everything above reports "ACTIVE" while requests still 502: Apigee's config is correct, but the forwarding instances behind the LB aren't actually serving traffic.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note before you start:&lt;/strong&gt; the fixes below both work on the &lt;strong&gt;instance template&lt;/strong&gt;, not just the live running instances. These forwarding VMs are preemptible — Google can reclaim and silently replace them at any time, and a replacement rebuilds straight from the template. If you patch a live instance directly instead of the template, the fix will quietly disappear the next time that VM gets swapped, and you'll be back here wondering why it "randomly broke again."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Cause #1: Missing service account on the instance template
&lt;/h2&gt;

&lt;p&gt;Check the boot log of one of the unhealthy instances:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gcloud compute instances get-serial-port-output &amp;lt;INSTANCE_NAME&amp;gt; &lt;span class="nt"&gt;--zone&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&amp;lt;ZONE&amp;gt; | &lt;span class="nb"&gt;tail&lt;/span&gt; &lt;span class="nt"&gt;-60&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Look for:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Instance has service account: false, ...
Failed to download from GCS: ... credentials: cannot fetch token ...
Trying unauthenticated download
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Confirm it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gcloud compute instance-templates describe apigee-proxy-&amp;lt;REGION&amp;gt; &lt;span class="se"&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;"yaml(properties.serviceAccounts)"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If this prints &lt;code&gt;null&lt;/code&gt;, the template has no service account attached, so the VM can never authenticate to Cloud Storage to pull its real startup script.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix — clone the template with a service account attached, then roll the MIG onto it:&lt;/strong&gt;&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;# Get every field from your existing template first so you replicate it exactly:&lt;/span&gt;
gcloud compute instance-templates describe apigee-proxy-&amp;lt;REGION&amp;gt; &lt;span class="nt"&gt;--format&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;yaml

gcloud compute instance-templates create apigee-proxy-&amp;lt;REGION&amp;gt;-fixed &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--machine-type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;e2-micro &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--image-project&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;debian-cloud &lt;span class="nt"&gt;--image-family&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;debian-12 &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;20GB &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--network&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;default &lt;span class="nt"&gt;--subnet&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;default &lt;span class="nt"&gt;--region&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&amp;lt;REGION&amp;gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--tags&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;https-server,apigee-proxy,gke-apigee-proxy &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--metadata&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;startup-script-url&lt;span class="o"&gt;=&lt;/span&gt;gs://apigee-5g-saas/apigee-envoy-proxy-release/latest/conf/startup-script.sh,ENDPOINT&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--service-account&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&amp;lt;PROJECT_NUMBER&amp;gt;-compute@developer.gserviceaccount.com &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--scopes&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;cloud-platform &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--preemptible&lt;/span&gt; &lt;span class="nt"&gt;--no-restart-on-failure&lt;/span&gt; &lt;span class="nt"&gt;--maintenance-policy&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;TERMINATE

gcloud compute instance-groups managed set-instance-template apigee-proxy-&amp;lt;REGION&amp;gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--template&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;apigee-proxy-&amp;lt;REGION&amp;gt;-fixed &lt;span class="nt"&gt;--region&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&amp;lt;REGION&amp;gt;

gcloud compute instance-groups managed rolling-action replace apigee-proxy-&amp;lt;REGION&amp;gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--region&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&amp;lt;REGION&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;Match every field from your &lt;code&gt;describe --format=yaml&lt;/code&gt; output — machine type, disk, network, tags, and especially the &lt;code&gt;scheduling&lt;/code&gt; block. &lt;code&gt;--preemptible&lt;/code&gt;, &lt;code&gt;--no-restart-on-failure&lt;/code&gt;, and &lt;code&gt;--maintenance-policy=TERMINATE&lt;/code&gt; must be specified together or &lt;code&gt;gcloud&lt;/code&gt; rejects the combination.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Cause #2: Blank &lt;code&gt;ENDPOINT&lt;/code&gt; metadata
&lt;/h2&gt;

&lt;p&gt;Even after fixing the service account, health checks can still fail. These forwarding VMs don't run a proxy application themselves — they install an &lt;code&gt;iptables&lt;/code&gt; DNAT rule redirecting incoming port-443 traffic to your real Apigee runtime instance's internal IP. That IP comes from an instance metadata key called &lt;code&gt;ENDPOINT&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Check it from inside an instance:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gcloud compute ssh &amp;lt;INSTANCE_NAME&amp;gt; &lt;span class="nt"&gt;--zone&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&amp;lt;ZONE&amp;gt;
curl &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Metadata-Flavor: Google"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"http://metadata.google.internal/computeMetadata/v1/instance/attributes/ENDPOINT"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If this returns nothing, that's the second bug. Get your runtime instance's real internal IP:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;gcloud auth print-access-token&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"https://apigee.googleapis.com/v1/organizations/&amp;lt;ORG&amp;gt;/instances"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Look for the &lt;code&gt;"host"&lt;/code&gt; field in the response (e.g. &lt;code&gt;10.51.204.98&lt;/code&gt;).&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;⚠️ &lt;strong&gt;Don't just patch the running instances — patch the template.&lt;/strong&gt; These forwarding VMs are typically &lt;code&gt;--preemptible&lt;/code&gt;, meaning Google can reclaim them at any time (often within 24h). When that happens, the managed instance group silently rebuilds a replacement &lt;strong&gt;straight from the template&lt;/strong&gt; — and if you only patched the live instance's metadata, the new one comes back with &lt;code&gt;ENDPOINT&lt;/code&gt; blank again, and you're right back to a 502 with no idea why it "randomly broke." Fix the template, not just the instance, or this will relapse on you.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;Fix — bake &lt;code&gt;ENDPOINT&lt;/code&gt; into a corrected template, then roll the instance group onto it (this is the fix that actually survives instance replacement):&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gcloud compute instance-templates create apigee-proxy-&amp;lt;REGION&amp;gt;-fixed-v2 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--machine-type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;e2-micro &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--image-project&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;debian-cloud &lt;span class="nt"&gt;--image-family&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;debian-12 &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;20GB &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--network&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;default &lt;span class="nt"&gt;--subnet&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;default &lt;span class="nt"&gt;--region&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&amp;lt;REGION&amp;gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--tags&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;https-server,apigee-proxy,gke-apigee-proxy &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--metadata&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;startup-script-url&lt;span class="o"&gt;=&lt;/span&gt;gs://apigee-5g-saas/apigee-envoy-proxy-release/latest/conf/startup-script.sh,ENDPOINT&lt;span class="o"&gt;=&lt;/span&gt;&amp;lt;RUNTIME_INTERNAL_IP&amp;gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--service-account&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&amp;lt;PROJECT_NUMBER&amp;gt;-compute@developer.gserviceaccount.com &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--scopes&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;cloud-platform &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--preemptible&lt;/span&gt; &lt;span class="nt"&gt;--no-restart-on-failure&lt;/span&gt; &lt;span class="nt"&gt;--maintenance-policy&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;TERMINATE

gcloud compute instance-groups managed set-instance-template apigee-proxy-&amp;lt;REGION&amp;gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--template&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;apigee-proxy-&amp;lt;REGION&amp;gt;-fixed-v2 &lt;span class="nt"&gt;--region&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&amp;lt;REGION&amp;gt;

gcloud compute instance-groups managed rolling-action replace apigee-proxy-&amp;lt;REGION&amp;gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--region&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&amp;lt;REGION&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the same pattern as the Cause #1 fix — clone the template with the correction included, point the MIG at it, roll. Once &lt;code&gt;ENDPOINT&lt;/code&gt; lives in the template itself, any future automatic replacement (preemption, autohealing, whatever) inherits the correct value with zero manual intervention.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;(If you only need a same-minute workaround while the rolling-replace above is in flight, you can patch the live instance directly — &lt;code&gt;gcloud compute instances add-metadata &amp;lt;INSTANCE_NAME&amp;gt; --zone=&amp;lt;ZONE&amp;gt; --metadata=ENDPOINT=&amp;lt;RUNTIME_INTERNAL_IP&amp;gt;&lt;/code&gt; followed by &lt;code&gt;sudo google_metadata_script_runner startup&lt;/code&gt; over SSH — but treat that as temporary only, not the real fix.)&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Verify the NAT rule landed on the new instances:&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="nb"&gt;sudo &lt;/span&gt;iptables &lt;span class="nt"&gt;-t&lt;/span&gt; nat &lt;span class="nt"&gt;-L&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt;
&lt;span class="c"&gt;# Look for: DNAT tcp dpt:443 to:&amp;lt;RUNTIME_INTERNAL_IP&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Confirm it's fixed
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gcloud compute backend-services get-health apigee-proxy-backend &lt;span class="nt"&gt;--global&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both instances should now show &lt;code&gt;healthState: HEALTHY&lt;/code&gt;. Then:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="s2"&gt;"https://&amp;lt;YOUR_HOSTNAME&amp;gt;.nip.io/hello-world"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You should get a real response instead of the 502 page.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This is one specific failure mode out of many possible causes of a 502 on Apigee X — always confirm the backend health check first before assuming this applies to you. Full context and the debugging process that led here: &lt;a href="https://dev.to/sunnyjayaraju/the-502-that-wouldnt-die-debugging-a-broken-apigee-eval-org-from-the-ground-up-4m2k"&gt;The 502 That Wouldn't Die&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>gcp</category>
      <category>apigee</category>
      <category>cloud</category>
      <category>devops</category>
    </item>
    <item>
      <title>The 502 That Wouldn't Die: Debugging a Broken Apigee Eval Org From the Ground Up</title>
      <dc:creator>Sunny JayaRaju</dc:creator>
      <pubDate>Sat, 05 Sep 2026 06:32:53 +0000</pubDate>
      <link>https://dev.to/sunnyjayaraju/the-502-that-wouldnt-die-debugging-a-broken-apigee-eval-org-from-the-ground-up-4m2k</link>
      <guid>https://dev.to/sunnyjayaraju/the-502-that-wouldnt-die-debugging-a-broken-apigee-eval-org-from-the-ground-up-4m2k</guid>
      <description>&lt;p&gt;&lt;em&gt;A real-world war story of chasing an Apigee X evaluation organization from "Server Error" to "Hello, Guest!" — the hard way, with &lt;code&gt;gcloud&lt;/code&gt;, &lt;code&gt;curl&lt;/code&gt;, and a lot of patience.&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;I spent hours debugging a persistent &lt;code&gt;502 Server Error&lt;/code&gt; on a fresh Apigee X evaluation organization. The Cloud Console showed every provisioning step green. The Apigee Management API confirmed the organization and runtime instance were both &lt;code&gt;ACTIVE&lt;/code&gt;. And yet every single request — even to a brand-new proxy — came back with a 502.&lt;/p&gt;

&lt;p&gt;The eventual root cause: &lt;strong&gt;the Apigee provisioning wizard built the proxy-forwarding instance template without a service account attached, and left a required &lt;code&gt;ENDPOINT&lt;/code&gt; metadata key blank.&lt;/strong&gt; Without a service account, the VMs couldn't authenticate to Cloud Storage to fetch their real startup script. Without &lt;code&gt;ENDPOINT&lt;/code&gt;, the fallback script had nothing to forward traffic to. The fix was to clone the broken instance template with the missing service account and the &lt;code&gt;ENDPOINT&lt;/code&gt; value both baked in, then roll the managed instance group onto it — fixing the template, not just the live instances, matters, since these forwarding VMs are preemptible and get silently replaced by Google; a fix applied only to a running instance quietly disappears the next time it's swapped (I found this out the hard way — see the Update section near the end).&lt;/p&gt;

&lt;p&gt;If you're staring at a 502 on a "successfully" provisioned Apigee eval org, this post is for you.&lt;/p&gt;




&lt;h2&gt;
  
  
  Background: how I got here
&lt;/h2&gt;

&lt;p&gt;I was setting up Apigee purely to practice — nothing fancy, no production intent, just a personal free-trial Google Cloud project to learn the platform. Along the way I made a very ordinary mistake: while creating a second project to experiment with, it ended up parented under &lt;strong&gt;"No organization"&lt;/strong&gt; instead of my actual Cloud Identity org, because I had several Console tabs open switching between projects mid-setup.&lt;/p&gt;

&lt;p&gt;Confused by the mismatch, I deleted that second project. Shortly after, my &lt;em&gt;original&lt;/em&gt; project's Apigee evaluation setup started throwing a &lt;strong&gt;resource-locked&lt;/strong&gt; error on the "Apigee evaluation organization" step, with a message like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;the resource is locked by another operation that is 1 percent completed so far
where organization &amp;lt;project-id&amp;gt; is currently being provisioned by operation: &amp;lt;uuid&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That project's setup never fully completed — the "Access routing" step permanently showed "Not configured." At that point, rather than trying to excavate a half-provisioned org with a stuck long-running operation, I made the call to &lt;strong&gt;shut down both projects and start clean in a brand-new project.&lt;/strong&gt; This is a legitimate, low-cost strategy for Apigee eval orgs specifically — they're free, disposable, and tied to your Cloud Billing account (not the project) for trial credits and the 60-day clock, so nuking a broken project costs you nothing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lesson #1: if you're mid-setup and juggling multiple projects, do it in separate browser profiles or windows, not tabs.&lt;/strong&gt; The Cloud Console's single-page-app state can leak between tabs in confusing ways (I saw one project's setup page briefly display another project's Apigee org name — almost certainly a UI caching artifact, not a real resource collision).&lt;/p&gt;




&lt;h2&gt;
  
  
  The rebuild: a promising start, then déjà vu
&lt;/h2&gt;

&lt;p&gt;With a fresh project, I walked through &lt;strong&gt;Apigee → Try Apigee for free → Set up Apigee Evaluation&lt;/strong&gt; step by step, in a single tab, waiting for each of the four steps to go green before touching anything else:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Enable APIs ✅&lt;/li&gt;
&lt;li&gt;Networking ✅&lt;/li&gt;
&lt;li&gt;Apigee evaluation organization&lt;/li&gt;
&lt;li&gt;Access routing&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Step 3 immediately failed with a generic &lt;strong&gt;"Something went wrong"&lt;/strong&gt; and offered a "Try Again" button. Naturally, my first thought was: &lt;em&gt;here we go again.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;But clicking "Try Again" resolved it within about nine minutes — which felt suspicious. Org creation is supposed to be a slow, heavyweight operation. Why would a failed attempt succeed almost instantly on retry?&lt;/p&gt;

&lt;h3&gt;
  
  
  Why the quick retry wasn't fishy
&lt;/h3&gt;

&lt;p&gt;It turns out this is documented, known behavior. Google's own &lt;a href="https://cloud.google.com/apigee/docs/api-platform/get-started/troubleshooting-install" rel="noopener noreferrer"&gt;Apigee installation troubleshooting guide&lt;/a&gt; calls out this exact failure mode:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;If Apigee returns a 403 when you first try to create the new organization, it could mean that one or more of your APIs have not been enabled... If you enabled this API recently, wait a few minutes for the action to propagate to our systems and retry.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Org creation is an asynchronous long-running operation (LRO) layered on top of API enablement from the prior step. There's a small window where the APIs &lt;em&gt;report&lt;/em&gt; as enabled but haven't fully propagated through Google's internal systems yet. The wizard's org-creation call hits that window, gets rejected, and shows "Something went wrong" — but the expensive networking/VPC-peering work from Step 2 already succeeded, so once the propagation catches up, the retry only has to do the comparatively cheap part (create the org record) and finishes fast.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Don't trust a UI checkmark on faith, though — verify independently.&lt;/strong&gt; From Cloud Shell:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gcloud config &lt;span class="nb"&gt;set &lt;/span&gt;project YOUR_PROJECT_ID
curl &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;gcloud auth print-access-token&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"https://apigee.googleapis.com/v1/organizations/YOUR_PROJECT_ID"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Look for &lt;code&gt;"state": "ACTIVE"&lt;/code&gt; in the response. This hits the Apigee Management API directly, bypassing the Console entirely — the actual ground truth, not a rendered checkmark.&lt;/p&gt;

&lt;p&gt;Step 4 (Access routing) subsequently completed cleanly, and the wizard showed the triumphant &lt;strong&gt;"Congratulations! Your Apigee organization is ready"&lt;/strong&gt; message with a "Launch" button pointing at a &lt;code&gt;*.nip.io&lt;/code&gt; test URL.&lt;/p&gt;




&lt;h2&gt;
  
  
  The real bug: green everywhere, 502 everywhere
&lt;/h2&gt;

&lt;p&gt;I clicked Launch. &lt;code&gt;502 Server Error. The server encountered a temporary error and could not complete your request. Please try again in 30 seconds.&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;I waited the requisite 15–20 minutes for what I assumed was normal load-balancer propagation lag, then tried again. Still 502. I deployed a fresh test proxy just to rule out something proxy-specific. Still 502.&lt;/p&gt;

&lt;p&gt;This is the point where "just wait longer" stops being a reasonable answer and it's time to actually verify each layer of the stack independently, from the top down.&lt;/p&gt;

&lt;h3&gt;
  
  
  Layer 1: Is the request even reaching Apigee's infrastructure?
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="s2"&gt;"https://YOUR_HOSTNAME.nip.io/hello-world"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The verbose output showed a &lt;strong&gt;clean TLS handshake with a valid, freshly-issued Google-managed certificate&lt;/strong&gt;, HTTP/2 negotiated, and a response that came back structured exactly like Google's generic Global Front End (GFE) "backend unhealthy" page — not a DNS failure, not a connection refusal, not an SSL error. This told me the request was reaching Google's load balancer correctly; the problem was somewhere &lt;em&gt;behind&lt;/em&gt; it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Layer 2: Is the Apigee org/instance actually healthy?
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;gcloud auth print-access-token&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"https://apigee.googleapis.com/v1/organizations/YOUR_ORG/instances"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This came back &lt;code&gt;"state": "ACTIVE"&lt;/code&gt; with a real internal host/port and service attachment. So the actual Apigee runtime — the expensive, slow-to-provision Message Processor infrastructure — was completely fine.&lt;/p&gt;

&lt;h3&gt;
  
  
  Layer 3: Are environment, deployment, and hostname binding all correct?
&lt;/h3&gt;

&lt;p&gt;Three more calls, all clean:&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;# Is the environment attached to the instance?&lt;/span&gt;
curl &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;gcloud auth print-access-token&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;".../instances/eval-instance/attachments"&lt;/span&gt;

&lt;span class="c"&gt;# Is a proxy actually deployed?&lt;/span&gt;
curl &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;gcloud auth print-access-token&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;".../environments/eval/deployments"&lt;/span&gt;

&lt;span class="c"&gt;# Is the hostname bound to an environment group?&lt;/span&gt;
curl &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;gcloud auth print-access-token&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;".../envgroups"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Environment attached, proxy deployed, hostname bound and &lt;code&gt;ACTIVE&lt;/code&gt;. Every configuration object in the Apigee control plane was correct. This ruled out the entire Apigee-managed side of the stack. The problem had to be in the one piece of infrastructure that Apigee's "Access routing" step provisions but doesn't directly manage day-to-day: &lt;strong&gt;the Compute Engine load balancer and its backing instance group.&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Layer 4: The load balancer's backend health
&lt;/h3&gt;

&lt;p&gt;Apigee X's Access Routing step builds a small Compute Engine managed instance group (MIG) of lightweight forwarding VMs that sit between the external HTTPS Load Balancer and the actual Apigee runtime instance (which lives on a private/internal IP). Checking this directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gcloud compute backend-services get-health apigee-proxy-backend &lt;span class="nt"&gt;--global&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;healthStatus&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;healthState&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;UNHEALTHY&lt;/span&gt;
  &lt;span class="na"&gt;instance&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;.../apigee-proxy-7ztd&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;healthState&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;UNHEALTHY&lt;/span&gt;
  &lt;span class="na"&gt;instance&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;.../apigee-proxy-np3w&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;There it was.&lt;/strong&gt; Zero of two backend instances were passing their health check. That's a 502 by design — a GFE-class load balancer will never forward traffic to a backend it considers unhealthy, no matter how long you wait.&lt;/p&gt;




&lt;h2&gt;
  
  
  Root-causing the unhealthy backend
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Ruling out the obvious suspect: firewall rules
&lt;/h3&gt;

&lt;p&gt;The most common cause of "backend permanently unhealthy" on a fresh GCP load balancer is a missing firewall rule allowing Google's health-check probe ranges (&lt;code&gt;130.211.0.0/22&lt;/code&gt; and &lt;code&gt;35.191.0.0/16&lt;/code&gt;) through to the target instances. I checked:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gcloud compute firewall-rules list &lt;span class="nt"&gt;--format&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"table(name,sourceRanges.list(),allowed[].map().firewall_rule().list(),targetTags.list())"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The rule existed, with the correct source ranges, correct port (443), and a matching target tag (&lt;code&gt;gke-apigee-proxy&lt;/code&gt;) — and a quick &lt;code&gt;gcloud compute instances describe&lt;/code&gt; confirmed the actual VMs carried that exact tag. Firewall theory eliminated.&lt;/p&gt;

&lt;h3&gt;
  
  
  Reading the actual boot log
&lt;/h3&gt;

&lt;p&gt;With the network layer cleared, the next move was to read what the instance itself thought happened during boot:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gcloud compute instances get-serial-port-output apigee-proxy-7ztd &lt;span class="nt"&gt;--zone&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;asia-south1-b | &lt;span class="nb"&gt;tail&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; 60
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Buried in there was the actual smoking gun:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Instance has service account: false, setting ACS client isEnabled to false
...
Failed to download from GCS: downloading object [apigee-envoy-proxy-release/latest/conf/startup-script.sh]...
credentials: cannot fetch token: metadata: GCE metadata "instance/service-accounts/default/token?..." not defined
Trying unauthenticated download
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The VM had no service account attached at all.&lt;/strong&gt; Without one, it can't mint an OAuth token, so it can't authenticate to Cloud Storage to pull down its &lt;em&gt;real&lt;/em&gt; startup script (the one that actually sets up the Envoy-based forwarding proxy). It silently fell back to an unauthenticated download, which only pulled a stub containing basic &lt;code&gt;sysctl&lt;/code&gt;/&lt;code&gt;iptables&lt;/code&gt; scaffolding — nothing that ever configures real port-forwarding to the Apigee runtime.&lt;/p&gt;

&lt;p&gt;Confirming this against the instance template sealed it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gcloud compute instance-templates describe apigee-proxy-asia-south1 &lt;span class="se"&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;"yaml(properties.serviceAccounts)"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Meanwhile, a healthy default Compute Engine service account existed and was enabled on the project (&lt;code&gt;gcloud iam service-accounts list&lt;/code&gt;) — it just wasn't referenced anywhere on this template. This was a genuine defect in how the Apigee provisioning wizard built this specific instance template — not a quota issue, not an org policy, not something I'd misconfigured.&lt;/p&gt;

&lt;h3&gt;
  
  
  The fix, part 1: rebuild the template with a service account
&lt;/h3&gt;

&lt;p&gt;You can't attach a service account to an existing instance template after the fact — templates are immutable. So the fix is to clone it with the missing piece added, then roll the managed instance group onto the new template:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gcloud compute instance-templates create apigee-proxy-asia-south1-fixed &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--machine-type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;e2-micro &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--image-project&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;debian-cloud &lt;span class="nt"&gt;--image-family&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;debian-12 &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;20GB &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--network&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;default &lt;span class="nt"&gt;--subnet&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;default &lt;span class="nt"&gt;--region&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;asia-south1 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--tags&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;https-server,apigee-proxy,gke-apigee-proxy &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--metadata&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;startup-script-url&lt;span class="o"&gt;=&lt;/span&gt;gs://apigee-5g-saas/apigee-envoy-proxy-release/latest/conf/startup-script.sh,ENDPOINT&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--service-account&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;YOUR_PROJECT_NUMBER-compute@developer.gserviceaccount.com &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--scopes&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;cloud-platform &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--preemptible&lt;/span&gt; &lt;span class="nt"&gt;--no-restart-on-failure&lt;/span&gt; &lt;span class="nt"&gt;--maintenance-policy&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;TERMINATE

gcloud compute instance-groups managed set-instance-template apigee-proxy-asia-south1 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--template&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;apigee-proxy-asia-south1-fixed &lt;span class="nt"&gt;--region&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;asia-south1

gcloud compute instance-groups managed rolling-action replace apigee-proxy-asia-south1 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--region&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;asia-south1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note:&lt;/strong&gt; copy every field from your existing template's &lt;code&gt;describe --format=yaml&lt;/code&gt; output exactly — machine type, disk, network, tags, and especially the &lt;code&gt;scheduling&lt;/code&gt; block (&lt;code&gt;preemptible&lt;/code&gt; + &lt;code&gt;no-restart-on-failure&lt;/code&gt; + &lt;code&gt;maintenance-policy=TERMINATE&lt;/code&gt; must all be specified together or &lt;code&gt;gcloud&lt;/code&gt; rejects the combination).&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;After the rolling replace, the new instances came up with the service account correctly attached — the boot log's authentication error was gone. But health checks &lt;em&gt;still&lt;/em&gt; failed. One bug down, one to go.&lt;/p&gt;

&lt;h3&gt;
  
  
  The fix, part 2: the missing &lt;code&gt;ENDPOINT&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;SSHing into a replacement instance and checking what was actually listening confirmed there was still no process bound to port 443:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gcloud compute ssh apigee-proxy-dgz1 &lt;span class="nt"&gt;--zone&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;asia-south1-b
&lt;span class="nb"&gt;sudo &lt;/span&gt;ss &lt;span class="nt"&gt;-tlnp&lt;/span&gt; | &lt;span class="nb"&gt;grep &lt;/span&gt;443   &lt;span class="c"&gt;# → nothing&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These forwarding VMs, it turns out, don't run a full proxy application themselves — they run a startup script that installs an &lt;code&gt;iptables&lt;/code&gt; DNAT (destination NAT) rule redirecting incoming port-443 traffic straight to the real Apigee runtime instance's internal IP. That destination IP is supplied via an instance metadata key called &lt;code&gt;ENDPOINT&lt;/code&gt;. On the original (and my recreated) template, that key existed — but was &lt;strong&gt;blank&lt;/strong&gt;:&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="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;items&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ENDPOINT&lt;/span&gt;
    &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With nowhere to forward to, the script had nothing meaningful to do beyond the generic network scaffolding — hence the suspiciously short, error-free-but-incomplete startup log.&lt;/p&gt;

&lt;p&gt;The fix: patch the metadata with the real internal IP of the Apigee runtime instance (retrieved earlier from the &lt;code&gt;instances&lt;/code&gt; API call in Layer 2), then force each VM to re-run its startup script without a full reboot:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gcloud compute instances add-metadata apigee-proxy-dgz1 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--zone&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;asia-south1-b &lt;span class="nt"&gt;--metadata&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;ENDPOINT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;10.51.204.98

gcloud compute instances add-metadata apigee-proxy-v4xb &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--zone&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;asia-south1-c &lt;span class="nt"&gt;--metadata&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;ENDPOINT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;10.51.204.98

gcloud compute ssh apigee-proxy-dgz1 &lt;span class="nt"&gt;--zone&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;asia-south1-b
&lt;span class="nb"&gt;sudo &lt;/span&gt;google_metadata_script_runner startup
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Checking the NAT table confirmed the fix landed:&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="nb"&gt;sudo &lt;/span&gt;iptables &lt;span class="nt"&gt;-t&lt;/span&gt; nat &lt;span class="nt"&gt;-L&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;Chain PREROUTING
DNAT  tcp dpt:443 to:10.51.204.98
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Repeated on the second instance, then the moment of truth:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gcloud compute backend-services get-health apigee-proxy-backend &lt;span class="nt"&gt;--global&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;healthState: HEALTHY   (apigee-proxy-dgz1)
healthState: HEALTHY   (apigee-proxy-v4xb)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And finally:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="s2"&gt;"https://YOUR_HOSTNAME.nip.io/hello-world"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Hello, Guest!
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Done.&lt;/p&gt;




&lt;h2&gt;
  
  
  Update: it relapsed two days later, and here's why
&lt;/h2&gt;

&lt;p&gt;Two days after publishing this, I opened the project to actually start practicing — and got the exact same 502 again. Same symptom, same 4/4-green setup wizard, same "everything reports ACTIVE" story. My first reaction was that something had regressed on its own, which felt alarming. It hadn't. The real explanation was a gap in the fix above that I'd missed the first time.&lt;/p&gt;

&lt;p&gt;Those &lt;code&gt;apigee-proxy-*&lt;/code&gt; forwarding VMs are provisioned as &lt;strong&gt;&lt;code&gt;--preemptible&lt;/code&gt;&lt;/strong&gt; — cheap, but reclaimable by Google at any time, sometimes within 24 hours. When Google reclaims one, the managed instance group automatically rebuilds a replacement, &lt;strong&gt;straight from the instance template&lt;/strong&gt; — not from whatever state the old VM happened to be in.&lt;/p&gt;

&lt;p&gt;Here's the gap: the &lt;code&gt;ENDPOINT&lt;/code&gt; fix above patches the metadata &lt;strong&gt;on the running instances&lt;/strong&gt; (&lt;code&gt;gcloud compute instances add-metadata ...&lt;/code&gt;), not on the template itself. That's a real, working fix — for as long as those exact VMs stay alive. But the template underneath (&lt;code&gt;apigee-proxy-asia-south1-fixed&lt;/code&gt;) still had &lt;code&gt;ENDPOINT&lt;/code&gt; blank the whole time. The moment either instance got preempted and silently replaced, the replacement came back from that same broken template — same blank &lt;code&gt;ENDPOINT&lt;/code&gt;, same 502, and from the outside it looked like the fix had mysteriously "worn off."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The durable fix is to bake the value into a new template revision, the same pattern as the service-account fix earlier:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gcloud compute instance-templates create apigee-proxy-asia-south1-fixed-v2 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--machine-type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;e2-micro &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--image-project&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;debian-cloud &lt;span class="nt"&gt;--image-family&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;debian-12 &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;20GB &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--network&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;default &lt;span class="nt"&gt;--subnet&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;default &lt;span class="nt"&gt;--region&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;asia-south1 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--tags&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;https-server,apigee-proxy,gke-apigee-proxy &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--metadata&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;startup-script-url&lt;span class="o"&gt;=&lt;/span&gt;gs://apigee-5g-saas/apigee-envoy-proxy-release/latest/conf/startup-script.sh,ENDPOINT&lt;span class="o"&gt;=&lt;/span&gt;10.51.204.98 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--service-account&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;315668123660-compute@developer.gserviceaccount.com &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--scopes&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;cloud-platform &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--preemptible&lt;/span&gt; &lt;span class="nt"&gt;--no-restart-on-failure&lt;/span&gt; &lt;span class="nt"&gt;--maintenance-policy&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;TERMINATE

gcloud compute instance-groups managed set-instance-template apigee-proxy-asia-south1 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--template&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;apigee-proxy-asia-south1-fixed-v2 &lt;span class="nt"&gt;--region&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;asia-south1

gcloud compute instance-groups managed rolling-action replace apigee-proxy-asia-south1 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--region&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;asia-south1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Once &lt;code&gt;ENDPOINT&lt;/code&gt; lives in the template rather than on individual instances, any future preemption or autoheal replacement inherits the correct value automatically — no more manual re-patching, ever, for this project.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The lesson generalizes beyond this one bug:&lt;/strong&gt; on any Compute Engine managed instance group, a fix applied via &lt;code&gt;add-metadata&lt;/code&gt; (or any live-instance edit) to a running VM is inherently temporary if that VM can be replaced by the group's own lifecycle — preemption, autohealing, or a manual scale event. The template is the actual source of truth for anything that needs to survive. Patch the instance for an immediate same-minute fix if you're in a hurry; patch the template for the fix that actually sticks.&lt;/p&gt;




&lt;h2&gt;
  
  
  The debugging methodology, distilled
&lt;/h2&gt;

&lt;p&gt;If you take nothing else from this post, take the layered verification approach — it's the reusable part:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Don't trust a single "it says it's ready" signal.&lt;/strong&gt; Console checkmarks, API &lt;code&gt;state: ACTIVE&lt;/code&gt; fields, and actual traffic serving are three different questions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Verify each layer of the request path independently&lt;/strong&gt;, from the outside in:

&lt;ul&gt;
&lt;li&gt;TLS/DNS reachability (&lt;code&gt;curl -v&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Apigee org + runtime instance state (Management API)&lt;/li&gt;
&lt;li&gt;Environment attachment, deployment, and hostname binding (Management API)&lt;/li&gt;
&lt;li&gt;Load balancer backend health (&lt;code&gt;gcloud compute backend-services get-health&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;The actual VM's boot log and listening ports (&lt;code&gt;get-serial-port-output&lt;/code&gt;, &lt;code&gt;ss -tlnp&lt;/code&gt;)&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Read the boot log before assuming propagation delay.&lt;/strong&gt; A silent early exit with no errors can look identical to "still starting" from the outside — the only way to tell the difference is to read what actually ran.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Understand what's supposed to be running before declaring something broken.&lt;/strong&gt; My first assumption — that these VMs should have a listening process — was wrong; they're a &lt;code&gt;DNAT&lt;/code&gt; relay, not an application server. Getting that model right changed what "healthy" even meant to check for.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;When something is auto-provisioned by a wizard, verify its artifacts independently&lt;/strong&gt; rather than assuming the automation is infallible. In this case, two separate fields on an auto-generated instance template were wrong, and neither surfaced as an explicit error anywhere in the Console.&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  Should you expect this to happen to you?
&lt;/h2&gt;

&lt;p&gt;Almost certainly not on every setup — this felt like a genuine, if rare, defect in how the Apigee X evaluation wizard built one specific instance template in one specific run. Most eval org setups likely complete without ever seeing this. But if you do hit a 502 that survives 20+ minutes of patient waiting on an otherwise "fully provisioned" eval org, the checklist above will get you to ground truth fast — instead of guessing at propagation delays indefinitely.&lt;/p&gt;

&lt;p&gt;If you're setting up Apigee X eval for the first time: budget for the fact that a "Something went wrong" on the org-creation step is often transient and not worth panicking over, but a 502 that survives a proper wait, with all Apigee-side config verified clean, is a signal to go look at the Compute Engine layer underneath — specifically the &lt;code&gt;apigee-proxy-*&lt;/code&gt; instance group, its template's service account, and the &lt;code&gt;ENDPOINT&lt;/code&gt; metadata key.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Questions or hit something similar? Drop a comment — happy to compare notes.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>gcp</category>
      <category>apigee</category>
      <category>cloud</category>
      <category>devops</category>
    </item>
    <item>
      <title>Building Enterprise-Ready APIs on Google Cloud Apigee X: 5 Hands-On Projects</title>
      <dc:creator>Sunny JayaRaju</dc:creator>
      <pubDate>Wed, 12 Aug 2026 18:19:08 +0000</pubDate>
      <link>https://dev.to/sunnyjayaraju/building-enterprise-ready-apis-on-google-cloud-apigee-x-5-hands-on-projects-2bn5</link>
      <guid>https://dev.to/sunnyjayaraju/building-enterprise-ready-apis-on-google-cloud-apigee-x-5-hands-on-projects-2bn5</guid>
      <description>&lt;h2&gt;
  
  
  Why I built an Apigee Lab
&lt;/h2&gt;

&lt;p&gt;Enterprise APIs are the backbone of modern platforms. But reading about API gateways is one thing — &lt;strong&gt;building them, breaking them, and hardening them&lt;/strong&gt; is where you actually learn the craft.&lt;/p&gt;

&lt;p&gt;That's exactly what I set out to do with &lt;strong&gt;&lt;a href="https://github.com/SunnyJayaRaju/Apigee-Lab" rel="noopener noreferrer"&gt;Apigee-Lab&lt;/a&gt;&lt;/strong&gt; — an open monorepo of 5 enterprise-inspired projects built on &lt;strong&gt;Google Cloud Apigee X&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Each project is fully documented — with architecture diagrams, Mermaid sequence diagrams, deployment guides, and real policy XML. No "hello world" filler. Just production patterns you can actually study and reuse.&lt;/p&gt;

&lt;p&gt;Here's what's inside.&lt;/p&gt;




&lt;h2&gt;
  
  
  1️⃣ Weather Shield Gateway — API Security Layer
&lt;/h2&gt;

&lt;p&gt;A secure, optimized gateway for weather API traffic.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What it demonstrates:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;JWT authentication&lt;/strong&gt; to protect routes&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Spike Arrest&lt;/strong&gt; to stop traffic surges and abuse&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Response caching&lt;/strong&gt; to cut backend load and latency&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Monetization quotas&lt;/strong&gt; (Silver/Gold tiers)
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;GET /weather-lab?city=London
Authorization: Bearer &amp;lt;jwt&amp;gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Request flow: spike arrest → JWT verify → quota check → cache check → backend → transform (XML→JSON).&lt;/p&gt;

&lt;p&gt;Read the full architecture in &lt;a href="https://github.com/SunnyJayaRaju/Apigee-Lab/tree/main/Weather-Shield-Gateway" rel="noopener noreferrer"&gt;project docs&lt;/a&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  2️⃣ Secure Bank Access — OAuth 2.0 Identity Flows
&lt;/h2&gt;

&lt;p&gt;A mock banking API that demonstrates clean &lt;strong&gt;identity and access management&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What it demonstrates:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Client Credentials Flow&lt;/strong&gt; for machine-to-machine security&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Conditional proxy flows&lt;/strong&gt; separating public (&lt;code&gt;/token&lt;/code&gt;) and protected (&lt;code&gt;/balance&lt;/code&gt;) routes&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;API Products, Developers, and Apps&lt;/strong&gt; to enforce scopes and limits&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The two-phase pattern: &lt;em&gt;the handshake&lt;/em&gt; (get a token) → &lt;em&gt;the access&lt;/em&gt; (verify and authorize).&lt;/p&gt;

&lt;p&gt;Read the full architecture in &lt;a href="https://github.com/SunnyJayaRaju/Apigee-Lab/tree/main/Secure-Bank-Access" rel="noopener noreferrer"&gt;project docs&lt;/a&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  3️⃣ Retail Mesh Orchestrator — Microservices Aggregation
&lt;/h2&gt;

&lt;p&gt;A &lt;strong&gt;composition layer&lt;/strong&gt; that consolidates multiple backend responses into one payload.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What it demonstrates:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Parallel Service Callouts&lt;/strong&gt; to cut round-trips&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;JavaScript transformations&lt;/strong&gt; for JSON/XML normalization&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fault-tolerant&lt;/strong&gt; routing during backend failures&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The classic &lt;em&gt;Backend-for-Frontend&lt;/em&gt; pattern, done with Apigee as the orchestrator.&lt;/p&gt;

&lt;p&gt;Read the full architecture in &lt;a href="https://github.com/SunnyJayaRaju/Apigee-Lab/tree/main/Retail-Mesh-Orchestrator" rel="noopener noreferrer"&gt;project docs&lt;/a&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  4️⃣ Apigee DevOps Pipeline — Full CI/CD Automation
&lt;/h2&gt;

&lt;p&gt;Instead of deploying proxies manually, this project automates the delivery flow.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What it demonstrates:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;GitHub Actions&lt;/strong&gt; workflows that lint, package, and version proxies&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;apigeelint&lt;/strong&gt; to catch structural and policy errors before merge&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Artifact staging&lt;/strong&gt; for controlled deployments&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every push runs a quality gate: build structure → lint → package → upload. Bad code never ships.&lt;/p&gt;

&lt;p&gt;Read the full pipeline in &lt;a href="https://github.com/SunnyJayaRaju/Apigee-Lab/tree/main/Apigee-DevOps-Pipeline" rel="noopener noreferrer"&gt;project docs&lt;/a&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  5️⃣ Security Governance — Shared Flows
&lt;/h2&gt;

&lt;p&gt;A centralized security module for &lt;strong&gt;global policy enforcement&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What it demonstrates:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Reusable Shared Flows&lt;/strong&gt; to standardize logic across proxies&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Spike Arrests and Security Headers&lt;/strong&gt; enforced globally via &lt;code&gt;FlowCallout&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Governance rules &lt;strong&gt;decoupled&lt;/strong&gt; from individual proxy logic&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One shared flow, referenced by many proxies. Consistent security across the entire org.&lt;/p&gt;

&lt;p&gt;Read the full architecture in &lt;a href="https://github.com/SunnyJayaRaju/Apigee-Lab/tree/main/Shared-Flows-Governance" rel="noopener noreferrer"&gt;project docs&lt;/a&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I learned building these
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Security-first design:&lt;/strong&gt; Every proxy needs an identity layer, traffic control, and a governance story — not just routing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reusability wins:&lt;/strong&gt; Shared Flows turn drift-prone duplicate policies into one maintained source of truth.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CI/CD is non-negotiable:&lt;/strong&gt; Linting and packaging Apigee bundles in CI catches problems before they ever reach &lt;code&gt;eval&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Composition is a superpower:&lt;/strong&gt; The gateway can be the orchestrator — merging multiple backends into one clean response.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Try it yourself
&lt;/h2&gt;

&lt;p&gt;All projects are documented so you can deploy them to your own Apigee X environment (&lt;code&gt;eval&lt;/code&gt; org) and verify them with curl or Postman.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Explore the full lab →&lt;/strong&gt; &lt;a href="https://github.com/SunnyJayaRaju/Apigee-Lab" rel="noopener noreferrer"&gt;github.com/SunnyJayaRaju/Apigee-Lab&lt;/a&gt; 🌟&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;If you build APIs, gateways, or microservices — I'd love your feedback, issues, or a PR. Star it if it helps, and let's keep learning in public.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This article is part of my public learning journey — you can follow more API engineering notes in my &lt;a href="https://github.com/SunnyJayaRaju/Curious-Explorer" rel="noopener noreferrer"&gt;Curious-Explorer knowledge base&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>apigee</category>
      <category>googlecloud</category>
      <category>api</category>
      <category>oauth2</category>
    </item>
  </channel>
</rss>
