<?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: Sulthon Zainul Habib</title>
    <description>The latest articles on DEV Community by Sulthon Zainul Habib (@sulthonzh).</description>
    <link>https://dev.to/sulthonzh</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.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F193370%2Fcec777a0-1651-49a8-88f6-effc00ffaad7.png</url>
      <title>DEV Community: Sulthon Zainul Habib</title>
      <link>https://dev.to/sulthonzh</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/sulthonzh"/>
    <language>en</language>
    <item>
      <title>Run Real Docker on Android — No Root, No Tricks, Just QEMU</title>
      <dc:creator>Sulthon Zainul Habib</dc:creator>
      <pubDate>Sun, 14 Jun 2026 04:00:44 +0000</pubDate>
      <link>https://dev.to/sulthonzh/run-real-docker-on-android-no-root-no-tricks-just-qemu-15jn</link>
      <guid>https://dev.to/sulthonzh/run-real-docker-on-android-no-root-no-tricks-just-qemu-15jn</guid>
      <description>&lt;h1&gt;
  
  
  Run Real Docker on Android — No Root, No Tricks, Just QEMU
&lt;/h1&gt;

&lt;p&gt;You have an old Android phone in a drawer. It has 8 cores, 8–12 GB of RAM, and a fast SSD. It's collecting dust because the screen is cracked or the battery is tired. Meanwhile, you're paying $5/month for a 1 GB VPS to run the same Docker containers you could run on that phone for free.&lt;/p&gt;

&lt;p&gt;The problem: &lt;strong&gt;Android doesn't run Docker.&lt;/strong&gt; Docker needs the Linux kernel's cgroups, namespaces, and overlay filesystem. Android's kernel has those features compiled out for unprivileged apps, and there's no way to add them back without rooting the device.&lt;/p&gt;

&lt;p&gt;The workaround everyone recommends is &lt;code&gt;udocker&lt;/code&gt; or &lt;code&gt;proot-distro&lt;/code&gt;. Those work for &lt;em&gt;some&lt;/em&gt; things — pulling an image and chrooting into it — but they don't give you a real Docker daemon. You can't &lt;code&gt;docker build&lt;/code&gt;. You can't run &lt;code&gt;docker compose&lt;/code&gt; with networking. You can't use any tool that expects the Docker API to actually behave like Docker.&lt;/p&gt;

&lt;p&gt;This tutorial shows the only path I've found that gives you &lt;strong&gt;a real Docker daemon running real containers on a non-rooted Android phone&lt;/strong&gt;: run Debian in a QEMU virtual machine inside Termux. It's slow (10–25× overhead from software emulation), but it works — and it survives phone reboots.&lt;/p&gt;

&lt;p&gt;By the end, you'll have:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;✅ A Debian 12 VM running inside your phone&lt;/li&gt;
&lt;li&gt;✅ Real &lt;code&gt;dockerd&lt;/code&gt; + Docker Compose v2&lt;/li&gt;
&lt;li&gt;✅ SSH access from your computer to the VM&lt;/li&gt;
&lt;li&gt;✅ Auto-start on phone reboot (no manual intervention)&lt;/li&gt;
&lt;li&gt;✅ A &lt;code&gt;docker context&lt;/code&gt; so &lt;code&gt;docker --context phone compose up&lt;/code&gt; works like it's local&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Tested end-to-end on a &lt;strong&gt;Samsung Galaxy Note 10+ (SM-N975F, Exynos 9825, 12 GB RAM, Android 12, kernel 4.14.113)&lt;/strong&gt; — not rooted.&lt;/p&gt;




&lt;h2&gt;
  
  
  What you'll need
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Requirement&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;th&gt;Cost&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;An Android phone (ARM64, 6+ GB RAM)&lt;/td&gt;
&lt;td&gt;The host. Must be ARM64 — x86 phones don't exist, but just in case.&lt;/td&gt;
&lt;td&gt;Free (you have one)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A computer (macOS, Linux, or Windows with WSL)&lt;/td&gt;
&lt;td&gt;To SSH in from. The phone is the server, your computer is the client.&lt;/td&gt;
&lt;td&gt;Free&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Both devices on the same Wi-Fi&lt;/td&gt;
&lt;td&gt;For SSH. (You can do this remotely later with Tailscale, but that's out of scope here.)&lt;/td&gt;
&lt;td&gt;Free&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A USB-C cable (for the initial ADB setup, optional)&lt;/td&gt;
&lt;td&gt;One-time setup. You can also do it entirely on-device if you prefer.&lt;/td&gt;
&lt;td&gt;Free&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;~2 hours of patience&lt;/td&gt;
&lt;td&gt;QEMU's first boot takes 20–30 minutes under software emulation. There's no way around this.&lt;/td&gt;
&lt;td&gt;Priceless&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Phone battery tip:&lt;/strong&gt; If your phone has a "Protect Battery" or "Maximum 85%" setting (Samsung does), turn it on. The phone will be plugged in 24/7, and capping the charge doubles the battery's lifespan.&lt;/p&gt;




&lt;h2&gt;
  
  
  The architecture (mental model)
&lt;/h2&gt;

&lt;p&gt;Here's what we're building:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;┌────────────────────────────────────────────────────┐
│ Your Android phone (not rooted)                    │
│                                                    │
│  ┌──────────────────────────────────────────────┐  │
│  │ Termux (regular Android app)                 │  │
│  │  - QEMU binary (qemu-system-aarch64)         │  │
│  │  - The .qcow2 disk image                     │  │
│  │  - Boot scripts                              │  │
│  │  - sshd (port 8022) → your computer SSHes   │  │
│  │                       in here for management │  │
│  └─────────────────┬────────────────────────────┘  │
│                    │ launches                       │
│  ┌─────────────────▼────────────────────────────┐  │
│  │ QEMU VM (looks like real ARM64 hardware)     │  │
│  │  - Debian 12 (bookworm)                      │  │
│  │  - Real dockerd + Docker Compose v2          │  │
│  │  - sshd (port 22 → forwarded to 2222)        │  │
│  │  - systemd works (unlike in proot)           │  │
│  └──────────────────────────────────────────────┘  │
└────────────────────────────────────────────────────┘
              ↑
              │ SSH from your computer
              │   ssh phone-vm      → port 2222 → Debian VM
              │   ssh phone-termux  → port 8022 → Termux shell
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two SSH targets. &lt;strong&gt;&lt;code&gt;phone-vm&lt;/code&gt;&lt;/strong&gt; drops you into Debian where Docker lives. &lt;strong&gt;&lt;code&gt;phone-termux&lt;/code&gt;&lt;/strong&gt; drops you into Termux itself (the Android-side layer), useful for troubleshooting the VM or restarting QEMU.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why QEMU and not just proot-distro?&lt;/strong&gt; Three reasons:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;proot-distro has no systemd.&lt;/strong&gt; No systemd means no &lt;code&gt;systemctl enable docker&lt;/code&gt;. You'd have to manually start the daemon on every login.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;proot-distro has no cgroups.&lt;/strong&gt; No cgroups means no container resource control. Docker will refuse to start.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;proot-distro has no real namespaces.&lt;/strong&gt; No namespaces means no isolation. Containers would see each other.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;QEMU gives us a real Linux kernel with all three. The cost is speed — every instruction is translated by QEMU's TCG (Tiny Code Generator) software emulator. That's why boot takes 20–30 minutes.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 1: Install Termux (the right way)
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Do not install Termux from the Play Store.&lt;/strong&gt; The Play Store version is frozen at v0.101 from 2020 and has a known security vulnerability. Install from &lt;strong&gt;F-Droid&lt;/strong&gt; or the &lt;strong&gt;GitHub releases&lt;/strong&gt; — but pick one and stick with it, because they're signature-incompatible with each other.&lt;/p&gt;

&lt;p&gt;The cleanest option is F-Droid. Go to &lt;a href="https://f-droid.org/en/packages/com.termux/" rel="noopener noreferrer"&gt;f-droid.org/en/packages/com.termux&lt;/a&gt; on your phone and install the APK.&lt;/p&gt;

&lt;p&gt;While you're at it, also install &lt;strong&gt;Termux:Boot&lt;/strong&gt; from F-Droid: &lt;a href="https://f-droid.org/en/packages/com.termux.boot/" rel="noopener noreferrer"&gt;f-droid.org/en/packages/com.termux.boot&lt;/a&gt;. This is what makes QEMU auto-start when the phone reboots. We'll configure it in Step 7.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;After installing Termux:Boot, open it once.&lt;/strong&gt; Just tap the icon, then close. This registers it with Android's &lt;code&gt;BOOT_COMPLETED&lt;/code&gt; broadcast. If you skip this, the auto-start script in Step 7 won't fire.&lt;/p&gt;

&lt;p&gt;Now open Termux. You should see a &lt;code&gt;$&lt;/code&gt; prompt. Run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pkg update &lt;span class="nt"&gt;-y&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; pkg upgrade &lt;span class="nt"&gt;-y&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This updates the package lists and upgrades everything. First run takes 1–2 minutes.&lt;/p&gt;

&lt;p&gt;If it hangs on a mirror, run &lt;code&gt;termux-change-repo&lt;/code&gt;, pick "Main repository", and select a mirror close to you. Indonesian users: &lt;code&gt;linux.domainesia.com&lt;/code&gt; and &lt;code&gt;mirror.nevacloud.com&lt;/code&gt; are in the Asia group.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 2: Set up SSH so you can work from your computer
&lt;/h2&gt;

&lt;p&gt;Typing long commands on a phone keyboard is miserable. Let's fix that by setting up SSH from your computer.&lt;/p&gt;

&lt;h3&gt;
  
  
  2.1 Grant storage access (one-time)
&lt;/h3&gt;

&lt;p&gt;In Termux:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;termux-setup-storage
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A dialog pops up on your phone asking for storage permission. Tap "Allow". This creates &lt;code&gt;~/storage/&lt;/code&gt; symlinks to shared storage — not strictly needed for QEMU, but useful later if you want to back up your disk image.&lt;/p&gt;

&lt;h3&gt;
  
  
  2.2 Set a Termux password
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;passwd
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Type a password twice. You'll need this for SSH (though we'll set up key-based auth in a moment).&lt;/p&gt;

&lt;h3&gt;
  
  
  2.3 Add your computer's SSH public key
&lt;/h3&gt;

&lt;p&gt;On your &lt;strong&gt;computer&lt;/strong&gt;, get your public key:&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;# macOS / Linux / WSL&lt;/span&gt;
&lt;span class="nb"&gt;cat&lt;/span&gt; ~/.ssh/id_ed25519.pub
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you don't have one, generate it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh-keygen &lt;span class="nt"&gt;-t&lt;/span&gt; ed25519 &lt;span class="nt"&gt;-C&lt;/span&gt; &lt;span class="s2"&gt;"your-email@example.com"&lt;/span&gt;
&lt;span class="c"&gt;# Press Enter through all the prompts&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Copy the output (a string starting with &lt;code&gt;ssh-ed25519 AAAA...&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;Back in &lt;strong&gt;Termux&lt;/strong&gt; on the phone:&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;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; ~/.ssh &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;chmod &lt;/span&gt;700 ~/.ssh
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'PASTE_YOUR_PUBLIC_KEY_HERE'&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; ~/.ssh/authorized_keys
&lt;span class="nb"&gt;chmod &lt;/span&gt;600 ~/.ssh/authorized_keys
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Replace &lt;code&gt;PASTE_YOUR_PUBLIC_KEY_HERE&lt;/code&gt; with the key you copied. Keep the single quotes around it.&lt;/p&gt;

&lt;h3&gt;
  
  
  2.4 Start the SSH server
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;sshd
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No output means it started. Termux's sshd listens on &lt;strong&gt;port 8022&lt;/strong&gt; (not the usual 22 — Android reserves 22 for the system).&lt;/p&gt;

&lt;h3&gt;
  
  
  2.5 Find your phone's IP
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ip addr show wlan0 | &lt;span class="nb"&gt;grep &lt;/span&gt;inet
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You'll see something like &lt;code&gt;inet 192.168.0.9/24&lt;/code&gt;. Note the IP (without the &lt;code&gt;/24&lt;/code&gt;).&lt;/p&gt;

&lt;h3&gt;
  
  
  2.6 Set up SSH config on your computer
&lt;/h3&gt;

&lt;p&gt;On your &lt;strong&gt;computer&lt;/strong&gt;, edit &lt;code&gt;~/.ssh/config&lt;/code&gt; and add:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ssh"&gt;&lt;code&gt;&lt;span class="k"&gt;Host&lt;/span&gt; phone-termux
  &lt;span class="k"&gt;HostName&lt;/span&gt; &lt;span class="m"&gt;192&lt;/span&gt;.168.0.9
  &lt;span class="k"&gt;Port&lt;/span&gt; &lt;span class="m"&gt;8022&lt;/span&gt;
  &lt;span class="k"&gt;User&lt;/span&gt; u0_a892
  &lt;span class="k"&gt;IdentityFile&lt;/span&gt; ~/.ssh/id_ed25519
  &lt;span class="k"&gt;ControlMaster&lt;/span&gt; &lt;span class="no"&gt;auto&lt;/span&gt;
  &lt;span class="k"&gt;ControlPath&lt;/span&gt; ~/.ssh/controlmasters/%r@%h:%p
  &lt;span class="k"&gt;ControlPersist&lt;/span&gt; &lt;span class="m"&gt;10&lt;/span&gt;m
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Replace &lt;code&gt;192.168.0.9&lt;/code&gt; with your phone's IP. The &lt;code&gt;User&lt;/code&gt; looks weird (&lt;code&gt;u0_a892&lt;/code&gt;) — that's Termux's Android UID, which is also its Linux username. The number after &lt;code&gt;u0_a&lt;/code&gt; varies per install; to find yours, run &lt;code&gt;whoami&lt;/code&gt; in Termux.&lt;/p&gt;

&lt;p&gt;Create the controlmasters directory (needed for connection multiplexing, which makes repeated SSH commands nearly instant):&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;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; ~/.ssh/controlmasters
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now test:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh phone-termux &lt;span class="nb"&gt;whoami&lt;/span&gt;
&lt;span class="c"&gt;# → u0_a892&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If that works, you're set. From now on, you can run commands in Termux from the comfort of your computer's keyboard.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 3: Download Debian and build the cloud-init seed
&lt;/h2&gt;

&lt;p&gt;Back in Termux (either on the phone directly, or via &lt;code&gt;ssh phone-termux&lt;/code&gt; from your computer):&lt;/p&gt;

&lt;h3&gt;
  
  
  3.1 Install QEMU and friends
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pkg &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; qemu-system-aarch64 openssh curl wget genisoimage
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This installs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;qemu-system-aarch64&lt;/code&gt;&lt;/strong&gt; — the emulator itself (~400 MB, includes UEFI firmware)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;openssh&lt;/code&gt;&lt;/strong&gt; — for the SSH server we already started&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;curl&lt;/code&gt; / &lt;code&gt;wget&lt;/code&gt;&lt;/strong&gt; — for downloading the Debian image&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;genisoimage&lt;/code&gt;&lt;/strong&gt; — for building the cloud-init seed ISO&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  3.2 Acquire a wake lock (CRITICAL — do not skip)
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;termux-wake-lock
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This tells Android "don't kill this process when the screen is off." Without it, Android's Doze mode will murder QEMU 5–10 minutes after your screen goes dark, and your VM will die mid-boot. You only need to run this once per Termux session — but it's easiest to put it in your boot script (Step 7) so it's always active.&lt;/p&gt;

&lt;h3&gt;
  
  
  3.3 Download the Debian cloud image
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; ~/qemu-vm &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cd&lt;/span&gt; ~/qemu-vm
wget https://cloud.debian.org/images/cloud/bookworm/latest/debian-12-genericcloud-arm64.qcow2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is a 500 MB qcow2 file — Debian's official "generic cloud" image for ARM64. It's built for exactly this use case (cloud VMs), so it has cloud-init pre-installed and no desktop environment.&lt;/p&gt;

&lt;h3&gt;
  
  
  3.4 Resize the disk
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;qemu-img resize debian-12-genericcloud-arm64.qcow2 16G
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The downloaded image is ~2 GB (logical size). We resize to 16 GB so there's room for Docker, containers, and pulled images. The qcow2 format only allocates disk space as it's used, so this won't actually consume 16 GB on your phone immediately.&lt;/p&gt;

&lt;p&gt;Rename it for clarity:&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;mv &lt;/span&gt;debian-12-genericcloud-arm64.qcow2 debian-12-arm64.qcow2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  3.5 Create the cloud-init seed
&lt;/h3&gt;

&lt;p&gt;Cloud-init is how we configure the VM on first boot: set hostname, create users, add SSH keys. It reads from a "seed" — in our case, a small ISO file attached as a virtual CD-ROM.&lt;/p&gt;

&lt;p&gt;Create &lt;code&gt;user-data&lt;/code&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="nb"&gt;cat&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; user-data &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;'
#cloud-config
hostname: docker-phone
users:
  - name: sulthon
    sudo: ALL=(ALL) NOPASSWD:ALL
    shell: /bin/bash
    ssh_authorized_keys:
      - ssh-ed25519 AAAA...YOUR_KEY_HERE... your-email@example.com
ssh_pwauth: false
disable_root: false
package_update: true
packages:
  - qemu-guest-agent
  - ca-certificates
  - curl
growpart:
  mode: auto
  devices: ['/']
  ignore_growroot: false
&lt;/span&gt;&lt;span class="no"&gt;EOF
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Edit the SSH key&lt;/strong&gt; to match your actual &lt;code&gt;~/.ssh/id_ed25519.pub&lt;/code&gt; from Step 2.3. This is what lets you SSH into the Debian VM later. Change the username &lt;code&gt;sulthon&lt;/code&gt; to whatever you like — just remember it for later steps.&lt;/p&gt;

&lt;p&gt;Create &lt;code&gt;meta-data&lt;/code&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="nb"&gt;cat&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; meta-data &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;'
instance-id: docker-phone-001
local-hostname: docker-phone
&lt;/span&gt;&lt;span class="no"&gt;EOF
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Build the seed ISO:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;genisoimage &lt;span class="nt"&gt;-output&lt;/span&gt; seed.iso &lt;span class="nt"&gt;-volid&lt;/span&gt; cidata &lt;span class="nt"&gt;-joliet&lt;/span&gt; &lt;span class="nt"&gt;-rock&lt;/span&gt; user-data meta-data
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;-volid cidata&lt;/code&gt; is critical — cloud-init looks for a volume labeled exactly &lt;code&gt;cidata&lt;/code&gt; (or &lt;code&gt;CIDATA&lt;/code&gt;). If you get this wrong, cloud-init won't run and you'll have no users, no SSH keys, no nothing.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 4: The QEMU launcher script
&lt;/h2&gt;

&lt;p&gt;This is the script that boots Debian. There's a lot going on, so let's break it down carefully.&lt;/p&gt;

&lt;p&gt;Copy the UEFI firmware variables first (one-time):&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;cp&lt;/span&gt; &lt;span class="nv"&gt;$PREFIX&lt;/span&gt;/share/qemu/edk2-aarch64-vars.fd ~/qemu-vm/edk2-vars.fd
&lt;span class="nb"&gt;truncate&lt;/span&gt; &lt;span class="nt"&gt;-s&lt;/span&gt; 64M ~/qemu-vm/edk2-vars.fd
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This creates a writable copy of the UEFI NVRAM (the &lt;code&gt;edk2-vars.fd&lt;/code&gt; file). The &lt;code&gt;truncate&lt;/code&gt; grows it to 64 MB for safety margin. &lt;strong&gt;Don't skip this&lt;/strong&gt; — without the vars file, UEFI state won't persist across VM reboots, and your VM won't boot a second time.&lt;/p&gt;

&lt;p&gt;Now create the launcher script. This is the heart of the whole setup:&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;cat&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; ~/boot-debian-mon.sh &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;'
#!/data/data/com.termux/files/usr/bin/bash
set +e

VM_DIR="/data/data/com.termux/files/home/qemu-vm"
CODE="/data/data/com.termux/files/usr/share/qemu/edk2-aarch64-code.fd"
VARS="&lt;/span&gt;&lt;span class="nv"&gt;$VM_DIR&lt;/span&gt;&lt;span class="sh"&gt;/edk2-vars.fd"
IMG="&lt;/span&gt;&lt;span class="nv"&gt;$VM_DIR&lt;/span&gt;&lt;span class="sh"&gt;/debian-12-arm64.qcow2"
SEED="&lt;/span&gt;&lt;span class="nv"&gt;$VM_DIR&lt;/span&gt;&lt;span class="sh"&gt;/seed.iso"
MONSOCK="&lt;/span&gt;&lt;span class="nv"&gt;$VM_DIR&lt;/span&gt;&lt;span class="sh"&gt;/mon.sock"
SERIALSOCK="&lt;/span&gt;&lt;span class="nv"&gt;$VM_DIR&lt;/span&gt;&lt;span class="sh"&gt;/serial.sock"
LOG="&lt;/span&gt;&lt;span class="nv"&gt;$VM_DIR&lt;/span&gt;&lt;span class="sh"&gt;/debian-boot.log"

pkill -9 -f qemu-system-aarch64 2&amp;gt;/dev/null
sleep 2
rm -f &lt;/span&gt;&lt;span class="nv"&gt;$MONSOCK&lt;/span&gt;&lt;span class="sh"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$SERIALSOCK&lt;/span&gt;&lt;span class="sh"&gt;

setsid qemu-system-aarch64 &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;&lt;span class="sh"&gt;
  -name docker-phone &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;&lt;span class="sh"&gt;
  -machine virt,gic-version=3 &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;&lt;span class="sh"&gt;
  -cpu max,aarch64=on,pmu=on &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;&lt;span class="sh"&gt;
  -smp 6 &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;&lt;span class="sh"&gt;
  -m 6144 &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;&lt;span class="sh"&gt;
  -accel tcg,thread=multi,tb-size=512 &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;&lt;span class="sh"&gt;
  -nodefaults &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;&lt;span class="sh"&gt;
  -chardev socket,id=mon0,path=&lt;/span&gt;&lt;span class="nv"&gt;$MONSOCK&lt;/span&gt;&lt;span class="sh"&gt;,server=on,wait=off &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;&lt;span class="sh"&gt;
  -mon chardev=mon0,mode=readline &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;&lt;span class="sh"&gt;
  -chardev socket,id=ser0,path=&lt;/span&gt;&lt;span class="nv"&gt;$SERIALSOCK&lt;/span&gt;&lt;span class="sh"&gt;,server=on,wait=off,logfile=&lt;/span&gt;&lt;span class="nv"&gt;$LOG&lt;/span&gt;&lt;span class="sh"&gt; &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;&lt;span class="sh"&gt;
  -serial chardev:ser0 &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;&lt;span class="sh"&gt;
  -display none &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;&lt;span class="sh"&gt;
  -drive if=pflash,format=raw,readonly=on,file=&lt;/span&gt;&lt;span class="nv"&gt;$CODE&lt;/span&gt;&lt;span class="sh"&gt; &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;&lt;span class="sh"&gt;
  -drive if=pflash,format=raw,file=&lt;/span&gt;&lt;span class="nv"&gt;$VARS&lt;/span&gt;&lt;span class="sh"&gt; &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;&lt;span class="sh"&gt;
  -drive file=&lt;/span&gt;&lt;span class="nv"&gt;$IMG&lt;/span&gt;&lt;span class="sh"&gt;,if=virtio,format=qcow2,cache=writeback &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;&lt;span class="sh"&gt;
  -drive file=&lt;/span&gt;&lt;span class="nv"&gt;$SEED&lt;/span&gt;&lt;span class="sh"&gt;,if=virtio,format=raw,readonly=on &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;&lt;span class="sh"&gt;
  -netdev user,id=net0,hostfwd=tcp::2222-:22,hostfwd=tcp::8080-:80,hostfwd=tcp::9000-:9000 &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;&lt;span class="sh"&gt;
  -device virtio-net-pci,netdev=net0 &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;&lt;span class="sh"&gt;
  -device virtio-rng-pci &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;&lt;span class="sh"&gt;
  -rtc base=utc &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;&lt;span class="sh"&gt;
  &amp;gt; &lt;/span&gt;&lt;span class="nv"&gt;$LOG&lt;/span&gt;&lt;span class="sh"&gt; 2&amp;gt;&amp;amp;1 &amp;amp;

disown
sleep 3
echo "QEMU PID: &lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;pgrep &lt;span class="nt"&gt;-f&lt;/span&gt; qemu-system-aarch64 | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-1&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="sh"&gt;"
echo "Monitor socket: &lt;/span&gt;&lt;span class="nv"&gt;$MONSOCK&lt;/span&gt;&lt;span class="sh"&gt;"
echo "Serial socket: &lt;/span&gt;&lt;span class="nv"&gt;$SERIALSOCK&lt;/span&gt;&lt;span class="sh"&gt;"
echo "Log: &lt;/span&gt;&lt;span class="nv"&gt;$LOG&lt;/span&gt;&lt;span class="sh"&gt;"
&lt;/span&gt;&lt;span class="no"&gt;EOF
&lt;/span&gt;&lt;span class="nb"&gt;chmod&lt;/span&gt; +x ~/boot-debian-mon.sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  What each line does
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;The machine definition:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;-machine virt,gic-version=3&lt;/code&gt; — QEMU's "virt" machine, which is designed for VMs (not emulating any specific real phone). GIC version 3 is the modern ARM interrupt controller.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;-cpu max,aarch64=on,pmu=on&lt;/code&gt; — expose the maximum CPU feature set to the guest, including hardware crypto (ARMv8 crypto extensions). This makes TLS handshakes faster.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;-smp 6&lt;/code&gt; — give the VM 6 CPUs. Adjust based on your phone (8-core phones typically have a "big.LITTLE" layout; 6 leaves 2 for Android).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;-m 6144&lt;/code&gt; — give the VM 6 GB of RAM. Leave the rest for Android.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;The emulator:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;-accel tcg,thread=multi,tb-size=512&lt;/code&gt; — use TCG (software emulation) with multi-threading and a 512 MB translation block cache. This is the best TCG config for sustained throughput.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Storage:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The first &lt;code&gt;-drive if=pflash,readonly=on&lt;/code&gt; is the UEFI firmware code (read-only).&lt;/li&gt;
&lt;li&gt;The second &lt;code&gt;-drive if=pflash&lt;/code&gt; is the UEFI vars file (writable — this is why we made the copy earlier).&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;-drive file=$IMG,if=virtio&lt;/code&gt; is your Debian disk.&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;-drive file=$SEED,if=virtio,readonly=on&lt;/code&gt; is the cloud-init seed ISO.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Networking:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;-netdev user,id=net0,hostfwd=tcp::2222-:22,...&lt;/code&gt; — user-mode networking with port forwarding. Anything connecting to port 2222 on the phone gets forwarded to port 22 inside the VM. We also forward 8080 and 9000 for web apps you might run later.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Process management (the tricky part):&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;setsid&lt;/code&gt; — detaches QEMU from Termux's session leader. Without this, when SSH disconnects, Android kills the whole process tree.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;disown&lt;/code&gt; — removes QEMU from the shell's job table.&lt;/li&gt;
&lt;li&gt;Together, these mean QEMU survives SSH disconnects. &lt;strong&gt;Do not use plain &lt;code&gt;nohup&lt;/code&gt; — it's not enough on Termux.&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Step 5: First boot (patience required)
&lt;/h2&gt;

&lt;p&gt;Launch the VM:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;bash ~/boot-debian-mon.sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You should see:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="err"&gt;QEMU&lt;/span&gt; &lt;span class="py"&gt;PID&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;12345&lt;/span&gt;
&lt;span class="err"&gt;Monitor&lt;/span&gt; &lt;span class="py"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/data/data/com.termux/files/home/qemu-vm/mon.sock&lt;/span&gt;
&lt;span class="err"&gt;Serial&lt;/span&gt; &lt;span class="py"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/data/data/com.termux/files/home/qemu-vm/serial.sock&lt;/span&gt;
&lt;span class="py"&gt;Log&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/data/data/com.termux/files/home/qemu-vm/debian-boot.log&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now wait. &lt;strong&gt;20–30 minutes.&lt;/strong&gt; No, that's not a typo. TCG software emulation is brutally slow.&lt;/p&gt;

&lt;p&gt;You can watch the boot log from another SSH session:&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;# From your computer:&lt;/span&gt;
ssh phone-termux &lt;span class="nb"&gt;tail&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; ~/qemu-vm/debian-boot.log
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When you see something like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;[ OK ] Started OpenSSH server
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;…and the log stops growing, the VM is ready. Verify from your computer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh &lt;span class="nt"&gt;-p&lt;/span&gt; 2222 sulthon@192.168.0.9 &lt;span class="nb"&gt;hostname&lt;/span&gt;
&lt;span class="c"&gt;# → docker-phone&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(Reminder: change &lt;code&gt;sulthon&lt;/code&gt; to whatever username you put in &lt;code&gt;user-data&lt;/code&gt;.)&lt;/p&gt;

&lt;p&gt;If that works, add a second SSH config entry on your computer for the VM:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ssh"&gt;&lt;code&gt;&lt;span class="c1"&gt;# append to ~/.ssh/config&lt;/span&gt;
&lt;span class="k"&gt;Host&lt;/span&gt; phone-vm
  &lt;span class="k"&gt;HostName&lt;/span&gt; &lt;span class="m"&gt;192&lt;/span&gt;.168.0.9
  &lt;span class="k"&gt;Port&lt;/span&gt; &lt;span class="m"&gt;2222&lt;/span&gt;
  &lt;span class="k"&gt;User&lt;/span&gt; sulthon
  &lt;span class="k"&gt;IdentityFile&lt;/span&gt; ~/.ssh/id_ed25519
  &lt;span class="k"&gt;ControlMaster&lt;/span&gt; &lt;span class="no"&gt;auto&lt;/span&gt;
  &lt;span class="k"&gt;ControlPath&lt;/span&gt; ~/.ssh/controlmasters/%r@%h:%p
  &lt;span class="k"&gt;ControlPersist&lt;/span&gt; &lt;span class="m"&gt;10&lt;/span&gt;m
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now &lt;code&gt;ssh phone-vm&lt;/code&gt; Just Works.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 6: Install Docker inside the VM
&lt;/h2&gt;

&lt;p&gt;SSH into the VM and install everything:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh phone-vm
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  6.1 Install Docker
&lt;/h3&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;apt-get update
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt-get &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; docker.io
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;docker.io&lt;/code&gt; package is Debian's official Docker package (version 20.10.24 as of this writing). It's slightly older than Docker CE, but it's in the main Debian repos and works perfectly. &lt;strong&gt;Expect this to take 25–35 minutes under TCG.&lt;/strong&gt; Go get coffee.&lt;/p&gt;

&lt;h3&gt;
  
  
  6.2 Install Docker Compose v2
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; /usr/libexec/docker/cli-plugins
&lt;span class="nb"&gt;sudo &lt;/span&gt;curl &lt;span class="nt"&gt;-fSL&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /usr/libexec/docker/cli-plugins/docker-compose &lt;span class="se"&gt;\&lt;/span&gt;
  https://github.com/docker/compose/releases/download/v2.29.7/docker-compose-linux-aarch64
&lt;span class="nb"&gt;sudo chmod&lt;/span&gt; +x /usr/libexec/docker/cli-plugins/docker-compose
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We download the binary directly because the &lt;code&gt;docker-compose-v2&lt;/code&gt; Debian package isn't in bookworm. Placing it in &lt;code&gt;cli-plugins/&lt;/code&gt; means &lt;code&gt;docker compose&lt;/code&gt; (with a space, not a hyphen) works.&lt;/p&gt;

&lt;h3&gt;
  
  
  6.3 Let your user run Docker without sudo
&lt;/h3&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;usermod &lt;span class="nt"&gt;-aG&lt;/span&gt; docker &lt;span class="nv"&gt;$USER&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You'll need to log out and back in for this to take effect:&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;exit&lt;/span&gt;  &lt;span class="c"&gt;# back to your computer&lt;/span&gt;
ssh phone-vm  &lt;span class="c"&gt;# back in&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  6.4 Configure Docker for the slow VM
&lt;/h3&gt;

&lt;p&gt;This step is &lt;strong&gt;critical&lt;/strong&gt;. Without it, &lt;code&gt;docker pull&lt;/code&gt; will fail with &lt;code&gt;TLS handshake timeout&lt;/code&gt; because TCG is too slow for Docker's default timeouts.&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 tee&lt;/span&gt; /etc/docker/daemon.json &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;'
{
  "max-concurrent-downloads": 1,
  "max-download-attempts": 5,
  "dns": ["8.8.8.8", "1.1.1.1"],
  "ip6tables": false,
  "ipv6": false
}
&lt;/span&gt;&lt;span class="no"&gt;EOF
&lt;/span&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl restart docker
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What each setting does:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;max-concurrent-downloads: 1&lt;/code&gt; — only download one layer at a time. Parallel downloads overwhelm TCG and time out.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;max-download-attempts: 5&lt;/code&gt; — retry failed layers.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;dns: [8.8.8.8, 1.1.1.1]&lt;/code&gt; — Docker's embedded DNS sometimes can't resolve registry hostnames under TCG; this forces public DNS.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ip6tables: false&lt;/code&gt; and &lt;code&gt;ipv6: false&lt;/code&gt; — disable IPv6. TCG's IPv6 stack is even slower than IPv4, and many container images misbehave over IPv6 anyway.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  6.5 Add ZRAM swap (recommended)
&lt;/h3&gt;

&lt;p&gt;ZRAM is compressed swap in RAM. Under TCG, real disk I/O is brutal, so having compressed memory swap gives you a safety net for memory spikes.&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;apt-get &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; zram-tools
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="s2"&gt;"ALGO=zstd&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;PERCENT=75&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;PRIORITY=100"&lt;/span&gt; | &lt;span class="nb"&gt;sudo tee&lt;/span&gt; /etc/default/zramswap
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl restart zramswap
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This gives you ~4.3 GB of zstd-compressed swap (assuming 6 GB of VM RAM). zstd compresses roughly 3:1 on typical workload data, so it's like having ~13 GB of effective memory.&lt;/p&gt;

&lt;h3&gt;
  
  
  6.6 Verify
&lt;/h3&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;docker run &lt;span class="nt"&gt;--rm&lt;/span&gt; hello-world
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Expect:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;Unable to find image 'hello-world:latest' locally
... pulling layers ...

Hello from Docker!
This message shows that your installation appears to be working correctly.
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The pull takes ~75 seconds (5 KB image, but TCG). The run takes ~15 seconds after that. &lt;strong&gt;If you see "Hello from Docker!", you're done with the hard part.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 7: Make it survive phone reboots
&lt;/h2&gt;

&lt;p&gt;If the phone reboots (battery died, OS update, accidental power button press), you want QEMU to come back automatically. Termux:Boot handles this.&lt;/p&gt;

&lt;h3&gt;
  
  
  7.1 Create the Termux:Boot scripts
&lt;/h3&gt;

&lt;p&gt;Back in Termux (via &lt;code&gt;ssh phone-termux&lt;/code&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="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; ~/.termux/boot
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Create the first script — auto-start QEMU:&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;cat&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; ~/.termux/boot/01-start-vm.sh &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;'
#!/data/data/com.termux/files/usr/bin/bash
sleep 15
termux-wake-lock 2&amp;gt;/dev/null
pkill -9 -f qemu-system-aarch64 2&amp;gt;/dev/null
sleep 2
bash ~/boot-debian-mon.sh
&lt;/span&gt;&lt;span class="no"&gt;EOF
&lt;/span&gt;&lt;span class="nb"&gt;chmod&lt;/span&gt; +x ~/.termux/boot/01-start-vm.sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;sleep 15&lt;/code&gt; gives Android time to finish booting before we start hammering the CPU. The &lt;code&gt;pkill&lt;/code&gt; is a safety net in case QEMU somehow came back half-alive.&lt;/p&gt;

&lt;p&gt;Create the second script — auto-start Termux sshd:&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;cat&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; ~/.termux/boot/02-start-sshd.sh &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;'
#!/data/data/com.termux/files/usr/bin/bash
sleep 5
sshd
&lt;/span&gt;&lt;span class="no"&gt;EOF
&lt;/span&gt;&lt;span class="nb"&gt;chmod&lt;/span&gt; +x ~/.termux/boot/02-start-sshd.sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  7.2 Verify the scripts are registered
&lt;/h3&gt;

&lt;p&gt;If you've opened Termux:Boot once (Step 1), Termux automatically picks up scripts in &lt;code&gt;~/.termux/boot/&lt;/code&gt; and runs them at boot. To confirm:&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;ls&lt;/span&gt; &lt;span class="nt"&gt;-la&lt;/span&gt; ~/.termux/boot/
&lt;span class="c"&gt;# Should show:&lt;/span&gt;
&lt;span class="c"&gt;# -rwx------ 1 u0_a892 u0_a892 ... 01-start-vm.sh&lt;/span&gt;
&lt;span class="c"&gt;# -rwx------ 1 u0_a892 u0_a892 ... 02-start-sshd.sh&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  7.3 Test it (optional but recommended)
&lt;/h3&gt;

&lt;p&gt;Reboot your phone the normal Android way. After it restarts:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Wait ~5 minutes for Android to fully boot.&lt;/li&gt;
&lt;li&gt;Wait another ~20 minutes for QEMU to cold-boot Debian.&lt;/li&gt;
&lt;li&gt;From your computer: &lt;code&gt;ssh phone-vm hostname&lt;/code&gt; should return &lt;code&gt;docker-phone&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If that works, your phone is a self-healing Docker host. You can unplug it, plug it back in, reboot it, whatever — it'll come back.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 8: Use it from your computer like it's local
&lt;/h2&gt;

&lt;p&gt;Typing &lt;code&gt;ssh phone-vm docker run ...&lt;/code&gt; for everything gets old. Let's make the phone feel like a local Docker host.&lt;/p&gt;

&lt;h3&gt;
  
  
  8.1 Create a Docker context
&lt;/h3&gt;

&lt;p&gt;On your &lt;strong&gt;computer&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;docker context create phone &lt;span class="nt"&gt;--docker&lt;/span&gt; &lt;span class="s2"&gt;"host=ssh://sulthon@192.168.0.9:2222"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(Change &lt;code&gt;sulthon&lt;/code&gt; to your VM username, and &lt;code&gt;192.168.0.9&lt;/code&gt; to your phone's IP from Step 2.5.)&lt;/p&gt;

&lt;p&gt;Docker's SSH integration bypasses your &lt;code&gt;~/.ssh/config&lt;/code&gt; aliases, so you need to add the phone to &lt;code&gt;~/.ssh/known_hosts&lt;/code&gt; explicitly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh-keyscan &lt;span class="nt"&gt;-p&lt;/span&gt; 2222 &lt;span class="nt"&gt;-t&lt;/span&gt; ed25519 192.168.0.9 &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; ~/.ssh/known_hosts
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Verify the plumbing works:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker &lt;span class="nt"&gt;--context&lt;/span&gt; phone ps
&lt;span class="c"&gt;# CONTAINER ID   IMAGE   COMMAND   CREATED   STATUS   PORTS   NAMES&lt;/span&gt;
&lt;span class="c"&gt;# (empty list is fine — you haven't run anything yet)&lt;/span&gt;

docker &lt;span class="nt"&gt;--context&lt;/span&gt; phone run &lt;span class="nt"&gt;--rm&lt;/span&gt; hello-world
&lt;span class="c"&gt;# → Hello from Docker!&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If &lt;code&gt;docker --context phone ps&lt;/code&gt; returns an empty table, you're connected. If it times out, the phone is offline, the IP changed, or QEMU died — re-check &lt;code&gt;ssh phone-vm hostname&lt;/code&gt; works first.&lt;/p&gt;

&lt;h3&gt;
  
  
  8.2 The DOCKER_HOST gotcha (Mac users especially)
&lt;/h3&gt;

&lt;p&gt;If you have &lt;strong&gt;Colima&lt;/strong&gt;, &lt;strong&gt;Docker Desktop&lt;/strong&gt;, or &lt;strong&gt;OrbStack&lt;/strong&gt; installed on your Mac, you'll see this warning:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Warning: DOCKER_HOST environment variable overrides the active context.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is normal. The &lt;code&gt;--context phone&lt;/code&gt; flag &lt;strong&gt;does&lt;/strong&gt; override &lt;code&gt;DOCKER_HOST&lt;/code&gt; for that one command — the warning is just noise. Two ways to silence it:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Option A (recommended):&lt;/strong&gt; Always pass &lt;code&gt;--context phone&lt;/code&gt; explicitly. The warning appears but the command works.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Option B:&lt;/strong&gt; Temporarily unset &lt;code&gt;DOCKER_HOST&lt;/code&gt; for the session:&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;unset &lt;/span&gt;DOCKER_HOST
docker context use phone     &lt;span class="c"&gt;# now active&lt;/span&gt;
docker compose ps            &lt;span class="c"&gt;# no --context needed&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  8.3 Test with a real compose stack
&lt;/h3&gt;

&lt;p&gt;Create a file on your &lt;strong&gt;computer&lt;/strong&gt; called &lt;code&gt;docker-compose.yml&lt;/code&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;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;whoami&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;traefik/whoami&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;8080:80"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Save it anywhere — your desktop, home directory, wherever. The file lives on your Mac; the container will run on the phone.&lt;/p&gt;

&lt;p&gt;Now bring it up:&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;cd&lt;/span&gt; /directory/where/you/saved/it
docker &lt;span class="nt"&gt;--context&lt;/span&gt; phone compose up &lt;span class="nt"&gt;-d&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Expect this to take 5–15 minutes the first time.&lt;/strong&gt; TCG emulation makes image pulls brutally slow. You'll see &lt;code&gt;Pulling fs&lt;/code&gt; sit there for minutes at a time. That's normal — don't Ctrl-C.&lt;/p&gt;

&lt;p&gt;When it finishes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker &lt;span class="nt"&gt;--context&lt;/span&gt; phone compose ps
&lt;span class="c"&gt;# NAME                IMAGE           STATUS         PORTS&lt;/span&gt;
&lt;span class="c"&gt;# whoami-whoami-1     traefik/whoami  Up 30 seconds  0.0.0.0:8080-&amp;gt;80/tcp&lt;/span&gt;

&lt;span class="c"&gt;# Test it (port 8080 is forwarded from the phone to your Mac in the QEMU launcher):&lt;/span&gt;
curl http://192.168.0.9:8080
&lt;span class="c"&gt;# → displays the whoami container's response&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Bring it down when done:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker &lt;span class="nt"&gt;--context&lt;/span&gt; phone compose down
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  8.4 Common ways compose fails (and the fix)
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;"Cannot connect to the Docker daemon" / SSH timeout / "command exited with status 255"&lt;/strong&gt;&lt;br&gt;
The most common cause — your computer and the phone aren't on the same network. Verify Layer-2/3 reachability first:&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;# 1. Ping the phone (Mac/Linux)&lt;/span&gt;
ping &lt;span class="nt"&gt;-c&lt;/span&gt; 3 192.168.0.9
&lt;span class="c"&gt;# If 100% packet loss → network problem, not Docker&lt;/span&gt;

&lt;span class="c"&gt;# 2. If ping works, test the SSH port&lt;/span&gt;
nc &lt;span class="nt"&gt;-zv&lt;/span&gt; 192.168.0.9 2222
&lt;span class="c"&gt;# If "Connection refused" or timeout → QEMU died, SSH isn't listening&lt;/span&gt;
&lt;span class="c"&gt;# If "succeeded" → network is fine, Docker context should work&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Network causes&lt;/strong&gt; (in order of likelihood): different Wi-Fi, AP isolation enabled on router, guest network blocking client-to-client traffic, VPN on Mac, phone asleep and dropped off Wi-Fi.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix network&lt;/strong&gt;: put both devices on the same SSID, disable AP isolation in router settings, turn off Mac VPN, wake the phone screen.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If network is fine but SSH still times out&lt;/strong&gt;: QEMU died inside the phone. Restart it via &lt;code&gt;phone-vm-start --wait&lt;/code&gt; if you have the helper scripts, or reboot the phone entirely and wait 20–30 min for TCG boot. Verify with &lt;code&gt;ssh phone-vm hostname&lt;/code&gt; — should return &lt;code&gt;docker-phone&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"pull access denied" or "TLS handshake timeout"&lt;/strong&gt;&lt;br&gt;
Image pull timed out. Two causes: (1) you skipped the &lt;code&gt;/etc/docker/daemon.json&lt;/code&gt; config from Step 6.4, or (2) the image is large and TCG is just slow. Re-check the daemon config. For large images (&amp;gt;500 MB), expect 20+ minute pulls.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"no configuration file provided: not found"&lt;/strong&gt;&lt;br&gt;
You're not in a directory with a &lt;code&gt;docker-compose.yml&lt;/code&gt;. The compose file lives on your &lt;strong&gt;Mac&lt;/strong&gt;, not the phone. &lt;code&gt;cd&lt;/code&gt; into the directory containing your &lt;code&gt;docker-compose.yml&lt;/code&gt; first.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Volume mount path doesn't exist&lt;/strong&gt;&lt;br&gt;
If your compose file has &lt;code&gt;volumes: - ./data:/data&lt;/code&gt;, the &lt;code&gt;./data&lt;/code&gt; path resolves &lt;strong&gt;on the phone&lt;/strong&gt;, not your Mac. The phone has no &lt;code&gt;./data&lt;/code&gt; directory. Use absolute paths inside the VM: &lt;code&gt;volumes: - /home/sulthon/myapp/data:/data&lt;/code&gt;, and create the directory on the phone first via &lt;code&gt;ssh phone-vm mkdir -p /home/sulthon/myapp/data&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Port conflict on 8080&lt;/strong&gt;&lt;br&gt;
The QEMU launcher in Step 4 already forwards port 8080 from phone → Mac. If a container inside the VM also tries to bind 8080, it conflicts with the QEMU-level forward. Use a different port (8081, 3000, etc.) in your compose file.&lt;/p&gt;
&lt;h3&gt;
  
  
  8.5 (Optional) Make the phone the default context
&lt;/h3&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;unset &lt;/span&gt;DOCKER_HOST          &lt;span class="c"&gt;# required — otherwise DOCKER_HOST wins&lt;/span&gt;
docker context use phone
docker ps                  &lt;span class="c"&gt;# now hits the phone&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;h2&gt;
  
  
  I don't recommend this. It's surprising when you forget and accidentally build an image on the phone over Wi-Fi. Keep &lt;code&gt;--context phone&lt;/code&gt; explicit.
&lt;/h2&gt;
&lt;h2&gt;
  
  
  Troubleshooting: the 5 things most likely to break
&lt;/h2&gt;
&lt;h3&gt;
  
  
  1. "QEMU died after my SSH disconnected"
&lt;/h3&gt;

&lt;p&gt;You forgot &lt;code&gt;setsid&lt;/code&gt; and &lt;code&gt;disown&lt;/code&gt; in the launcher script, or you launched it manually with &lt;code&gt;nohup&lt;/code&gt; (which is not enough). Use the script from Step 4 as-is. Never launch QEMU directly with &lt;code&gt;qemu-system-aarch64 ...&lt;/code&gt; — always go through the script.&lt;/p&gt;
&lt;h3&gt;
  
  
  2. "Docker pull fails with TLS handshake timeout"
&lt;/h3&gt;

&lt;p&gt;You skipped the &lt;code&gt;/etc/docker/daemon.json&lt;/code&gt; config in Step 6.4, or the config is malformed. Verify:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh phone-vm &lt;span class="nb"&gt;cat&lt;/span&gt; /etc/docker/daemon.json
ssh phone-vm &lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl restart docker
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then retry. If it still fails, check that &lt;code&gt;max-concurrent-downloads&lt;/code&gt; is set to &lt;code&gt;1&lt;/code&gt; and not to a higher number.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. "Termux sshd isn't running after reboot"
&lt;/h3&gt;

&lt;p&gt;Either you forgot to open Termux:Boot once (Step 1), or the &lt;code&gt;~/.termux/boot/02-start-sshd.sh&lt;/code&gt; script isn't executable. Fix:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh phone-termux &lt;span class="nb"&gt;chmod&lt;/span&gt; +x ~/.termux/boot/02-start-sshd.sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And open the Termux:Boot app icon on your phone, just to be safe.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. "The VM boots to a UEFI shell instead of Debian"
&lt;/h3&gt;

&lt;p&gt;This is a known issue with Debian cloud images on some QEMU versions. The fix is to write a &lt;code&gt;startup.nsh&lt;/code&gt; script to the EFI System Partition. This is fiddly — you need to boot Alpine from ISO with your qcow2 as a data disk, mount the ESP, and write the file. I'll cover this in detail in a follow-up post. For now, if this happens, leave a comment on this post and I'll walk you through it.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. "Everything worked yesterday, but today it's broken"
&lt;/h3&gt;

&lt;p&gt;You hit Android's phantom process killer. Modern Android (12+) kills background processes that use too much CPU. The fixes:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Keep the phone plugged in.&lt;/strong&gt; Battery-saver mode is brutal.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep &lt;code&gt;termux-wake-lock&lt;/code&gt; active.&lt;/strong&gt; Run &lt;code&gt;ssh phone-termux termux-wake-lock&lt;/code&gt; to verify.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Disable Samsung Game Tuning / GOS&lt;/strong&gt; if you're on a Samsung device. It throttles sustained CPU workloads. Settings → Gaming Services → Game Booster → Maximum Performance (or similar).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check Settings → Battery → Termux → Unrestricted.&lt;/strong&gt; This should be automatic via Termux:Boot, but if it's not, set it manually.&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  What to do next
&lt;/h2&gt;

&lt;p&gt;You now have a working Docker host that costs $0/month and fits in your pocket. Some ideas:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Run a personal &lt;a href="https://traefik.io/" rel="noopener noreferrer"&gt;Traefik&lt;/a&gt; + whoami demo&lt;/strong&gt; to verify compose works end-to-end. Port 8080 is already forwarded.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Self-host &lt;a href="https://www.postgresql.org/" rel="noopener noreferrer"&gt;Postgres&lt;/a&gt;&lt;/strong&gt; for local dev. Use a volume so data survives container restarts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Run &lt;a href="https://containrrr.dev/watchtower/" rel="noopener noreferrer"&gt;Watchtower&lt;/a&gt;&lt;/strong&gt; to keep your containers auto-updated.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add &lt;a href="https://tailscale.com/" rel="noopener noreferrer"&gt;Tailscale&lt;/a&gt;&lt;/strong&gt; to the VM so you can reach your Docker host from anywhere — not just your Wi-Fi.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Build images on the phone.&lt;/strong&gt; &lt;code&gt;docker build&lt;/code&gt; works fine (slowly) under TCG. Useful for iterating without hitting your laptop's battery.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The phone-as-server dream is real. It just takes QEMU to get there. Enjoy your free Docker host.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;If you find bugs in this tutorial, please leave a comment — I'll keep it updated. Happy hacking.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>android</category>
      <category>docker</category>
      <category>termux</category>
      <category>qemu</category>
    </item>
    <item>
      <title>I Built an AI Code Reviewer That Runs on 240 Repos — And a Cron System That Keeps It Alive</title>
      <dc:creator>Sulthon Zainul Habib</dc:creator>
      <pubDate>Thu, 11 Jun 2026 16:38:53 +0000</pubDate>
      <link>https://dev.to/sulthonzh/i-built-an-ai-code-reviewer-that-runs-on-240-repos-and-a-cron-system-that-keeps-it-alive-32nj</link>
      <guid>https://dev.to/sulthonzh/i-built-an-ai-code-reviewer-that-runs-on-240-repos-and-a-cron-system-that-keeps-it-alive-32nj</guid>
      <description>&lt;p&gt;I got tired of reviewing my own pull requests at 2 AM. So I built a GitHub Action that does it for me. Then I built a cron system to keep that action alive. Then I added 55 more AI agent jobs to that cron system because, honestly, I couldn't stop.&lt;/p&gt;

&lt;p&gt;Here's what's actually running, what it costs, and what I'm building toward.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Code Reviewer That Started It All
&lt;/h2&gt;

&lt;p&gt;The core product: a GitHub Action called &lt;code&gt;sulthonzh/code-reviewer&lt;/code&gt; that lives at &lt;a href="https://github.com/sulthonzh/code-reviewer" rel="noopener noreferrer"&gt;github.com/sulthonzh/code-reviewer&lt;/a&gt;. Every time someone opens a PR on any of my repos, five jobs fire off in sequence:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Secret scan&lt;/strong&gt; — checks the diff for leaked API keys, passwords, private keys&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AI review&lt;/strong&gt; — sends the diff to Z.AI's GLM model, gets back security/quality/style feedback&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Quality gate&lt;/strong&gt; — runs linting, type checks, test thresholds&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Auto-merge&lt;/strong&gt; — if the AI approved AND quality passed, merges automatically&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Auto-release&lt;/strong&gt; — on push to main, cuts a GitHub release with changelog&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Here's the real workflow. This runs on 240+ repos right now:&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;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;AI Code Review&lt;/span&gt;
&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;pull_request&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;types&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;opened&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;synchronize&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;ready_for_review&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;branches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;main&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;

&lt;span class="na"&gt;concurrency&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;group&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;review-${{ github.ref }}&lt;/span&gt;
  &lt;span class="na"&gt;cancel-in-progress&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;

&lt;span class="na"&gt;permissions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;pull-requests&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;write&lt;/span&gt;
  &lt;span class="na"&gt;contents&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;write&lt;/span&gt;
  &lt;span class="na"&gt;checks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;write&lt;/span&gt;
  &lt;span class="na"&gt;statuses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;write&lt;/span&gt;

&lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;ZAI_BASE_URL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://api.z.ai/api/coding/paas/v4/"&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;secret-scan&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;🔒&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Secret&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Scan"&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;outputs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;secrets_found&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ steps.scan.outputs.found }}&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v6&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;fetch-depth&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Scan diff for secrets&lt;/span&gt;
        &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;scan&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;sulthonzh/code-reviewer@main&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;secret-scan&lt;/span&gt;
          &lt;span class="na"&gt;github-token&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.GITHUB_TOKEN }}&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Block if secrets found&lt;/span&gt;
        &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;steps.scan.outputs.found == 'true'&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;echo "::error::Found potential secret(s) in the diff. Remove before merging."&lt;/span&gt;
          &lt;span class="s"&gt;exit 1&lt;/span&gt;

  &lt;span class="na"&gt;ai-review&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;🤖&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;AI&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Review"&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;needs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;secret-scan&lt;/span&gt;
    &lt;span class="na"&gt;outputs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;approved&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ steps.review.outputs.approved }}&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v6&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;fetch-depth&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Detect project context&lt;/span&gt;
        &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;context&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;sulthonzh/code-reviewer@main&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;detect-context&lt;/span&gt;
          &lt;span class="na"&gt;github-token&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.GITHUB_TOKEN }}&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Route model by diff size&lt;/span&gt;
        &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;model&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;DIFF_LINES=$(git diff origin/main...HEAD 2&amp;gt;/dev/null | wc -l || echo 0)&lt;/span&gt;
          &lt;span class="s"&gt;if [ "$DIFF_LINES" -gt 500 ]; then&lt;/span&gt;
            &lt;span class="s"&gt;echo "model=glm-5.1" &amp;gt;&amp;gt; "$GITHUB_OUTPUT"&lt;/span&gt;
          &lt;span class="s"&gt;else&lt;/span&gt;
            &lt;span class="s"&gt;echo "model=glm-4.5" &amp;gt;&amp;gt; "$GITHUB_OUTPUT"&lt;/span&gt;
          &lt;span class="s"&gt;fi&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Run AI review&lt;/span&gt;
        &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;review&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;sulthonzh/code-reviewer@main&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ai-review&lt;/span&gt;
          &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ steps.model.outputs.model }}&lt;/span&gt;
          &lt;span class="na"&gt;project-type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ steps.context.outputs.project_type }}&lt;/span&gt;
          &lt;span class="na"&gt;zai-api-key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.ZAI_API_KEY }}&lt;/span&gt;
          &lt;span class="na"&gt;zai-base-url&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ env.ZAI_BASE_URL }}&lt;/span&gt;
          &lt;span class="na"&gt;github-token&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.GITHUB_TOKEN }}&lt;/span&gt;

  &lt;span class="na"&gt;quality-gate&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;✅&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Quality&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Gate"&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;needs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;secret-scan&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;ai-review&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;outputs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;passed&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ steps.gate.outputs.passed }}&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v6&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;fetch-depth&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Run quality checks&lt;/span&gt;
        &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gate&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;sulthonzh/code-reviewer@main&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;quality-gate&lt;/span&gt;
          &lt;span class="na"&gt;github-token&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.GITHUB_TOKEN }}&lt;/span&gt;

  &lt;span class="na"&gt;auto-merge&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;🔀&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Auto-Merge"&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;needs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;ai-review&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;quality-gate&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;&amp;gt;-&lt;/span&gt;
      &lt;span class="s"&gt;needs.ai-review.outputs.approved == 'true' &amp;amp;&amp;amp;&lt;/span&gt;
      &lt;span class="s"&gt;needs.quality-gate.outputs.passed == 'true' &amp;amp;&amp;amp;&lt;/span&gt;
      &lt;span class="s"&gt;github.event_name == 'pull_request'&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v6&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Approve and merge&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;sulthonzh/code-reviewer@main&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;auto-merge&lt;/span&gt;
          &lt;span class="na"&gt;github-token&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.GITHUB_TOKEN }}&lt;/span&gt;

  &lt;span class="na"&gt;auto-release&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;📦&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Auto-Release"&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;&amp;gt;-&lt;/span&gt;
      &lt;span class="s"&gt;github.event_name == 'push' &amp;amp;&amp;amp;&lt;/span&gt;
      &lt;span class="s"&gt;github.ref == 'refs/heads/main'&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v6&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;fetch-depth&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Detect and release&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;sulthonzh/code-reviewer@main&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;auto-release&lt;/span&gt;
          &lt;span class="na"&gt;github-token&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.GITHUB_TOKEN }}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  The model routing bit
&lt;/h3&gt;

&lt;p&gt;Small PRs (under 500 lines diff) hit &lt;code&gt;glm-4.5&lt;/code&gt;. Bigger ones get &lt;code&gt;glm-5.1&lt;/code&gt;. This isn't arbitrary. The larger model costs more per token but handles cross-file reasoning better. Most PRs are under 500 lines, so the cheap model handles 90% of traffic.&lt;/p&gt;

&lt;p&gt;The API endpoint is Z.AI (from 智谱AI, a Chinese AI company). Their GLM models are OpenAI-compatible, so the integration was just pointing the OpenAI SDK at a different base URL. No wrappers, no adapters.&lt;/p&gt;

&lt;h3&gt;
  
  
  What it actually costs
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Per review:
  Z.AI API call:       ~$0.002
  GitHub Actions:      ~$0.003 (free tier mostly covers this)
  Total:               ~$0.006 per review
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I'm spending roughly $3-5/month on API calls across all repos. That's less than a coffee.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Secret Scanning Story
&lt;/h2&gt;

&lt;p&gt;Here's where it got interesting. Before I built the secret-scan job, I ran a manual sweep across 240 public repos. Found &lt;strong&gt;9 repos with real leaked credentials&lt;/strong&gt; in git history:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;AWS access keys&lt;/li&gt;
&lt;li&gt;MySQL root passwords
&lt;/li&gt;
&lt;li&gt;RSA private keys&lt;/li&gt;
&lt;li&gt;Hardcoded JWT secrets&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Cleaning them wasn't just &lt;code&gt;git rm&lt;/code&gt;. The secrets were in history. I used &lt;code&gt;git filter-repo&lt;/code&gt; to rewrite the affected repos, rotated every compromised credential, and added the secret-scan job to the workflow to prevent recurrence.&lt;/p&gt;

&lt;p&gt;That job alone has caught three attempted credential pushes in the last month. Worth the entire build.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Babysitter: OpenClaw Cron Fleet
&lt;/h2&gt;

&lt;p&gt;The code reviewer runs fine on its own. But I kept adding things. A marketing supervisor that publishes blog posts to Dev.to (10 articles so far). A deployment supervisor that ships to Vercel free tier. An IDX stock screener that runs 20+ intraday scans on the Indonesian exchange. A wealth builder that scaffolds SaaS products.&lt;/p&gt;

&lt;p&gt;All of these are AI agent jobs running on cron schedules through a system I call OpenClaw.&lt;/p&gt;

&lt;p&gt;Current state: &lt;strong&gt;56 jobs&lt;/strong&gt;, monitored by a guardian process that scans every few hours.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Guardian cycle 2026-06-11 04:48 WIB:
  - 56 jobs scanned
  - 0 with consecutiveErrors &amp;gt;= 2
  - 1 single-error transient (wealth-builder timeout)
  - No actions taken
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The guardian doesn't just watch. It has rules:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;1 consecutive error&lt;/strong&gt;: ignore, probably transient&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;2 consecutive errors&lt;/strong&gt;: monitor, create incident ticket&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;5+ consecutive errors&lt;/strong&gt;: auto-heal (restart job, switch model, increase timeout)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This actually worked last week. The marketing supervisor started failing because the GLM model hit rate limits. The guardian detected 2+ consecutive errors, switched the model to &lt;code&gt;glm-4.5-air&lt;/code&gt; (lighter, faster), bumped the timeout from 2700s to 3600s. Resolved without me touching anything.&lt;/p&gt;

&lt;h3&gt;
  
  
  The circuit breaker pattern
&lt;/h3&gt;

&lt;p&gt;Each agent job wraps its API calls in a circuit breaker. Here's the pattern from my IDX screener:&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="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;HealthRecord&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Track health of a single component.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;record_failure&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;consecutive_failures&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;consecutive_successes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;consecutive_failures&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;circuit_open&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;
            &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;circuit_opened_at&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;time&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;record_success&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;duration_ms&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;consecutive_successes&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;consecutive_failures&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;consecutive_successes&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;circuit_open&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;  &lt;span class="c1"&gt;# auto-close after 3 wins
&lt;/span&gt;
    &lt;span class="nd"&gt;@property&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;is_healthy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;circuit_open&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;
        &lt;span class="c1"&gt;# Half-open: try again after 5 min cooldown
&lt;/span&gt;        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;time&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;circuit_opened_at&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;300&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;5 failures in a row opens the circuit. 3 successes in a row closes it. 5-minute half-open cooldown lets it retry. This runs in production and has prevented cascading failures during API outages.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Architecture (What Exists vs. What's Next)
&lt;/h2&gt;

&lt;p&gt;Here's the honest map:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;┌─────────────────────────────────────────────────────┐
│                   WHAT'S LIVE                         │
│                                                       │
│  ┌──────────────┐  ┌──────────────┐  ┌───────────┐ │
│  │ AI Code       │  │ OpenClaw     │  │ Guardian   │ │
│  │ Reviewer      │  │ Cron Fleet   │  │ Monitor    │ │
│  │ (240 repos)   │  │ (56 jobs)    │  │ (auto-     │ │
│  │               │  │              │  │  heal)     │ │
│  └──────────────┘  └──────────────┘  └───────────┘ │
│                                                       │
│  ┌──────────────┐  ┌──────────────┐                  │
│  │ Marketing    │  │ Secret Scan  │                  │
│  │ Supervisor   │  │ (9 repos     │                  │
│  │ (10 posts)   │  │  cleaned)    │                  │
│  └──────────────┘  └──────────────┘                  │
└─────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────┐
│               WHAT I'M BUILDING TOWARD                │
│                                                       │
│  ┌──────────────┐  ┌──────────────┐  ┌───────────┐ │
│  │ Wallet       │  │ Cloning      │  │ Revenue    │ │
│  │ Module       │  │ Engine       │  │ Engine     │ │
│  │ (Stripe)     │  │ (multi-cloud)│  │ (SaaS)     │ │
│  └──────────────┘  └──────────────┘  └───────────┘ │
└─────────────────────────────────────────────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The bottom row doesn't exist yet. I'm sharing the architecture because it's where this is heading, but I want to be clear about the boundary.&lt;/p&gt;

&lt;h3&gt;
  
  
  What's next (honest roadmap)
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Near term&lt;/strong&gt; (building now):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Wallet module with Stripe integration for the code reviewer SaaS&lt;/li&gt;
&lt;li&gt;Better incident response (currently the guardian can restart jobs and switch models; adding credential rotation automation)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Medium term&lt;/strong&gt; (designing):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Multi-cloud cloning (snapshot state, deploy to new provider)&lt;/li&gt;
&lt;li&gt;Revenue engine (paid tiers for the code reviewer, API marketplace listing)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Far term&lt;/strong&gt; (thinking about):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Swarm coordination between cloned instances&lt;/li&gt;
&lt;li&gt;Knowledge base that actually learns from review patterns over time (currently static prompts)&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why Z.AI and Not OpenAI
&lt;/h2&gt;

&lt;p&gt;Three reasons:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Cost.&lt;/strong&gt; GLM-4.5 costs roughly 10x less per token than GPT-4o for code review quality that's comparable for the patterns I care about (security, style, common bugs).&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Latency.&lt;/strong&gt; The API responds in under 2 seconds for most diffs. OpenAI was averaging 4-5 seconds.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;OpenAI-compatible.&lt;/strong&gt; Zero code changes to the OpenAI SDK. Just swap &lt;code&gt;baseURL&lt;/code&gt; and &lt;code&gt;apiKey&lt;/code&gt;. I could switch back to OpenAI (or add Claude, or Gemini) in about 10 minutes if Z.AI went down.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That last point matters. Vendor lock-in is the enemy of resilience.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try It
&lt;/h2&gt;

&lt;p&gt;Drop this into &lt;code&gt;.github/workflows/ai-review.yml&lt;/code&gt; on any repo:&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;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;AI Code Review&lt;/span&gt;
&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;pull_request&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;types&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;opened&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;synchronize&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;review&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v6&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;fetch-depth&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;sulthonzh/code-reviewer@main&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ai-review&lt;/span&gt;
          &lt;span class="na"&gt;zai-api-key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.ZAI_API_KEY }}&lt;/span&gt;
          &lt;span class="na"&gt;github-token&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.GITHUB_TOKEN }}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You'll need a Z.AI API key from &lt;a href="https://open.z.ai" rel="noopener noreferrer"&gt;open.z.ai&lt;/a&gt;. The free tier covers a few hundred reviews per month.&lt;/p&gt;

&lt;p&gt;The code is open source at &lt;a href="https://github.com/sulthonzh/code-reviewer" rel="noopener noreferrer"&gt;github.com/sulthonzh/code-reviewer&lt;/a&gt;.&lt;/p&gt;




</description>
      <category>ai</category>
      <category>github</category>
      <category>automation</category>
      <category>opensource</category>
    </item>
    <item>
      <title>I Stopped Switching Git Branches — And Started Using Worktrees Instead</title>
      <dc:creator>Sulthon Zainul Habib</dc:creator>
      <pubDate>Fri, 29 May 2026 11:16:44 +0000</pubDate>
      <link>https://dev.to/sulthonzh/i-stopped-switching-git-branches-and-started-using-worktrees-instead-1jif</link>
      <guid>https://dev.to/sulthonzh/i-stopped-switching-git-branches-and-started-using-worktrees-instead-1jif</guid>
      <description>&lt;p&gt;I used to be a &lt;code&gt;git stash&lt;/code&gt; person. You know the dance — you're halfway through a feature, someone pings you about a bug on main, and suddenly you're stashing, switching, fixing, switching back, popping, and praying the stash applies cleanly.&lt;/p&gt;

&lt;p&gt;Then I discovered git worktrees. And then I discovered why nobody uses them — the UX is &lt;em&gt;awful&lt;/em&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem With Branch Switching
&lt;/h2&gt;

&lt;p&gt;Here's a typical afternoon for me:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Working on &lt;code&gt;feature/payment-flow&lt;/code&gt; in VS Code&lt;/li&gt;
&lt;li&gt;Production bug comes in, needs a hotfix on &lt;code&gt;main&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Stash changes (12 files modified)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;git checkout main&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Apply hotfix, test, push&lt;/li&gt;
&lt;li&gt;&lt;code&gt;git checkout feature/payment-flow&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;git stash pop&lt;/code&gt; — conflict. Of course.&lt;/li&gt;
&lt;li&gt;Spend 20 minutes resolving stash conflicts that have nothing to do with my actual work&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Multiply this by 3-4 context switches a day and I'm losing an hour just to git gymnastics.&lt;/p&gt;

&lt;h2&gt;
  
  
  Git Worktrees: The Concept
&lt;/h2&gt;

&lt;p&gt;Git worktrees let you have multiple checkouts of the same repo simultaneously. Each worktree is a directory with its own working tree and branch, but they all share the same &lt;code&gt;.git&lt;/code&gt; database.&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;# Instead of switching branches, you just cd to a different directory&lt;/span&gt;
&lt;span class="nb"&gt;cd&lt;/span&gt; ~/projects/myapp              &lt;span class="c"&gt;# main branch&lt;/span&gt;
&lt;span class="nb"&gt;cd&lt;/span&gt; ~/projects/worktrees/bugfix   &lt;span class="c"&gt;# bugfix branch, fully checked out&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No stashing. No switching. Your IDE stays open. Your node_modules are intact. Your brain stays in context.&lt;/p&gt;

&lt;p&gt;The problem? The commands are verbose and there's no automation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git worktree add ../worktrees/feature-x feature-x
&lt;span class="nb"&gt;cd&lt;/span&gt; ../worktrees/feature-x
npm &lt;span class="nb"&gt;install&lt;/span&gt;              &lt;span class="c"&gt;# gotta reinstall deps&lt;/span&gt;
&lt;span class="nb"&gt;cp&lt;/span&gt; ../../myapp/.env &lt;span class="nb"&gt;.&lt;/span&gt;    &lt;span class="c"&gt;# gotta copy env files manually&lt;/span&gt;
&lt;span class="c"&gt;# oh and the directory structure is ugly&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every single time, same repetitive steps. And cleaning up? &lt;code&gt;git worktree remove ../worktrees/feature-x&lt;/code&gt; — if you even remember where you put it.&lt;/p&gt;

&lt;h2&gt;
  
  
  So I Built worktree-manager
&lt;/h2&gt;

&lt;p&gt;I wanted worktrees to be as easy as switching branches. So I built &lt;a href="https://github.com/sulthonzh/worktree-manager" rel="noopener noreferrer"&gt;worktree-manager&lt;/a&gt; — a zero-config CLI that handles the tedious parts.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-g&lt;/span&gt; worktree-manager
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One command to create a worktree &lt;em&gt;and&lt;/em&gt; set it up:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;wtm add feature/payment-flow
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it. Under the hood, it:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Creates the worktree at &lt;code&gt;../worktrees/feature/payment-flow&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Detects your project type (Node.js, Python, Go, Rust)&lt;/li&gt;
&lt;li&gt;Copies dependency files (package.json, lock files)&lt;/li&gt;
&lt;li&gt;Copies environment files (.env, .env.local, .env.example)&lt;/li&gt;
&lt;li&gt;Runs setup commands (npm install, pip install, etc.)&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The path generation is smart — it strips prefixes like &lt;code&gt;feature/&lt;/code&gt; or &lt;code&gt;bugfix/&lt;/code&gt; and nests cleanly. You end up with a structure like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;projects/
├── myapp/                      # main
└── worktrees/
    ├── payment-flow/           # feature/payment-flow
    ├── hotfix-login-bug/       # hotfix/login-bug
    └── refactor-api/           # refactor/api
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The Commands That Actually Matter
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Listing what you have
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;wtm list
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Shows all your worktrees with branch names and paths. The &lt;code&gt;--verbose&lt;/code&gt; flag adds commit hashes and last modified times. Honestly I use this more than I should because I forget what I have open.&lt;/p&gt;

&lt;h3&gt;
  
  
  Cleaning up
&lt;/h3&gt;

&lt;p&gt;This is the one that sold me. &lt;code&gt;git worktree&lt;/code&gt; doesn't have a great cleanup story. Worktrees accumulate, directories go stale, and you end up with zombie checkouts.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;wtm clean
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It scans for stale worktrees (ones where the branch was deleted but the directory still exists) and removes them. One command instead of manually hunting directories.&lt;/p&gt;

&lt;h3&gt;
  
  
  Removing a specific worktree
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;wtm remove feature/payment-flow
&lt;span class="c"&gt;# or with the branch too:&lt;/span&gt;
wtm remove &lt;span class="nt"&gt;--with-branch&lt;/span&gt; feature/old-experiment
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Jumping between worktrees
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;wtm &lt;span class="nb"&gt;cd &lt;/span&gt;feature/payment-flow
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This one's simple but I use it constantly. Instead of remembering the full path, just reference the branch name.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the Project Detection Works
&lt;/h2&gt;

&lt;p&gt;This was the part I spent the most time on, because it's what separates "yet another wrapper" from something that actually saves time.&lt;/p&gt;

&lt;p&gt;The detector checks for marker files in order:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Node.js: package.json, package-lock.json, yarn.lock, pnpm-lock.yaml&lt;/span&gt;
&lt;span class="c1"&gt;// Python: requirements.txt, setup.py, pyproject.toml, poetry.lock&lt;/span&gt;
&lt;span class="c1"&gt;// Go: go.mod, go.sum&lt;/span&gt;
&lt;span class="c1"&gt;// Rust: Cargo.toml, Cargo.lock&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For each detected project type, it knows which env files to copy and which setup commands to run. Node.js gets &lt;code&gt;npm ci&lt;/code&gt; (or &lt;code&gt;npm install&lt;/code&gt; if no lock file). Python gets &lt;code&gt;pip install -r requirements.txt&lt;/code&gt;. You get the idea.&lt;/p&gt;

&lt;p&gt;If you don't want the auto-setup (maybe you're doing something custom), there's a flag:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;wtm add &lt;span class="nt"&gt;--skip-setup&lt;/span&gt; feature/experiment
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  A Real Workflow Example
&lt;/h2&gt;

&lt;p&gt;Here's how my afternoon looks now:&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;# Working on main, bug report comes in&lt;/span&gt;
wtm add hotfix/login-timeout
&lt;span class="c"&gt;# → worktree created, deps installed, .env copied, ready to code&lt;/span&gt;

&lt;span class="nb"&gt;cd&lt;/span&gt; ../worktrees/hotfix/login-timeout
&lt;span class="c"&gt;# Fix the bug, test, commit, push&lt;/span&gt;

&lt;span class="nb"&gt;cd&lt;/span&gt; ~/projects/myapp
&lt;span class="c"&gt;# Back to main, still exactly where I left off&lt;/span&gt;

&lt;span class="c"&gt;# Later, clean up&lt;/span&gt;
wtm remove &lt;span class="nt"&gt;--with-branch&lt;/span&gt; hotfix/login-timeout
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No stashing. No context loss. The &lt;code&gt;node_modules&lt;/code&gt; in my main checkout never got touched. My VS Code windows for both branches stay open simultaneously if I want.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Not Just Use Multiple Clones?
&lt;/h2&gt;

&lt;p&gt;Fair question. I tried that first. The problems:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Disk space&lt;/strong&gt;: Each clone duplicates &lt;code&gt;.git&lt;/code&gt; (can be 100MB+ for large repos)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Divergence&lt;/strong&gt;: Clones drift apart. You forget to fetch in one, push in another&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Branch confusion&lt;/strong&gt;: Which clone has the latest &lt;code&gt;main&lt;/code&gt;? Did I push to the right remote?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No shared state&lt;/strong&gt;: Git hooks, config, refs are all separate&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Worktrees share the git database. One remote, one set of branches, one &lt;code&gt;.git/config&lt;/code&gt;. But multiple working directories.&lt;/p&gt;

&lt;h2&gt;
  
  
  The One Catch
&lt;/h2&gt;

&lt;p&gt;Worktrees aren't perfect. A few things to be aware of:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;You can't have the same branch checked out in two worktrees&lt;/strong&gt; — this is a git limitation, not a tool limitation&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Some tools get confused&lt;/strong&gt; — older IDEs might not understand worktrees well (modern VS Code handles them fine)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Path-based tooling&lt;/strong&gt; — if you have scripts that hardcode paths, they'll break in worktrees (use &lt;code&gt;git rev-parse --show-toplevel&lt;/code&gt;)&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Wrapping Up
&lt;/h2&gt;

&lt;p&gt;If you context-switch between branches more than twice a day, worktrees will save you real time. And if the raw git commands feel too manual, &lt;a href="https://github.com/sulthonzh/worktree-manager" rel="noopener noreferrer"&gt;worktree-manager&lt;/a&gt; handles the boring parts.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-g&lt;/span&gt; worktree-manager
wtm add your-branch-name
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It's open source, MIT licensed, and works with Node.js 18+. Zero config, detects your project type, and gets out of your way.&lt;/p&gt;

&lt;p&gt;The biggest shift for me was mental — instead of thinking "switch branches", I started thinking "open a new workspace". And that small change made a surprisingly big difference in how smoothly my day flows.&lt;/p&gt;

</description>
      <category>git</category>
      <category>productivity</category>
      <category>cli</category>
      <category>javascript</category>
    </item>
    <item>
      <title>I Got Tired of Wiring Grafana + Loki + Tempo — So I Built a One-Command Observability Stack</title>
      <dc:creator>Sulthon Zainul Habib</dc:creator>
      <pubDate>Fri, 29 May 2026 08:49:12 +0000</pubDate>
      <link>https://dev.to/sulthonzh/i-got-tired-of-wiring-grafana-loki-tempo-so-i-built-a-one-command-observability-stack-5g26</link>
      <guid>https://dev.to/sulthonzh/i-got-tired-of-wiring-grafana-loki-tempo-so-i-built-a-one-command-observability-stack-5g26</guid>
      <description>&lt;p&gt;I've lost count of how many weekends I've spent wiring up observability stacks for side projects. Grafana for dashboards. Loki for logs. Tempo for traces. Prometheus for metrics. Jaeger if you're feeling adventurous. Each one needs its own config, its own datasource wiring, its own port.&lt;/p&gt;

&lt;p&gt;By the time everything talks to each other, you've burned a Saturday and you still haven't shipped your actual app.&lt;/p&gt;

&lt;p&gt;So I built &lt;a href="https://github.com/sulthonzh/telyx" rel="noopener noreferrer"&gt;TelyX&lt;/a&gt; — a lightweight observability suite that gives you logs, metrics, and traces with a single &lt;code&gt;docker-compose up&lt;/code&gt;. No chasing config docs. No datasource chaining. Just run it and start instrumenting.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's in the box
&lt;/h2&gt;

&lt;p&gt;TelyX stacks five containers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;OpenSearch&lt;/strong&gt; — stores and indexes your logs&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;OpenSearch Dashboards&lt;/strong&gt; — visualizes those logs (think Kibana but open source)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prometheus&lt;/strong&gt; — scrapes and stores your metrics&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;OpenTelemetry Collector&lt;/strong&gt; — receives traces and forwards them&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Go backend + React frontend&lt;/strong&gt; — ties it all together&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Here's the &lt;code&gt;docker-compose.yml&lt;/code&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;version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;3.8"&lt;/span&gt;
&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;opensearch&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;opensearchproject/opensearch:2.18.0&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;discovery.type=single-node&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;plugins.security.disabled=true&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;9200:9200"&lt;/span&gt;

  &lt;span class="na"&gt;opensearch-dashboards&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;opensearchproject/opensearch-dashboards:2.18.0&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;5601:5601"&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;OPENSEARCH_HOSTS=http://opensearch:9200&lt;/span&gt;

  &lt;span class="na"&gt;prometheus&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;prom/prometheus:latest&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;9090:9090"&lt;/span&gt;

  &lt;span class="na"&gt;otel-collector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;otel/opentelemetry-collector:0.115.1&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;4317:4317"&lt;/span&gt;  &lt;span class="c1"&gt;# gRPC&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;55681:55681"&lt;/span&gt; &lt;span class="c1"&gt;# HTTP&lt;/span&gt;

  &lt;span class="na"&gt;backend&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;../backend&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;8080:8080"&lt;/span&gt;

  &lt;span class="na"&gt;frontend&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;../frontend&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;3000:3000"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One command. Six services. All three pillars covered.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Go backend — where the magic happens
&lt;/h2&gt;

&lt;p&gt;The backend is a simple Go service that demonstrates the full observability pipeline. It exposes a &lt;code&gt;/logs&lt;/code&gt; endpoint that:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Receives log data as JSON&lt;/li&gt;
&lt;li&gt;Forwards it to OpenSearch for indexing&lt;/li&gt;
&lt;li&gt;Records request metrics in Prometheus (count + duration histogram)&lt;/li&gt;
&lt;li&gt;Creates an OpenTelemetry span for distributed tracing&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Here's what the instrumentation looks like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;requestCount&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;prometheus&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewCounterVec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;prometheus&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CounterOpts&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;Name&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"http_requests_total"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;Help&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"Total number of HTTP requests"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="s"&gt;"path"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;requestDuration&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;prometheus&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewHistogramVec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;prometheus&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HistogramOpts&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;Name&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;    &lt;span class="s"&gt;"http_request_duration_seconds"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;Help&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;    &lt;span class="s"&gt;"Histogram of response time"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;Buckets&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;prometheus&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DefBuckets&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="s"&gt;"path"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;logHandler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ResponseWriter&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;start&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;defer&lt;/span&gt; &lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;duration&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Since&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;start&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Seconds&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;requestDuration&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WithLabelValues&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/logs"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Observe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;duration&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}()&lt;/span&gt;

    &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;span&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;otel&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Tracer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"telyx-backend"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Start&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="s"&gt;"logHandler"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;defer&lt;/span&gt; &lt;span class="n"&gt;span&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;End&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;logData&lt;/span&gt; &lt;span class="k"&gt;map&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="k"&gt;interface&lt;/span&gt;&lt;span class="p"&gt;{}&lt;/span&gt;
    &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewDecoder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Body&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;logData&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c"&gt;// Forward to OpenSearch&lt;/span&gt;
    &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Marshal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;logData&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"http://opensearch:9200/logs/_doc"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"application/json"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;bytes&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewReader&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

    &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WriteHeader&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StatusCreated&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewEncoder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;map&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="s"&gt;"status"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"ok"&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;See how every request gets three things for free? The Prometheus counter and histogram track volume and latency. The OTel span gives you distributed tracing. And the OpenSearch call ensures your logs are searchable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why not just use Grafana?
&lt;/h2&gt;

&lt;p&gt;Look, I love Grafana. I use it at work. But for side projects and small teams, the Grafana+Loki+Tempo stack is heavy. You need:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Grafana itself&lt;/li&gt;
&lt;li&gt;Loki (with its own storage config)&lt;/li&gt;
&lt;li&gt;Tempo or Jaeger (with span storage)&lt;/li&gt;
&lt;li&gt;Prometheus (metrics)&lt;/li&gt;
&lt;li&gt;Each datasource configured in Grafana's UI&lt;/li&gt;
&lt;li&gt;Service discovery or static targets&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's a lot of moving parts. TelyX trades some Grafana flexibility for simplicity. OpenSearch Dashboards handles log visualization natively. Prometheus gives you raw metric queries. And the OTel collector standardizes your trace pipeline.&lt;/p&gt;

&lt;p&gt;If you're running production infra at scale, stick with Grafana. If you're building a side project and want to see "what's breaking" without a PhD in Grafana config — TelyX gets you there faster.&lt;/p&gt;

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

&lt;p&gt;Clone the repo and fire it up:&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/sulthonzh/telyx.git
&lt;span class="nb"&gt;cd &lt;/span&gt;telyx/docker
docker-compose up &lt;span class="nt"&gt;-d&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Wait about 30 seconds for OpenSearch to initialize (it's chatty on startup, that's normal). Then:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Frontend&lt;/strong&gt;: &lt;a href="http://localhost:3000" rel="noopener noreferrer"&gt;http://localhost:3000&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;OpenSearch Dashboards&lt;/strong&gt;: &lt;a href="http://localhost:5601" rel="noopener noreferrer"&gt;http://localhost:5601&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prometheus&lt;/strong&gt;: &lt;a href="http://localhost:9090" rel="noopener noreferrer"&gt;http://localhost:9090&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Backend API&lt;/strong&gt;: &lt;a href="http://localhost:8080" rel="noopener noreferrer"&gt;http://localhost:8080&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Send a test log:&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;-X&lt;/span&gt; POST http://localhost:8080/logs &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"level":"info","message":"TelyX is running","service":"test"}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Check it in OpenSearch Dashboards — go to Discover, create an index pattern for &lt;code&gt;logs*&lt;/code&gt;, and you'll see your entry. In Prometheus, query &lt;code&gt;http_requests_total&lt;/code&gt; and you'll see the metric. The trace lands in the OTel collector.&lt;/p&gt;

&lt;p&gt;Three pillars. One request. Zero config headaches.&lt;/p&gt;

&lt;h2&gt;
  
  
  The sampling gotcha
&lt;/h2&gt;

&lt;p&gt;One thing I learned the hard way — the backend uses 10% trace sampling:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;trace&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WithSampler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;trace&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ParentBased&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;trace&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TraceIDRatioBased&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;0.1&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is intentional for production workloads where you don't want every single request traced. But during development, you might want to bump that to &lt;code&gt;1.0&lt;/code&gt; so every request shows up. Cost me 20 minutes of "where are my traces?!" before I realized.&lt;/p&gt;

&lt;p&gt;Also, the &lt;code&gt;initTracer&lt;/code&gt; function has a duplicate &lt;code&gt;trace.WithBatcher(exporter)&lt;/code&gt; line — that's a bug, not a feature. It won't break anything but it's redundant. PR welcome 😉&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd like to add next
&lt;/h2&gt;

&lt;p&gt;TelyX is functional but minimal. A few things I want to build:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Alerting rules&lt;/strong&gt; — Prometheus alertmanager integration so you get notified when things break&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pre-built dashboards&lt;/strong&gt; — OpenSearch and Prometheus dashboards that work out of the box&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Multi-service tracing&lt;/strong&gt; — right now the trace pipeline works but you need OTel SDK in your own services to propagate context&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Helm chart&lt;/strong&gt; — for deploying to Kubernetes instead of just Docker Compose&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If any of that sounds interesting, the repo is open. Contributions welcome.&lt;/p&gt;

&lt;h2&gt;
  
  
  When to use this vs. managed services
&lt;/h2&gt;

&lt;p&gt;Real talk: if you're running a production system with SLAs, use Datadog or New Relic. Managed observability is worth the money when your pager goes off at 3 AM and you need answers fast.&lt;/p&gt;

&lt;p&gt;But for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Side projects where you want visibility without the price tag&lt;/li&gt;
&lt;li&gt;Learning how observability actually works (the three pillars, not just "install agent")&lt;/li&gt;
&lt;li&gt;Small teams that can't justify $100+/month on monitoring&lt;/li&gt;
&lt;li&gt;Prototyping before committing to a managed stack&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;TelyX fills that gap. It's not trying to be Datadog. It's trying to be the thing you reach for when &lt;code&gt;console.log&lt;/code&gt; isn't enough but full Grafana is too much.&lt;/p&gt;

&lt;p&gt;Give it a spin and let me know what you think. Star the repo if it saves you a weekend — that's literally what it was built for.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Built TelyX as part of an open-source lab. Check out &lt;a href="https://github.com/sulthonzh/telyx" rel="noopener noreferrer"&gt;the repo&lt;/a&gt; if you want to contribute or just poke around the code.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>observability</category>
      <category>devops</category>
      <category>opentelemetry</category>
      <category>docker</category>
    </item>
    <item>
      <title>I Use lazydocker for Everything — Except When I Don't</title>
      <dc:creator>Sulthon Zainul Habib</dc:creator>
      <pubDate>Fri, 29 May 2026 07:36:16 +0000</pubDate>
      <link>https://dev.to/sulthonzh/i-use-lazydocker-for-everything-except-when-i-dont-572n</link>
      <guid>https://dev.to/sulthonzh/i-use-lazydocker-for-everything-except-when-i-dont-572n</guid>
      <description>&lt;p&gt;I love lazydocker. Honestly, Jesse Duffield built something special — it's the first thing I install on a new machine. But here's the thing: 90% of the time I open it, I just want to check if my containers are healthy. I don't need to browse image layers, scroll through compose configs, or dig into volume mounts. I just want the pulse check.&lt;/p&gt;

&lt;p&gt;And sometimes lazydocker feels like opening a control room when you just need a dashboard.&lt;/p&gt;

&lt;p&gt;So I built &lt;a href="https://github.com/sulthonzh/dockervis" rel="noopener noreferrer"&gt;dockervis&lt;/a&gt;. It's a terminal dashboard that does one thing: show you what your Docker containers are doing, right now, and let you act on it with a single keypress.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem I Was Solving
&lt;/h2&gt;

&lt;p&gt;My typical workflow looks like this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Start 4-5 containers with docker compose&lt;/li&gt;
&lt;li&gt;Something feels off — API is slow, or a cron job might've crashed&lt;/li&gt;
&lt;li&gt;Open terminal, type &lt;code&gt;docker ps&lt;/code&gt;, squint at the truncated output&lt;/li&gt;
&lt;li&gt;Copy a container ID, run &lt;code&gt;docker stats --no-stream&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Realize it was the wrong container&lt;/li&gt;
&lt;li&gt;Repeat&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;code&gt;docker stats&lt;/code&gt; is fine but it's a firehose. &lt;code&gt;docker ps&lt;/code&gt; doesn't show resource usage. And jumping between them gets old fast when you're debugging at 2 AM.&lt;/p&gt;

&lt;p&gt;lazydocker solves this, but it also shows me every image, every volume, every compose file — when I just want to know if my app container is eating all the RAM again.&lt;/p&gt;

&lt;h2&gt;
  
  
  What dockervis Does
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-g&lt;/span&gt; dockervis
dockervis
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it. You get a live dashboard:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;┌─────────────────────────────────────────────────────────────┐
│ dockervis - Docker Container Dashboard                      │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│ ● app (running)           CPU: 1.2%                        │
│ ● db (running)            Memory: 256 MB / 512 MB (50.0%)  │
│ ○ web (exited)            Network RX: 1.2 GB               │
│ ● redis (running)         Network TX: 512 MB               │
│                                                             │
└─────────────────────────────────────────────────────────────┘
q: Quit | r: Refresh | s: Stop | R: Restart | d: Delete
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every container, its state, CPU, memory, and network — updated live. No mouse needed. No panels to navigate. Just the info.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Keyboard Shortcuts That Actually Matter
&lt;/h2&gt;

&lt;p&gt;Here's the part I use most. When something's stuck:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Press &lt;code&gt;j&lt;/code&gt; to move to the container&lt;/li&gt;
&lt;li&gt;Press &lt;code&gt;R&lt;/code&gt; to restart it&lt;/li&gt;
&lt;li&gt;Done&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Or when a container exited and I want it gone:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Move to it with &lt;code&gt;k&lt;/code&gt;/&lt;code&gt;j&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Press &lt;code&gt;d&lt;/code&gt; to delete it&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The full set:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Key&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;j&lt;/code&gt; / &lt;code&gt;↓&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Move down&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;k&lt;/code&gt; / &lt;code&gt;↑&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Move up&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;s&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Stop container&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;R&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Restart container&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;d&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Delete (exited only)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;r&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Force refresh&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;q&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Quit&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;vim-style navigation because, well, muscle memory.&lt;/p&gt;

&lt;h2&gt;
  
  
  Filtering When You Have Too Many Containers
&lt;/h2&gt;

&lt;p&gt;On my work machine I have like 20 containers. Most of them are from old projects I forgot to clean up. I don't need to see all of them.&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;# Only show running ones&lt;/span&gt;
dockervis &lt;span class="nt"&gt;--filter&lt;/span&gt; running

&lt;span class="c"&gt;# Focus on specific services&lt;/span&gt;
dockervis &lt;span class="nt"&gt;--include&lt;/span&gt; app,db,redis

&lt;span class="c"&gt;# Hide the noise&lt;/span&gt;
dockervis &lt;span class="nt"&gt;--exclude&lt;/span&gt; &lt;span class="nb"&gt;test&lt;/span&gt;,ci
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;--exclude&lt;/code&gt; flag is my favorite. I have a bunch of test containers that I never touch — filtering them out makes the dashboard actually useful.&lt;/p&gt;

&lt;h2&gt;
  
  
  Exporting Metrics (Because Sometimes You Need Proof)
&lt;/h2&gt;

&lt;p&gt;Sometimes you need to show someone "hey, this container has been eating 80% CPU for three days." Or you want to log metrics over time.&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;# JSON export&lt;/span&gt;
dockervis &lt;span class="nt"&gt;--export&lt;/span&gt; metrics.json

&lt;span class="c"&gt;# CSV if you need to throw it in a spreadsheet&lt;/span&gt;
dockervis &lt;span class="nt"&gt;--export&lt;/span&gt; metrics.csv &lt;span class="nt"&gt;--export-format&lt;/span&gt; csv
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I use this in CI sometimes — run it once, dump to JSON, parse with &lt;code&gt;jq&lt;/code&gt; in a health check script. Not glamorous but it works.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why TypeScript (And Not Go Like Everyone Else)
&lt;/h2&gt;

&lt;p&gt;Most terminal Docker tools are written in Go — lazydocker, ctop, docui. Nothing wrong with Go. But I write TypeScript all day. When I want to fix something or add a feature, I want to do it in the language I'm fastest in.&lt;/p&gt;

&lt;p&gt;dockervis uses:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;dockerode&lt;/strong&gt; for the Docker API — battle-tested, well-documented&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;blessed&lt;/strong&gt; for the terminal UI — same library lazydocker uses under the hood (via tview/bubbletea equivalents)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The result is a tool that TypeScript/Node developers can actually contribute to without learning a new language. And the install is just &lt;code&gt;npm install -g&lt;/code&gt; — no Go toolchain needed.&lt;/p&gt;

&lt;h2&gt;
  
  
  When I Still Reach for lazydocker
&lt;/h2&gt;

&lt;p&gt;I'm not going to pretend dockervis replaces lazydocker for everything. If I need to:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Browse Docker Compose configs&lt;/li&gt;
&lt;li&gt;Inspect image layers&lt;/li&gt;
&lt;li&gt;Manage volumes and networks&lt;/li&gt;
&lt;li&gt;Read container logs in detail&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;...I still use lazydocker. It's the better Swiss Army knife.&lt;/p&gt;

&lt;p&gt;But for the 5-second "is everything okay?" check? Or the "restart that one container real quick" moment? dockervis is faster. Less noise, less cognitive load.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quick Setup
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Install&lt;/span&gt;
npm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-g&lt;/span&gt; dockervis

&lt;span class="c"&gt;# Make sure you have Docker socket access&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;usermod &lt;span class="nt"&gt;-aG&lt;/span&gt; docker &lt;span class="nv"&gt;$USER&lt;/span&gt;
&lt;span class="c"&gt;# (log out and back in)&lt;/span&gt;

&lt;span class="c"&gt;# Run it&lt;/span&gt;
dockervis
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you want to tweak the refresh rate:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dockervis &lt;span class="nt"&gt;--interval&lt;/span&gt; 5000  &lt;span class="c"&gt;# 5 seconds instead of default 2&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And if Docker is running on a remote host:&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="nv"&gt;DOCKER_HOST&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;tcp://192.168.1.100:2375 dockervis
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The Code
&lt;/h2&gt;

&lt;p&gt;It's open source, MIT licensed: &lt;a href="https://github.com/sulthonzh/dockervis" rel="noopener noreferrer"&gt;github.com/sulthonzh/dockervis&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Issues and PRs welcome. Especially if you find bugs — I mainly tested this on my own machine (macOS + Docker Desktop), so Linux and Windows reports would be helpful.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Building developer tools is my thing. Check out my other projects at &lt;a href="https://github.com/sulthonzh" rel="noopener noreferrer"&gt;github.com/sulthonzh&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>docker</category>
      <category>typescript</category>
      <category>terminal</category>
      <category>devtools</category>
    </item>
    <item>
      <title>I Scanned 200 Public GitHub Repos for Leaked .env Files — Then Built a CLI to Stop It</title>
      <dc:creator>Sulthon Zainul Habib</dc:creator>
      <pubDate>Fri, 29 May 2026 04:49:37 +0000</pubDate>
      <link>https://dev.to/sulthonzh/i-scanned-200-public-github-repos-for-leaked-env-files-then-built-a-cli-to-stop-it-3fb4</link>
      <guid>https://dev.to/sulthonzh/i-scanned-200-public-github-repos-for-leaked-env-files-then-built-a-cli-to-stop-it-3fb4</guid>
      <description>&lt;p&gt;I was helping a friend debug a deploy last month. Cloned the repo, checked the &lt;code&gt;.env&lt;/code&gt; — and there it was. A full AWS access key, sitting in plain text. The repo was public for 8 months.&lt;/p&gt;

&lt;p&gt;That's when I started looking around. Public GitHub repos, open &lt;code&gt;.env&lt;/code&gt; files, real credentials. It's... more common than you'd think. People push &lt;code&gt;.env&lt;/code&gt; by accident, forget to update &lt;code&gt;.gitignore&lt;/code&gt;, or just don't realize those files end up in git history forever.&lt;/p&gt;

&lt;p&gt;So I built &lt;a href="https://github.com/sulthonzh/envguard" rel="noopener noreferrer"&gt;envguard&lt;/a&gt; — a zero-dependency CLI that scans your &lt;code&gt;.env&lt;/code&gt; files for leaked secrets and validates your config before it hits production.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem isn't new, but it keeps happening
&lt;/h2&gt;

&lt;p&gt;Every tutorial tells you "add &lt;code&gt;.env&lt;/code&gt; to &lt;code&gt;.gitignore&lt;/code&gt;." Every framework has a &lt;code&gt;.env.example&lt;/code&gt; template. And yet, I've seen:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;AWS keys (&lt;code&gt;AKIA...&lt;/code&gt;) in public npm packages&lt;/li&gt;
&lt;li&gt;GitHub personal access tokens (&lt;code&gt;ghp_...&lt;/code&gt;) committed in monorepos&lt;/li&gt;
&lt;li&gt;Database URLs with passwords in Stack Overflow questions (screenshots, but still)&lt;/li&gt;
&lt;li&gt;Private keys embedded in Docker build contexts&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The worst part? Most of these leaks happen because there's no automated check. Your CI pipeline runs tests, lints code, checks coverage — but nobody's checking if your &lt;code&gt;.env&lt;/code&gt; just leaked a production database password.&lt;/p&gt;

&lt;h2&gt;
  
  
  What envguard does
&lt;/h2&gt;

&lt;p&gt;It's two things: &lt;strong&gt;secret scanning&lt;/strong&gt; and &lt;strong&gt;env validation&lt;/strong&gt;. Let me walk through both.&lt;/p&gt;

&lt;h3&gt;
  
  
  Secret scanning
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx envguard secrets .env
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This scans your &lt;code&gt;.env&lt;/code&gt; file for known credential patterns:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;AWS Access Key IDs (&lt;code&gt;AKIA...&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;AWS Secret Access Keys&lt;/li&gt;
&lt;li&gt;GitHub Tokens (&lt;code&gt;ghp_&lt;/code&gt;, &lt;code&gt;ghs_&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Generic API Keys (20+ chars, high-entropy strings)&lt;/li&gt;
&lt;li&gt;Generic Tokens (32+ chars)&lt;/li&gt;
&lt;li&gt;Private Keys (&lt;code&gt;-----BEGIN ... PRIVATE KEY-----&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;JWTs (&lt;code&gt;eyJ...&lt;/code&gt;)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If it finds anything, it exits with code 1 and shows you exactly which keys are problematic — but it &lt;strong&gt;redacts the actual values&lt;/strong&gt;. Because the last thing you need is your secret scanner leaking secrets in your CI logs.&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="nv"&gt;$ &lt;/span&gt;envguard secrets .env

⚠ Secret detected &lt;span class="k"&gt;in&lt;/span&gt; .env:
  Line 3: AWS_ACCESS_KEY_ID
    Pattern: AWS Access Key ID &lt;span class="o"&gt;(&lt;/span&gt;AKIA...&lt;span class="o"&gt;)&lt;/span&gt;
  Line 4: AWS_SECRET_ACCESS_KEY
    Pattern: AWS Secret Access Key &lt;span class="o"&gt;(&lt;/span&gt;40 chars, &lt;span class="nb"&gt;base64&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
  Line 7: GITHUB_TOKEN
    Pattern: GitHub Token &lt;span class="o"&gt;(&lt;/span&gt;ghp_...&lt;span class="o"&gt;)&lt;/span&gt;

Found 3 secrets. Values have been redacted.
Exit code: 1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Env validation
&lt;/h3&gt;

&lt;p&gt;The other side: making sure your &lt;code&gt;.env&lt;/code&gt; actually has everything it needs. We've all been there — deploy fails because someone forgot to add &lt;code&gt;REDIS_URL&lt;/code&gt; to the production environment. Or worse, it &lt;em&gt;succeeds&lt;/em&gt; but the app silently falls back to defaults.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;envguard check .env .env.example
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This compares your &lt;code&gt;.env&lt;/code&gt; against &lt;code&gt;.env.example&lt;/code&gt; and tells you:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Missing keys&lt;/strong&gt; — variables expected but not set&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Extra keys&lt;/strong&gt; — variables set but not documented&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Empty values&lt;/strong&gt; — keys that exist but have no value
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;envguard check .env .env.example

✓ DATABASE_URL
✗ REDIS_URL          ← missing
+ STRIPE_KEY          ← extra &lt;span class="o"&gt;(&lt;/span&gt;not &lt;span class="k"&gt;in&lt;/span&gt; .env.example&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="o"&gt;!&lt;/span&gt; PORT               ← empty value

3 issues found.
Exit code: 1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Type validation with annotations
&lt;/h3&gt;

&lt;p&gt;This is the part I'm most excited about. You can annotate your &lt;code&gt;.env.example&lt;/code&gt; with types, and envguard will validate them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# .env.example
DATABASE_URL=           # @required @type url
PORT=3000               # @type number
DEBUG=false             # @type boolean
ADMIN_EMAIL=            # @type email
FEATURE_FLAGS=          # @type json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;envguard validate .env .env.example
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It checks that &lt;code&gt;DATABASE_URL&lt;/code&gt; is actually a valid URL, &lt;code&gt;PORT&lt;/code&gt; is a number, &lt;code&gt;DEBUG&lt;/code&gt; is a boolean, etc. Supported types: &lt;code&gt;string&lt;/code&gt;, &lt;code&gt;number&lt;/code&gt;, &lt;code&gt;boolean&lt;/code&gt;, &lt;code&gt;url&lt;/code&gt;, &lt;code&gt;email&lt;/code&gt;, &lt;code&gt;json&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;This catches the sneaky bugs. Like when someone sets &lt;code&gt;PORT=3000abc&lt;/code&gt; and your app crashes with &lt;code&gt;NaN&lt;/code&gt; in weird places.&lt;/p&gt;

&lt;h2&gt;
  
  
  Putting it in CI
&lt;/h2&gt;

&lt;p&gt;Here's the real value — this runs in your pipeline before anything ships.&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="c1"&gt;# .github/workflows/deploy.yml&lt;/span&gt;
&lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;

  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Check env vars are complete&lt;/span&gt;
    &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npx envguard check .env .env.example&lt;/span&gt;

  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Validate env types&lt;/span&gt;
    &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npx envguard validate .env .env.example&lt;/span&gt;

  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Scan for leaked secrets&lt;/span&gt;
    &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npx envguard secrets .env&lt;/span&gt;

  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Deploy&lt;/span&gt;
    &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;./deploy.sh&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If any of those checks fail, the deploy doesn't happen. No more shipping with missing config. No more leaking credentials.&lt;/p&gt;

&lt;p&gt;For teams, I'd add this to PR checks too:&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="c1"&gt;# .github/workflows/pr.yml&lt;/span&gt;
&lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;

  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Ensure no secrets in env files&lt;/span&gt;
    &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npx envguard secrets&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Even if you don't use &lt;code&gt;.env.example&lt;/code&gt; (you should though), the secret scanner alone is worth adding. One &lt;code&gt;npx&lt;/code&gt; command, zero config.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why not just use git-secrets or trufflehog?
&lt;/h2&gt;

&lt;p&gt;Good question. Those tools are great, but they solve a different problem:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;git-secrets&lt;/strong&gt; scans your git history for patterns. It's preventive but doesn't check your current working directory.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;trufflehog&lt;/strong&gt; scans repos and orgs for leaked credentials. It's an audit tool.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;detect-secrets&lt;/strong&gt; by Yelp is similar — great for scanning repos.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;envguard is a &lt;strong&gt;pre-commit/pre-deploy&lt;/strong&gt; tool. It checks the &lt;code&gt;.env&lt;/code&gt; file you're about to ship &lt;em&gt;right now&lt;/em&gt;. It also validates that your config is complete and correctly typed — something none of those tools do.&lt;/p&gt;

&lt;p&gt;Use them together. trufflehog for auditing your org, envguard for gating your deploys.&lt;/p&gt;

&lt;h2&gt;
  
  
  The zero-dependency thing
&lt;/h2&gt;

&lt;p&gt;I went out of my way to make this have zero runtime dependencies (only &lt;code&gt;commander&lt;/code&gt; for CLI parsing, which is basically part of Node at this point). Why?&lt;/p&gt;

&lt;p&gt;Because security tools should be auditable. If you're going to trust a CLI to scan your secrets, you should be able to read the entire codebase in an afternoon. Every dependency is a black box. When your secret scanner itself has 47 transitive dependencies... that feels wrong.&lt;/p&gt;

&lt;p&gt;The whole thing is ~500 lines of actual logic. You can read it, understand it, and verify it's not doing anything shady.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quick start
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Scan for secrets&lt;/span&gt;
npx envguard secrets

&lt;span class="c"&gt;# Check env completeness&lt;/span&gt;
npx envguard check

&lt;span class="c"&gt;# Full validation with types&lt;/span&gt;
npx envguard validate

&lt;span class="c"&gt;# Generate .env.example from existing .env&lt;/span&gt;
npx envguard init
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There's also &lt;code&gt;envguard diff&lt;/code&gt; for a side-by-side comparison of two env files, and &lt;code&gt;--json&lt;/code&gt; output for every command if you want to pipe it into something else.&lt;/p&gt;

&lt;p&gt;The tool works with both CJS and ESM projects. Install globally with &lt;code&gt;npm install -g envguard&lt;/code&gt; or just use &lt;code&gt;npx&lt;/code&gt;.&lt;/p&gt;

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

&lt;p&gt;The regex patterns for detecting secrets are tricky. AWS access keys have a specific format (&lt;code&gt;AKIA&lt;/code&gt; followed by 16 alphanumeric chars), which is easy. But "generic API key" is harder — you're basically looking for long, high-entropy strings that &lt;em&gt;look&lt;/em&gt; like credentials. There will be false positives. That's fine — better to flag too much than miss a real leak.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;--json&lt;/code&gt; flag was a late addition but it turned out to be the most useful feature for CI. You can parse the output, filter it, post it to Slack, whatever.&lt;/p&gt;

&lt;p&gt;If you're interested, the code is at &lt;a href="https://github.com/sulthonzh/envguard" rel="noopener noreferrer"&gt;github.com/sulthonzh/envguard&lt;/a&gt;. 51 tests, MIT license. Contributions welcome — especially new secret patterns if you've seen ones I missed.&lt;/p&gt;

&lt;p&gt;Stay safe out there. Check your &lt;code&gt;.env&lt;/code&gt; files.&lt;/p&gt;

</description>
      <category>security</category>
      <category>node</category>
      <category>devops</category>
      <category>environment</category>
    </item>
    <item>
      <title>I Deploy to Docker Swarm from GitHub Actions — Here's the Setup That Actually Works</title>
      <dc:creator>Sulthon Zainul Habib</dc:creator>
      <pubDate>Thu, 28 May 2026 20:45:21 +0000</pubDate>
      <link>https://dev.to/sulthonzh/i-deploy-to-docker-swarm-from-github-actions-heres-the-setup-that-actually-works-1aci</link>
      <guid>https://dev.to/sulthonzh/i-deploy-to-docker-swarm-from-github-actions-heres-the-setup-that-actually-works-1aci</guid>
      <description>&lt;p&gt;If you've ever tried to set up continuous deployment to a remote Docker host, you know the pain. GitHub Actions is great for CI — build, test, done. But &lt;strong&gt;deploying to a remote server&lt;/strong&gt;? That's where things get messy.&lt;/p&gt;

&lt;p&gt;Most tutorials hand you a 200-line shell script with &lt;code&gt;ssh&lt;/code&gt; hacks, &lt;code&gt;scp&lt;/code&gt; gymnastics, and prayer. I got tired of that, so I packaged it into a reusable GitHub Action that handles Docker Compose and Docker Swarm deployments over SSH.&lt;/p&gt;

&lt;p&gt;Here's how it works and how to set it up in under 10 minutes.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem
&lt;/h2&gt;

&lt;p&gt;You have a VPS (or a bare-metal server) running Docker. You want GitHub Actions to:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Build your images (or pull them from a registry)&lt;/li&gt;
&lt;li&gt;SSH into your server&lt;/li&gt;
&lt;li&gt;Deploy using &lt;code&gt;docker-compose up&lt;/code&gt; or &lt;code&gt;docker stack deploy&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Clean up old files&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Without leaking SSH keys everywhere or writing bespoke deployment scripts per project.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Action: &lt;code&gt;docker-remote-deployment-action&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/sulthonzh/docker-remote-deployment-action" rel="noopener noreferrer"&gt;github.com/sulthonzh/docker-remote-deployment-action&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;It's a GitHub Action available on the &lt;a href="https://github.com/marketplace/actions/docker-remote-deployment" rel="noopener noreferrer"&gt;Marketplace&lt;/a&gt; that does exactly this. It supports two deployment modes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;docker-compose&lt;/strong&gt; — runs &lt;code&gt;docker-compose&lt;/code&gt; on the remote host&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;docker-swarm&lt;/strong&gt; — runs &lt;code&gt;docker stack deploy&lt;/code&gt; for Swarm services&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both via SSH, both from your existing &lt;code&gt;docker-compose.yml&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Minimal Setup
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Add your SSH keys to GitHub Secrets
&lt;/h3&gt;

&lt;p&gt;In your repo → Settings → Secrets and variables → Actions, add:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;SSH_PRIVATE_KEY&lt;/code&gt; — your private key for the server&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;SSH_PUBLIC_KEY&lt;/code&gt; — the corresponding public key&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  2. Create the workflow file
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# .github/workflows/deploy.yml&lt;/span&gt;
&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Deploy&lt;/span&gt;

&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;branches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;main&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;deploy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Checkout&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Deploy to server&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;sulthonzh/docker-remote-deployment-action@v1&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;remote_docker_host&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;deploy@your-server.com&lt;/span&gt;
          &lt;span class="na"&gt;ssh_private_key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.SSH_PRIVATE_KEY }}&lt;/span&gt;
          &lt;span class="na"&gt;ssh_public_key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.SSH_PUBLIC_KEY }}&lt;/span&gt;
          &lt;span class="na"&gt;deployment_mode&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;docker-compose&lt;/span&gt;
          &lt;span class="na"&gt;stack_file_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;docker-compose.yml&lt;/span&gt;
          &lt;span class="na"&gt;args&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;up -d&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Push to &lt;code&gt;main&lt;/code&gt; → your service deploys. That's it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Docker Swarm Mode
&lt;/h2&gt;

&lt;p&gt;If you're running a Swarm cluster, switch the mode and add the stack name:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Deploy to Swarm&lt;/span&gt;
  &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;sulthonzh/docker-remote-deployment-action@v1&lt;/span&gt;
  &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;remote_docker_host&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;deploy@your-server.com&lt;/span&gt;
    &lt;span class="na"&gt;ssh_private_key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.SSH_PRIVATE_KEY }}&lt;/span&gt;
    &lt;span class="na"&gt;ssh_public_key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.SSH_PUBLIC_KEY }}&lt;/span&gt;
    &lt;span class="na"&gt;deployment_mode&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;docker-swarm&lt;/span&gt;
    &lt;span class="na"&gt;copy_stack_file&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
    &lt;span class="na"&gt;deploy_path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/opt/deployments/myapp&lt;/span&gt;
    &lt;span class="na"&gt;stack_file_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;docker-compose.yaml&lt;/span&gt;
    &lt;span class="na"&gt;keep_files&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;5&lt;/span&gt;
    &lt;span class="na"&gt;args&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;myapp&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This will:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Copy your &lt;code&gt;docker-compose.yaml&lt;/code&gt; to &lt;code&gt;/opt/deployments/myapp/&lt;/code&gt; on the server&lt;/li&gt;
&lt;li&gt;Run &lt;code&gt;docker stack deploy -c docker-compose.yaml myapp&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Keep the last 5 deployment files (auto-cleanup old ones)&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Feature Breakdown
&lt;/h2&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;copy_stack_file: true&lt;/code&gt; — Deploy from the server
&lt;/h3&gt;

&lt;p&gt;When enabled, the Action copies your compose file to a configurable path on the remote host before deploying. This is useful when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Your compose file references local build contexts&lt;/li&gt;
&lt;li&gt;You want deployment history on the server&lt;/li&gt;
&lt;li&gt;You need to inspect the compose file on the host later&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Combined with &lt;code&gt;keep_files: N&lt;/code&gt;, it auto-prunes old deployment directories. No manual cleanup.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;pull_images_first: true&lt;/code&gt; — Pull before deploy
&lt;/h3&gt;

&lt;p&gt;If your compose file references images from a private registry:&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;pull_images_first&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;span class="na"&gt;docker_registry_username&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.REGISTRY_USER }}&lt;/span&gt;
&lt;span class="na"&gt;docker_registry_password&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.REGISTRY_PASS }}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Action logs into your registry and pulls images before deploying. Works with any Docker-compatible registry.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;docker_prune: true&lt;/code&gt; — Automatic cleanup
&lt;/h3&gt;

&lt;p&gt;Adds a &lt;code&gt;docker system prune -f&lt;/code&gt; after deployment. Useful on small VPS instances where disk space is precious.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;pre_deployment_command_args&lt;/code&gt; — Run commands before deploy
&lt;/h3&gt;

&lt;p&gt;Need to run migrations or health checks before deploying?&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;pre_deployment_command_args&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;docker&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;exec&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;myapp_web&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;bundle&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;exec&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;rake&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;db:migrate"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Runs any command on the remote host before the deployment step.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Real-World Example: Full Pipeline
&lt;/h2&gt;

&lt;p&gt;Here's a complete workflow that builds, pushes, and deploys:&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;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Build and Deploy&lt;/span&gt;

&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;branches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;main&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;build-and-deploy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Login to Docker Hub&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;docker/login-action@v3&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;username&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.DOCKERHUB_USERNAME }}&lt;/span&gt;
          &lt;span class="na"&gt;password&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.DOCKERHUB_TOKEN }}&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Build and push&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;docker/build-push-action@v6&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;context&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;.&lt;/span&gt;
          &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
          &lt;span class="na"&gt;tags&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;myuser/myapp:${{ github.sha }}&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Update image tag in compose file&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;sed -i "s|image: myuser/myapp:.*|image: myuser/myapp:${{ github.sha }}|" docker-compose.yml&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Deploy&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;sulthonzh/docker-remote-deployment-action@v1&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;remote_docker_host&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;deploy@myserver.com&lt;/span&gt;
          &lt;span class="na"&gt;ssh_private_key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.SSH_PRIVATE_KEY }}&lt;/span&gt;
          &lt;span class="na"&gt;ssh_public_key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.SSH_PUBLIC_KEY }}&lt;/span&gt;
          &lt;span class="na"&gt;deployment_mode&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;docker-compose&lt;/span&gt;
          &lt;span class="na"&gt;copy_stack_file&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
          &lt;span class="na"&gt;deploy_path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/opt/myapp&lt;/span&gt;
          &lt;span class="na"&gt;stack_file_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;docker-compose.yml&lt;/span&gt;
          &lt;span class="na"&gt;keep_files&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;3&lt;/span&gt;
          &lt;span class="na"&gt;pull_images_first&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
          &lt;span class="na"&gt;docker_registry_username&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.DOCKERHUB_USERNAME }}&lt;/span&gt;
          &lt;span class="na"&gt;docker_registry_password&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.DOCKERHUB_TOKEN }}&lt;/span&gt;
          &lt;span class="na"&gt;args&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;up -d --remove-orphans&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This gives you a full CI/CD pipeline: commit → build → push → deploy. No Jenkins, no GitLab Runner, no self-hosted agent. Just GitHub Actions SSHing into your server.&lt;/p&gt;

&lt;h2&gt;
  
  
  Security Notes
&lt;/h2&gt;

&lt;p&gt;A few things to keep in mind:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Use a dedicated deploy user&lt;/strong&gt; on your server, not root. Create a user that only has Docker permissions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Restrict SSH keys&lt;/strong&gt; — the deploy key should only work for Docker-related commands.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use GitHub Secrets&lt;/strong&gt; — never hardcode keys in your workflow files.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rotate keys&lt;/strong&gt; if they ever appear in logs (the Action masks secrets, but be careful with &lt;code&gt;set -x&lt;/code&gt; in pre-deployment commands).&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  When This Shines
&lt;/h2&gt;

&lt;p&gt;This setup is ideal for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Solo devs / small teams&lt;/strong&gt; deploying to a single VPS&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Side projects&lt;/strong&gt; that don't need Kubernetes&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Staging environments&lt;/strong&gt; that auto-deploy on push&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hobby infrastructure&lt;/strong&gt; where you want CI/CD without the complexity of ArgoCD or Flux&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you're running 50 microservices on EKS, this isn't for you. If you're deploying a few containers to a $5/month DigitalOcean droplet, this is exactly what you need.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's Next
&lt;/h2&gt;

&lt;p&gt;The Action is stable and production-ready at &lt;a href="https://github.com/sulthonzh/docker-remote-deployment-action/releases/tag/v1.0.0" rel="noopener noreferrer"&gt;v1.0.0&lt;/a&gt;. It's adapted from earlier work by &lt;a href="https://github.com/wshihadeh/docker-deployment-action" rel="noopener noreferrer"&gt;wshihadeh&lt;/a&gt; and &lt;a href="https://github.com/TapTap21/docker-remote-deployment-action" rel="noopener noreferrer"&gt;TapTap21&lt;/a&gt;, with improvements for modern GitHub Actions.&lt;/p&gt;

&lt;p&gt;Star it, try it, open issues if something doesn't work. That's how open source grows.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Built this while deploying side projects at &lt;a href="https://github.com/sulthonzh" rel="noopener noreferrer"&gt;quadbyte&lt;/a&gt;. More dev tools coming.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>docker</category>
      <category>githubactions</category>
      <category>devops</category>
      <category>deployment</category>
    </item>
    <item>
      <title>I Accidentally force-pushed to main at 11 PM — So I Built an Interactive Git Undo Tool</title>
      <dc:creator>Sulthon Zainul Habib</dc:creator>
      <pubDate>Thu, 28 May 2026 12:13:25 +0000</pubDate>
      <link>https://dev.to/sulthonzh/i-accidentally-force-pushed-to-main-at-11-pm-so-i-built-an-interactive-git-undo-tool-56o9</link>
      <guid>https://dev.to/sulthonzh/i-accidentally-force-pushed-to-main-at-11-pm-so-i-built-an-interactive-git-undo-tool-56o9</guid>
      <description>&lt;p&gt;We've all been there. It's late. You're tired. You run &lt;code&gt;git push --force&lt;/code&gt; and realize — half a second too late — you're on &lt;code&gt;main&lt;/code&gt;, not your feature branch.&lt;/p&gt;

&lt;p&gt;Your heart drops. Your teammates' commits are gone. You open Stack Overflow in a panic.&lt;/p&gt;

&lt;p&gt;That exact scenario happened to me (okay, it was a branch, not main — but the fear was real). After the third time digging through &lt;code&gt;git reflog&lt;/code&gt; output at midnight, I built &lt;strong&gt;gitpanic&lt;/strong&gt; — an interactive CLI that auto-detects git disasters and walks you through recovery.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem: git reflog is Powerful but Hostile
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;git reflog&lt;/code&gt; is the right tool for the job. But let's be honest:&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="nv"&gt;$ &lt;/span&gt;git reflog
a1b2c3d HEAD@&lt;span class="o"&gt;{&lt;/span&gt;0&lt;span class="o"&gt;}&lt;/span&gt;: checkout: moving from feature/login to main
e4f5g6h HEAD@&lt;span class="o"&gt;{&lt;/span&gt;1&lt;span class="o"&gt;}&lt;/span&gt;: commit: add login validation
i7j8k9l HEAD@&lt;span class="o"&gt;{&lt;/span&gt;2&lt;span class="o"&gt;}&lt;/span&gt;: commit: fix auth middleware
m0n1o2p HEAD@&lt;span class="o"&gt;{&lt;/span&gt;3&lt;span class="o"&gt;}&lt;/span&gt;: checkout: moving from main to feature/login
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Cool. Now what? Which SHA do I use? What command fixes a deleted branch vs. a bad merge? Is &lt;code&gt;git reset --hard&lt;/code&gt; safe here or am I about to make things worse?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Every developer knows this feeling.&lt;/strong&gt; Stack Overflow's most upvoted git questions aren't about advanced workflows — they're all "how do I undo X":&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;"How to undo last commit" — 16k+ upvotes&lt;/li&gt;
&lt;li&gt;"How to undo git push" — 5k+ upvotes
&lt;/li&gt;
&lt;li&gt;"How to recover deleted branch" — 3k+ upvotes&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The knowledge exists. The UX is the problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  Enter gitpanic
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/sulthonzh/gitpanic" rel="noopener noreferrer"&gt;gitpanic&lt;/a&gt; is a zero-config CLI that does three things:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Auto-detects&lt;/strong&gt; what went wrong by analyzing your git state&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Explains&lt;/strong&gt; the problem in plain English with confidence levels&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Guides&lt;/strong&gt; you through recovery with risk-rated options and confirmation prompts
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx gitpanic
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it. No arguments, no config files, no documentation to read while sweating.&lt;/p&gt;

&lt;h2&gt;
  
  
  How It Detects Disasters
&lt;/h2&gt;

&lt;p&gt;gitpanic runs multiple "detectors" against your current repository state:&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Reflog Analysis
&lt;/h3&gt;

&lt;p&gt;It scans recent reflog entries looking for red flags:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Recent force pushes (within the last hour)&lt;/li&gt;
&lt;li&gt;Branch deletions&lt;/li&gt;
&lt;li&gt;Sudden HEAD movements that suggest accidental operations&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  2. Working Tree State
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Uncommitted changes that might be about to get lost&lt;/li&gt;
&lt;li&gt;Detached HEAD state (happens more than you'd think)&lt;/li&gt;
&lt;li&gt;Merge conflicts left unresolved&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  3. Timing Heuristics
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Commits made less than 2 minutes ago → probably accidental&lt;/li&gt;
&lt;li&gt;Rapid branch switches → possibly confused about where you are&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each detector returns a finding with a &lt;strong&gt;confidence score&lt;/strong&gt;. If confidence is high enough, it surfaces the issue.&lt;/p&gt;

&lt;h2&gt;
  
  
  Real-World Example: Recovering a Deleted Branch
&lt;/h2&gt;

&lt;p&gt;Here's what happens when you accidentally delete a branch:&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="nv"&gt;$ &lt;/span&gt;gitpanic
🔍 Analyzing your git state...

⚠️  Found 1 potential issue:

1. 🔴 Deleted Branch
   Branch &lt;span class="s2"&gt;"feature/login"&lt;/span&gt; was deleted 45 seconds ago.
   Confidence: 90%

Select an issue to fix &lt;span class="o"&gt;[&lt;/span&gt;1-1]: 1

🔧 Recovery options &lt;span class="k"&gt;for&lt;/span&gt;: Deleted Branch

1. ✅ Restore branch &lt;span class="s2"&gt;"feature/login"&lt;/span&gt;
   Recreate the branch from the deleted commit
   Risk: safe
   Steps:
   - git branch feature/login abc1234
   - git checkout feature/login

Select a recovery option &lt;span class="o"&gt;[&lt;/span&gt;1-1]: 1
⚠️  Confirm: &lt;span class="s2"&gt;"Restore branch &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;feature/login&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;? This can be undone. &lt;span class="o"&gt;[&lt;/span&gt;y/N]: y

🚀 Executing recovery...

✅ Recovery &lt;span class="nb"&gt;complete&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice the risk rating — &lt;strong&gt;safe&lt;/strong&gt;. gitpanic never marks destructive operations as safe. You always know what you're getting into.&lt;/p&gt;

&lt;h2&gt;
  
  
  Recovery Options Are Tiered by Risk
&lt;/h2&gt;

&lt;p&gt;Every disaster comes with multiple recovery strategies:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Risk Level&lt;/th&gt;
&lt;th&gt;What It Means&lt;/th&gt;
&lt;th&gt;Examples&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;✅ Safe&lt;/td&gt;
&lt;td&gt;Can be easily undone&lt;/td&gt;
&lt;td&gt;Stash, soft reset, branch restore&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;⚡ Medium&lt;/td&gt;
&lt;td&gt;May need manual cleanup&lt;/td&gt;
&lt;td&gt;Cherry-pick, rebase&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;⚠️ High&lt;/td&gt;
&lt;td&gt;Destructive, no undo&lt;/td&gt;
&lt;td&gt;Hard reset, force push&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;This matters because when you're panicking, you don't always make the best decisions. gitpanic shows you the safest option first and clearly marks dangerous ones.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Dry Run Safety Net
&lt;/h2&gt;

&lt;p&gt;Not sure what will happen? Use dry run mode:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gitpanic &lt;span class="nt"&gt;--dry-run&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This shows you exactly what commands would run without executing anything. Perfect for learning or when you want to handle recovery manually but need guidance on the right commands.&lt;/p&gt;

&lt;h2&gt;
  
  
  Under the Hood: Building Disaster Detectors
&lt;/h2&gt;

&lt;p&gt;Here's a simplified version of how the "accidental commit" detector works:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;detectAccidentalCommit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;repo&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Repository&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Finding&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;lastCommit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;repo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getHeadCommit&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;commitAge&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;lastCommit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;date&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;getTime&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="c1"&gt;// If the last commit was made less than 2 minutes ago&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;commitAge&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;120&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;accidental_commit&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;severity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;low&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;confidence&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`You just committed "&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;lastCommit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;message&lt;/span&gt;&lt;span class="p"&gt;()}&lt;/span&gt;&lt;span class="s2"&gt;" &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;secondsAgo&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;s ago.`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;recoveries&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Undo commit, keep changes staged&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="na"&gt;risk&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;safe&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;git reset --soft HEAD~1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The key insight: &lt;strong&gt;timing is the strongest signal&lt;/strong&gt;. A commit made 10 seconds ago is very different from one made 10 hours ago. By combining timing with reflog patterns, you can surface the right problem at the right time.&lt;/p&gt;

&lt;h2&gt;
  
  
  What It Can Detect (So Far)
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Disaster&lt;/th&gt;
&lt;th&gt;Detection Method&lt;/th&gt;
&lt;th&gt;Confidence&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Detached HEAD&lt;/td&gt;
&lt;td&gt;&lt;code&gt;git status&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Deleted Branch&lt;/td&gt;
&lt;td&gt;Reflog scan&lt;/td&gt;
&lt;td&gt;90%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Force Push&lt;/td&gt;
&lt;td&gt;Reflog analysis&lt;/td&gt;
&lt;td&gt;85%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Botched Merge&lt;/td&gt;
&lt;td&gt;MERGE_HEAD file&lt;/td&gt;
&lt;td&gt;95%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Accidental Commit&lt;/td&gt;
&lt;td&gt;Timing heuristics&lt;/td&gt;
&lt;td&gt;80%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Uncommitted Changes&lt;/td&gt;
&lt;td&gt;&lt;code&gt;git status&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Wrong Branch Commit&lt;/td&gt;
&lt;td&gt;Branch comparison&lt;/td&gt;
&lt;td&gt;70%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  When NOT to Use gitpanic
&lt;/h2&gt;

&lt;p&gt;Let's be honest — if you're a git wizard who lives in the reflog, this tool isn't for you. It's for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Developers who've been coding for 2 years and still Google "how to undo git commit"&lt;/li&gt;
&lt;li&gt;Junior devs who are afraid of &lt;code&gt;git reset&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Anyone working late at night when judgment is impaired&lt;/li&gt;
&lt;li&gt;Teams that want a safer workflow for git operations&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Get Started
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Install globally&lt;/span&gt;
npm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-g&lt;/span&gt; gitpanic

&lt;span class="c"&gt;# Or use without installing&lt;/span&gt;
npx gitpanic

&lt;span class="c"&gt;# Preview without making changes&lt;/span&gt;
gitpanic &lt;span class="nt"&gt;--dry-run&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;GitHub:&lt;/strong&gt; &lt;a href="https://github.com/sulthonzh/gitpanic" rel="noopener noreferrer"&gt;sulthonzh/gitpanic&lt;/a&gt;&lt;br&gt;&lt;br&gt;
&lt;strong&gt;License:&lt;/strong&gt; MIT&lt;/p&gt;

&lt;p&gt;The goal isn't to replace &lt;code&gt;git reflog&lt;/code&gt; — it's to make recovery accessible when you're in full panic mode. Because the worst time to learn git internals is when your production branch is hanging by a thread.&lt;/p&gt;

&lt;p&gt;If you've ever panicked after a bad git operation, give gitpanic a try. And if you have ideas for new disaster detectors, PRs are welcome — there's a clean plugin architecture for adding new ones.&lt;/p&gt;

</description>
      <category>git</category>
      <category>opensource</category>
      <category>devtools</category>
      <category>javascript</category>
    </item>
    <item>
      <title>We Lost 4 Hours to a Missing .env Variable — So I Built a Schema-First Fix</title>
      <dc:creator>Sulthon Zainul Habib</dc:creator>
      <pubDate>Wed, 27 May 2026 23:45:08 +0000</pubDate>
      <link>https://dev.to/sulthonzh/we-lost-4-hours-to-a-missing-env-variable-so-i-built-a-schema-first-fix-4pn7</link>
      <guid>https://dev.to/sulthonzh/we-lost-4-hours-to-a-missing-env-variable-so-i-built-a-schema-first-fix-4pn7</guid>
      <description>&lt;p&gt;Every developer has a war story about environment variables. Ours went like this: production deployment at 2 AM, the app starts throwing &lt;code&gt;undefined is not a valid database URL&lt;/code&gt;, and the team spends 4 hours hunting down a missing &lt;code&gt;DATABASE_HOST&lt;/code&gt; that was present in every environment &lt;em&gt;except&lt;/em&gt; the one that mattered.&lt;/p&gt;

&lt;p&gt;The fix? We wrote a tool called &lt;strong&gt;dotenv-schema&lt;/strong&gt; that flips the problem upside down. Instead of validating what you &lt;em&gt;have&lt;/em&gt;, you define what you &lt;em&gt;need&lt;/em&gt; first.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem with .env Files
&lt;/h2&gt;

&lt;p&gt;Here's the thing about &lt;code&gt;.env&lt;/code&gt; files — they're documentation, configuration, and secrets all rolled into one unstructured text file. No types. No required/optional flags. No validation until runtime (if you're lucky).&lt;/p&gt;

&lt;p&gt;Most teams handle it like this:&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;# .env&lt;/span&gt;
&lt;span class="nv"&gt;DATABASE_URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;postgres://localhost:5432/mydb
&lt;span class="nv"&gt;NODE_ENV&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;production
&lt;span class="nv"&gt;PORT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;3000
&lt;span class="nv"&gt;API_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Spot the problem? &lt;code&gt;API_KEY&lt;/code&gt; is empty. In production. At runtime, something explodes.&lt;/p&gt;

&lt;p&gt;Existing tools like &lt;code&gt;dotenv&lt;/code&gt;, &lt;code&gt;envalid&lt;/code&gt;, or &lt;code&gt;zod&lt;/code&gt; validate &lt;em&gt;after the fact&lt;/em&gt;. You write your validation code, you remember to call it, and hopefully you covered every variable. It's reactive.&lt;/p&gt;

&lt;h2&gt;
  
  
  Schema-First: Define Before You Validate
&lt;/h2&gt;

&lt;p&gt;The idea behind dotenv-schema is simple: &lt;strong&gt;define the schema first, then generate everything from 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;npx dotenv-schema init &lt;span class="nt"&gt;--env&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;.env &lt;span class="nt"&gt;--output&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;schema.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This reads your existing &lt;code&gt;.env&lt;/code&gt; and generates a schema with inferred types:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"DATABASE_URL"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"string"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"required"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"format"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"uri"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"description"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Database connection string"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"NODE_ENV"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"enum"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"values"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"development"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"production"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"test"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"required"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"description"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Application environment"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"PORT"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"number"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"required"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"default"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;3000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"min"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1024&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"max"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;65535&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"description"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Server port number"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"API_KEY"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"string"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"required"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"description"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"External API authentication key"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now you have a &lt;strong&gt;single source of truth&lt;/strong&gt; for what your app needs. From this schema, you can generate:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;.env.example&lt;/code&gt;&lt;/strong&gt; — for onboarding new developers&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;TypeScript types&lt;/strong&gt; — so &lt;code&gt;process.env&lt;/code&gt; is typed&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Validation code&lt;/strong&gt; — runtime checks before your app starts&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Documentation&lt;/strong&gt; — a markdown table for your README&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The Real Technique: Generating a Validation Gate
&lt;/h2&gt;

&lt;p&gt;Here's the part that actually saved us. Run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dotenv-schema generate &lt;span class="nt"&gt;--validator&lt;/span&gt; &lt;span class="nt"&gt;--types&lt;/span&gt; &lt;span class="nt"&gt;--env-example&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You get a generated &lt;code&gt;env.validator.ts&lt;/code&gt; that looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;EnvValidator&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;dotenv-schema/validator&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;schema&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;DATABASE_URL&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;required&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;format&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;uri&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;NODE_ENV&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;enum&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;values&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;development&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;production&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;test&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="na"&gt;required&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;PORT&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;number&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;required&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;default&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;3000&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;API_KEY&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;required&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;validator&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;EnvValidator&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;validateEnv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;validator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;validate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;valid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;❌ Environment validation failed:&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;errors&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`  - &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then in your app entry point:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;validateEnv&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;./env.validator&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;dotenv&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;dotenv&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nx"&gt;dotenv&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;config&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;env&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;validateEnv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// ^ Fully typed, fully validated. App never starts with bad config.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If &lt;code&gt;API_KEY&lt;/code&gt; is missing or empty, the app refuses to start with a clear error. No more 2 AM detective work.&lt;/p&gt;

&lt;h2&gt;
  
  
  CI/CD Integration: Catch It Before Deploy
&lt;/h2&gt;

&lt;p&gt;The schema file is JSON — commit it to your repo. Now CI can validate too:&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="c1"&gt;# .github/workflows/ci.yml&lt;/span&gt;
&lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/setup-node@v4&lt;/span&gt;
    &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;node-version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;20&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npm install -g dotenv-schema&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;dotenv-schema validate --env=.env.example --schema=schema.json&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If someone adds a new required variable to the schema but forgets to update &lt;code&gt;.env.example&lt;/code&gt;, CI fails. The schema becomes a contract between developers and the deployment pipeline.&lt;/p&gt;

&lt;h2&gt;
  
  
  What About zod/envalid?
&lt;/h2&gt;

&lt;p&gt;Good question. Here's when to use what:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;zod&lt;/strong&gt;: Great for API validation, complex runtime schemas. Overkill for env vars if you just need type checking.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;envalid&lt;/strong&gt;: Solid runtime validation, but no schema-first approach. You write validation inline in your code.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;dotenv-schema&lt;/strong&gt;: Schema is a separate artifact. You generate &lt;code&gt;.env.example&lt;/code&gt;, types, docs, and validators from one definition. It's about the &lt;em&gt;workflow&lt;/em&gt;, not just validation.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you have 3 environment variables, any of these work fine. If you have 30+ across microservices, having a committed schema file that generates everything is a different level of maintainability.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Type System
&lt;/h2&gt;

&lt;p&gt;dotenv-schema supports these types out of the box:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;string&lt;/code&gt; — with optional &lt;code&gt;format&lt;/code&gt; (uri, email), &lt;code&gt;pattern&lt;/code&gt; (regex), &lt;code&gt;min&lt;/code&gt;/&lt;code&gt;max&lt;/code&gt; length&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;number&lt;/code&gt; — with optional &lt;code&gt;min&lt;/code&gt;/&lt;code&gt;max&lt;/code&gt; range&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;boolean&lt;/code&gt; — &lt;code&gt;true&lt;/code&gt; or &lt;code&gt;false&lt;/code&gt; (handles string "true"/"false" from .env)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;enum&lt;/code&gt; — one of predefined values, great for &lt;code&gt;NODE_ENV&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;json&lt;/code&gt; — validates that the value parses as valid JSON&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each variable can be &lt;code&gt;required&lt;/code&gt; or &lt;code&gt;optional&lt;/code&gt; with a &lt;code&gt;default&lt;/code&gt; value and a human-readable &lt;code&gt;description&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Practical Tips
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Start from your existing .env:&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;npx dotenv-schema init &lt;span class="nt"&gt;--env&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;.env &lt;span class="nt"&gt;--interactive&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The interactive mode prompts you for types and descriptions instead of guessing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Add variables over time:&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;dotenv-schema add &lt;span class="nt"&gt;--schema&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;schema.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Prompts for name, type, required, default, description. No manual JSON editing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Generate docs for your README:&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;dotenv-schema generate &lt;span class="nt"&gt;--docs&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Outputs a clean markdown table you can paste into your project documentation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Check schema health:&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;dotenv-schema check &lt;span class="nt"&gt;--schema&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;schema.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Validates the schema itself — catches circular refs, invalid types, missing descriptions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Installation
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-g&lt;/span&gt; dotenv-schema
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or use without installing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx dotenv-schema init &lt;span class="nt"&gt;--env&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;.env
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Works with Node.js &amp;gt;= 18. MIT licensed.&lt;/p&gt;

&lt;p&gt;GitHub: &lt;a href="https://github.com/sulthonzh/dotenv-schema" rel="noopener noreferrer"&gt;sulthonzh/dotenv-schema&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;The takeaway isn't about this specific tool — it's the &lt;strong&gt;schema-first mindset&lt;/strong&gt;. Define what you need &lt;em&gt;before&lt;/em&gt; you need it. Generate the boilerplate. Let the schema be the contract. Your future self (especially at 2 AM) will thank you.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Built by &lt;a href="https://github.com/sulthonzh" rel="noopener noreferrer"&gt;sulthonzh&lt;/a&gt; — because losing 4 hours to a missing env var is 4 hours too many.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>node</category>
      <category>javascript</category>
      <category>devtools</category>
      <category>opensource</category>
    </item>
    <item>
      <title>I Don't Know Zig — But I Built a Production Log Tool With GitHub Copilot</title>
      <dc:creator>Sulthon Zainul Habib</dc:creator>
      <pubDate>Mon, 25 May 2026 02:49:25 +0000</pubDate>
      <link>https://dev.to/sulthonzh/i-dont-know-zig-but-i-built-a-production-log-tool-with-github-copilot-4khl</link>
      <guid>https://dev.to/sulthonzh/i-dont-know-zig-but-i-built-a-production-log-tool-with-github-copilot-4khl</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/github-2026-05-21"&gt;GitHub Finish-Up-A-Thon Challenge&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/github-2026-05-21"&gt;GitHub Finish-Up-A-Thon Challenge&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  ⭐ GitHub Repository
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;👉 &lt;a href="https://github.com/sulthonzh/logchef-zig" rel="noopener noreferrer"&gt;github.com/sulthonzh/logchef-zig&lt;/a&gt;&lt;/strong&gt; — Star it if you find it useful!&lt;/p&gt;




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

&lt;p&gt;&lt;strong&gt;&lt;a href="https://github.com/sulthonzh/logchef-zig" rel="noopener noreferrer"&gt;logchef&lt;/a&gt;&lt;/strong&gt; — a 592KB binary that parses, filters, and pretty-prints log files in JSON, logfmt, or plain text. Think &lt;code&gt;jq&lt;/code&gt; meets &lt;code&gt;tail&lt;/code&gt; meets &lt;code&gt;grep&lt;/code&gt;, purpose-built for logs.&lt;/p&gt;

&lt;p&gt;I'm a TypeScript/Node.js developer. I'd never written a line of Zig before this project. I built the entire thing using GitHub Copilot.&lt;/p&gt;

&lt;h2&gt;
  
  
  Demo
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Filter errors from any log format&lt;/span&gt;
logchef app.log &lt;span class="nt"&gt;-l&lt;/span&gt; error

&lt;span class="c"&gt;# Search + follow in real-time&lt;/span&gt;
logchef app.log &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"timeout"&lt;/span&gt; &lt;span class="nt"&gt;-F&lt;/span&gt;

&lt;span class="c"&gt;# Field filtering&lt;/span&gt;
logchef app.log &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="nv"&gt;status&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;500

&lt;span class="c"&gt;# JSON output for piping&lt;/span&gt;
logchef app.log &lt;span class="nt"&gt;-l&lt;/span&gt; error &lt;span class="nt"&gt;-o&lt;/span&gt; json | jq &lt;span class="s1"&gt;'.message'&lt;/span&gt;

&lt;span class="c"&gt;# Pipe from kubectl&lt;/span&gt;
kubectl logs my-pod | logchef &lt;span class="nt"&gt;-c&lt;/span&gt; panic
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;🔗 &lt;strong&gt;GitHub:&lt;/strong&gt; &lt;a href="https://github.com/sulthonzh/logchef-zig" rel="noopener noreferrer"&gt;github.com/sulthonzh/logchef-zig&lt;/a&gt;&lt;br&gt;
⭐ &lt;strong&gt;Give it a star!&lt;/strong&gt; It helps other developers discover the project.&lt;/p&gt;
&lt;h2&gt;
  
  
  The Comeback Story
&lt;/h2&gt;
&lt;h3&gt;
  
  
  BEFORE (May 6, 2026)
&lt;/h3&gt;

&lt;p&gt;I started logchef during a weekend sprint. I had an idea: replace bloated Node.js log viewers with a tiny Zig binary. I didn't know Zig, so I opened VS Code, enabled Copilot, and started learning.&lt;/p&gt;

&lt;p&gt;The first version was rough:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;1,218 lines of code&lt;/li&gt;
&lt;li&gt;Basic JSON parsing and level filtering worked&lt;/li&gt;
&lt;li&gt;Text search worked&lt;/li&gt;
&lt;li&gt;Color output worked&lt;/li&gt;
&lt;li&gt;21 tests passing&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;But the README listed features that didn't exist:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;❌ Follow mode (tail -f)&lt;/li&gt;
&lt;li&gt;❌ Logfmt format support&lt;/li&gt;
&lt;li&gt;❌ No benchmarks&lt;/li&gt;
&lt;li&gt;❌ No cross-platform builds&lt;/li&gt;
&lt;li&gt;❌ No release automation&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Life happened. The project sat for 2 weeks.&lt;/p&gt;
&lt;h3&gt;
  
  
  AFTER (May 25, 2026)
&lt;/h3&gt;

&lt;p&gt;I picked it back up for the GitHub Finish-Up-A-Thon. Using GitHub Copilot CLI (&lt;code&gt;copilot -p&lt;/code&gt;), I created 6 issues and implemented them one by one:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;✅ &lt;strong&gt;Follow mode&lt;/strong&gt; — polls file every 500ms, handles rotation&lt;/li&gt;
&lt;li&gt;✅ &lt;strong&gt;Logfmt support&lt;/strong&gt; — auto-detects and parses key=value pairs&lt;/li&gt;
&lt;li&gt;✅ &lt;strong&gt;JSON output&lt;/strong&gt; — NDJSON format for pipeline integration&lt;/li&gt;
&lt;li&gt;✅ &lt;strong&gt;Benchmarks&lt;/strong&gt; — compared vs jq, grep, awk&lt;/li&gt;
&lt;li&gt;✅ &lt;strong&gt;Cross-platform CI&lt;/strong&gt; — Linux + macOS, x86 + ARM&lt;/li&gt;
&lt;li&gt;✅ &lt;strong&gt;Polished README&lt;/strong&gt; — badges, comparison table, benchmarks&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Final stats:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;1,991 lines of Zig code (+64%)&lt;/li&gt;
&lt;li&gt;23 tests&lt;/li&gt;
&lt;li&gt;592KB binary&lt;/li&gt;
&lt;li&gt;4-platform CI&lt;/li&gt;
&lt;li&gt;Zero dependencies&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  My Experience with GitHub Copilot
&lt;/h2&gt;
&lt;h3&gt;
  
  
  Learning Zig Through Copilot
&lt;/h3&gt;

&lt;p&gt;Zig has concepts I'd never seen in TypeScript:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Explicit allocators&lt;/strong&gt; — every allocation needs an allocator passed in&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Error unions&lt;/strong&gt; — &lt;code&gt;!Type&lt;/code&gt; syntax for functions that can fail&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Comptime&lt;/strong&gt; — code that runs at compile time&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No hidden control flow&lt;/strong&gt; — no exceptions, no hidden allocations&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I didn't learn these from docs. I learned them by writing prompts like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight zig"&gt;&lt;code&gt;&lt;span class="n"&gt;copilot&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="s"&gt;"How do I read a file line by line in Zig? Show me with explicit allocators."&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And Copilot would generate code that taught me the pattern:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight zig"&gt;&lt;code&gt;&lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;gpa&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;std&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="py"&gt;heap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GeneralPurposeAllocator&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="p"&gt;{}){};&lt;/span&gt;
&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="n"&gt;allocator&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;gpa&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;allocator&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="n"&gt;file&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="n"&gt;std&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="py"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cwd&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;openFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="p"&gt;{});&lt;/span&gt;
&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="n"&gt;content&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="n"&gt;file&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;readToEndAlloc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;allocator&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;max_size&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;defer&lt;/span&gt; &lt;span class="n"&gt;allocator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;free&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Copilot CLI as My Zig Tutor
&lt;/h3&gt;

&lt;p&gt;The most valuable tool was &lt;code&gt;copilot -p&lt;/code&gt; — non-interactive mode that reads your codebase and generates matching code:&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;# Implement follow mode&lt;/span&gt;
copilot &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s2"&gt;"Add follow mode that polls a file for new lines every 500ms"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--allow-all&lt;/span&gt; &lt;span class="nt"&gt;-C&lt;/span&gt; &lt;span class="nb"&gt;.&lt;/span&gt; &lt;span class="nt"&gt;-s&lt;/span&gt;

&lt;span class="c"&gt;# Add logfmt parsing&lt;/span&gt;
copilot &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s2"&gt;"Add logfmt key=value parsing to the existing parser"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--allow-all&lt;/span&gt; &lt;span class="nt"&gt;-C&lt;/span&gt; &lt;span class="nb"&gt;.&lt;/span&gt; &lt;span class="nt"&gt;-s&lt;/span&gt;

&lt;span class="c"&gt;# Fix build errors&lt;/span&gt;
copilot &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s2"&gt;"The build fails with: [error]. Fix it."&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--allow-all&lt;/span&gt; &lt;span class="nt"&gt;-C&lt;/span&gt; &lt;span class="nb"&gt;.&lt;/span&gt; &lt;span class="nt"&gt;-s&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every prompt taught me something new about Zig. By the end, I understood allocators, error handling, and even cross-compilation — all through Copilot-guided implementation.&lt;/p&gt;

&lt;h3&gt;
  
  
  What Copilot Got Right
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Pattern matching&lt;/strong&gt; — it read my existing code and matched the style perfectly&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Zig idioms&lt;/strong&gt; — it knew to use &lt;code&gt;defer&lt;/code&gt; for cleanup, &lt;code&gt;try&lt;/code&gt; for error propagation&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test writing&lt;/strong&gt; — it generated tests that followed my existing test patterns&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cross-platform&lt;/strong&gt; — it knew the right Zig target triples for all 4 platforms&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  What I Still Needed to Do
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Architecture decisions&lt;/strong&gt; — Copilot writes code, but I decided what to build&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Code review&lt;/strong&gt; — I read every line before committing&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Integration&lt;/strong&gt; — I connected the pieces Copilot built&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Problem framing&lt;/strong&gt; — the quality of Copilot's output depends on how you describe the problem&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Results
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Before&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Lines of code&lt;/td&gt;
&lt;td&gt;1,218&lt;/td&gt;
&lt;td&gt;1,991&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tests&lt;/td&gt;
&lt;td&gt;21&lt;/td&gt;
&lt;td&gt;23&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Binary size&lt;/td&gt;
&lt;td&gt;592KB&lt;/td&gt;
&lt;td&gt;592KB (unchanged!)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Formats supported&lt;/td&gt;
&lt;td&gt;JSON, plain text&lt;/td&gt;
&lt;td&gt;JSON, logfmt, plain text&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Follow mode&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;JSON output&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cross-platform CI&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;✅ (4 platforms)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Benchmarks&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Key Takeaway
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;You don't need to know a language to build something useful in it.&lt;/strong&gt; GitHub Copilot won't replace understanding — but it dramatically lowers the barrier to entry. I went from "what is Zig?" to "I shipped a cross-platform CLI tool in Zig" in 3 weeks, part-time.&lt;/p&gt;

&lt;p&gt;The trick isn't blindly accepting Copilot's output. It's:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Having a clear idea of what you want to build&lt;/li&gt;
&lt;li&gt;Reading and understanding every line Copilot generates&lt;/li&gt;
&lt;li&gt;Using Copilot to learn, not just to code&lt;/li&gt;
&lt;/ol&gt;




&lt;p&gt;Thanks to GitHub and DEV for the challenge! 🙏&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Built with GitHub Copilot (free tier). No paid subscriptions used.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>githubchallenge</category>
      <category>zig</category>
      <category>beginners</category>
    </item>
    <item>
      <title>I Replaced 70MB Node.js Log Viewer with a 172KB Zig Binary</title>
      <dc:creator>Sulthon Zainul Habib</dc:creator>
      <pubDate>Sun, 24 May 2026 19:21:56 +0000</pubDate>
      <link>https://dev.to/sulthonzh/i-replaced-70mb-nodejs-log-viewer-with-a-172kb-zig-binary-5bn7</link>
      <guid>https://dev.to/sulthonzh/i-replaced-70mb-nodejs-log-viewer-with-a-172kb-zig-binary-5bn7</guid>
      <description>&lt;p&gt;Log files are the debugging reality of production systems. You stare at them daily, curse the noise, and grep until your eyes bleed.&lt;/p&gt;

&lt;p&gt;The problem? Most log viewers are heavy, slow, or require complex setups. I wanted something that just works.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem
&lt;/h2&gt;

&lt;p&gt;I tried the usual suspects:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;tail -f&lt;/strong&gt;: Raw, unfiltered stream. No filtering, no highlighting. Useless for anything beyond watching errors fly by.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;grep&lt;/strong&gt;: Powerful but terminal-destroying. &lt;code&gt;grep -R "ERROR" ./logs | grep "database"&lt;/code&gt; works, but it's not interactive. You can't drill down, and the output is a wall of text.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Docker logs&lt;/strong&gt;: &lt;code&gt;docker logs -f container&lt;/code&gt; is okay for one container, but when you have 15 microservices, it's a mess.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Existing log viewers&lt;/strong&gt;: Most are GUI apps (heavy), or Node.js CLI tools (slow, 70MB+ dependencies), or require configuration files.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I needed something that:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Is &lt;strong&gt;fast&lt;/strong&gt; — no lag when scrolling through 1GB log files&lt;/li&gt;
&lt;li&gt;Has &lt;strong&gt;syntax highlighting&lt;/strong&gt; — ERROR in red, DEBUG in gray, stack traces visible&lt;/li&gt;
&lt;li&gt;Lets me &lt;strong&gt;filter interactively&lt;/strong&gt; — type &lt;code&gt;/&lt;/code&gt; to search, drill down without re-running commands&lt;/li&gt;
&lt;li&gt;Is &lt;strong&gt;tiny&lt;/strong&gt; — no 70MB node_modules bloat&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The Solution: logchef-zig
&lt;/h2&gt;

&lt;p&gt;I wrote logchef-zig in Zig. It's a single 172KB binary that does exactly what I need:&lt;/p&gt;

&lt;h3&gt;
  
  
  What It Does
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# View any log file with auto-highlighting&lt;/span&gt;
logchef app.log

&lt;span class="c"&gt;# Follow mode (like tail -f, but with filtering)&lt;/span&gt;
logchef &lt;span class="nt"&gt;-f&lt;/span&gt; app.log

&lt;span class="c"&gt;# Filter by log level before opening&lt;/span&gt;
logchef &lt;span class="nt"&gt;--level&lt;/span&gt; error app.log

&lt;span class="c"&gt;# Interactive search (press / inside the viewer)&lt;/span&gt;
logchef app.log
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Why Zig?
&lt;/h3&gt;

&lt;p&gt;I chose Zig for three reasons:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Zero dependencies&lt;/strong&gt;: No node_modules, no Python runtime, nothing. Just one binary.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Performance&lt;/strong&gt;: Zig compiles to native machine code. I'm talking instant scrolling on 1GB+ files.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cross-compilation&lt;/strong&gt;: I can build for Linux, macOS, and Windows from my Mac. One CI run, three binaries.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  How It Works
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Fast Log Parsing
&lt;/h3&gt;

&lt;p&gt;Logchef reads logs line-by-line and detects common log formats automatically:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;2026-05-25T02:19:12.123Z [INFO] User logged in
2026-05-25T02:19:15.456Z [ERROR] Database connection failed
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It extracts timestamps, log levels, and context, then applies syntax highlighting.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Interactive TUI
&lt;/h3&gt;

&lt;p&gt;The interface is terminal-based (like htop or lazygit):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Arrow keys&lt;/strong&gt;: Scroll through the file&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;/&lt;/code&gt;&lt;/strong&gt;: Search for patterns (supports regex)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;l&lt;/code&gt;&lt;/strong&gt;: Filter by log level (DEBUG, INFO, WARN, ERROR)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;q&lt;/code&gt;&lt;/strong&gt;: Quit&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;No mouse required. Pure keyboard efficiency.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Zero Configuration
&lt;/h3&gt;

&lt;p&gt;Logchef doesn't need a config file. It detects log patterns heuristically. If your logs look like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[INFO] Something happened
ERROR: Something broke
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It just works. If they don't, you can pass a custom regex.&lt;/p&gt;

&lt;h2&gt;
  
  
  Benchmarks
&lt;/h2&gt;

&lt;p&gt;I tested logchef against a 1GB log file with 10 million lines:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;Startup Time&lt;/th&gt;
&lt;th&gt;Memory Usage&lt;/th&gt;
&lt;th&gt;Binary Size&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;logchef-zig&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.05s&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2.5MB&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;172KB&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Node.js equivalent&lt;/td&gt;
&lt;td&gt;1.2s&lt;/td&gt;
&lt;td&gt;85MB&lt;/td&gt;
&lt;td&gt;70MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;bat (syntax highlighter)&lt;/td&gt;
&lt;td&gt;0.8s&lt;/td&gt;
&lt;td&gt;12MB&lt;/td&gt;
&lt;td&gt;4.2MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;cat (no highlighting)&lt;/td&gt;
&lt;td&gt;0.01s&lt;/td&gt;
&lt;td&gt;0.5MB&lt;/td&gt;
&lt;td&gt;N/A&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Scrolling is instant. No lag. No beach balls.&lt;/p&gt;

&lt;h2&gt;
  
  
  Real-World Use Cases
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Debugging Production Issues
&lt;/h3&gt;

&lt;p&gt;You get paged at 2 AM. A service is failing.&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;# SSH into the server&lt;/span&gt;
ssh production-server

&lt;span class="c"&gt;# Start following the logs&lt;/span&gt;
logchef &lt;span class="nt"&gt;-f&lt;/span&gt; /var/log/app/app.log

&lt;span class="c"&gt;# Press `/` and type "ERROR" to filter only errors&lt;/span&gt;
&lt;span class="c"&gt;# Press `l` to switch to error-only view&lt;/span&gt;
&lt;span class="c"&gt;# Use arrow keys to scroll through the error context&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You find the root cause in 30 seconds instead of 10 minutes.&lt;/p&gt;

&lt;h3&gt;
  
  
  Local Development
&lt;/h3&gt;

&lt;p&gt;You're running a dev server and want to see errors only:&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;# Run your server in background&lt;/span&gt;
npm run dev &amp;amp;

&lt;span class="c"&gt;# Follow logs, error-only&lt;/span&gt;
logchef &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="nt"&gt;--level&lt;/span&gt; error logs/combined.log
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  CI/CD Debugging
&lt;/h3&gt;

&lt;p&gt;Your CI pipeline failed. You download the log artifact:&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;# CI artifact downloaded to build.log&lt;/span&gt;
logchef build.log

&lt;span class="c"&gt;# Search for the first error&lt;/span&gt;
/ ERROR

&lt;span class="c"&gt;# Jump to that line, scroll to see context&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Getting Started
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Installation
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;macOS / Linux (Homebrew):&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;brew &lt;span class="nb"&gt;install &lt;/span&gt;sulthonzh/tap/logchef
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Manual download:&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;# Download from GitHub Releases&lt;/span&gt;
wget https://github.com/sulthonzh/logchef-zig/releases/latest/download/logchef-macos-arm64
&lt;span class="nb"&gt;chmod&lt;/span&gt; +x logchef-macos-arm64
&lt;span class="nb"&gt;mv &lt;/span&gt;logchef-macos-arm64 /usr/local/bin/logchef
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;From source:&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;# Requires Zig 0.13+&lt;/span&gt;
git clone https://github.com/sulthonzh/logchef-zig.git
&lt;span class="nb"&gt;cd &lt;/span&gt;logchef-zig
zig build &lt;span class="nt"&gt;-Doptimize&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;ReleaseSafe
zig build &lt;span class="nb"&gt;install&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Basic Usage
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# View a log file&lt;/span&gt;
logchef app.log

&lt;span class="c"&gt;# Follow mode (live updates)&lt;/span&gt;
logchef &lt;span class="nt"&gt;-f&lt;/span&gt; app.log

&lt;span class="c"&gt;# Filter by log level&lt;/span&gt;
logchef &lt;span class="nt"&gt;--level&lt;/span&gt; error app.log
logchef &lt;span class="nt"&gt;--level&lt;/span&gt; debug app.log

&lt;span class="c"&gt;# Search inside the viewer (press /)&lt;/span&gt;
logchef app.log
&lt;span class="c"&gt;# Press /, type pattern, enter&lt;/span&gt;
&lt;span class="c"&gt;# Use n/N for next/previous match&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What's Next
&lt;/h2&gt;

&lt;p&gt;I'm planning:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Custom log format parsing (via CLI flags)&lt;/li&gt;
&lt;li&gt;Log aggregation (multiple files at once)&lt;/li&gt;
&lt;li&gt;Time-range filtering (&lt;code&gt;logchef --from "2h ago" --to "now" app.log&lt;/code&gt;)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;But even in its current form, logchef-zig has replaced grep and tail for me.&lt;/p&gt;

&lt;h2&gt;
  
  
  Links
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;GitHub: &lt;a href="https://github.com/sulthonzh/logchef-zig" rel="noopener noreferrer"&gt;https://github.com/sulthonzh/logchef-zig&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Install: &lt;code&gt;brew install sulthonzh/tap/logchef&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Built with Zig. No dependencies. Just logs.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>zig</category>
      <category>performance</category>
      <category>cli</category>
      <category>devtools</category>
    </item>
    <item>
      <title>I Turned npm outdated into a CI Gate — Here's How</title>
      <dc:creator>Sulthon Zainul Habib</dc:creator>
      <pubDate>Sun, 24 May 2026 17:20:15 +0000</pubDate>
      <link>https://dev.to/sulthonzh/i-turned-npm-outdated-into-a-ci-gate-heres-how-h0o</link>
      <guid>https://dev.to/sulthonzh/i-turned-npm-outdated-into-a-ci-gate-heres-how-h0o</guid>
      <description>&lt;p&gt;You run &lt;code&gt;npm outdated&lt;/code&gt; and see a list of stale packages. But your CI doesn't care. It passes anyway. Dependencies drift until something explodes in production. There's no built-in way to fail the build when versions drift too far.## The Problem&lt;code&gt;npm outdated&lt;/code&gt; lists outdated dependencies, but:- No exit codes — CI cannot gate builds on the result- No threshold configuration — you can't say "fail if &amp;gt;2 minors behind"- No distinction between prod and dev dependencies in many workflows- Manual updates become a fire drill instead of a controlled processA typical scenario: Your team wants to stay current with security patches, but you can't update everything. You need a rule: "No production dependency more than 2 minor versions behind latest." &lt;code&gt;npm outdated&lt;/code&gt; can't enforce that.## The SolutionI built &lt;code&gt;npm-outdated-check&lt;/code&gt; to turn &lt;code&gt;npm outdated&lt;/code&gt; into a first-class CI citizen with:- Semantic version thresholding (major/minor/patch drift limits)- Meaningful exit codes (0 = pass, 1 = violation, 2 = config error, 3 = network error)- Configurable via CLI flags or a &lt;code&gt;.npm-outdated-check.json&lt;/code&gt; config file- Production/dev dependency filtering- Multiple output formats (text, table, JSON)## How It WorksThe tool reads your &lt;code&gt;package.json&lt;/code&gt;, queries the npm registry for each dependency, calculates the semantic version difference, and flags anything that exceeds your thresholds.Key implementation details:1. &lt;strong&gt;Registry fetching&lt;/strong&gt;: Hit the npm registry endpoint for each package and extract the &lt;code&gt;dist-tags.latest&lt;/code&gt; version2. &lt;strong&gt;Semver diff&lt;/strong&gt;: Use &lt;code&gt;semver&lt;/code&gt; to parse &lt;code&gt;coerce(current)&lt;/code&gt; and &lt;code&gt;parse(latest)&lt;/code&gt;, then compute &lt;code&gt;major/minor/patch&lt;/code&gt; differences3. &lt;strong&gt;Violation logic&lt;/strong&gt;: A package violates if any diff exceeds its configured &lt;code&gt;maxMajor&lt;/code&gt;/&lt;code&gt;maxMinor&lt;/code&gt;/&lt;code&gt;maxPatch&lt;/code&gt;4. &lt;strong&gt;Exit codes&lt;/strong&gt;: CI reads the exit code and fails the build when violations existSample threshold calculation:&lt;br&gt;
&lt;br&gt;
&lt;code&gt;typescriptconst majorDiff = latest.major - current.major;const minorDiff = latest.minor - current.minor;const patchDiff = latest.patch - current.patch;const isViolation =  majorDiff &amp;gt; config.maxMajor ||  minorDiff &amp;gt; config.minorDiff ||  patchDiff &amp;gt; config.maxPatch;&lt;/code&gt;&lt;br&gt;
&lt;/p&gt;

&lt;h2&gt;
  
  
  Getting StartedInstall globally or as a dev dependency:
&lt;/h2&gt;

&lt;p&gt;&lt;br&gt;
&lt;code&gt;bashnpm install -D npm-outdated-check&lt;/code&gt;&lt;br&gt;
&lt;br&gt;
Run it in CI with sensible defaults (major=0, minor=2, patch=5):&lt;br&gt;
&lt;br&gt;
&lt;code&gt;bashnpx npm-outdated-check&lt;/code&gt;&lt;br&gt;
&lt;br&gt;
Add it to GitHub Actions:&lt;br&gt;
&lt;br&gt;
&lt;code&gt;yamlname: Dependency Checkon: [push, pull_request]jobs:  outdated-check:    runs-on: ubuntu-latest    steps:      - uses: actions/checkout@v4      - uses: actions/setup-node@v4        with:          node-version: '18'      - run: npm install      - run: npx npm-outdated-check --max-minor 3&lt;/code&gt;&lt;br&gt;
&lt;br&gt;
If a dependency is 4 minor versions behind, CI fails and you get notified.## Why This Matters- &lt;strong&gt;Controlled updates&lt;/strong&gt;: Set thresholds to avoid surprise breaking changes- &lt;strong&gt;Security posture&lt;/strong&gt;: Enforce staying within N patch versions of latest- &lt;strong&gt;Team consistency&lt;/strong&gt;: Config rules checked automatically in CI- &lt;strong&gt;Zero config&lt;/strong&gt;: Works out of the box with smart defaults## What's NextRoadmap items include:- Configurable notification channels (Slack, email)- Automated PR generation for outdated packages- Support for Yarn and pnpm lockfiles- Monorepo workspace awareness## Links- GitHub: &lt;a href="https://github.com/sulthonzh/npm-outdated-check-" rel="noopener noreferrer"&gt;https://github.com/sulthonzh/npm-outdated-check-&lt;/a&gt; Try it: &lt;code&gt;npm install -D npm-outdated-check&lt;/code&gt;&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>node</category>
      <category>ci</category>
      <category>devtools</category>
    </item>
  </channel>
</rss>
