<?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: Khadirullah Mohammad</title>
    <description>The latest articles on DEV Community by Khadirullah Mohammad (@khadirullah).</description>
    <link>https://dev.to/khadirullah</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3346735%2Faaef3a6a-bde0-41e3-acfc-5f6b5f04347b.png</url>
      <title>DEV Community: Khadirullah Mohammad</title>
      <link>https://dev.to/khadirullah</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/khadirullah"/>
    <language>en</language>
    <item>
      <title>Accidentally Pushed a Password? How to Scrub Secrets from Git History</title>
      <dc:creator>Khadirullah Mohammad</dc:creator>
      <pubDate>Tue, 01 Sep 2026 07:36:47 +0000</pubDate>
      <link>https://dev.to/khadirullah/accidentally-pushed-a-password-how-to-scrub-secrets-from-git-history-37jg</link>
      <guid>https://dev.to/khadirullah/accidentally-pushed-a-password-how-to-scrub-secrets-from-git-history-37jg</guid>
      <description>&lt;p&gt;It is the moment every developer dreads. You just pushed a massive feature to your public GitHub repository, and as you double-check your commits, your stomach drops. Staring right back at you is a hardcoded AWS Access Key.&lt;/p&gt;

&lt;p&gt;Your first instinct might be to quickly delete the line, run &lt;code&gt;git commit -m "removed api key"&lt;/code&gt;, and push again. &lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do not do this.&lt;/strong&gt; &lt;/p&gt;

&lt;p&gt;Git is a version control system. Its purpose is to preserve the complete history of your repository. If you just delete the key in a new commit, automated bots scraping GitHub will still find the exposed key sitting perfectly intact in your commit history. &lt;/p&gt;

&lt;p&gt;In &lt;a href="https://khadirullah.com/blog/git-filter-repo-clean-history/" rel="noopener noreferrer"&gt;my previous guide, I showed you how to completely erase deleted files from Git history&lt;/a&gt;. Today, we are going to use the same tool, &lt;code&gt;git filter-repo&lt;/code&gt;, to perform surgical &lt;strong&gt;Global Text Replacement&lt;/strong&gt;. &lt;/p&gt;

&lt;p&gt;We will rewrite reachable Git objects containing the leaked text and, when necessary, scrub commit messages (and annotated tag messages) using Git filter-repo's message replacement option.&lt;/p&gt;

&lt;h2&gt;
  
  
  ⚠️ First Response: Invalidate and Backup Immediately
&lt;/h2&gt;

&lt;p&gt;Before you do anything with Git, &lt;strong&gt;revoke the leaked credential&lt;/strong&gt;. &lt;br&gt;
If it is an AWS key, deactivate/delete the exposed key in AWS IAM immediately. &lt;em&gt;(Note: GitHub's Secret Scanning often detects exposed AWS keys automatically. This is helpful, but you still must manually revoke the key!)&lt;/em&gt; Automated scanners can discover exposed credentials quickly, so treat any leaked secret as compromised and rotate it first.&lt;/p&gt;

&lt;p&gt;Second, &lt;strong&gt;create a full backup of your repository&lt;/strong&gt;. &lt;code&gt;git filter-repo&lt;/code&gt; is a highly destructive command. If you make a typo in your replacements file, it will rewrite every commit in your repository. Copy your &lt;code&gt;.git&lt;/code&gt; folder to a safe location before proceeding.&lt;/p&gt;
&lt;h3&gt;
  
  
  When NOT to rewrite history
&lt;/h3&gt;

&lt;p&gt;Don't rewrite history if:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The repository is heavily shared among many developers, and the secret was already rotated.&lt;/li&gt;
&lt;li&gt;Regulatory requirements strictly require preserving the original history.&lt;/li&gt;
&lt;li&gt;The coordination effort to wipe history across all team members would be more disruptive than simply revoking the credential.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If these apply, just revoke the key and move on. Otherwise, proceed with the cleanup.&lt;/p&gt;
&lt;h3&gt;
  
  
  Is it too late? (The Wayback Machine Anxiety)
&lt;/h3&gt;

&lt;p&gt;If you realize you leaked a secret three days ago, your first thought might be panic, followed by defeat: &lt;em&gt;"Bots or internet archives probably already scraped it. What's the point of rewriting history now?"&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do not give up.&lt;/strong&gt; This is a core cybersecurity concept called &lt;strong&gt;Defense in Depth&lt;/strong&gt;. &lt;br&gt;
Just because a burglar &lt;em&gt;could&lt;/em&gt; have already smashed a window doesn't mean you should leave the front door wide open forever. Most automated secret scanners prioritize newly pushed commits and the current repository state, although historical commits may still be indexed or scanned.&lt;/p&gt;

&lt;p&gt;By scrubbing your history now, you reduce future exposure from the repository's visible history and prevent new clones from receiving the leaked value through normal history traversal.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why Interactive Rebase Is Usually the Wrong Tool
&lt;/h2&gt;

&lt;p&gt;Many developers try to fix this by using an interactive rebase (&lt;code&gt;git rebase -i&lt;/code&gt;). They rewind history back to the specific commit where they leaked the secret, manually delete it, and replay the history.&lt;/p&gt;

&lt;p&gt;While this works for a typo in your most recent commit, it is a terrible idea for a leaked secret because:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Human Error:&lt;/strong&gt; If you accidentally pasted that API key in multiple files across different commits, you will likely miss one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tedious:&lt;/strong&gt; Rebasing hundreds of commits is slow and prone to painful merge conflicts.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;We need a solution that is automated, thorough, and far less error-prone.&lt;/p&gt;
&lt;h2&gt;
  
  
  The Right Way: Global Text Replacement
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/newren/git-filter-repo" rel="noopener noreferrer"&gt;git-filter-repo&lt;/a&gt; is the Git project's recommended replacement for the older git filter-branch command. Instead of dealing with files directly, it manipulates the internal &lt;code&gt;.git&lt;/code&gt; database.&lt;/p&gt;
&lt;h3&gt;
  
  
  1. Create a Replacements File
&lt;/h3&gt;

&lt;p&gt;Instead of writing a complex terminal command, &lt;code&gt;filter-repo&lt;/code&gt; lets you use a simple text file to map out your redactions. &lt;/p&gt;

&lt;p&gt;Create a temporary file outside of your repository (e.g., &lt;code&gt;~/replacements.txt&lt;/code&gt;) and define your rules using the exact string, followed by &lt;code&gt;==&amp;gt;&lt;/code&gt;, and the replacement text.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# replacements.txt
AKIAIOSFODNN7EXAMPLE==&amp;gt;REDACTED_AWS_KEY
wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY==&amp;gt;REDACTED_AWS_SECRET
MySuperSecretPassword123!==&amp;gt;REDACTED
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;(Note: If you leave the right side of the arrow blank, the tool will just delete the string entirely. For more advanced cases, &lt;code&gt;git filter-repo&lt;/code&gt; also supports regular-expression replacements, although exact string matching is usually the safest option for secrets.)&lt;/em&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Verify and Execute the Scrub
&lt;/h3&gt;

&lt;p&gt;Before you run the command, locate commits that introduced or removed the secret using the &lt;code&gt;-S&lt;/code&gt; flag, which searches the exact text added or removed in past commits:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git log &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="nt"&gt;-S&lt;/span&gt; &lt;span class="s2"&gt;"AKIAIOSFODNN7EXAMPLE"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;(This returns commits where the number of occurrences of the specified string changed, along with their diffs.)&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Now, run the replacement command. Make sure you have no unsaved changes in your working directory!&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git filter-repo &lt;span class="nt"&gt;--replace-text&lt;/span&gt; ~/replacements.txt &lt;span class="nt"&gt;--force&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In seconds, &lt;code&gt;filter-repo&lt;/code&gt; will process reachable objects, rewrite blobs containing the matching text, and repack the repository. &lt;/p&gt;

&lt;p&gt;If you run your verification command again:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git log &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="nt"&gt;-S&lt;/span&gt; &lt;span class="s2"&gt;"AKIAIOSFODNN7EXAMPLE"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It should return no matching commits in the rewritten history. For additional confidence, also scan the repository contents and objects directly (e.g., &lt;code&gt;git grep "AKIAIOSFODNN7EXAMPLE" $(git rev-list --all)&lt;/code&gt;).&lt;/p&gt;

&lt;h2&gt;
  
  
  Bonus: Scrubbing Commit Messages
&lt;/h2&gt;

&lt;p&gt;What if you accidentally wrote the secret &lt;em&gt;inside&lt;/em&gt; a commit message itself? &lt;br&gt;
&lt;em&gt;(e.g., &lt;code&gt;git commit -m "Testing AWS connection with key AKIAIOSFODNN7EXAMPLE"&lt;/code&gt;)&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;To verify if your secret is hiding in a commit message, you can search all commit metadata directly using:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git log &lt;span class="nt"&gt;--all&lt;/span&gt; &lt;span class="nt"&gt;--grep&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"AKIAIOSFODNN7EXAMPLE"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;--replace-text&lt;/code&gt; command rewrites matching text found in Git blobs (file contents stored throughout history). It does not rewrite commit messages. To scrub your actual commit metadata, you can use the &lt;code&gt;--replace-message&lt;/code&gt; flag with the exact same text file! It scans commit messages (and annotated tag messages) and rewrites matching text.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git filter-repo &lt;span class="nt"&gt;--replace-message&lt;/span&gt; ~/replacements.txt &lt;span class="nt"&gt;--force&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;💡 Tip:&lt;/strong&gt;&lt;br&gt;
&lt;strong&gt;Pro-Tip:&lt;/strong&gt; If you need to scrub both file contents and commit messages, you can specify both options in a single invocation to rewrite history only once: &lt;code&gt;git filter-repo --replace-text ~/replacements.txt --replace-message ~/replacements.txt --force&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The Butterfly Effect (Hashes)
&lt;/h2&gt;

&lt;p&gt;When you run these commands, you will notice that your commit IDs (hashes) have all completely changed. Why?&lt;/p&gt;

&lt;p&gt;Git uses cryptographic hashing for integrity. A commit's hash is generated from its exact contents, metadata, and the hash of its parent.&lt;br&gt;
&lt;/p&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;graph TD
    classDef old fill:#fecaca,stroke:#dc2626,stroke-width:2px,color:#000
    classDef new fill:#bbf7d0,stroke:#16a34a,stroke-width:2px,color:#000
    classDef neutral fill:#e2e8f0,stroke:#64748b,stroke-width:2px,color:#000

    subgraph Before: Leaked Secret
        A[Commit 1 &amp;lt;br&amp;gt; Hash: a1b2c3d]:::neutral --&amp;gt; B[Commit 2 &amp;lt;br&amp;gt; Contains 'AKIA...' &amp;lt;br&amp;gt; Hash: e4f5g6h]:::old
        B --&amp;gt; C[Commit 3 &amp;lt;br&amp;gt; Hash: i7j8k9l]:::old
    end

    subgraph After: git filter-repo
        D[Commit 1 &amp;lt;br&amp;gt; Hash: a1b2c3d]:::neutral --&amp;gt; E[Commit 2 &amp;lt;br&amp;gt; Contains 'REDACTED' &amp;lt;br&amp;gt; NEW Hash: z9y8x7w]:::new
        E --&amp;gt; F[Commit 3 &amp;lt;br&amp;gt; NEW Hash: v6u5t4s]:::new
    end&lt;/code&gt;&lt;/pre&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;ℹ️ Info:&lt;/strong&gt;&lt;br&gt;
Because we changed a single word in &lt;strong&gt;Commit 2&lt;/strong&gt;, it became a brand-new commit with a different hash. Because Commit 2 changed, &lt;strong&gt;Commit 3&lt;/strong&gt; (its child) also had to get a new hash. This chain reaction rewrites every commit from the affected point forward to the present day.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Advanced Tips and Common Pitfalls
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. The Exact Match Trap
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;git filter-repo&lt;/code&gt; replaces exact strings. If your &lt;code&gt;replacements.txt&lt;/code&gt; says &lt;code&gt;MyPassword123&lt;/code&gt;, but you actually typed &lt;code&gt;MyPassword1234&lt;/code&gt; in one file, it will completely ignore it! Keep your replacement strings as short and specific as possible (matching is byte-for-byte and case-sensitive; target the exact secret, not the surrounding code).&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Deep Search: &lt;code&gt;-S&lt;/code&gt; vs &lt;code&gt;-G&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;When verifying that your secret is gone, we used &lt;code&gt;git log -S "secret"&lt;/code&gt;. This searches for commits where the &lt;em&gt;number of occurrences&lt;/em&gt; of the string changed. For an even deeper verification, you can use &lt;code&gt;-G&lt;/code&gt; (e.g., &lt;code&gt;git log -p -G "secret"&lt;/code&gt;). This runs a Regular Expression search across the actual diff lines, catching edge cases where &lt;code&gt;-S&lt;/code&gt; might fail.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. The "No Modified Files" Illusion
&lt;/h3&gt;

&lt;p&gt;After running the scrub, you might run &lt;code&gt;git status&lt;/code&gt; and be confused when it says &lt;code&gt;working tree clean&lt;/code&gt;. Why aren't your files marked as "Modified"? &lt;br&gt;
Because &lt;code&gt;filter-repo&lt;/code&gt; alters your history backward in time, your physical files are instantly synced to match the newly rewritten history. Since your hard drive perfectly matches the new commit, Git sees zero unsaved changes!&lt;/p&gt;
&lt;h3&gt;
  
  
  4. Git Submodules Are Separate
&lt;/h3&gt;

&lt;p&gt;If the leaked secret exists inside a submodule repository (like a Hugo theme), &lt;code&gt;git filter-repo&lt;/code&gt; running in the parent repository will not clean it. You will have to run the cleanup process inside that specific submodule's repository separately. &lt;/p&gt;
&lt;h3&gt;
  
  
  5. Where do the old commits go? (Garbage Collection)
&lt;/h3&gt;

&lt;p&gt;If you are worried that the old, dirty files containing your leaked secrets are still lingering on your hard drive, you do not need to worry about this immediately. Unreachable objects remain in your local repository until Git's garbage collection eventually prunes them after the appropriate expiration period (or sooner if you explicitly expire reflogs and run &lt;code&gt;git gc&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;&lt;em&gt;(Advanced tip: If you want to force immediate cleanup, you can manually run &lt;code&gt;git reflog expire --expire=now --all&lt;/code&gt; followed by &lt;code&gt;git gc --prune=now&lt;/code&gt;. Note that &lt;code&gt;--aggressive&lt;/code&gt; is optional and usually unnecessary for simply removing unreachable objects. **Warning:&lt;/em&gt;* After expiring reflogs and running garbage collection, the old objects are no longer recoverable from that local clone. Copies may still exist in other clones, forks, backups, or hosting-provider storage. Only do this after you're certain the rewritten history is correct.)*&lt;/p&gt;
&lt;h2&gt;
  
  
  The Final Step: Force Pushing
&lt;/h2&gt;

&lt;p&gt;Because your entire history has been rewritten, GitHub will not recognize your new commits. In fact, &lt;code&gt;git filter-repo&lt;/code&gt; may remove the &lt;code&gt;origin&lt;/code&gt; remote after rewriting history as a safety precaution, especially when operating on a freshly cloned repository. This prevents accidental force-pushing before you verify the rewritten history.&lt;/p&gt;
&lt;h3&gt;
  
  
  1. Reconnect the Remote
&lt;/h3&gt;

&lt;p&gt;First, reconnect your repository to GitHub:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git remote add origin https://github.com/yourusername/your-repo.git
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  2. Verification (Diff)
&lt;/h3&gt;

&lt;p&gt;Before you do something as destructive as a force push, you can verify that your project files contain only the intended differences by comparing your local history to what is currently live on GitHub. &lt;em&gt;(Remember that rewritten commit hashes do not necessarily mean your project files changed. You're verifying the contents of your working tree, not whether commit IDs match. Replace &lt;code&gt;main&lt;/code&gt; with your repository's default branch if necessary.)&lt;/em&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git fetch origin
git diff origin/main HEAD &lt;span class="nt"&gt;--name-status&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;💡 Tip:&lt;/strong&gt;&lt;br&gt;
&lt;strong&gt;Bonus Terminal Tip:&lt;/strong&gt; If your diff is massive and you want to save it to a file to review it carefully, you might try &lt;code&gt;git fetch origin &amp;amp;&amp;amp; git diff origin/main HEAD &amp;gt; diff.log&lt;/code&gt;. Don't panic if you still see the &lt;code&gt;git fetch&lt;/code&gt; progress print to your terminal! &lt;code&gt;git fetch&lt;/code&gt; outputs to &lt;code&gt;stderr&lt;/code&gt; (Standard Error), while &lt;code&gt;&amp;gt;&lt;/code&gt; only redirects &lt;code&gt;stdout&lt;/code&gt; (Standard Output). Your pure diff data is safely in the log file!&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  3. The Force Push (All Branches and Tags!)
&lt;/h3&gt;

&lt;p&gt;If you only force push your &lt;code&gt;main&lt;/code&gt; branch, the compromised commits might still be hiding on GitHub inside your &lt;code&gt;dev&lt;/code&gt; branch or your old release tags! &lt;/p&gt;

&lt;p&gt;To completely eradicate the leaked secret from every corner of the remote repository, you must overwrite &lt;strong&gt;all&lt;/strong&gt; branches and &lt;strong&gt;all&lt;/strong&gt; tags:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git push origin &lt;span class="nt"&gt;--force&lt;/span&gt; &lt;span class="nt"&gt;--all&lt;/span&gt;
git push origin &lt;span class="nt"&gt;--force&lt;/span&gt; &lt;span class="nt"&gt;--tags&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;(Review your local refs before pushing. If your repository contains unrelated tags or branches that should not be replaced, push only the affected refs instead.)&lt;/em&gt;&lt;br&gt;
&lt;em&gt;(Note: If you created signed tags, rewriting history changes the tagged commits, so existing cryptographic signatures on annotated tags are no longer valid.)&lt;/em&gt;&lt;br&gt;
&lt;em&gt;(Alternative: &lt;code&gt;git push origin --mirror&lt;/code&gt; mirrors every local reference to the remote. This is convenient for complete repository rewrites, but be aware that it also deletes remote refs that no longer exist locally.)&lt;/em&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Notify Collaborators (Crucial!)
&lt;/h3&gt;

&lt;p&gt;Anyone who previously cloned the repository should re-clone it or carefully reset their local branches to the rewritten history. They should also delete or recreate any local branches that still reference the old history after migrating to the rewritten repository. Merging old branches back into the cleaned repository can accidentally reintroduce the removed commits!&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Rotate Dependent Secrets
&lt;/h3&gt;

&lt;p&gt;If applications, CI pipelines, or deployments used the exposed credential, update those systems with the new credential after rotation. Cleaning Git history does not update already deployed environments!&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;💡 Tip:&lt;/strong&gt;&lt;br&gt;
&lt;strong&gt;Working on a team?&lt;/strong&gt; If you are worried about accidentally overwriting a colleague's recent push, you can use &lt;code&gt;--force-with-lease&lt;/code&gt; instead of &lt;code&gt;--force&lt;/code&gt;. This acts as a safety valve: Git will refuse to overwrite the remote branch if someone else has pushed new commits to it since you last fetched!&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;⚠️ Warning:&lt;/strong&gt;&lt;br&gt;
&lt;strong&gt;Expect a slow push!&lt;/strong&gt; A normal &lt;code&gt;git push&lt;/code&gt; only uploads a few kilobytes of recent changes. Because &lt;code&gt;git filter-repo&lt;/code&gt; just assigned brand-new hashes to every commit in your history, GitHub doesn't recognize any of them. Your computer will have to compress and re-upload your entire repository history from scratch. Large repositories with extensive histories may take several minutes to rewrite and upload.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;⚠️ Warning:&lt;/strong&gt;&lt;br&gt;
&lt;strong&gt;Check GitHub Alerts:&lt;/strong&gt; If GitHub Secret Scanning generated an alert for the leaked credential, rewriting Git history does not automatically close the alert. After rotating the credential and cleaning the repository, review the alert in GitHub to confirm the secret has been addressed.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  After the Push: GitHub's Server Cache
&lt;/h2&gt;

&lt;p&gt;Git hosting providers may retain cached views, references, or backups of rewritten objects. Open pull requests and issues that reference old commit hashes can also keep the data temporarily accessible.&lt;/p&gt;

&lt;p&gt;Remember that rewriting your repository does not remove the secret from existing forks or clones. If the credential was sensitive, treat it as compromised even after cleaning the history.&lt;/p&gt;

&lt;p&gt;For sensitive leaks, &lt;a href="https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/removing-sensitive-data-from-a-repository" rel="noopener noreferrer"&gt;contact GitHub Support&lt;/a&gt; to request a garbage collection on their servers and purge any cached views of the old commits.&lt;/p&gt;

&lt;p&gt;Some Git hosting providers retain hidden references (such as pull request refs or backup refs) that are not updated by normal pushes. If a secret is especially sensitive, contact your hosting provider to ensure any server-side references are cleaned up.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prevention: Stop It From Happening Again
&lt;/h2&gt;

&lt;p&gt;The best cleanup is the one you never have to do:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;.gitignore&lt;/code&gt;&lt;/strong&gt;: Block &lt;code&gt;.env&lt;/code&gt;, credentials files, and build artifacts before they are ever staged.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/gitleaks/gitleaks" rel="noopener noreferrer"&gt;gitleaks&lt;/a&gt;&lt;/strong&gt;: A pre-commit hook that scans your staged changes for hardcoded secrets, API keys, and tokens before they ever enter a commit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/Yelp/detect-secrets" rel="noopener noreferrer"&gt;detect-secrets&lt;/a&gt;&lt;/strong&gt;: An alternative secret scanner by Yelp that maintains a baseline of known secrets in your repo.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GitHub Push Protection&lt;/strong&gt;: If your organization uses GitHub Secret Scanning with Push Protection, GitHub may automatically block pushes containing supported secret types before they reach the remote.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For a deeper dive on &lt;code&gt;.gitignore&lt;/code&gt;, pre-commit hooks, and Git LFS for large files, see my companion guide: &lt;a href="https://khadirullah.com/blog/git-filter-repo-clean-history/#prevention-stop-it-from-happening-again" rel="noopener noreferrer"&gt;Removing Objects From Git History&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Your leaked secrets have now been removed from Git history, and your repository is safe once again. Just remember: &lt;strong&gt;Always revoke the credential first, clean up the history second.&lt;/strong&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Found this helpful? Follow me for more real-world Linux troubleshooting and DevOps content.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://khadirullah.com/blog/git-filter-repo-scrub-secrets/" rel="noopener noreferrer"&gt;khadirullah.com&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>git</category>
      <category>security</category>
      <category>devops</category>
      <category>tutorials</category>
    </item>
    <item>
      <title>The Ultimate Guide to Automating SVG Rasterization: Inkscape vs ImageMagick vs Headless Chrome</title>
      <dc:creator>Khadirullah Mohammad</dc:creator>
      <pubDate>Mon, 31 Aug 2026 11:48:03 +0000</pubDate>
      <link>https://dev.to/khadirullah/the-ultimate-guide-to-automating-svg-rasterization-inkscape-vs-imagemagick-vs-headless-chrome-3887</link>
      <guid>https://dev.to/khadirullah/the-ultimate-guide-to-automating-svg-rasterization-inkscape-vs-imagemagick-vs-headless-chrome-3887</guid>
      <description>&lt;p&gt;&lt;strong&gt;If you are automating SVG-to-PNG conversions on a Linux server and notice your colors look washed out or your drop shadows are missing, this post is for you.&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Engine&lt;/th&gt;
&lt;th&gt;Browser CSS&lt;/th&gt;
&lt;th&gt;SVG Filters&lt;/th&gt;
&lt;th&gt;Emoji&lt;/th&gt;
&lt;th&gt;Speed&lt;/th&gt;
&lt;th&gt;Best Use&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Headless Chrome&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;Medium&lt;/td&gt;
&lt;td&gt;Browser-accurate rendering&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Inkscape&lt;/td&gt;
&lt;td&gt;⚠️ SVG 1.1&lt;/td&gt;
&lt;td&gt;✅ (SVG 1.1)&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;Fast&lt;/td&gt;
&lt;td&gt;Standards-compliant SVG&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ImageMagick (&lt;code&gt;librsvg&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;⚠️ SVG 1.1&lt;/td&gt;
&lt;td&gt;✅ (SVG 1.1)&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;Very Fast&lt;/td&gt;
&lt;td&gt;Batch rasterization&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;When building out the DesignOps pipeline for this blog (as detailed in my guide on &lt;a href="https://khadirullah.com/blog/hugo-svg-webp-social-fallback/" rel="noopener noreferrer"&gt;Fixing Broken Hugo Open Graph Previews&lt;/a&gt;), my goal was simple: use a Python script to automatically read my vector &lt;code&gt;featured.svg&lt;/code&gt; images, inject my branding, and convert them to &lt;code&gt;social-fallback.webp&lt;/code&gt; thumbnails for LinkedIn and Twitter.&lt;/p&gt;

&lt;p&gt;The pipeline worked flawlessly, until I started using modern SVG features like &lt;code&gt;feGaussianBlur&lt;/code&gt;, &lt;code&gt;rgba&lt;/code&gt; alpha blending, and complex neon gradients. Suddenly, the automated images didn't look right. The colors were muddy, and some CSS filters disappeared entirely.&lt;/p&gt;

&lt;p&gt;This led me down a rabbit hole of testing three different command-line rendering engines. Here is what I found.&lt;/p&gt;

&lt;h2&gt;
  
  
  Phase 1: The Browser-Oriented SVG Test
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;(Want to follow along? Download the &lt;a href="https://khadirullah.com/blog/svg-rasterization-engine-showdown/media/old.svg" rel="noopener noreferrer"&gt;old.svg&lt;/a&gt; and &lt;a href="https://khadirullah.com/blog/svg-rasterization-engine-showdown/media/new.svg" rel="noopener noreferrer"&gt;new.svg&lt;/a&gt; files used in this test to compare the source code yourself.)&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;To test the engines, I started with a browser-oriented SVG that used modern CSS concepts (like &lt;code&gt;rgba()&lt;/code&gt;) and newer shorthand filters (like &lt;code&gt;feDropShadow&lt;/code&gt;). It featured dark transparent cards layered over a neon linear gradient, with a heavy &lt;code&gt;feGaussianBlur&lt;/code&gt; applied to a background circle.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Test environment:&lt;/strong&gt; Debian 13 (Trixie) · Inkscape 1.4 · ImageMagick 7.1.1-43 (librsvg 2.60) · Google Chrome 148. Cross-validated on Fedora 44 (Inkscape 1.4.4, ImageMagick 7.1.2-13, librsvg 2.62, Chrome 149) with identical results.&lt;/p&gt;

&lt;p&gt;Results may differ slightly with newer rendering engines, but the underlying compatibility differences described here remain relevant.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Here is how the three major Linux rasterization tools handled this browser-oriented SVG. All screenshots below were generated from the same source SVG without any manual edits.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. ImageMagick (librsvg)
&lt;/h3&gt;

&lt;p&gt;On many Linux distributions, ImageMagick relies on &lt;code&gt;librsvg&lt;/code&gt; to render SVG files. The exact renderer depends on how ImageMagick was built, but &lt;code&gt;librsvg&lt;/code&gt; is the most common delegate on modern systems.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8oq8d5pycm50az3dnohc.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8oq8d5pycm50az3dnohc.webp" alt="ImageMagick Render" width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Verdict:&lt;/strong&gt; &lt;code&gt;librsvg&lt;/code&gt; failed to render several of the browser-oriented filters, stripping away the glows and drop shadows.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Inkscape (Command Line)
&lt;/h3&gt;

&lt;p&gt;Inkscape is a phenomenal vector graphics editor, but let's see how its command-line renderer handled the CSS values.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5evixl8x2hyhg764dgln.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5evixl8x2hyhg764dgln.webp" alt="Inkscape Render" width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Verdict:&lt;/strong&gt; Inkscape ignored the &lt;code&gt;rgba()&lt;/code&gt; attribute values, causing those fills to disappear, and failed to process the &lt;code&gt;feDropShadow&lt;/code&gt; filter.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Headless Google Chrome
&lt;/h3&gt;

&lt;p&gt;SVGs are fundamentally a web technology. By using Chrome's built-in headless screenshot feature, we can force a literal web browser to render the SVG exactly as intended. Unlike standalone SVG renderers, Chrome uses the same rendering engine that powers modern web pages (Blink/Skia), so CSS, filters, fonts, gradients, and SVG features behave exactly as they do in the browser.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyfkhljsubb7ayuuvkdq5.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyfkhljsubb7ayuuvkdq5.webp" alt="Headless Chrome Render" width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Verdict:&lt;/strong&gt; The colors are pixel-perfect, and it handled the web CSS flawlessly.&lt;/p&gt;

&lt;h3&gt;
  
  
  Gotcha: Chrome Bottom Cutoff
&lt;/h3&gt;

&lt;p&gt;There is one critical trap with Headless Chrome that cost me hours of debugging. If you wrap your SVG in a simple HTML page like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;style&amp;gt;body&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;margin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="nl"&gt;overflow&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="nb"&gt;hidden&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;/style&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;body&amp;gt;&lt;/span&gt;&lt;span class="c"&gt;&amp;lt;!-- SVG here --&amp;gt;&lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;/body&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Chrome treats the SVG as a responsive element. It scales the SVG to fill the viewport width, then calculates the height from the aspect ratio. If the calculated height exceeds &lt;code&gt;--window-size&lt;/code&gt;, &lt;strong&gt;the bottom of the image gets silently clipped&lt;/strong&gt;. &lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fw8ee3g4nynor8el39ohi.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fw8ee3g4nynor8el39ohi.webp" alt="Headless Chrome Render, notice the bottom is cut off" width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;You end up with a screenshot that looks correct at first glance but is missing content at the bottom edge.&lt;/p&gt;

&lt;p&gt;The fix is a single CSS rule that locks the SVG to exact pixel dimensions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;style&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;html&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nt"&gt;body&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;margin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;padding&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;overflow&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="nb"&gt;hidden&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nt"&gt;svg&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;display&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="nb"&gt;block&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="m"&gt;1200px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="m"&gt;630px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/style&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This forces Chrome to render the SVG at exactly 1200×630 pixels, matching the &lt;code&gt;--window-size&lt;/code&gt; boundary perfectly. Here is the same SVG rendered again with the CSS fix applied:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7luh17irzru920jqkc6h.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7luh17irzru920jqkc6h.webp" alt="Chrome Render with CSS fix, full image, no cutoff" width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;No more clipping. The full image is captured edge-to-edge.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;🐛 Bug:&lt;/strong&gt;&lt;br&gt;
&lt;strong&gt;Chrome Version matters!&lt;/strong&gt; During testing, older Chrome versions (specifically &lt;code&gt;v147.x&lt;/code&gt;) had a bug where SVG filters (like &lt;code&gt;feDropShadow&lt;/code&gt;) confused the headless screenshot engine's bounding box calculations, causing the bottom of the image to clip &lt;em&gt;despite&lt;/em&gt; the CSS fix. Upgrading to Chrome &lt;code&gt;v148&lt;/code&gt; or newer (tested on &lt;code&gt;148.0.7778.215&lt;/code&gt; and &lt;code&gt;150.0.7871.181&lt;/code&gt;) resolves this layout bug entirely. If you still see clipping, check your browser version!&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Why Inkscape and ImageMagick Break (Code Analysis)
&lt;/h2&gt;

&lt;p&gt;After seeing Inkscape and ImageMagick fail so spectacularly on the first test, I traced the rendering failures back to the source code. The issue was not the engines; it was my browser-oriented authoring style. Here is the exact code that broke them, and the strict SVG 1.1 equivalent that fixed it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Root Cause 1: &lt;code&gt;rgba()&lt;/code&gt; CSS Colors
&lt;/h3&gt;

&lt;p&gt;This is the single most common cause of invisible elements in Inkscape/ImageMagick exports.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="c"&gt;&amp;lt;!-- ❌ This works in browsers but breaks in Inkscape/ImageMagick --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;rect&lt;/span&gt; &lt;span class="na"&gt;fill=&lt;/span&gt;&lt;span class="s"&gt;"rgba(255,255,255,0.03)"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;stop&lt;/span&gt; &lt;span class="na"&gt;stop-color=&lt;/span&gt;&lt;span class="s"&gt;"rgba(39, 39, 42, 0.8)"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;

&lt;span class="c"&gt;&amp;lt;!-- ✅ SVG-native equivalent that works everywhere --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;rect&lt;/span&gt; &lt;span class="na"&gt;fill=&lt;/span&gt;&lt;span class="s"&gt;"#ffffff"&lt;/span&gt; &lt;span class="na"&gt;fill-opacity=&lt;/span&gt;&lt;span class="s"&gt;"0.03"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;stop&lt;/span&gt; &lt;span class="na"&gt;stop-color=&lt;/span&gt;&lt;span class="s"&gt;"#27272a"&lt;/span&gt; &lt;span class="na"&gt;stop-opacity=&lt;/span&gt;&lt;span class="s"&gt;"0.8"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;rgba()&lt;/code&gt; is a CSS color function, &lt;strong&gt;not a valid SVG attribute value&lt;/strong&gt;. Browsers support it because they blend CSS and SVG rendering. Inkscape and &lt;code&gt;librsvg&lt;/code&gt; follow the SVG spec more strictly and either ignore these values or render them as fully transparent, making entire cards, rows, and subtle fills disappear.&lt;/p&gt;

&lt;h3&gt;
  
  
  Root Cause 2: &lt;code&gt;feDropShadow&lt;/code&gt;
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="c"&gt;&amp;lt;!-- ❌ Shorthand filter. Inkscape 1.4 warns: "unknown type: svg:feDropShadow" --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;filter&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"shadow"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;feDropShadow&lt;/span&gt; &lt;span class="na"&gt;dx=&lt;/span&gt;&lt;span class="s"&gt;"0"&lt;/span&gt; &lt;span class="na"&gt;dy=&lt;/span&gt;&lt;span class="s"&gt;"10"&lt;/span&gt; &lt;span class="na"&gt;stdDeviation=&lt;/span&gt;&lt;span class="s"&gt;"15"&lt;/span&gt; &lt;span class="na"&gt;flood-color=&lt;/span&gt;&lt;span class="s"&gt;"#000"&lt;/span&gt; &lt;span class="na"&gt;flood-opacity=&lt;/span&gt;&lt;span class="s"&gt;"0.6"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/filter&amp;gt;&lt;/span&gt;

&lt;span class="c"&gt;&amp;lt;!-- ✅ SVG 1.1 compatible equivalent --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;filter&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"shadow"&lt;/span&gt; &lt;span class="na"&gt;x=&lt;/span&gt;&lt;span class="s"&gt;"-10%"&lt;/span&gt; &lt;span class="na"&gt;y=&lt;/span&gt;&lt;span class="s"&gt;"-10%"&lt;/span&gt; &lt;span class="na"&gt;width=&lt;/span&gt;&lt;span class="s"&gt;"130%"&lt;/span&gt; &lt;span class="na"&gt;height=&lt;/span&gt;&lt;span class="s"&gt;"140%"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;feFlood&lt;/span&gt; &lt;span class="na"&gt;flood-color=&lt;/span&gt;&lt;span class="s"&gt;"#000000"&lt;/span&gt; &lt;span class="na"&gt;flood-opacity=&lt;/span&gt;&lt;span class="s"&gt;"0.6"&lt;/span&gt; &lt;span class="na"&gt;result=&lt;/span&gt;&lt;span class="s"&gt;"flood"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;feComposite&lt;/span&gt; &lt;span class="na"&gt;in=&lt;/span&gt;&lt;span class="s"&gt;"flood"&lt;/span&gt; &lt;span class="na"&gt;in2=&lt;/span&gt;&lt;span class="s"&gt;"SourceAlpha"&lt;/span&gt; &lt;span class="na"&gt;operator=&lt;/span&gt;&lt;span class="s"&gt;"in"&lt;/span&gt; &lt;span class="na"&gt;result=&lt;/span&gt;&lt;span class="s"&gt;"shadow-shape"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;feGaussianBlur&lt;/span&gt; &lt;span class="na"&gt;in=&lt;/span&gt;&lt;span class="s"&gt;"shadow-shape"&lt;/span&gt; &lt;span class="na"&gt;stdDeviation=&lt;/span&gt;&lt;span class="s"&gt;"15"&lt;/span&gt; &lt;span class="na"&gt;result=&lt;/span&gt;&lt;span class="s"&gt;"shadow-blur"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;feOffset&lt;/span&gt; &lt;span class="na"&gt;dx=&lt;/span&gt;&lt;span class="s"&gt;"0"&lt;/span&gt; &lt;span class="na"&gt;dy=&lt;/span&gt;&lt;span class="s"&gt;"10"&lt;/span&gt; &lt;span class="na"&gt;result=&lt;/span&gt;&lt;span class="s"&gt;"shadow-offset"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;feMerge&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;feMergeNode&lt;/span&gt; &lt;span class="na"&gt;in=&lt;/span&gt;&lt;span class="s"&gt;"shadow-offset"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;feMergeNode&lt;/span&gt; &lt;span class="na"&gt;in=&lt;/span&gt;&lt;span class="s"&gt;"SourceGraphic"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/feMerge&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/filter&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;feDropShadow&lt;/code&gt; is a newer shorthand filter supported by modern browsers but not consistently implemented by non-browser renderers. When Inkscape encounters an unknown filter type, &lt;strong&gt;it silently drops the entire filtered element&lt;/strong&gt;. If your shadow filter is applied to a card group, the whole card vanishes.&lt;/p&gt;

&lt;h3&gt;
  
  
  Root Cause 3: Emoji and &lt;code&gt;system-ui&lt;/code&gt; Fonts
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="c"&gt;&amp;lt;!-- Emoji renders as colored icons in Chrome, empty boxes in Inkscape/Magick --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;text&lt;/span&gt; &lt;span class="na"&gt;font-family=&lt;/span&gt;&lt;span class="s"&gt;"monospace"&lt;/span&gt; &lt;span class="na"&gt;font-size=&lt;/span&gt;&lt;span class="s"&gt;"20"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;🎨&lt;span class="nt"&gt;&amp;lt;/text&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;text&lt;/span&gt; &lt;span class="na"&gt;font-family=&lt;/span&gt;&lt;span class="s"&gt;"monospace"&lt;/span&gt; &lt;span class="na"&gt;font-size=&lt;/span&gt;&lt;span class="s"&gt;"20"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;✅&lt;span class="nt"&gt;&amp;lt;/text&amp;gt;&lt;/span&gt;

&lt;span class="c"&gt;&amp;lt;!-- system-ui is a browser-only alias. Inkscape does not recognize it --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;text&lt;/span&gt; &lt;span class="na"&gt;font-family=&lt;/span&gt;&lt;span class="s"&gt;"system-ui, -apple-system, sans-serif"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Title&lt;span class="nt"&gt;&amp;lt;/text&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Inkscape and ImageMagick have no access to color emoji fonts and don't understand browser-only font aliases like &lt;code&gt;system-ui&lt;/code&gt;. Text positioning may shift due to different font metrics.&lt;/p&gt;

&lt;h2&gt;
  
  
  Phase 2: The Strict SVG 1.1 Rematch
&lt;/h2&gt;

&lt;p&gt;Once I refactored the code to use strict SVG 1.1 attributes (no &lt;code&gt;rgba()&lt;/code&gt;, verbose filter chains, and vector paths instead of emoji fonts), I ran the exact same file through Inkscape and ImageMagick again.&lt;/p&gt;

&lt;p&gt;Here is the result.&lt;/p&gt;

&lt;h3&gt;
  
  
  ImageMagick (Strict SVG 1.1)
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpcemzuji0keajp2ahih8.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpcemzuji0keajp2ahih8.webp" alt="ImageMagick Fixed Render" width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Verdict:&lt;/strong&gt; Flawless. The filters and gradients render perfectly.&lt;/p&gt;

&lt;h3&gt;
  
  
  Inkscape (Strict SVG 1.1)
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8hjncdrb2vg1aj7bs6sx.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8hjncdrb2vg1aj7bs6sx.webp" alt="Inkscape Fixed Render" width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Verdict:&lt;/strong&gt; Absolutely perfect. It is visually indistinguishable from the Chrome render.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Ultimate Automation Script
&lt;/h2&gt;

&lt;p&gt;You don't need a heavy Node.js/Puppeteer installation to use Headless Chrome. If you have Google Chrome installed on your Linux server, you can execute it directly from a Python script using &lt;code&gt;subprocess&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Here is the exact Python snippet I use in my DesignOps pipeline to generate pixel-perfect WebP fallbacks from SVGs:&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="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;subprocess&lt;/span&gt;

&lt;span class="n"&gt;svg_path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;featured.svg&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;temp_png&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;temp-screenshot.png&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;final_webp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;social-fallback.webp&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="c1"&gt;# 1. Read the SVG content
&lt;/span&gt;&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;svg_path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;svg_content&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="c1"&gt;# 2. Wrap it in an HTML page with CRITICAL CSS sizing.
#    Without `svg { width:1200px; height:630px; }`, Chrome treats the SVG
#    as responsive and the bottom gets clipped by the viewport!
&lt;/span&gt;&lt;span class="n"&gt;temp_html&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;temp-social.html&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;html_wrapper&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;!DOCTYPE html&amp;gt;
&amp;lt;html&amp;gt;
&amp;lt;head&amp;gt;
&amp;lt;style&amp;gt;
  html, body {{ margin:0; padding:0; overflow:hidden; background:transparent; }}
  svg {{ display:block; width:1200px; height:630px; }}
&amp;lt;/style&amp;gt;
&amp;lt;/head&amp;gt;
&amp;lt;body&amp;gt;
&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;svg_content&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;
&amp;lt;/body&amp;gt;
&amp;lt;/html&amp;gt;&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;temp_html&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;w&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;html_wrapper&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# 3. Use Headless Chrome to take a pixel-perfect screenshot.
#    The absolute file:// URI is required for Chrome to load local files.
&lt;/span&gt;&lt;span class="n"&gt;abs_html_uri&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;file://&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;os&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="nf"&gt;abspath&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;temp_html&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="n"&gt;subprocess&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;google-chrome&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; 
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--headless&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; 
    &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--screenshot=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;temp_png&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; 
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--window-size=1200,630&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--default-background-color=00000000&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--disable-gpu&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--no-sandbox&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;abs_html_uri&lt;/span&gt;
&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;check&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# 4. Strip metadata and convert to lossy WebP (q=90 for optimal size)
&lt;/span&gt;&lt;span class="n"&gt;subprocess&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;exiftool&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-all=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-overwrite_original&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;temp_png&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="n"&gt;check&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;subprocess&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cwebp&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; 
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-q&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;90&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; 
    &lt;span class="n"&gt;temp_png&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; 
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-o&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;final_webp&lt;/span&gt;
&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;check&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# 5. Cleanup
&lt;/span&gt;&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;remove&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;temp_png&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;remove&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;temp_html&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Key detail:&lt;/strong&gt; The CSS rule &lt;code&gt;svg { display:block; width:1200px; height:630px; }&lt;/code&gt; is critical. Without it, Chrome treats the SVG as a responsive element that scales to the viewport width and calculates height from the aspect ratio, which can cause the bottom of your image to be cut off by the &lt;code&gt;--window-size&lt;/code&gt; boundary.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Performance note:&lt;/strong&gt; This script starts a new Chrome process for every image. For a handful of Open Graph images that is perfectly fine. For large batches, keeping Chrome alive through the &lt;a href="https://chromedevtools.github.io/devtools-protocol/" rel="noopener noreferrer"&gt;Chrome DevTools Protocol&lt;/a&gt; or using Puppeteer/Playwright can reduce startup overhead.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;So, which engine should you use for your DesignOps pipeline?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Option A: Headless Chrome&lt;/strong&gt;&lt;br&gt;
If you want to design SVGs like web pages (using CSS, &lt;code&gt;rgba()&lt;/code&gt;, and emojis), you &lt;strong&gt;must&lt;/strong&gt; use Headless Chrome. It gives you maximum flexibility and guarantees that what you see in the browser is what you get in the exported image.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Option B: Inkscape or ImageMagick&lt;/strong&gt;&lt;br&gt;
If you want the speed and low overhead of Inkscape or ImageMagick, you &lt;strong&gt;must&lt;/strong&gt; author strict, disciplined SVG 1.1 code. No CSS colors, no shorthand filters. &lt;/p&gt;

&lt;p&gt;The takeaway: Each renderer targets a different subset of the SVG ecosystem. Pick the engine that matches your authoring style.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Found this helpful? Follow me for more real-world Linux troubleshooting and DevOps content.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://khadirullah.com/blog/svg-rasterization-engine-showdown/" rel="noopener noreferrer"&gt;khadirullah.com&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>designops</category>
      <category>svg</category>
      <category>imagemagick</category>
      <category>inkscape</category>
    </item>
    <item>
      <title>Fixing Broken Hugo Open Graph Previews with WebP Social Fallbacks</title>
      <dc:creator>Khadirullah Mohammad</dc:creator>
      <pubDate>Sat, 29 Aug 2026 07:59:44 +0000</pubDate>
      <link>https://dev.to/khadirullah/fixing-broken-hugo-open-graph-previews-with-webp-social-fallbacks-4im6</link>
      <guid>https://dev.to/khadirullah/fixing-broken-hugo-open-graph-previews-with-webp-social-fallbacks-4im6</guid>
      <description>&lt;p&gt;Vector graphics (SVGs) are the holy grail of modern web design. They render with pixel-perfect clarity on any screen size, maintain incredibly small file sizes, and can be styled with CSS. However, if you rely on SVGs for your website's featured images, you'll quickly run into a frustrating problem: &lt;strong&gt;they completely break Open Graph (OG) social media previews&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;When you share a link on LinkedIn, Twitter, or WhatsApp, their scrapers look for a rasterized image to display in the preview card. Unfortunately, none of these platforms support SVG files for Open Graph images. The result? A broken or missing preview card that severely hurts your click-through rate.&lt;/p&gt;

&lt;p&gt;Here is the exact engineering solution I implemented to fix this on my Hugo website (using the Blowfish theme).&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Why WebP for Social Fallbacks?
&lt;/h2&gt;

&lt;p&gt;The obvious solution to the SVG problem is to provide a rasterized fallback image (like a PNG or JPEG). However, standard lossy image compression often introduces ugly color banding and blurriness around sharp text, ruining the premium feel of the vector original.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;WebP&lt;/strong&gt; is a modern image format that provides superior compression with two modes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Lossless WebP&lt;/strong&gt; (&lt;code&gt;cwebp -lossless&lt;/code&gt;): Preserves exact pixel clarity, zero quality loss. For this blog post, a metadata-stripped PNG went from &lt;strong&gt;135 KB&lt;/strong&gt; to &lt;strong&gt;87 KB&lt;/strong&gt; as a lossless WebP, a &lt;strong&gt;35% reduction&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lossy WebP&lt;/strong&gt; (&lt;code&gt;cwebp -q 90&lt;/code&gt;): Near-identical visual quality with dramatically smaller files. The same image compressed to just &lt;strong&gt;~30 KB&lt;/strong&gt;, a &lt;strong&gt;78% reduction&lt;/strong&gt; from PNG, while remaining visually indistinguishable from the original.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both lossless and lossy WebP are fully supported across all major social media scrapers (Twitter, LinkedIn, Facebook, WhatsApp). I recommend &lt;strong&gt;lossy WebP at quality 90&lt;/strong&gt; for social fallbacks simply because the file size savings are massive and there is no visible quality difference for OG preview cards.&lt;/p&gt;

&lt;h3&gt;
  
  
  Format Comparison for Open Graph Fallbacks
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Feature&lt;/th&gt;
&lt;th&gt;PNG&lt;/th&gt;
&lt;th&gt;Lossless WebP (&lt;code&gt;-lossless&lt;/code&gt;)&lt;/th&gt;
&lt;th&gt;Lossy WebP (&lt;code&gt;-q 90&lt;/code&gt;)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Social Platform Support&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;✅ Universal&lt;/td&gt;
&lt;td&gt;✅ Fully Supported&lt;/td&gt;
&lt;td&gt;✅ Fully Supported&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;File Size&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;~135 KB&lt;/td&gt;
&lt;td&gt;~87 KB (~35% smaller)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~30 KB (~78% smaller)&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Quality&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Lossless&lt;/td&gt;
&lt;td&gt;Lossless&lt;/td&gt;
&lt;td&gt;Crisp (Indistinguishable)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Best For&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Archival assets&lt;/td&gt;
&lt;td&gt;Internal site images&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Social media preview cards&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  When to Use Which
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Lossy WebP (&lt;code&gt;-q 90&lt;/code&gt;)&lt;/strong&gt; is the smart choice for Open Graph social fallbacks. Social platforms downscale, re-compress, and cache your image anyway. Pixel-perfect fidelity is wasted on a preview card that renders at thumbnail size in a feed. The ~78% file size savings over PNG means faster scraper fetches and snappier link previews.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lossless WebP (&lt;code&gt;-lossless&lt;/code&gt;)&lt;/strong&gt; is better for images displayed &lt;em&gt;inside&lt;/em&gt; your blog post (screenshots, diagrams, code snippets) where readers zoom in and sharp text matters. The extra kilobytes are worth it when the image is the content itself, not just a social thumbnail.&lt;/p&gt;

&lt;p&gt;To generate a crisp WebP social fallback, strip EXIF metadata using &lt;code&gt;exiftool&lt;/code&gt; and convert with &lt;code&gt;cwebp&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="c"&gt;# Step 1: Strip all EXIF metadata from the rasterized PNG&lt;/span&gt;
exiftool &lt;span class="nt"&gt;-all&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nt"&gt;-overwrite_original&lt;/span&gt; input.png

&lt;span class="c"&gt;# Step 2: Convert to WebP (lossy q=90 for optimal size)&lt;/span&gt;
cwebp &lt;span class="nt"&gt;-q&lt;/span&gt; 90 input.png &lt;span class="nt"&gt;-o&lt;/span&gt; social-fallback.webp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Important:&lt;/strong&gt; Most social media platforms require OG images to be at least &lt;strong&gt;1200×630 pixels&lt;/strong&gt;. Make sure your rasterized source image is rendered at this resolution before converting to WebP.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Note:&lt;/strong&gt; If you want to automate this conversion pipeline, check out my deep-dive &lt;a href="https://khadirullah.com/blog/svg-rasterization-engine-showdown/" rel="noopener noreferrer"&gt;Ultimate Guide to Automating SVG Rasterization&lt;/a&gt; to see a rendering compatibility comparison of the best command-line tools for the job.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  2. The Hugo vs. Blowfish Conflict
&lt;/h2&gt;

&lt;p&gt;Now that we have our crisp fallback image, we need to tell Hugo to use it for Open Graph tags, while still using &lt;code&gt;featured.svg&lt;/code&gt; for the actual website layout.&lt;/p&gt;

&lt;p&gt;This is where things get tricky, especially if you are using a feature-rich theme like Blowfish. &lt;/p&gt;

&lt;p&gt;Blowfish automatically searches for images in your page bundle using greedy wildcards (like &lt;code&gt;*feature*&lt;/code&gt; or &lt;code&gt;*cover*&lt;/code&gt;). If you name your fallback image &lt;code&gt;featured.webp&lt;/code&gt; alongside your &lt;code&gt;featured.svg&lt;/code&gt;, the theme's logic might accidentally prioritize the WebP image and render it on your webpage instead of the SVG!&lt;/p&gt;

&lt;h2&gt;
  
  
  3. The "Social Fallback" Solution
&lt;/h2&gt;

&lt;p&gt;To solve this conflict, we need a two-step approach that "blinds" the theme from accidentally using the fallback image on the frontend, while explicitly feeding it to the SEO scrapers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 1: Rename the fallback image.&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
Instead of naming it &lt;code&gt;featured.webp&lt;/code&gt;, name it something the theme's wildcard search won't catch, such as &lt;code&gt;social-fallback.webp&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 2: Explicitly define the image in Front Matter.&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
In your Hugo blog post's Markdown file, use the &lt;code&gt;images&lt;/code&gt; array in the YAML front matter to explicitly point the Open Graph templates to this specific file.&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;title&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Your&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Awesome&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Blog&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Post"&lt;/span&gt;
&lt;span class="na"&gt;date&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;2026-07-19&lt;/span&gt;
&lt;span class="na"&gt;images&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;social-fallback.webp"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  The Result
&lt;/h3&gt;

&lt;p&gt;With this setup:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Your website's frontend continues to perfectly render the crisp, lightweight &lt;code&gt;featured.svg&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Hugo's built-in SEO templates generate &lt;code&gt;&amp;lt;meta property="og:image" content=".../social-fallback.webp"&amp;gt;&lt;/code&gt; and &lt;code&gt;&amp;lt;meta name="twitter:image" content=".../social-fallback.webp"&amp;gt;&lt;/code&gt; in the &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt; of your HTML.&lt;/li&gt;
&lt;li&gt;When shared on LinkedIn, Twitter, Facebook, or WhatsApp, scrapers pull the lightweight 30 KB WebP image.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;You get the best of both worlds: vector rendering on your site and pristine, fast-loading social media preview cards.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Verification Across Platforms
&lt;/h2&gt;

&lt;p&gt;After deploying changes, verify that the &lt;code&gt;og:image&lt;/code&gt; and &lt;code&gt;twitter:image&lt;/code&gt; tags are being picked up correctly. Here is how each platform rendered the WebP preview card for this blog:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fkhadirullah.com%2Fblog%2Fhugo-svg-webp-social-fallback%2Fmedia%2Ftwitter-webp90-preview.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fkhadirullah.com%2Fblog%2Fhugo-svg-webp-social-fallback%2Fmedia%2Ftwitter-webp90-preview.webp" alt="Twitter/X showing the blog preview card with WebP fallback rendering correctly" width="581" height="518"&gt;&lt;/a&gt;&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fkhadirullah.com%2Fblog%2Fhugo-svg-webp-social-fallback%2Fmedia%2Flinkedin-webp90-preview.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fkhadirullah.com%2Fblog%2Fhugo-svg-webp-social-fallback%2Fmedia%2Flinkedin-webp90-preview.webp" alt="LinkedIn post composer showing the blog preview card with WebP fallback" width="729" height="444"&gt;&lt;/a&gt;&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fkhadirullah.com%2Fblog%2Fhugo-svg-webp-social-fallback%2Fmedia%2Ffacebook-webp90-preview.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fkhadirullah.com%2Fblog%2Fhugo-svg-webp-social-fallback%2Fmedia%2Ffacebook-webp90-preview.webp" alt="Facebook post composer showing the blog preview card with WebP fallback" width="489" height="566"&gt;&lt;/a&gt;&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fkhadirullah.com%2Fblog%2Fhugo-svg-webp-social-fallback%2Fmedia%2Fwhatsapp-webp90-preview.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fkhadirullah.com%2Fblog%2Fhugo-svg-webp-social-fallback%2Fmedia%2Fwhatsapp-webp90-preview.webp" alt="WhatsApp chat showing the blog preview with WebP fallback" width="439" height="370"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;All four platforms rendered the WebP fallback perfectly.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Tip:&lt;/strong&gt; If any platform shows a stale or missing image, try appending a dummy query parameter (e.g. &lt;code&gt;?v=2&lt;/code&gt;) to your URL to force a fresh crawl. Social platform scrapers cache aggressively, and stale cache is the most common reason for previews appearing broken.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Summary Checklist
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Use WebP&lt;/strong&gt; for Open Graph social fallbacks. Lossy &lt;code&gt;-q 90&lt;/code&gt; gives ~78% file size savings over PNG with no visible quality loss.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Name the file &lt;code&gt;social-fallback.webp&lt;/code&gt;&lt;/strong&gt; to avoid Blowfish theme resource matching collisions with &lt;code&gt;featured.svg&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add &lt;code&gt;images: ["social-fallback.webp"]&lt;/code&gt;&lt;/strong&gt; to your YAML front matter to feed Hugo SEO templates.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Always cache-bust&lt;/strong&gt; when testing. Append &lt;code&gt;?v=2&lt;/code&gt; to your URL to force scrapers to re-fetch.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Found this helpful? Follow me for more real-world Linux troubleshooting and DevOps content.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://khadirullah.com/blog/hugo-svg-webp-social-fallback/" rel="noopener noreferrer"&gt;khadirullah.com&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>hugo</category>
      <category>seo</category>
      <category>webdev</category>
      <category>svg</category>
    </item>
    <item>
      <title>Git Submodule Silently Became Regular Files: How I Diagnosed and Fixed It</title>
      <dc:creator>Khadirullah Mohammad</dc:creator>
      <pubDate>Fri, 28 Aug 2026 08:43:19 +0000</pubDate>
      <link>https://dev.to/khadirullah/git-submodule-silently-became-regular-files-how-i-diagnosed-and-fixed-it-157h</link>
      <guid>https://dev.to/khadirullah/git-submodule-silently-became-regular-files-how-i-diagnosed-and-fixed-it-157h</guid>
      <description>&lt;p&gt;I needed to update my Hugo theme (Blowfish) from v2.97.0 to the latest version. The theme was supposed to be a git submodule. My &lt;code&gt;.gitmodules&lt;/code&gt; file said so. So I ran the standard update command:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git submodule update &lt;span class="nt"&gt;--remote&lt;/span&gt; &lt;span class="nt"&gt;--merge&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nothing happened. No output, no errors, no update.&lt;/p&gt;

&lt;p&gt;I tried the manual approach:&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;themes/blowfish
git pull origin main
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It pulled from &lt;strong&gt;my site repository&lt;/strong&gt;, not the Blowfish theme repository. Something was fundamentally broken.&lt;/p&gt;

&lt;p&gt;This post covers how I diagnosed the problem, fixed the submodule, and then dealt with the Cloudflare Pages deployment failure that came immediately after.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note:&lt;/strong&gt;&lt;br&gt;
&lt;strong&gt;Backstory:&lt;/strong&gt; I originally set up the Blowfish theme using &lt;code&gt;git submodule add&lt;/code&gt;, and my &lt;code&gt;.gitmodules&lt;/code&gt; was correctly configured from the start. But at some point while troubleshooting config issues, I deleted the &lt;code&gt;themes/&lt;/code&gt; folder and copy-pasted the theme files back manually. I didn't realize that this single action silently destroyed the submodule. Git replaced the submodule's internal &lt;code&gt;160000 commit&lt;/code&gt; gitlink with a regular &lt;code&gt;040000 tree&lt;/code&gt; entry, turning it into ordinary tracked files. The &lt;code&gt;.gitmodules&lt;/code&gt; file stayed behind untouched, so everything &lt;em&gt;looked&lt;/em&gt; correct. It wasn't until I tried to update the theme months later that I discovered the mismatch.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Hugo themes support multiple installation methods: &lt;strong&gt;git submodule&lt;/strong&gt;, &lt;strong&gt;manual copy&lt;/strong&gt;, and &lt;strong&gt;Hugo modules&lt;/strong&gt;. Manually copying files into &lt;code&gt;themes/&lt;/code&gt; is a &lt;a href="https://blowfish.page/docs/installation/" rel="noopener noreferrer"&gt;perfectly valid installation method&lt;/a&gt;. The problem arises specifically when your repo has a &lt;code&gt;.gitmodules&lt;/code&gt; entry claiming a submodule exists, but Git's index tracks the files as regular committed files. That state conflict is what causes &lt;code&gt;git submodule update&lt;/code&gt; to silently do nothing.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note:&lt;/strong&gt;&lt;br&gt;
&lt;strong&gt;Related:&lt;/strong&gt; After fixing this submodule issue, I discovered that the old theme files were still bloating my git history. I wrote a separate guide on how I cleaned them up: &lt;a href="https://khadirullah.com/blog/git-filter-repo-clean-history/" rel="noopener noreferrer"&gt;Removing Objects From Git History with git filter-repo&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Quick Answer (TL;DR)
&lt;/h2&gt;

&lt;p&gt;If &lt;code&gt;git submodule update&lt;/code&gt; does nothing and &lt;code&gt;git submodule status&lt;/code&gt; returns empty output, your theme was committed as regular files, not as a proper submodule.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix it:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 1. Remove the fake submodule files&lt;/span&gt;
git &lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-rf&lt;/span&gt; themes/blowfish

&lt;span class="c"&gt;# 2. Clean cached submodule data&lt;/span&gt;
&lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-rf&lt;/span&gt; .git/modules/themes/blowfish

&lt;span class="c"&gt;# 3. Remove the stale .gitmodules entry&lt;/span&gt;
git config &lt;span class="nt"&gt;--file&lt;/span&gt; .gitmodules &lt;span class="nt"&gt;--remove-section&lt;/span&gt; submodule.themes/blowfish

&lt;span class="c"&gt;# 4. Commit the removal&lt;/span&gt;
git add .gitmodules
git commit &lt;span class="nt"&gt;-m&lt;/span&gt; &lt;span class="s2"&gt;"fix: remove improperly tracked theme files"&lt;/span&gt;

&lt;span class="c"&gt;# 5. Re-add as a proper submodule&lt;/span&gt;
git submodule add https://github.com/nunocoracao/blowfish.git themes/blowfish
git commit &lt;span class="nt"&gt;-m&lt;/span&gt; &lt;span class="s2"&gt;"feat: add blowfish theme as proper git submodule"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then update &lt;code&gt;HUGO_VERSION&lt;/code&gt; in your deployment platform to match the version the new theme requires.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Symptoms
&lt;/h2&gt;

&lt;p&gt;Three clues told me this wasn't a real submodule:&lt;/p&gt;

&lt;h3&gt;
  
  
  1. No &lt;code&gt;.git&lt;/code&gt; file inside the theme directory
&lt;/h3&gt;

&lt;p&gt;A properly initialized submodule has a &lt;code&gt;.git&lt;/code&gt; &lt;strong&gt;file&lt;/strong&gt; (not a directory) that points to the parent repo's &lt;code&gt;.git/modules/&lt;/code&gt; folder:&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; themes/blowfish/.git
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# Expected (proper submodule):
-rw-r--r-- 1 user user 43 Jul 15 00:55 themes/blowfish/.git

# What I got:
ls: cannot access 'themes/blowfish/.git': No such file or directory
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  2. &lt;code&gt;git submodule status&lt;/code&gt; returned nothing
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git submodule status
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# Expected:
 1f14448333d43207780f1a97317562a3b9ddbbd0 themes/blowfish (v2.104.0)

# What I got:
(empty, no output at all)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  3. Git commands inside the theme directory operated on the parent repo
&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;cd &lt;/span&gt;themes/blowfish
git remote &lt;span class="nt"&gt;-v&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# Expected:
origin  https://github.com/nunocoracao/blowfish.git (fetch)

# What I got:
origin  https://github.com/khadirullah/khadirullah.com (fetch)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;themes/blowfish/&lt;/code&gt; directory had no git identity of its own. Git treated it as part of the parent repository.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Root Cause
&lt;/h2&gt;

&lt;p&gt;Hugo themes can be installed in three ways: as a &lt;strong&gt;git submodule&lt;/strong&gt;, as &lt;strong&gt;manually copied files&lt;/strong&gt;, or as a &lt;strong&gt;Hugo module&lt;/strong&gt;. All three are valid. The Blowfish theme &lt;a href="https://blowfish.page/docs/installation/" rel="noopener noreferrer"&gt;documents all three methods&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;In my case, I originally installed Blowfish as a proper submodule using &lt;code&gt;git submodule add&lt;/code&gt;. But later, while troubleshooting config issues, I deleted the &lt;code&gt;themes/&lt;/code&gt; folder and copy-pasted the theme files back manually. What I didn't realize is that this single action silently destroyed the submodule.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why deleting and re-adding files breaks a submodule
&lt;/h3&gt;

&lt;p&gt;Internally, Git tracks a proper submodule as a special entry called a &lt;strong&gt;gitlink&lt;/strong&gt;, a &lt;code&gt;160000 commit&lt;/code&gt; object that points to a specific commit in the external repository:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# What a proper submodule looks like in Git's index:
160000 commit abc123...    themes/blowfish
^^^^^^
# This is a gitlink. Git knows this is an external repo
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When you delete a submodule directory and copy-paste the files back manually, Git replaces the gitlink with a &lt;strong&gt;regular tree&lt;/strong&gt;, a &lt;code&gt;040000 tree&lt;/code&gt; object that treats the files as part of your main repository:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# What happened after I deleted and copy-pasted:
040000 tree 6aaaa7...    themes/blowfish
^^^^^^
# This is a regular directory. Git treats these as your own files
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These mode numbers are part of Git's internal file type system (derived from Unix file permissions). Here's a quick reference:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Mode&lt;/th&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;What it means&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;100644&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;blob&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Regular file (not executable)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;100755&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;blob&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Executable file&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;040000&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;tree&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Regular directory, files are stored in your repo&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;160000&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;commit&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Gitlink (submodule)&lt;/strong&gt;, only a commit hash is stored, files live in the external repo&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;120000&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;blob&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Symbolic link&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;You can check what mode Git is using for any path with &lt;code&gt;git ls-tree HEAD &amp;lt;path&amp;gt;&lt;/code&gt;. If your submodule shows &lt;code&gt;040000 tree&lt;/code&gt; instead of &lt;code&gt;160000 commit&lt;/code&gt;, it's broken.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;.gitmodules&lt;/code&gt; file isn't affected by this. It stays behind, still claiming the submodule exists. But Git's index now disagrees, creating a silent conflict.&lt;/p&gt;

&lt;p&gt;The result was a contradictory state:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;.gitmodules&lt;/code&gt; said &lt;em&gt;"there's a submodule at &lt;code&gt;themes/blowfish&lt;/code&gt;"&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;Git's index said &lt;em&gt;"these are regular tracked files"&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;No submodule metadata existed in &lt;code&gt;.git/modules/&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This kind of mismatch can happen to anyone. All it takes is deleting and re-adding theme files without using &lt;code&gt;git submodule add&lt;/code&gt;. The &lt;code&gt;.gitmodules&lt;/code&gt; file alone doesn't make something a submodule. Git needs the full submodule registration.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note:&lt;/strong&gt;&lt;br&gt;
&lt;strong&gt;⚠️ Actions that silently destroy a submodule:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Deleting the submodule directory and copy-pasting files back&lt;/li&gt;
&lt;li&gt;Running &lt;code&gt;rm -rf themes/your-theme&lt;/code&gt; followed by manually adding files&lt;/li&gt;
&lt;li&gt;Using your file manager to drag-and-drop theme files into the directory&lt;/li&gt;
&lt;li&gt;Any operation that replaces the directory without going through &lt;code&gt;git submodule add&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;The safe way to reinstall a submodule&lt;/strong&gt; is to always use &lt;code&gt;git submodule deinit&lt;/code&gt; + &lt;code&gt;git rm&lt;/code&gt; to remove it, and &lt;code&gt;git submodule add&lt;/code&gt; to re-add it.&lt;br&gt;
&lt;/p&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;flowchart TD
    A["git submodule add URL themes/blowfish"] --&amp;gt; B["✅ Proper submodule\n(160000 commit gitlink)"]
    C["Copy files into themes/blowfish/\n(no .gitmodules entry)"] --&amp;gt; D["✅ Valid manual install\n(040000 tree)"]
    E["Delete submodule dir + copy files back\n(stale .gitmodules remains)"] --&amp;gt; F["❌ Broken state\n(.gitmodules ≠ Git index)"]

    B --&amp;gt; G["git submodule update works"]
    D --&amp;gt; H["Update manually by\nre-downloading the theme"]
    F --&amp;gt; I["git submodule update\nsilently does nothing"]

    style A fill:#1e3a5f,stroke:#60a5fa,color:#e0f2fe
    style B fill:#14532d,stroke:#4ade80,color:#dcfce7
    style C fill:#1e3a5f,stroke:#60a5fa,color:#e0f2fe
    style D fill:#14532d,stroke:#4ade80,color:#dcfce7
    style E fill:#7c2d12,stroke:#f97316,color:#fed7aa
    style F fill:#7f1d1d,stroke:#ef4444,color:#fecaca
    style G fill:#14532d,stroke:#4ade80,color:#dcfce7
    style H fill:#14532d,stroke:#4ade80,color:#dcfce7
    style I fill:#7f1d1d,stroke:#ef4444,color:#fecaca&lt;/code&gt;&lt;/pre&gt;



&lt;h2&gt;
  
  
  The Fix
&lt;/h2&gt;

&lt;p&gt;The solution is to remove the tracked files, clean up submodule metadata, and re-add the theme as a proper submodule.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1: Remove the theme files from git tracking
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git &lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-rf&lt;/span&gt; themes/blowfish
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This removes all the theme files from git's index and from disk. The &lt;code&gt;-rf&lt;/code&gt; flag handles the recursive directory removal.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: Clean cached submodule data
&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;rm&lt;/span&gt; &lt;span class="nt"&gt;-rf&lt;/span&gt; .git/modules/themes/blowfish
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This removes any partial submodule metadata that might exist from a previous failed initialization attempt. If the directory doesn't exist, the command silently succeeds.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: Remove the old &lt;code&gt;.gitmodules&lt;/code&gt; entry
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git config &lt;span class="nt"&gt;--file&lt;/span&gt; .gitmodules &lt;span class="nt"&gt;--remove-section&lt;/span&gt; submodule.themes/blowfish
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This cleans up the stale submodule configuration. If you had other submodules, their entries remain untouched.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 4: Stage and commit the removal
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git add .gitmodules
git commit &lt;span class="nt"&gt;-m&lt;/span&gt; &lt;span class="s2"&gt;"fix: remove improperly tracked theme files"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At this point, your repository has no theme, which is correct. The next step brings it back properly.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 5: Re-add as a proper submodule
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git submodule add https://github.com/nunocoracao/blowfish.git themes/blowfish
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This does three things:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Clones the latest version of the Blowfish repository into &lt;code&gt;themes/blowfish/&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Creates a &lt;code&gt;.git&lt;/code&gt; file inside &lt;code&gt;themes/blowfish/&lt;/code&gt; pointing to &lt;code&gt;.git/modules/themes/blowfish/&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Updates &lt;code&gt;.gitmodules&lt;/code&gt; with the correct submodule entry&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Step 6: Commit the proper submodule
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git commit &lt;span class="nt"&gt;-m&lt;/span&gt; &lt;span class="s2"&gt;"feat: add blowfish theme as proper git submodule"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 7: Verify
&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;# Check the submodule is recognized&lt;/span&gt;
git submodule status
&lt;span class="c"&gt;# Output: 1f14448333d43207780f1a97317562a3b9ddbbd0 themes/blowfish (v2.104.0)&lt;/span&gt;

&lt;span class="c"&gt;# Check the .git file exists&lt;/span&gt;
&lt;span class="nb"&gt;ls&lt;/span&gt; &lt;span class="nt"&gt;-la&lt;/span&gt; themes/blowfish/.git
&lt;span class="c"&gt;# Output: -rw-r--r-- 1 user user 43 Jul 15 00:55 themes/blowfish/.git&lt;/span&gt;

&lt;span class="c"&gt;# Check the theme version&lt;/span&gt;
&lt;span class="nb"&gt;cat &lt;/span&gt;themes/blowfish/package.json | &lt;span class="nb"&gt;grep &lt;/span&gt;version
&lt;span class="c"&gt;# Output: "version": "2.104.0",&lt;/span&gt;

&lt;span class="c"&gt;# Build the site&lt;/span&gt;
hugo serve
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Future Updates Now Just Work
&lt;/h2&gt;

&lt;p&gt;With the submodule properly set up, updating the theme is a single command:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git submodule update &lt;span class="nt"&gt;--remote&lt;/span&gt; &lt;span class="nt"&gt;--merge&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To pin the submodule to track the &lt;code&gt;main&lt;/code&gt; branch (recommended for themes that follow semantic versioning):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git config &lt;span class="nt"&gt;-f&lt;/span&gt; .gitmodules submodule.themes/blowfish.branch main
git add .gitmodules
git commit &lt;span class="nt"&gt;-m&lt;/span&gt; &lt;span class="s2"&gt;"fix: pin blowfish submodule to main branch"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After updating, always test your site before pushing:&lt;br&gt;
&lt;/p&gt;

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

&lt;/div&gt;



&lt;p&gt;Theme updates can introduce breaking changes: new shortcodes, renamed partials, or changed CSS classes. Review the theme's changelog before updating to a major version.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quick Diagnostic Checklist
&lt;/h2&gt;

&lt;p&gt;If your Hugo theme submodule isn't behaving correctly, run through these checks:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Check&lt;/th&gt;
&lt;th&gt;Command&lt;/th&gt;
&lt;th&gt;Expected (working)&lt;/th&gt;
&lt;th&gt;Broken&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;.git&lt;/code&gt; file exists&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ls themes/your-theme/.git&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;File exists (43 bytes)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;No such file&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Submodule status&lt;/td&gt;
&lt;td&gt;&lt;code&gt;git submodule status&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Shows commit hash + path&lt;/td&gt;
&lt;td&gt;Empty output&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Remote URL&lt;/td&gt;
&lt;td&gt;&lt;code&gt;cd themes/your-theme &amp;amp;&amp;amp; git remote -v&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Theme repo URL&lt;/td&gt;
&lt;td&gt;Your site repo URL&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Theme version&lt;/td&gt;
&lt;td&gt;`cat themes/your-theme/package.json \&lt;/td&gt;
&lt;td&gt;grep version`&lt;/td&gt;
&lt;td&gt;Current version&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If any of these fail, your theme is not a real submodule. Follow the fix above.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why This Matters
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note:&lt;/strong&gt; If you intentionally installed your Hugo theme by copying files (without submodules), this section doesn't apply to you. Manual installs work fine. You just update by re-downloading the theme. This guide is specifically for repos where &lt;code&gt;.gitmodules&lt;/code&gt; claims a submodule exists but Git doesn't actually track one.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A broken submodule is invisible until you need it. Your site builds fine, your deployments work, everything looks normal. The problem only surfaces when you try to update, and by then, you've been shipping an outdated theme for months.&lt;/p&gt;

&lt;p&gt;For Hugo sites specifically:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Security patches&lt;/strong&gt; in the theme won't reach you&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;New shortcodes&lt;/strong&gt; (like &lt;code&gt;accordion&lt;/code&gt; in Blowfish v2.100+) won't be available&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hugo version compatibility&lt;/strong&gt; fixes won't apply, and your build warnings pile up&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CI/CD pipelines&lt;/strong&gt; that run &lt;code&gt;git submodule update --init --recursive&lt;/code&gt; will either fail silently or clone an empty directory&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The "Gotcha": Cloudflare Pages Deployment Failure After Fixing
&lt;/h2&gt;

&lt;p&gt;I fixed the submodule locally, everything worked in &lt;code&gt;hugo serve&lt;/code&gt;, so I committed and pushed the fix.&lt;/p&gt;

&lt;p&gt;And then my Cloudflare Pages deployment immediately crashed.&lt;/p&gt;

&lt;h3&gt;
  
  
  What happened
&lt;/h3&gt;

&lt;p&gt;Locally, everything built perfectly because I was running a modern version of Hugo:&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;hugo version
hugo v0.164.0-ce2470e7012b5ab5fc4e10ebe4027e9f8d9e00dc+extended linux/amd64 &lt;span class="nv"&gt;BuildDate&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;2026-07-06T16:39:30Z &lt;span class="nv"&gt;VendorInfo&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;gohugoio
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But on Cloudflare Pages, the build failed with this error:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;2026-07-15T10:22:25.178761Z Detected the following tools from environment: hugo@extended_0.152.2
2026-07-15T10:22:27.074274Z WARN  Module "blowfish" is not compatible with this Hugo version
2026-07-15T10:22:27.211531Z Error: error building site: render: failed to render pages:
  execute of template failed: template: index.html:3:47:
  executing "index.html" at &amp;lt;site&amp;gt;: can't evaluate field Locale in type *langs.Language
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When I updated the Blowfish theme submodule to the latest version, it pulled in code that required Hugo &lt;code&gt;0.158.0&lt;/code&gt; or higher. However, in my Cloudflare Pages &lt;strong&gt;Settings &amp;gt; Environment variables&lt;/strong&gt;, I had hardcoded &lt;code&gt;HUGO_VERSION&lt;/code&gt; to &lt;code&gt;0.152.2&lt;/code&gt; months ago.&lt;/p&gt;

&lt;p&gt;Because Cloudflare was forced to use an older Hugo version, it couldn't understand the new &lt;code&gt;Locale&lt;/code&gt; fields in the updated theme templates, causing the entire build to fail.&lt;/p&gt;

&lt;h3&gt;
  
  
  Diagnosing the failure
&lt;/h3&gt;

&lt;p&gt;The first clue was in the Cloudflare Pages deployment dashboard. The build settings showed &lt;code&gt;HUGO_VERSION&lt;/code&gt; was stuck at the old value:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsuwxsv3m0se2de35t2n9.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsuwxsv3m0se2de35t2n9.webp" alt="Cloudflare Pages build settings showing the failed deployment with HUGO_VERSION set to 0.152.2 in the environment variables" width="800" height="376"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;GitHub's integration also reported the failure immediately:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fq1z6asd8ag18fe98qd7f.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fq1z6asd8ag18fe98qd7f.webp" alt="GitHub showing the Cloudflare Workers and Pages integration reporting " width="800" height="161"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Step-by-step fix on Cloudflare Pages
&lt;/h3&gt;

&lt;p&gt;Here is exactly how to fix the Hugo version mismatch on Cloudflare Pages:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Go to Settings &amp;gt; Variables and Secrets&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;In the Cloudflare Pages dashboard, navigate to your project's &lt;strong&gt;Settings&lt;/strong&gt; tab, then find &lt;strong&gt;Variables and Secrets&lt;/strong&gt; in the sidebar. You'll see the old &lt;code&gt;HUGO_VERSION&lt;/code&gt; value:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkghrr0l851sv2m67yo78.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkghrr0l851sv2m67yo78.webp" alt="The Variables and Secrets panel showing HUGO_VERSION still set to 0.152.2" width="800" height="196"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Edit the &lt;code&gt;HUGO_VERSION&lt;/code&gt; variable&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Click &lt;strong&gt;Edit&lt;/strong&gt;, then update the value to match your local Hugo version. In my case, I changed it from &lt;code&gt;0.152.2&lt;/code&gt; to &lt;code&gt;0.164.0&lt;/code&gt;:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fuux6wq7ipjtcsc4fl72d.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fuux6wq7ipjtcsc4fl72d.webp" alt="Editing the HUGO_VERSION variable, changing the value from 0.152.2 to 0.164.0 and saving" width="398" height="944"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Verify the saved value&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;After saving, confirm the variable shows the updated version:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnhjbk87o6j4czxz4qusd.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnhjbk87o6j4czxz4qusd.webp" alt="Variables and Secrets panel now showing HUGO_VERSION updated to 0.164.0" width="800" height="181"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note:&lt;/strong&gt;&lt;br&gt;
&lt;strong&gt;Important:&lt;/strong&gt; If you look at the build settings right now on a previous deployment, they may &lt;em&gt;still&lt;/em&gt; show the old version. The new value only takes effect on the &lt;strong&gt;next&lt;/strong&gt; deployment.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;4. Navigate to the failed deployment&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Go to the &lt;strong&gt;Deployments&lt;/strong&gt; page. You'll see the failed deployment listed:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgjjdjny4n8677my2uoq8.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgjjdjny4n8677my2uoq8.webp" alt="All deployments list showing the failed production deployment from 24 minutes ago" width="800" height="103"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Retry the deployment&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Click &lt;strong&gt;View Details&lt;/strong&gt; on the failed deployment, then click &lt;strong&gt;Manage deployment&lt;/strong&gt; and select &lt;strong&gt;Retry deployment&lt;/strong&gt;:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftj96bix7nt20j1lafe2l.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftj96bix7nt20j1lafe2l.webp" alt="Deployment details page with the " width="800" height="242"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;6. Watch it build&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The retried deployment will pick up the new Hugo version. You'll see the status change to "active":&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9wctrgxzwqyy2gukfjen.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9wctrgxzwqyy2gukfjen.webp" alt="The retried deployment showing status " width="800" height="448"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;7. Confirm success&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;After the build completes, the deployment status should show "success":&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5zc83ba12haqbol4znmg.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5zc83ba12haqbol4znmg.webp" alt="Deployment details showing status " width="799" height="460"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The build log confirms everything worked. All four stages (initialize, clone repo, build, deploy) passed:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5n4qxbs7hy5pce99e2hp.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5n4qxbs7hy5pce99e2hp.webp" alt="Build log showing all four stages with green checkmarks confirming the submodule was cloned correctly and Hugo 0.164.0 was installed" width="800" height="514"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Notice line 6 in the build log: &lt;em&gt;"Submodule 'themes/blowfish' (&lt;a href="https://github.com/nunocoracao/blowfish.git" rel="noopener noreferrer"&gt;https://github.com/nunocoracao/blowfish.git&lt;/a&gt;) registered for path 'themes/blowfish'"&lt;/em&gt;. This confirms Cloudflare is now properly cloning the submodule during the build process. Before the fix, the theme files were just regular files in the repo, so no submodule cloning happened.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;8. Verify on GitHub&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;GitHub's integration also updates to show the successful deployment:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0arhnp9gwxj8gknls8jc.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0arhnp9gwxj8gknls8jc.webp" alt="GitHub now showing " width="800" height="161"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;9. Final state&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The deployments page now shows both the failed (old) and successful (retried) deployments:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxafu3y910st8kq6tdeac.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxafu3y910st8kq6tdeac.webp" alt="All deployments list showing the successful production deployment (3 minutes ago) above the old failed deployment (30 minutes ago)" width="800" height="157"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Other deployment platforms
&lt;/h3&gt;

&lt;p&gt;If you're not using Cloudflare Pages, update the Hugo version in your platform's configuration:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Platform&lt;/th&gt;
&lt;th&gt;Where to update&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Cloudflare Pages&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Settings → Variables and Secrets → &lt;code&gt;HUGO_VERSION&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;GitHub Actions&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;.github/workflows/*.yml&lt;/code&gt; → &lt;code&gt;hugo-version&lt;/code&gt; in the hugo-setup action&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Netlify&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;netlify.toml&lt;/code&gt; → &lt;code&gt;HUGO_VERSION&lt;/code&gt; environment variable&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Vercel&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;vercel.json&lt;/code&gt; or Project Settings → Environment Variables&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Hugo themes declare minimum version requirements in their &lt;code&gt;config.toml&lt;/code&gt; or &lt;code&gt;module.toml&lt;/code&gt;. When a theme updates to use new Hugo features (like the &lt;code&gt;Locale&lt;/code&gt; field added in Hugo 0.158.0), older Hugo versions can't parse the templates.&lt;/p&gt;

&lt;p&gt;This is especially common with actively maintained themes like Blowfish that take advantage of new Hugo APIs soon after they're released.&lt;/p&gt;

&lt;p&gt;The timeline in my case:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Months ago:&lt;/strong&gt; Set &lt;code&gt;HUGO_VERSION=0.152.2&lt;/code&gt; in Cloudflare Pages&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;July 2026:&lt;/strong&gt; Fixed the submodule, which pulled Blowfish v2.104.0&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Blowfish v2.104.0&lt;/strong&gt; requires Hugo ≥ 0.158.0&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cloudflare&lt;/strong&gt; tried to build with Hugo 0.152.2 → template error&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The fix: always check the theme's minimum Hugo version after updating, and keep your deployment platform's Hugo version in sync with your local version.&lt;br&gt;
&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pin your Hugo version in your repo&lt;/strong&gt;, not just in your deployment platform:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight toml"&gt;&lt;code&gt;&lt;span class="c"&gt;# In your site's config/_default/config.toml or hugo.toml&lt;/span&gt;
&lt;span class="nn"&gt;[module]&lt;/span&gt;
  &lt;span class="nn"&gt;[module.hugoVersion]&lt;/span&gt;
    &lt;span class="py"&gt;extended&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
    &lt;span class="py"&gt;min&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"0.158.0"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This makes Hugo print a clear warning if someone tries to build with an incompatible version, instead of failing with a cryptic template error.&lt;/p&gt;

&lt;p&gt;You can also add a version check to your CI pipeline:&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;REQUIRED_VERSION&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"0.158.0"&lt;/span&gt;
&lt;span class="nv"&gt;CURRENT_VERSION&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;hugo version | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-oP&lt;/span&gt; &lt;span class="s1"&gt;'\d+\.\d+\.\d+'&lt;/span&gt; | &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="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'%s\n'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$REQUIRED_VERSION&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$CURRENT_VERSION&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;sort&lt;/span&gt; &lt;span class="nt"&gt;-V&lt;/span&gt; | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-n1&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$REQUIRED_VERSION&lt;/span&gt;&lt;span class="s2"&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;then
  &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"ERROR: Hugo &lt;/span&gt;&lt;span class="nv"&gt;$CURRENT_VERSION&lt;/span&gt;&lt;span class="s2"&gt; is below minimum &lt;/span&gt;&lt;span class="nv"&gt;$REQUIRED_VERSION&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="nb"&gt;exit &lt;/span&gt;1
&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;h2&gt;
  
  
  Cleaning Up the Old Theme Files From Git History
&lt;/h2&gt;

&lt;p&gt;After fixing the submodule, I noticed that the old theme files (hundreds of files from the improperly committed copy) were still taking up space in my git history. Even though they were removed from the current working tree, the old commits still referenced them.&lt;/p&gt;

&lt;p&gt;If you find yourself in the same situation and want to clean up the bloat, I've written a detailed guide on how to do it: &lt;strong&gt;&lt;a href="https://khadirullah.com/blog/git-filter-repo-clean-history/" rel="noopener noreferrer"&gt;Removing Objects From Git History with git filter-repo&lt;/a&gt;&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Problem&lt;/th&gt;
&lt;th&gt;Solution&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;git submodule update&lt;/code&gt; does nothing&lt;/td&gt;
&lt;td&gt;Theme was committed as regular files. Remove and re-add properly&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;.gitmodules&lt;/code&gt; exists but &lt;code&gt;git submodule status&lt;/code&gt; is empty&lt;/td&gt;
&lt;td&gt;The submodule was never properly registered with &lt;code&gt;git submodule add&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Deployment fails after fixing the submodule&lt;/td&gt;
&lt;td&gt;Update &lt;code&gt;HUGO_VERSION&lt;/code&gt; in your deployment platform to match the new theme's requirements&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Old theme files still in git history&lt;/td&gt;
&lt;td&gt;Use &lt;code&gt;git filter-repo&lt;/code&gt; to purge them (&lt;a href="https://khadirullah.com/blog/git-filter-repo-clean-history/" rel="noopener noreferrer"&gt;guide&lt;/a&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Fix it once, and every future update is one command.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Found this helpful? Follow me for more real-world Linux troubleshooting and DevOps content.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://khadirullah.com/blog/git-submodule-regular-files-fix/" rel="noopener noreferrer"&gt;khadirullah.com&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>git</category>
      <category>gitsubmodules</category>
      <category>hugo</category>
      <category>devops</category>
    </item>
    <item>
      <title>I Deleted Files From Git But They Were Still There: Removing Objects From Git History</title>
      <dc:creator>Khadirullah Mohammad</dc:creator>
      <pubDate>Thu, 27 Aug 2026 05:25:35 +0000</pubDate>
      <link>https://dev.to/khadirullah/i-deleted-files-from-git-but-they-were-still-there-removing-objects-from-git-history-o98</link>
      <guid>https://dev.to/khadirullah/i-deleted-files-from-git-but-they-were-still-there-removing-objects-from-git-history-o98</guid>
      <description>&lt;p&gt;This started as a simple cleanup task, but it revealed an important Git concept: deleting a file from your working tree does not remove it from repository history.&lt;/p&gt;

&lt;p&gt;I was auditing my Hugo blog's deployment when I noticed something: &lt;strong&gt;24 unused PNG screenshots&lt;/strong&gt; were being deployed to Cloudflare Pages on every build. They were sitting inside a Hugo page bundle. Hugo page bundles can contain resource files that participate in the build pipeline. Depending on the resource type and how the files are referenced, unused assets may still be processed or copied into generated output. This can accidentally publish assets that are never referenced.&lt;/p&gt;

&lt;p&gt;Easy fix, right? Delete the folder, commit, push. Done.&lt;/p&gt;

&lt;p&gt;Except it's not done. Those 3MB of PNGs still live in git history. A normal clone can download the old objects as long as they remain reachable through repository references such as branches or tags. They are invisible in the current working tree, but they remain part of reachable history until the references are rewritten. They are still stored in git's object database and reachable through old history references until those references are removed and garbage collection runs.&lt;/p&gt;

&lt;p&gt;This post covers how I found them, removed them from history, and verified they're truly gone.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note:&lt;/strong&gt;&lt;br&gt;
&lt;strong&gt;When you'd actually need this in production:&lt;/strong&gt; Someone commits an API key, a 500MB dataset, or build artifacts to the repo. Deleting the file in a new commit doesn't remove it from history. Anyone with repo access can still find it. This is the procedure to purge it completely.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Quick Answer (TL;DR)
&lt;/h2&gt;

&lt;p&gt;If a secret was exposed:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Rotate/revoke it immediately&lt;/li&gt;
&lt;li&gt;Rewrite history&lt;/li&gt;
&lt;li&gt;Force push rewritten refs&lt;/li&gt;
&lt;li&gt;Ask collaborators to refresh clones&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;For general history cleanup, deleting a file with &lt;code&gt;git rm&lt;/code&gt; only removes it from future commits. To remove it from all Git history, run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git filter-repo &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--path&lt;/span&gt; path/to/file &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--invert-paths&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--force&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then force-push the rewritten history:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git push origin main &lt;span class="nt"&gt;--force-with-lease&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;--force-with-lease&lt;/code&gt; is safer because it prevents overwriting remote changes you do not have locally.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Finally, verify the removal and run local garbage collection. Read on for the full investigation and safety walkthrough.&lt;/p&gt;

&lt;h2&gt;
  
  
  Who should read this?
&lt;/h2&gt;

&lt;p&gt;Choose the section based on your situation:&lt;/p&gt;

&lt;p&gt;🟢 &lt;strong&gt;Accidentally committed a large file?&lt;/strong&gt;&lt;br&gt;
→ Jump to Cleanup Process&lt;/p&gt;

&lt;p&gt;🔴 &lt;strong&gt;Accidentally committed a password/API key?&lt;/strong&gt;&lt;br&gt;
→ Rotate the secret first, then follow history rewrite&lt;/p&gt;

&lt;p&gt;🔵 &lt;strong&gt;Want to understand Git internals?&lt;/strong&gt;&lt;br&gt;
→ Continue through the investigation section&lt;/p&gt;

&lt;p&gt;🟣 &lt;strong&gt;Maintaining a production repository?&lt;/strong&gt;&lt;br&gt;
→ Read backup, tags, verification, and aftercare sections&lt;/p&gt;
&lt;h2&gt;
  
  
  Which tool do I need?
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Situation&lt;/th&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Remove one recent commit&lt;/td&gt;
&lt;td&gt;&lt;code&gt;git rebase -i&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Remove file everywhere&lt;/td&gt;
&lt;td&gt;&lt;code&gt;git filter-repo&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Replace leaked secret&lt;/td&gt;
&lt;td&gt;&lt;code&gt;git filter-repo --blob-callback&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Huge files&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;git filter-repo&lt;/code&gt; or Git LFS&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;
&lt;h2&gt;
  
  
  Why &lt;code&gt;git rm&lt;/code&gt; Doesn't Fix This
&lt;/h2&gt;

&lt;p&gt;When you run &lt;code&gt;git rm&lt;/code&gt; and commit, git records: &lt;em&gt;"this file no longer exists from this commit forward."&lt;/em&gt; But the original blob (the file's content) remains stored in Git's object database because the old commit history still references the blob. Once those references are removed, the object becomes unreachable and can eventually be removed by garbage collection.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Commit abc123 ── "Added 24 PNGs"
    └── git stored each PNG as a "blob" object in .git/objects/

Commit def456 ── "Deleted 24 PNGs"  
    └── git recorded "these files are gone from THIS version"
         BUT the blobs from abc123 still exist
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Think of it like a filing cabinet where old copies remain until Git confirms nothing references them anymore and garbage collection removes them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which history rewrite method do you need?
&lt;/h2&gt;

&lt;p&gt;Before running commands, decide what you actually want to remove.&lt;/p&gt;

&lt;p&gt;Use git filter-repo:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git filter-repo &lt;span class="nt"&gt;--path&lt;/span&gt; path/to/file &lt;span class="nt"&gt;--invert-paths&lt;/span&gt; &lt;span class="nt"&gt;--force&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Example:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;leaked &lt;code&gt;.env&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;accidentally committed videos&lt;/li&gt;
&lt;li&gt;generated build files
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The file itself is useful, but some content inside it must disappear.&lt;/p&gt;

&lt;p&gt;Examples:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;remove a password from &lt;code&gt;config.yml&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;remove an API key from a JSON file&lt;/li&gt;
&lt;li&gt;remove a private URL&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For recent commits, use interactive rebase.&lt;br&gt;
For many commits, use &lt;code&gt;git filter-repo --blob-callback&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;h3&gt;
  
  
  The complete workflow
&lt;/h3&gt;



&lt;pre data-lang="mermaid"&gt;&lt;code&gt;flowchart LR
    A["🔍 Investigate"] --&amp;gt; B["💾 Backup"]
    B --&amp;gt; C["📂 Fresh Clone"]
    C --&amp;gt; D["🧹 filter-repo"]
    D --&amp;gt; E["✅ Verify"]
    E --&amp;gt; F["🚀 Force Push"]
    F --&amp;gt; G["🏷️ Update Tags"]

    style A fill:#1e3a5f,stroke:#60a5fa,color:#e0f2fe
    style B fill:#1e3a5f,stroke:#60a5fa,color:#e0f2fe
    style C fill:#1e3a5f,stroke:#60a5fa,color:#e0f2fe
    style D fill:#7c2d12,stroke:#f97316,color:#fed7aa
    style E fill:#1e3a5f,stroke:#60a5fa,color:#e0f2fe
    style F fill:#7c2d12,stroke:#f97316,color:#fed7aa
    style G fill:#1e3a5f,stroke:#60a5fa,color:#e0f2fe&lt;/code&gt;&lt;/pre&gt;



&lt;h2&gt;
  
  
  Step 1: Investigate and Find the Large Files
&lt;/h2&gt;

&lt;p&gt;Before rewriting history, you need to know what you're dealing with.&lt;/p&gt;

&lt;h3&gt;
  
  
  Find the biggest files in your entire repo history
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git rev-list &lt;span class="nt"&gt;--objects&lt;/span&gt; &lt;span class="nt"&gt;--all&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | git cat-file &lt;span class="nt"&gt;--batch-check&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'%(objecttype) %(objectname) %(objectsize) %(rest)'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;grep &lt;/span&gt;blob &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;sort&lt;/span&gt; &lt;span class="nt"&gt;-k3&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-20&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;This command is written for Unix-like shells. Windows users may need Git Bash or PowerShell equivalents.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;What each part does:&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Command&lt;/th&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;git rev-list --objects --all&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Lists every object (blob, tree, commit) across all branches&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;git cat-file --batch-check&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;For each object, prints its type, hash, size, and file path&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;grep blob&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Filters to only files (not directories or commits)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;sort -k3 -n -r&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Sorts by size column, numeric, reverse (biggest first)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;This is the command you run when someone asks "why is our repo 2GB?" It reveals every large file ever committed, even deleted ones.&lt;/p&gt;

&lt;h3&gt;
  
  
  Find which commits touched a specific path
&lt;/h3&gt;

&lt;p&gt;In my terminal (and the screenshots below), I originally ran a simple path match:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git log &lt;span class="nt"&gt;--oneline&lt;/span&gt; &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="s1"&gt;'*ScreenShots*'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;While that works in many shells, glob matching (&lt;code&gt;*&lt;/code&gt;) can behave inconsistently across different operating systems. A safer and more robust command for deep investigation is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git log &lt;span class="nt"&gt;--all&lt;/span&gt; &lt;span class="nt"&gt;--name-only&lt;/span&gt; &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="s1"&gt;'**ScreenShots**'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note:&lt;/strong&gt;&lt;br&gt;
&lt;strong&gt;Why is this second version better?&lt;/strong&gt; &lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;--all&lt;/code&gt; ensures you search across every branch in the repository's history (including deleted ones).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;--name-only&lt;/code&gt; explicitly lists the modified files.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;**&lt;/code&gt; ensures it matches folders nested deep in the repository structure regardless of your OS shell.&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;

&lt;p&gt;This tells you exactly which commits added, modified, or deleted files matching that pattern. You need this to understand what the history rewrite will affect.&lt;/p&gt;

&lt;h3&gt;
  
  
  Find when a file was first added
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git log &lt;span class="nt"&gt;--diff-filter&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;A &lt;span class="nt"&gt;--oneline&lt;/span&gt; &lt;span class="nt"&gt;--&lt;/span&gt; path/to/file.txt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Flag&lt;/th&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--diff-filter=A&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Show only commits where the file was &lt;strong&gt;A&lt;/strong&gt;dded (first committed)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--diff-filter=D&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Show only commits where the file was &lt;strong&gt;D&lt;/strong&gt;eleted&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--diff-filter=M&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Show only commits where the file was &lt;strong&gt;M&lt;/strong&gt;odified&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;This is how you trace back to the exact commit that introduced a file. Useful when you need to know "when did this secret/binary first enter the repo?"&lt;/p&gt;

&lt;h3&gt;
  
  
  View a file at a specific commit
&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;# See the file contents as they were at that commit&lt;/span&gt;
git show abc123:path/to/file.txt

&lt;span class="c"&gt;# See what changed in that specific commit&lt;/span&gt;
git show abc123 &lt;span class="nt"&gt;--stat&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;git show &amp;lt;commit&amp;gt;:&amp;lt;path&amp;gt;&lt;/code&gt; lets you read any file at any point in history without checking it out. Combine this with &lt;code&gt;--diff-filter=A&lt;/code&gt; to find the commit, then inspect exactly what was added.&lt;/p&gt;

&lt;h3&gt;
  
  
  Check your repo size
&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;du&lt;/span&gt; &lt;span class="nt"&gt;-sh&lt;/span&gt; .git
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run this before and after cleanup to verify the blobs are actually gone. In my case: &lt;strong&gt;76MB before&lt;/strong&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  My investigation output
&lt;/h3&gt;

&lt;p&gt;Here's what my actual investigation looked like:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note:&lt;/strong&gt;&lt;br&gt;
&lt;strong&gt;Note on screenshots:&lt;/strong&gt; The commit hashes visible in the screenshots below (such as the commit count and refs) reflect the repository state at the time this cleanup was performed. Since then, additional history rewrites have changed all commit hashes, which is exactly what &lt;code&gt;git filter-repo&lt;/code&gt; does by design.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgkxsoubuqnavreohnkzy.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgkxsoubuqnavreohnkzy.webp" alt="Repo size before cleanup at 76MB" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fj9kf99gcvrskvckd01h8.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fj9kf99gcvrskvckd01h8.webp" alt="The 20 largest blobs in my repo history, with theme assets and blog media dominating" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frhixfmz4v5rarzk4o3a5.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frhixfmz4v5rarzk4o3a5.webp" alt="All 24 ScreenShot PNGs still living in the git object store" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhdksk3ami4ungx9zh7iv.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhdksk3ami4ungx9zh7iv.webp" alt="Only one commit introduced all the screenshots" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Check current status and tracking
&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;# What files are modified, deleted, or untracked?&lt;/span&gt;
git status &lt;span class="nt"&gt;--short&lt;/span&gt;

&lt;span class="c"&gt;# Which remote branch is your local branch tracking?&lt;/span&gt;
git branch &lt;span class="nt"&gt;-vv&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;git status --short&lt;/code&gt; gives a compact view where each line starts with a status code:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Code&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;M&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Modified&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;Deleted&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;??&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Untracked (new file git doesn't know about)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;A&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Added (staged for commit)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;git branch -vv&lt;/code&gt; shows whether your local branch is tracking a remote branch. After &lt;code&gt;filter-repo&lt;/code&gt;, the tracking info is gone, so you'll need to set it up again.&lt;/p&gt;

&lt;h3&gt;
  
  
  Use &lt;code&gt;git filter-repo --analyze&lt;/code&gt; for automated reports
&lt;/h3&gt;

&lt;p&gt;Instead of building the bash pipeline manually, &lt;code&gt;git filter-repo&lt;/code&gt; has a built-in analysis command that generates reports automatically:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git filter-repo &lt;span class="nt"&gt;--analyze&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F88blqcbr3ewykeelvjv6.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F88blqcbr3ewykeelvjv6.webp" alt="filter-repo --analyze processes all blobs and commits, writing reports to .git/filter-repo/analysis/" width="799" height="392"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Notice the output says &lt;strong&gt;"Processed 17 commits"&lt;/strong&gt;. I ran this while my working tree was still dirty. I hadn't committed the pending changes yet. That matters because &lt;code&gt;filter-repo&lt;/code&gt; (the actual history rewrite) &lt;strong&gt;requires a clean working tree&lt;/strong&gt;. So I had to commit first, which added one more commit, bringing the total to 18.&lt;/p&gt;

&lt;p&gt;When I ran &lt;code&gt;--analyze&lt;/code&gt; again after committing, the analysis directory already existed from the first run:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1c1ngzss4oe6vrjwn2ww.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1c1ngzss4oe6vrjwn2ww.webp" alt="Running --analyze again shows the error, then --force overwrites it, now showing 18 commits" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Two things to notice here:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The &lt;code&gt;Error: dir already exists&lt;/code&gt; error means you need &lt;code&gt;--force&lt;/code&gt; to overwrite a previous analysis&lt;/li&gt;
&lt;li&gt;The commit count jumped from &lt;strong&gt;17 → 18&lt;/strong&gt; because that extra commit is the one I made to get a clean working tree before running &lt;code&gt;filter-repo&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This creates a &lt;code&gt;.git/filter-repo/analysis/&lt;/code&gt; directory with several &lt;code&gt;.txt&lt;/code&gt; reports. The most useful ones:&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;# Which deleted files are eating the most space?&lt;/span&gt;
&lt;span class="nb"&gt;cat&lt;/span&gt; .git/filter-repo/analysis/path-deleted-sizes.txt | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-20&lt;/span&gt;

&lt;span class="c"&gt;# Which deleted directories are the biggest offenders?&lt;/span&gt;
&lt;span class="nb"&gt;cat&lt;/span&gt; .git/filter-repo/analysis/directories-deleted-sizes.txt | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-20&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbzxyphvl0z7t7low72mx.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbzxyphvl0z7t7low72mx.webp" alt="path-deleted-sizes.txt showing every deleted file ranked by size, with ScreenShots PNGs dominating" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flxgyftv4yjj6w3opkrj5.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flxgyftv4yjj6w3opkrj5.webp" alt="directories-deleted-sizes.txt showing the ScreenShots directory as the #1 deleted directory at 3MB" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This confirmed what the manual investigation already showed: the &lt;code&gt;ScreenShots/&lt;/code&gt; directory was the biggest offender among deleted content, totaling ~3MB of wasted space in the object store.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Choose Your Tool (filter-branch vs filter-repo)
&lt;/h2&gt;

&lt;p&gt;There are two tools for rewriting git history. Here's the comparison:&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;git filter-branch&lt;/code&gt; (the old way, deprecated)
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git filter-branch &lt;span class="nt"&gt;--force&lt;/span&gt; &lt;span class="nt"&gt;--index-filter&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s1"&gt;'git rm --cached --ignore-unmatch -r path/to/folder/'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--prune-empty&lt;/span&gt; &lt;span class="nt"&gt;--tag-name-filter&lt;/span&gt; &lt;span class="nb"&gt;cat&lt;/span&gt; &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="nt"&gt;--all&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Flag&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;--force&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Run even if a backup from a previous filter-branch exists&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--index-filter&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Runs a git command on every commit's index without checking out files&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;git rm --cached --ignore-unmatch -r&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Remove from index, don't error if file doesn't exist in that commit, recursive&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--prune-empty&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Delete commits that become empty after removing files&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--tag-name-filter cat&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Rewrite tags to point to new commit hashes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;-- --all&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Apply to all branches and refs&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Slow.&lt;/strong&gt; Spawns a shell process per commit. Hours on large repos.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Officially deprecated.&lt;/strong&gt; Git itself warns you to use &lt;code&gt;filter-repo&lt;/code&gt; instead.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Creates backup refs&lt;/strong&gt; in &lt;code&gt;refs/original/&lt;/code&gt; you must manually clean up.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fragile quoting.&lt;/strong&gt; Easy to break with special characters in paths.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;git filter-repo&lt;/code&gt; (the modern way, recommended)
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git filter-repo &lt;span class="nt"&gt;--path&lt;/span&gt; path/to/folder/ &lt;span class="nt"&gt;--invert-paths&lt;/span&gt; &lt;span class="nt"&gt;--force&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Flag&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;--path &amp;lt;path&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Select all files matching this path&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--invert-paths&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Flip the selection: exclude instead of include&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--force&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Override safety checks and allow the rewrite to run in situations where filter-repo would normally stop&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Often significantly faster, especially on repositories with many commits.&lt;/strong&gt; Processes the entire history in memory.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No manual cleanup.&lt;/strong&gt; No backup refs left behind.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Safer.&lt;/strong&gt; &lt;code&gt;git filter-repo&lt;/code&gt; may remove the &lt;code&gt;origin&lt;/code&gt; remote as a safety measure, especially when it detects a clone that looks like it came directly from a remote repository. This reduces the chance of accidentally pushing rewritten history before verifying the results.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Simple syntax.&lt;/strong&gt; One readable line.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Install it:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Debian/Ubuntu&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install &lt;/span&gt;git-filter-repo

&lt;span class="c"&gt;# Or via pip&lt;/span&gt;
pip &lt;span class="nb"&gt;install &lt;/span&gt;git-filter-repo
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Before You Rewrite History: Make a Backup
&lt;/h2&gt;

&lt;p&gt;Before running any destructive commands, create a backup clone of your repository. If you make a mistake, you can safely restore from this clone.&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 &lt;span class="nt"&gt;--mirror&lt;/span&gt; https://github.com/your-username/your-repo.git repo-backup.git
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Why use &lt;code&gt;--mirror&lt;/code&gt; instead of a normal clone?&lt;/strong&gt; A mirror clone creates a complete, 1:1 backup of the repository, including all branches, tags, remote-tracking references, and the complete object database. Unlike a regular clone, which creates a working copy and checks out only the default branch, a mirror clone copies &lt;strong&gt;all Git references (&lt;code&gt;refs/*&lt;/code&gt;)&lt;/strong&gt; and is intended specifically for backup and repository migration, making it the safest choice before rewriting history. Like all Git clones, it only backs up committed repository data, not uncommitted working directory changes.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;If the rewrite unexpectedly goes wrong, you can restore from this backup:&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;# To get a working local copy back:&lt;/span&gt;
git clone repo-backup.git restored-repo
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;The restored repository starts as a normal clone of the mirror backup.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A mirror backup is a recovery point. Do not continue normal development inside the mirror repository.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;⚠️ &lt;strong&gt;Warning:&lt;/strong&gt; &lt;code&gt;git push --mirror&lt;/code&gt; updates &lt;strong&gt;all&lt;/strong&gt; references on the remote (branches, tags, and other refs) to exactly match the mirror repository. Use it only when you intentionally want to restore the entire repository to the backed-up state.&lt;br&gt;
&lt;/p&gt;
&lt;/blockquote&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# To restore the remote repository to its original state:&lt;/span&gt;
&lt;span class="nb"&gt;cd &lt;/span&gt;repo-backup.git
git push &lt;span class="nt"&gt;--mirror&lt;/span&gt; https://github.com/your-username/your-repo.git
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  When to Actually Use This
&lt;/h2&gt;

&lt;p&gt;Rewriting git history is a destructive operation. Use it only when:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Scenario&lt;/th&gt;
&lt;th&gt;Urgency&lt;/th&gt;
&lt;th&gt;Action&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;Leaked secret&lt;/strong&gt; (API key, password)&lt;/td&gt;
&lt;td&gt;🔴 Immediate&lt;/td&gt;
&lt;td&gt;Rotate the secret first, then filter-repo&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;Huge binary&lt;/strong&gt; (500MB video, dataset)&lt;/td&gt;
&lt;td&gt;🟡 Moderate&lt;/td&gt;
&lt;td&gt;Filter-repo when clone times become a problem&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;Legal/compliance&lt;/strong&gt; requirement&lt;/td&gt;
&lt;td&gt;🟡 Moderate&lt;/td&gt;
&lt;td&gt;Filter-repo as directed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;3MB of PNGs&lt;/strong&gt; (my case)&lt;/td&gt;
&lt;td&gt;🟢 Low&lt;/td&gt;
&lt;td&gt;Optional cleanup, but a useful exercise for learning history rewriting&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Step 3: The Cleanup Process
&lt;/h2&gt;

&lt;p&gt;Here's the exact process I followed on my repo. In my case, the path to remove was &lt;code&gt;content/blog/2026/amazon-linux-qemu-local-lab/media/ScreenShots/&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  3.1: Commit and push all current work first
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git add &lt;span class="nt"&gt;-A&lt;/span&gt;
git commit &lt;span class="nt"&gt;-m&lt;/span&gt; &lt;span class="s2"&gt;"your commit message"&lt;/span&gt;
git push origin main
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Why?&lt;/strong&gt; Before rewriting history, commit or stash any important local changes. &lt;code&gt;git filter-repo&lt;/code&gt; performs destructive history rewriting and may refuse to run in some repository states to prevent accidental data loss. It also may remove the &lt;code&gt;origin&lt;/code&gt; remote as a safety measure, especially when it detects that the repository appears to be a normal clone of a remote repository. Push your latest changes so they're safe on the remote before rewriting.&lt;/p&gt;

&lt;h3&gt;
  
  
  3.2: Run filter-repo
&lt;/h3&gt;

&lt;p&gt;⚠️ &lt;strong&gt;Before running this command:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Create a backup&lt;/li&gt;
&lt;li&gt;Make sure your working tree is clean&lt;/li&gt;
&lt;li&gt;Confirm the path you are removing&lt;/li&gt;
&lt;li&gt;Understand that commit hashes will change
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git filter-repo &lt;span class="nt"&gt;--path&lt;/span&gt; content/blog/2026/amazon-linux-qemu-local-lab/media/ScreenShots/ &lt;span class="nt"&gt;--invert-paths&lt;/span&gt; &lt;span class="nt"&gt;--force&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgck65s48qnoh7leokzhq.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgck65s48qnoh7leokzhq.webp" alt="filter-repo rewrites history: parsed 18 commits, removed the origin remote, repacked objects in 1.57 seconds" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What happens under the hood:&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Walks through every commit in your repo&lt;/li&gt;
&lt;li&gt;For each commit, checks: "does this commit contain files in the specified path?"&lt;/li&gt;
&lt;li&gt;If yes, rewrites that commit &lt;em&gt;without&lt;/em&gt; those files&lt;/li&gt;
&lt;li&gt;Every commit after it also gets a new hash (because its parent changed)&lt;/li&gt;
&lt;li&gt;The old blobs become orphaned, and no commit points to them anymore
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;BEFORE:
  abc123 (has PNGs) → def456 → ghi789

AFTER:
  NEW_A (no PNGs) → NEW_B → NEW_C
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Commit messages, authors, and timestamps usually remain unchanged unless explicitly modified. The edited commit receives a new hash. Every descendant commit also receives a new hash because their parent commit reference changed.&lt;/p&gt;

&lt;p&gt;Notice the &lt;code&gt;NOTICE: Removing 'origin' remote&lt;/code&gt; message. Since &lt;code&gt;git filter-repo&lt;/code&gt; removed the &lt;code&gt;origin&lt;/code&gt; remote in this case as a safety measure, we will add the remote back after verifying the rewritten history.&lt;/p&gt;

&lt;h3&gt;
  
  
  3.3: Add the remote back and force push
&lt;/h3&gt;

&lt;p&gt;Since &lt;code&gt;filter-repo&lt;/code&gt; removed our remotes as a safety feature, we need to add the remote back and force push the rewritten history.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git remote add origin https://github.com/your-username/your-repo.git

&lt;span class="c"&gt;# In my screenshots, I used a standard force push because I was the only one working on this repo:&lt;/span&gt;
git push origin main &lt;span class="nt"&gt;--force&lt;/span&gt;

&lt;span class="c"&gt;# However, the generally recommended, safer command is:&lt;/span&gt;
git push origin main &lt;span class="nt"&gt;--force-with-lease&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note:&lt;/strong&gt; If your branch is protected, Git may reject the force push. You may need to temporarily allow force pushes or perform the rewrite through your repository's administrative workflow.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;Why do we have to force push at all?&lt;/strong&gt; Every commit hash changed. Git sees the rewritten history as completely different commits. Normal &lt;code&gt;git push&lt;/code&gt; says &lt;em&gt;"add my new commits on top of what's there"&lt;/em&gt;, but there is no shared history to build on; the histories have diverged. Force pushing tells the remote: &lt;em&gt;"replace the existing branch history with this rewritten version."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why is &lt;code&gt;--force-with-lease&lt;/code&gt; better than &lt;code&gt;--force&lt;/code&gt;?&lt;/strong&gt; A standard &lt;code&gt;--force&lt;/code&gt; blindly overwrites the remote branch. If a collaborator pushed a new commit while you were running &lt;code&gt;filter-repo&lt;/code&gt;, &lt;code&gt;--force&lt;/code&gt; will delete their work. &lt;code&gt;--force-with-lease&lt;/code&gt; checks the remote first and aborts if there are new commits you haven't seen yet.&lt;/p&gt;

&lt;p&gt;The force push output looks 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;Enumerating objects: 52, done.
Counting objects: 100% (52/52), done.
Delta compression using up to 4 threads
Compressing objects: 100% (28/28), done.
Total 52 (delta 24), reused 48 (delta 22)
remote: Resolving deltas: 100% (24/24), done.
To https://github.com/your-username/your-repo.git
 + a1b2c3d...e5f6g7h main -&amp;gt; main (forced update)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That last line with &lt;code&gt;(forced update)&lt;/code&gt; confirms the rewritten branch history replaced the old one on the remote.&lt;/p&gt;

&lt;h3&gt;
  
  
  Important: Push rewritten tags too
&lt;/h3&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Transparency Note:&lt;/strong&gt; My personal blog repository did not use tags, so I did not need to run these tag commands during my cleanup. However, repositories that use tags need to handle them separately. A tag that still points to an old commit can keep rewritten history reachable, and updating tags may also trigger tag-based CI/CD workflows. The following is the standard procedure for synchronizing rewritten tags.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;code&gt;git filter-repo&lt;/code&gt; rewrites the commits that branches and tags reference. However, tags need separate attention because remote tag references are not updated when you only force-push a branch.&lt;/p&gt;

&lt;p&gt;If your repository uses tags, verify them after the rewrite and push updated tags explicitly. If you only run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git push origin main &lt;span class="nt"&gt;--force&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;only the &lt;code&gt;main&lt;/code&gt; branch is updated. Existing remote tags will continue pointing to the old commits.&lt;/p&gt;

&lt;p&gt;Those old tag references can keep the old history reachable, including files you intended to remove.&lt;/p&gt;

&lt;p&gt;Push the rewritten tags:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;For repositories with many tags, prefer updating specific tags rather than force-pushing the entire tag namespace.&lt;br&gt;
Example: &lt;code&gt;git push origin v1.0.0 --force&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;em&gt;(If you still want to push all tags):&lt;/em&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git push origin &lt;span class="nt"&gt;--force&lt;/span&gt; &lt;span class="nt"&gt;--tags&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;Note: This updates existing tags but does not remove remote tags that no longer exist locally.&lt;br&gt;
⚠️ &lt;strong&gt;Avoid this on shared repositories unless you have verified the tag namespace.&lt;/strong&gt; Updating tags can affect releases and automation.&lt;/p&gt;

&lt;p&gt;⚠️ &lt;strong&gt;Does pushing tags rerun CI/CD? Yes.&lt;/strong&gt;&lt;br&gt;
A normal branch force push (&lt;code&gt;git push origin main --force&lt;/code&gt;) usually triggers workflows configured for push events on branches. A tag update (&lt;code&gt;git push origin --force --tags&lt;/code&gt;) can trigger workflows configured with:&lt;/p&gt;


&lt;pre class="highlight yaml"&gt;&lt;code&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;tags&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;*'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;


&lt;p&gt;If your pipeline publishes releases, Docker images, or deployments from tags, verify the workflow before updating tags.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This updates existing local tags on the remote, but it does not delete remote tags that were removed locally. Use &lt;code&gt;git push origin --delete &amp;lt;tag-name&amp;gt;&lt;/code&gt; for those cases.&lt;/p&gt;

&lt;p&gt;For example, if you had a tag:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;v1.0.0 -&amp;gt; old commit containing deleted files
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After &lt;code&gt;filter-repo&lt;/code&gt;, your local tag becomes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;v1.0.0 -&amp;gt; rewritten commit without deleted files
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git push origin &lt;span class="nt"&gt;--force&lt;/span&gt; &lt;span class="nt"&gt;--tags&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;updates the remote tag:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Remote before:
v1.0.0 -&amp;gt; old history

Remote after:
v1.0.0 -&amp;gt; rewritten history
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Removing tags that no longer exist locally
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;git push --force --tags&lt;/code&gt; updates tags that exist locally, but it does &lt;strong&gt;not automatically delete remote tags that were removed during the rewrite&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;If you deleted a tag locally and want it removed from the remote:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git push origin &lt;span class="nt"&gt;--delete&lt;/span&gt; &amp;lt;tag-name&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git push origin &lt;span class="nt"&gt;--delete&lt;/span&gt; old-release
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Remote tags:
v1.0.0
v1.1.0
old-release
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Local tags after cleanup:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;v1.0.0
v1.1.0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After deleting:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Remote tags:
v1.0.0
v1.1.0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;old-release&lt;/code&gt; tag no longer keeps the old history reachable.&lt;/p&gt;



&lt;p&gt;If you intentionally want the remote tag namespace to &lt;strong&gt;exactly match&lt;/strong&gt; your local tags (deleting any remote tags that don't exist locally) you can use an explicit refspec with pruning:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;🔴 &lt;strong&gt;Danger: This command deletes remote tags.&lt;/strong&gt; Any tag that exists on the remote but is missing from your local repository will be permanently removed from the remote. If your local repo is not the complete source of truth for tags (e.g., other team members create release tags, or CI creates tags), you will lose those tags. &lt;strong&gt;Only use this if you are the sole maintainer of the tag namespace.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;
&lt;/blockquote&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git push &lt;span class="nt"&gt;--prune&lt;/span&gt; origin refs/tags/&lt;span class="k"&gt;*&lt;/span&gt;:refs/tags/&lt;span class="k"&gt;*&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This tells Git:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Push every local tag to the matching remote tag&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Delete&lt;/strong&gt; remote tags that no longer exist locally&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This does not prune Git objects. It prunes remote &lt;strong&gt;tag references&lt;/strong&gt; that are missing locally.&lt;/p&gt;

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

&lt;p&gt;Remote tags before:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;v1.0.0
v1.1.0
v2.0.0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Local tags after filter-repo cleanup:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;v1.0.0
v2.0.0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After running the prune command:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Remote tags:
v1.0.0
v2.0.0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The remote &lt;code&gt;v1.1.0&lt;/code&gt; tag is deleted because it does not exist locally.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;🔴 &lt;strong&gt;Before running this command, verify your local tags are correct.&lt;/strong&gt; Run &lt;code&gt;git tag -l&lt;/code&gt; locally and compare against the remote with &lt;code&gt;git ls-remote --tags origin&lt;/code&gt;. If any tags are missing locally that should be kept, do not use this command. Use individual &lt;code&gt;git push origin --delete &amp;lt;tag-name&amp;gt;&lt;/code&gt; instead.&lt;/p&gt;
&lt;/blockquote&gt;



&lt;blockquote&gt;
&lt;p&gt;⚠️ &lt;strong&gt;Never run &lt;code&gt;git pull&lt;/code&gt; between filter-repo and force push.&lt;/strong&gt; Pulling downloads the old history from the remote and tries to merge it with your rewritten history, defeating the entire purpose. If you accidentally pull, restore from a backup and redo the filter-repo step.&lt;/p&gt;
&lt;/blockquote&gt;



&lt;p&gt;Even after all branches and tags are updated, hosting providers such as GitHub may retain unreachable objects for a period before garbage collection. In repositories with open or historical pull requests, additional internal references may also temporarily keep rewritten commits reachable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Working with collaborators:&lt;/strong&gt; Anyone who has already cloned this repository still has the old commits locally. They should re-clone the repository or carefully reset their local branches after the rewrite. Continuing to push from the old clone can accidentally reintroduce the removed history.&lt;/p&gt;

&lt;h3&gt;
  
  
  3.4: Restore upstream tracking
&lt;/h3&gt;

&lt;p&gt;After rewriting history and re-adding the remote, verify your upstream tracking configuration. If it is missing, restore it with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git branch &lt;span class="nt"&gt;--set-upstream-to&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;origin/main main
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;What this does:&lt;/strong&gt; Tells git that your local &lt;code&gt;main&lt;/code&gt; branch should track &lt;code&gt;origin/main&lt;/code&gt;. Now &lt;code&gt;git pull&lt;/code&gt; and &lt;code&gt;git push&lt;/code&gt; work without specifying the branch every time.&lt;/p&gt;

&lt;p&gt;You can verify tracking is restored:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git branch &lt;span class="nt"&gt;-vv&lt;/span&gt;
&lt;span class="c"&gt;# Should show: main 7fe75b2 [origin/main] your latest commit message&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  3.5: Clean up local storage
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git reflog expire &lt;span class="nt"&gt;--expire&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;now &lt;span class="nt"&gt;--all&lt;/span&gt;
git gc &lt;span class="nt"&gt;--prune&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;now &lt;span class="nt"&gt;--aggressive&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Why?&lt;/strong&gt; Even after filter-repo, old commits may still be reachable through reflogs until they expire. Git keeps these references as a safety mechanism, preventing garbage collection of the underlying objects. These commands:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;reflog expire&lt;/code&gt; → clears the safety net (we've verified everything is correct)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;gc --prune=now&lt;/code&gt; → removes unreachable objects that are no longer referenced by commits, branches, tags, or reflogs&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;--aggressive&lt;/code&gt; → recompresses all objects for optimal packing&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Before running this, make sure you have a backup or are confident the rewrite is correct. After garbage collection, recovering deleted objects becomes much harder.&lt;/p&gt;

&lt;p&gt;This removes unreachable objects from your local repository. Remote hosting services run their own garbage collection schedules, so old objects may remain temporarily on platforms such as GitHub.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note:&lt;/strong&gt;&lt;br&gt;
&lt;strong&gt;Note:&lt;/strong&gt; The &lt;code&gt;--aggressive&lt;/code&gt; flag is optional. It performs expensive recompression and is usually unnecessary for large repositories unless you specifically want maximum packing. It can take several minutes on large repositories, so don't panic if it appears to hang. On my 18-commit repo it took about 6 seconds.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F72p1ve9qsy7cv7l4bb9c.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F72p1ve9qsy7cv7l4bb9c.webp" alt="reflog expire + gc --prune=now --aggressive took 6 seconds, final repo size: 73MB" width="799" height="392"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: Verify
&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;# Check repo size (should be smaller)&lt;/span&gt;
&lt;span class="nb"&gt;du&lt;/span&gt; &lt;span class="nt"&gt;-sh&lt;/span&gt; .git

&lt;span class="c"&gt;# Confirm files are gone from history (should return nothing)&lt;/span&gt;
git log &lt;span class="nt"&gt;--all&lt;/span&gt; &lt;span class="nt"&gt;--oneline&lt;/span&gt; &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="s1"&gt;'path/to/deleted/folder/'&lt;/span&gt;

&lt;span class="c"&gt;# Confirm no matching objects in the store&lt;/span&gt;
git rev-list &lt;span class="nt"&gt;--objects&lt;/span&gt; &lt;span class="nt"&gt;--all&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="s1"&gt;'path/to/deleted/folder/'&lt;/span&gt; 
&lt;span class="c"&gt;# Should return nothing&lt;/span&gt;

&lt;span class="c"&gt;# Confirm commit count is the same&lt;/span&gt;
git log &lt;span class="nt"&gt;--oneline&lt;/span&gt; | &lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt;

&lt;span class="c"&gt;# Verify tags point to rewritten commits&lt;/span&gt;
git show-ref &lt;span class="nt"&gt;--tags&lt;/span&gt;

&lt;span class="c"&gt;# Optional: view all refs visually&lt;/span&gt;
git log &lt;span class="nt"&gt;--all&lt;/span&gt; &lt;span class="nt"&gt;--oneline&lt;/span&gt; &lt;span class="nt"&gt;--decorate&lt;/span&gt;

&lt;span class="c"&gt;# Verify unreachable objects are gone locally&lt;/span&gt;
git fsck &lt;span class="nt"&gt;--full&lt;/span&gt; &lt;span class="nt"&gt;--no-reflogs&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;git fsck&lt;/code&gt; checks for unreachable objects. After a successful cleanup, the removed blobs should no longer appear as reachable objects.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;If your repository uses tags, verify they point to the rewritten commits before considering the cleanup complete.&lt;/p&gt;

&lt;p&gt;Here's my actual verification output:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpfbzyb771hpwxb3cb2p7.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpfbzyb771hpwxb3cb2p7.webp" alt="Repo size after cleanup at 73MB, down from 76MB" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnegpbbqxjdwvafo6aqmm.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnegpbbqxjdwvafo6aqmm.webp" alt="grep for ScreenShots/ in the object store returns nothing, proving they're gone" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqemp6yo33xwblm8d990t.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqemp6yo33xwblm8d990t.webp" alt="git log shows no commits touching ScreenShots, as if they never existed" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1arykyhhbtdrkxjr6725.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1arykyhhbtdrkxjr6725.webp" alt="Commit count is still 18, so no commits were lost" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fips8i1gduvuxwsno3ut6.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fips8i1gduvuxwsno3ut6.webp" alt="git branch -vv shows no tracking info, which is expected after filter-repo removed the remote" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;My results:&lt;/p&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;
&lt;code&gt;.git&lt;/code&gt; size&lt;/td&gt;
&lt;td&gt;76 MB&lt;/td&gt;
&lt;td&gt;73 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ScreenShots in history&lt;/td&gt;
&lt;td&gt;24 files across 1 commit&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Commit count&lt;/td&gt;
&lt;td&gt;18&lt;/td&gt;
&lt;td&gt;18&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Why did the commit count stay the same?&lt;/strong&gt; Although every rewritten commit received a new hash, the total number of commits stayed the same because &lt;code&gt;git filter-repo&lt;/code&gt; rewrote existing commits rather than removing them. The count changes only if commits become empty and are removed, for example when using pruning options that remove commits with no remaining changes.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The PNG files themselves were around 3MB, and we saw a corresponding 3MB drop (76MB to 73MB) in the repository size.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why My Repository Only Shrunk 3MB
&lt;/h2&gt;

&lt;p&gt;You might wonder why removing 24 PNGs totaling 3MB only reduced the repository size from 76MB to 73MB. Git does not store files as simple 1-to-1 copies on disk. It packs objects, uses delta compression, and deduplicates identical files. In larger repositories, removing a 3MB directory does not always reduce the &lt;code&gt;.git&lt;/code&gt; folder by exactly 3MB. The real value of this process is understanding how to remove unwanted history, especially secrets or large artifacts that can be hundreds of megabytes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prevention: Stop It from Happening Again
&lt;/h2&gt;

&lt;p&gt;The real DevOps skill isn't the cleanup, it's making sure you never need it:&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;.gitignore&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;Block common offenders before they're ever committed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# Build artifacts
public/
resources/

# Large media you don't want in git
*.psd
*.mp4
*.zip
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Pre-commit hooks
&lt;/h3&gt;

&lt;p&gt;Use &lt;a href="https://pre-commit.com/" rel="noopener noreferrer"&gt;pre-commit&lt;/a&gt; to block large files automatically:&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;# .pre-commit-config.yaml&lt;/span&gt;
&lt;span class="na"&gt;repos&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;repo&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://github.com/pre-commit/pre-commit-hooks&lt;/span&gt;
    &lt;span class="na"&gt;rev&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;v4.6.0&lt;/span&gt;
    &lt;span class="na"&gt;hooks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&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;check-added-large-files&lt;/span&gt;
        &lt;span class="na"&gt;args&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;--maxkb=500'&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Git LFS
&lt;/h3&gt;

&lt;p&gt;For repos that legitimately need large files (design assets, datasets), use &lt;a href="https://git-lfs.com/" rel="noopener noreferrer"&gt;Git LFS&lt;/a&gt; to store them outside the git object database.&lt;/p&gt;

&lt;h2&gt;
  
  
  Advanced: Removing Specific Content from Git History
&lt;/h2&gt;

&lt;p&gt;Sometimes the problem is not an entire file.&lt;/p&gt;

&lt;p&gt;Example:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A config file contains both safe settings and a leaked password&lt;/li&gt;
&lt;li&gt;Deleting the whole file would remove useful history&lt;/li&gt;
&lt;li&gt;You only need to remove the sensitive line&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There are two approaches depending on the situation:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Problem&lt;/th&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Remove a folder from every commit&lt;/td&gt;
&lt;td&gt;&lt;code&gt;git filter-repo --path --invert-paths&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Replace a leaked password everywhere&lt;/td&gt;
&lt;td&gt;&lt;code&gt;git filter-repo --blob-callback&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fix one recent commit before pushing&lt;/td&gt;
&lt;td&gt;&lt;code&gt;git rebase -i&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note:&lt;/strong&gt;&lt;br&gt;
&lt;strong&gt;Simpler alternative for text replacement:&lt;/strong&gt; If you need to find-and-replace a known secret string across your entire history, &lt;code&gt;git filter-repo&lt;/code&gt; also supports a dedicated &lt;code&gt;--replace-text&lt;/code&gt; flag with a simple replacements file. See my companion guide: &lt;a href="https://khadirullah.com/blog/git-filter-repo-scrub-secrets/" rel="noopener noreferrer"&gt;Scrubbing Secrets from Git History&lt;/a&gt; for a focused walkthrough of this approach.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;For example, imagine you committed this file months ago:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;config.txt

DATABASE_HOST=db.example.com
DATABASE_USER=app
DATABASE_PASSWORD=mysecretpassword
LOG_LEVEL=info
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You still need the configuration file and its history, but the password string itself must disappear from every previous commit.&lt;/p&gt;

&lt;p&gt;If the commit is recent enough that rewriting the following commits is practical, interactive rebase is the simplest approach. If the commit has already been pushed to a shared branch, coordinate with your team before force pushing.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;⚠️ &lt;strong&gt;Important:&lt;/strong&gt; Interactive rebase rewrites commit hashes. Avoid using it on shared branches unless you understand the impact. The commit hash of that commit and all later commits will change, and collaborators will need to synchronize with the rewritten history.&lt;br&gt;
&lt;/p&gt;
&lt;/blockquote&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Find the commit that added the lines&lt;/span&gt;
git log &lt;span class="nt"&gt;--diff-filter&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;A &lt;span class="nt"&gt;--oneline&lt;/span&gt; &lt;span class="nt"&gt;--&lt;/span&gt; config.txt
&lt;span class="c"&gt;# Output: abc123 feat: add database configuration&lt;/span&gt;

&lt;span class="c"&gt;# Start interactive rebase from one commit BEFORE the target&lt;/span&gt;
git rebase &lt;span class="nt"&gt;-i&lt;/span&gt; abc123~1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;What &lt;code&gt;abc123~1&lt;/code&gt; means:&lt;/strong&gt; The &lt;code&gt;~1&lt;/code&gt; suffix means "one commit before &lt;code&gt;abc123&lt;/code&gt;." Rebase needs to start from the commit &lt;em&gt;before&lt;/em&gt; the one you want to edit, because it replays commits starting from that point forward.&lt;/p&gt;

&lt;p&gt;Your editor opens with a list of commits:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;pick abc123 feat: add database configuration
pick def456 fix: update logging
pick ghi789 docs: update README
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Change &lt;code&gt;pick&lt;/code&gt; to &lt;code&gt;edit&lt;/code&gt; for the target commit:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;edit abc123 feat: add database configuration
pick def456 fix: update logging
pick ghi789 docs: update README
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Save and close. Git pauses at that commit. Now edit the file:&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;# Remove the 2 offending lines from config.txt&lt;/span&gt;
nano config.txt

&lt;span class="c"&gt;# Stage the fix&lt;/span&gt;
git add config.txt

&lt;span class="c"&gt;# Amend the paused commit (keeps the same message)&lt;/span&gt;
git commit &lt;span class="nt"&gt;--amend&lt;/span&gt; &lt;span class="nt"&gt;--no-edit&lt;/span&gt;

&lt;span class="c"&gt;# Continue replaying the remaining commits&lt;/span&gt;
git rebase &lt;span class="nt"&gt;--continue&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;What happened:&lt;/strong&gt; Git rewound history to &lt;code&gt;abc123&lt;/code&gt;, let you edit it, then replayed all subsequent commits on top. The rewritten history no longer contains those 2 lines.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;⚠️ &lt;strong&gt;After any rebase that changes history, you need to force push:&lt;/strong&gt;&lt;/p&gt;


&lt;pre class="highlight shell"&gt;&lt;code&gt;git push origin main &lt;span class="nt"&gt;--force&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/blockquote&gt;



&lt;p&gt;If the sensitive content appears in multiple commits or you need to search-and-replace across the entire history:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git filter-repo &lt;span class="nt"&gt;--blob-callback&lt;/span&gt; &lt;span class="s1"&gt;'
if b"DB_PASSWORD=example_password" in blob.data:
    blob.data = blob.data.replace(
        b"DB_PASSWORD=example_password",
        b"DB_PASSWORD=REDACTED"
    )
'&lt;/span&gt; &lt;span class="nt"&gt;--force&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This scans blobs during the history rewrite process and replaces the matching byte sequence wherever it appears. Use this when the sensitive value may exist across multiple commits and you do not know exactly which commits contain it.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;⚠️ &lt;strong&gt;Security Warning:&lt;/strong&gt; This only replaces the exact byte sequence you provide; it does not automatically detect secrets. If the value was a real password, API key, or token, rotate it immediately before or during the cleanup process. Rewriting Git history removes references from the repository, but anyone who accessed the secret before the rewrite may still have it.&lt;/p&gt;
&lt;/blockquote&gt;



&lt;h2&gt;
  
  
  Quick Reference Card
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Task&lt;/th&gt;
&lt;th&gt;Command&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Find largest files in history&lt;/td&gt;
&lt;td&gt;`git rev-list --objects --all \&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Find commits that touched a path&lt;/td&gt;
&lt;td&gt;{% raw %}&lt;code&gt;git log --all --oneline -- 'path/*'&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Check repo size&lt;/td&gt;
&lt;td&gt;&lt;code&gt;du -sh .git&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Check file status (compact)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;git status --short&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Check branch tracking&lt;/td&gt;
&lt;td&gt;&lt;code&gt;git branch -vv&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Find when a file was first added&lt;/td&gt;
&lt;td&gt;&lt;code&gt;git log --diff-filter=A --oneline -- path/to/file&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;View file at a specific commit&lt;/td&gt;
&lt;td&gt;&lt;code&gt;git show &amp;lt;commit&amp;gt;:path/to/file&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Analyze repo for large/deleted files&lt;/td&gt;
&lt;td&gt;&lt;code&gt;git filter-repo --analyze&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Verify objects exist in history&lt;/td&gt;
&lt;td&gt;`git rev-list --objects --all \&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Purge a path from all history&lt;/td&gt;
&lt;td&gt;{% raw %}&lt;code&gt;git filter-repo --path &amp;lt;path&amp;gt; --invert-paths --force&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Re-add remote after filter-repo&lt;/td&gt;
&lt;td&gt;&lt;code&gt;git remote add origin &amp;lt;url&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Force push rewritten history&lt;/td&gt;
&lt;td&gt;&lt;code&gt;git push origin main --force&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Restore upstream tracking&lt;/td&gt;
&lt;td&gt;&lt;code&gt;git branch --set-upstream-to=origin/main main&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Edit a specific past commit&lt;/td&gt;
&lt;td&gt;&lt;code&gt;git rebase -i &amp;lt;commit&amp;gt;~1&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Replace specific content across history&lt;/td&gt;
&lt;td&gt;&lt;code&gt;git filter-repo --blob-callback '&amp;lt;script&amp;gt;' --force&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Clear reflog and garbage collect&lt;/td&gt;
&lt;td&gt;&lt;code&gt;git reflog expire --expire=now --all &amp;amp;&amp;amp; git gc --prune=now&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;--aggressive&lt;/code&gt; can be added when deeper repacking is needed, but it is slower and usually unnecessary.&lt;/p&gt;

&lt;h2&gt;
  
  
  Aftercare: Repository Consumers
&lt;/h2&gt;

&lt;p&gt;After rewriting history:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Developers should delete old clones or re-clone&lt;/li&gt;
&lt;li&gt;CI runners with cached repositories should be refreshed&lt;/li&gt;
&lt;li&gt;Docker build caches should be rebuilt if they copied repository contents&lt;/li&gt;
&lt;li&gt;Forks and mirrors may still contain the old objects&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Copy-Paste Summary
&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;# Backup&lt;/span&gt;
git clone &lt;span class="nt"&gt;--mirror&lt;/span&gt; &amp;lt;repo-url&amp;gt;

&lt;span class="c"&gt;# Analyze&lt;/span&gt;
git filter-repo &lt;span class="nt"&gt;--analyze&lt;/span&gt;

&lt;span class="c"&gt;# Remove path&lt;/span&gt;
git filter-repo &lt;span class="nt"&gt;--path&lt;/span&gt; &amp;lt;path&amp;gt; &lt;span class="nt"&gt;--invert-paths&lt;/span&gt; &lt;span class="nt"&gt;--force&lt;/span&gt;

&lt;span class="c"&gt;# Verify&lt;/span&gt;
git log &lt;span class="nt"&gt;--all&lt;/span&gt; &lt;span class="nt"&gt;--&lt;/span&gt; &amp;lt;path&amp;gt;

&lt;span class="c"&gt;# Push rewritten history&lt;/span&gt;
git push origin main &lt;span class="nt"&gt;--force-with-lease&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Most developers rarely need to rewrite Git history. It becomes important when something goes wrong and you need to understand what is actually stored inside your repository.&lt;/p&gt;

&lt;p&gt;In my case, the problem was only a few unused PNG screenshots. They were not causing a major issue, but they gave me a chance to understand how Git objects work, why deleted files can still remain in history, and how history rewriting, verification, and garbage collection fit together.&lt;/p&gt;

&lt;p&gt;The main thing I learned is that removing a file from the current branch is not the same as removing it from repository history. Git is designed to preserve history, which is one of its strengths, but accidental commits remain part of repository history until their references are removed and garbage collection makes the objects unreachable.&lt;/p&gt;

&lt;p&gt;A leaked secret, a large binary file, or unwanted build artifacts can become a much bigger problem than a few screenshots. In those situations, knowing how to investigate the repository, create a backup, rewrite history safely, and verify the result can save a lot of time.&lt;/p&gt;

&lt;p&gt;History rewriting should still be treated carefully. It changes commit hashes, requires force pushing, and can affect other people working with the repository. Anyone who already cloned the repository may still have the old history locally. After a rewrite, collaborators should re-clone or carefully reset their local branches before pushing new changes. Before doing it on an important project, make sure you understand the impact and have a recovery plan.&lt;/p&gt;

&lt;p&gt;For future projects, preventing the problem is always easier than cleaning it up later. A proper &lt;code&gt;.gitignore&lt;/code&gt;, pre-commit checks, secret scanning, and Git LFS for large files can prevent many of these situations before they happen.&lt;/p&gt;

&lt;p&gt;Accidental commits happen in real-world software projects. Understanding how Git stores data and knowing how to recover from these situations is a valuable skill.&lt;/p&gt;

&lt;h2&gt;
  
  
  References &amp;amp; Documentation
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://github.com/newren/git-filter-repo" rel="noopener noreferrer"&gt;git-filter-repo (Official GitHub)&lt;/a&gt; : The recommended tool for rewriting git history&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://git-scm.com/docs/git-filter-branch" rel="noopener noreferrer"&gt;Git Documentation: git-filter-branch&lt;/a&gt; : The deprecated predecessor (includes the recommendation to use filter-repo)&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/removing-sensitive-data-from-a-repository" rel="noopener noreferrer"&gt;GitHub Docs: Removing sensitive data&lt;/a&gt; : GitHub's official guide for purging secrets&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://docs.github.com/en/code-security/secret-scanning/about-secret-scanning" rel="noopener noreferrer"&gt;GitHub Docs: About secret scanning&lt;/a&gt; : Prevent secrets from being committed in the first place&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://git-scm.com/book/en/v2/Git-Internals-Git-Objects" rel="noopener noreferrer"&gt;Git Internals: Git Objects&lt;/a&gt; : How blobs, trees, and commits work under the hood&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://pre-commit.com/" rel="noopener noreferrer"&gt;pre-commit framework&lt;/a&gt; : Automate checks before every commit&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://git-lfs.com/" rel="noopener noreferrer"&gt;Git LFS&lt;/a&gt; : Large File Storage for git repos&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Found this helpful? Follow me for more real-world Linux troubleshooting and DevOps content.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://khadirullah.com/blog/git-filter-repo-clean-history/" rel="noopener noreferrer"&gt;khadirullah.com&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>git</category>
      <category>devops</category>
      <category>tutorial</category>
      <category>github</category>
    </item>
    <item>
      <title>How to Create Linked Clones in Virt-Manager (GUI)</title>
      <dc:creator>Khadirullah Mohammad</dc:creator>
      <pubDate>Wed, 26 Aug 2026 10:12:50 +0000</pubDate>
      <link>https://dev.to/khadirullah/how-to-create-linked-clones-in-virt-manager-gui-2p5m</link>
      <guid>https://dev.to/khadirullah/how-to-create-linked-clones-in-virt-manager-gui-2p5m</guid>
      <description>&lt;p&gt;If you run virtual machines locally using QEMU/KVM, you should be using &lt;strong&gt;Linked Clones&lt;/strong&gt;. &lt;/p&gt;

&lt;p&gt;Instead of copying a 2GB+ master template file over and over (which takes time and wastes SSD space), a linked clone creates a tiny, empty file (often just a few kilobytes). It treats your master template as a read-only base layer. &lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;When the VM reads data, it reads from the master. &lt;/li&gt;
&lt;li&gt;When the VM writes data, it writes only to the new, tiny clone file.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Creating linked clones via the command line is simple (&lt;code&gt;qemu-img create -b ...&lt;/code&gt;), but trying to do it entirely within the &lt;strong&gt;Virt-Manager GUI&lt;/strong&gt; can be incredibly confusing. There is no simple "Linked Clone" button.&lt;/p&gt;

&lt;p&gt;Here is the exact workflow to achieve this using Virt-Manager's Storage Pools and Backing Stores.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Secret: Storage Pools
&lt;/h2&gt;

&lt;p&gt;Virt-Manager is very strict about where files live. To create a linked clone in the GUI, &lt;strong&gt;both your master template and your new clone must live inside recognized Storage Pools.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If you just leave your master template in your &lt;code&gt;~/Downloads&lt;/code&gt; folder, Virt-Manager's UI will not let you select it as a backing file. &lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1: Create a Template Pool
&lt;/h3&gt;

&lt;p&gt;Let's create a dedicated, organized space for our master OS images.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Open &lt;strong&gt;Virtual Machine Manager&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Go to &lt;strong&gt;Edit&lt;/strong&gt; (in the top menu bar) and select &lt;strong&gt;Connection Details&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Click the &lt;strong&gt;Storage&lt;/strong&gt; tab.&lt;/li&gt;
&lt;li&gt;Click the &lt;strong&gt;&lt;code&gt;+&lt;/code&gt; (Add Pool)&lt;/strong&gt; button at the bottom left.&lt;/li&gt;
&lt;li&gt;Name the pool &lt;code&gt;templates&lt;/code&gt; and set the Type to &lt;code&gt;dir: Filesystem Directory&lt;/code&gt;. Click &lt;strong&gt;Forward&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Set the Target Path to a clean directory (e.g., &lt;code&gt;/home/youruser/VMs/templates&lt;/code&gt;) and click &lt;strong&gt;Finish&lt;/strong&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Now, use your regular Linux file manager (Nautilus, Dolphin, etc.) to &lt;strong&gt;move or copy your master &lt;code&gt;.qcow2&lt;/code&gt; image into that new folder&lt;/strong&gt;.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;⚠️ &lt;strong&gt;Crucial Warning:&lt;/strong&gt; Never boot a VM directly from this master template! If the master file gets modified, any linked clones relying on it will break instantly. Keep this file pristine.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Step 2: Generate the Linked Clone
&lt;/h2&gt;

&lt;p&gt;Now that Virt-Manager knows where your templates live, creating the clone is easy.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Still in the &lt;strong&gt;Connection Details -&amp;gt; Storage&lt;/strong&gt; window, select your &lt;strong&gt;default active pool&lt;/strong&gt; on the left (usually called &lt;code&gt;default&lt;/code&gt;, pointing to &lt;code&gt;/var/lib/libvirt/images/&lt;/code&gt;). This is where you want the actual VM to live and run.&lt;/li&gt;
&lt;li&gt;Click the &lt;strong&gt;&lt;code&gt;+&lt;/code&gt; (New Volume)&lt;/strong&gt; button located &lt;em&gt;above&lt;/em&gt; the volume list.&lt;/li&gt;
&lt;li&gt;Configure the new volume:

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Name:&lt;/strong&gt; &lt;code&gt;my-new-vm-disk.qcow2&lt;/code&gt; (or whatever you prefer)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Format:&lt;/strong&gt; &lt;code&gt;qcow2&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Look at the bottom of this window and expand the &lt;strong&gt;Backing Store&lt;/strong&gt; section.&lt;/li&gt;
&lt;li&gt;Click &lt;strong&gt;Browse...&lt;/strong&gt; next to the backing store path.&lt;/li&gt;
&lt;li&gt;In the window that pops up, select your &lt;code&gt;templates&lt;/code&gt; pool from the left menu.&lt;/li&gt;
&lt;li&gt;Click on your master &lt;code&gt;.qcow2&lt;/code&gt; file and click &lt;strong&gt;Choose Volume&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Click &lt;strong&gt;Finish&lt;/strong&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Virt-Manager will instantly generate the tiny, linked clone disk inside your active pool. It acts as a lightweight delta layer pointing directly to your master template!&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: Build the VM
&lt;/h2&gt;

&lt;p&gt;Finally, let's attach our new linked clone to a VM.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Close the Connection Details window.&lt;/li&gt;
&lt;li&gt;Click the &lt;strong&gt;New Virtual Machine&lt;/strong&gt; button (top left icon).&lt;/li&gt;
&lt;li&gt;Select &lt;strong&gt;Import existing disk image&lt;/strong&gt; and click Forward.&lt;/li&gt;
&lt;li&gt;Click &lt;strong&gt;Browse...&lt;/strong&gt; and select the &lt;code&gt;my-new-vm-disk.qcow2&lt;/code&gt; you just generated in your active pool.&lt;/li&gt;
&lt;li&gt;Finish the setup wizard by allocating RAM and CPUs.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That's it! You have successfully deployed a space-saving linked clone entirely within the Virt-Manager GUI.&lt;/p&gt;

&lt;h3&gt;
  
  
  What's Next?
&lt;/h3&gt;

&lt;p&gt;If you are using this method to provision cloud images (like Ubuntu Cloud or Amazon Linux), check out my full guide on &lt;a href="https://khadirullah.com/blog/amazon-linux-qemu-local-lab/" rel="noopener noreferrer"&gt;How to Run Amazon Linux 2023 Locally with QEMU/KVM and Cloud-Init&lt;/a&gt; to learn how to inject your SSH keys and configure your instances on first boot!&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Found this helpful? Follow me for more real-world Linux troubleshooting and DevOps content.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://khadirullah.com/blog/virt-manager-linked-clones/" rel="noopener noreferrer"&gt;khadirullah.com&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>linux</category>
      <category>kvm</category>
      <category>qemu</category>
      <category>virtmanager</category>
    </item>
    <item>
      <title>How to Run Amazon Linux 2023 Locally with QEMU/KVM and Cloud-Init</title>
      <dc:creator>Khadirullah Mohammad</dc:creator>
      <pubDate>Thu, 09 Jul 2026 15:53:50 +0000</pubDate>
      <link>https://dev.to/khadirullah/how-to-run-amazon-linux-2023-locally-with-qemukvm-and-cloud-init-127o</link>
      <guid>https://dev.to/khadirullah/how-to-run-amazon-linux-2023-locally-with-qemukvm-and-cloud-init-127o</guid>
      <description>&lt;p&gt;Cloud costs add up fast when you're learning DevOps. Running multiple virtual machines on AWS to practice Ansible, Docker, or Kubernetes means paying for every hour of compute, even when you're just experimenting.&lt;/p&gt;

&lt;p&gt;The solution? Run the &lt;strong&gt;exact same Amazon Linux images&lt;/strong&gt; that AWS uses on EC2, but locally on your machine using QEMU/KVM. No AWS account needed, no billing surprises, and your VMs boot in seconds.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Using VirtualBox, VMware, or Hyper-V?&lt;/strong&gt; This guide demonstrates the setup with QEMU/KVM and Virt-Manager, but the Cloud-Init concepts (the &lt;code&gt;user-data&lt;/code&gt; and &lt;code&gt;meta-data&lt;/code&gt; files, the &lt;code&gt;seed.iso&lt;/code&gt; creation, and the password behavior deep dive) apply identically to any hypervisor. The only difference is how you attach the disk image and CD-ROM drive in your VM manager's UI.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The catch? These cloud images don't have a default password. They expect to talk to AWS's metadata service to get their SSH keys and user configuration. On your local machine, that service doesn't exist.&lt;/p&gt;

&lt;p&gt;This guide shows you how to solve that problem using &lt;strong&gt;Cloud-Init's NoCloud method&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Note: This guide is structured to help beginners get a quick win using the Virt-Manager GUI, but it concludes with an advanced, 8-test deep-dive into Cloud-Init password behavior for senior engineers.&lt;/em&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;🔥&lt;br&gt;
&lt;strong&gt;Hardware Note:&lt;/strong&gt; I'm running this on a modern quad-core desktop CPU with 16GB RAM. Each Amazon Linux VM idles at ~100-150MB RAM with 1 vCPU. You can comfortably run 2-3 VMs simultaneously on similar hardware.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Why Use Cloud Images Instead of Regular ISOs?
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Cloud Image (.qcow2)&lt;/th&gt;
&lt;th&gt;Standard ISO (.iso)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Boot time&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;~5 seconds&lt;/td&gt;
&lt;td&gt;10-15 minute install first&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;RAM at idle&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;~100-150 MB (headless)&lt;/td&gt;
&lt;td&gt;300+ MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Disk size&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;~1.8 GB&lt;/td&gt;
&lt;td&gt;2-4 GB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Setup method&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Cloud-Init (automated)&lt;/td&gt;
&lt;td&gt;Manual wizard&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;DevOps relevance&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;✅ Mirrors production EC2 workflow&lt;/td&gt;
&lt;td&gt;❌ Nobody installs EC2 from an ISO&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Cloud images are pre-built, minimal, headless server images. Exactly what runs on every EC2 instance. Using them locally teaches you the same provisioning workflow that real cloud infrastructure uses.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Problem: The Missing Metadata Service
&lt;/h2&gt;

&lt;p&gt;When an EC2 instance boots on AWS, Cloud-Init reaches out to a special internal IP address (&lt;code&gt;169.254.169.254&lt;/code&gt;) to fetch its configuration. AWS's hypervisor intercepts this request, looks up your SSH key pairs and instance settings, and streams them back to the VM.&lt;/p&gt;

&lt;p&gt;On your local QEMU setup, that IP address goes nowhere. Cloud-Init panics, finds no configuration, and you're stuck at a login screen with no credentials.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Solution: NoCloud
&lt;/h2&gt;

&lt;p&gt;Cloud-Init has a fallback mechanism called &lt;strong&gt;NoCloud&lt;/strong&gt;. Instead of reaching out to a network endpoint, it checks for a locally attached storage drive with the volume label &lt;code&gt;cidata&lt;/code&gt;. We create a tiny virtual CD-ROM (&lt;code&gt;seed.iso&lt;/code&gt;) containing our configuration files and mount it to the VM. Cloud-Init reads it on first boot and configures everything automatically.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fphsvfm91ktg3veq6mpcu.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fphsvfm91ktg3veq6mpcu.png" alt="Flowchart showing how the Amazon Linux cloud image boots, fails to find the AWS metadata service at 169.254.169.254, falls back to the NoCloud data source, and successfully mounts the local seed.iso drive to apply configurations." width="800" height="202"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 1: Download the Amazon Linux 2023 Image
&lt;/h2&gt;

&lt;p&gt;Grab the official KVM/QCOW2 image from the &lt;a href="https://cdn.amazonlinux.com/al2023/os-images/latest/" rel="noopener noreferrer"&gt;Amazon Linux 2023 downloads page&lt;/a&gt;. Choose the &lt;code&gt;kvm&lt;/code&gt; directory for x86_64 or &lt;code&gt;kvm-arm64&lt;/code&gt; for ARM-based hosts.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Organize Your Files
&lt;/h2&gt;

&lt;p&gt;Never use the downloaded file directly. QEMU modifies the &lt;code&gt;.qcow2&lt;/code&gt; file when the VM boots. Keep a clean master copy as a template.&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;# Create your lab structure&lt;/span&gt;
&lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; ~/VMs/templates ~/VMs/active-vms/vm1

&lt;span class="c"&gt;# Save the original as a master template&lt;/span&gt;
&lt;span class="nb"&gt;mv&lt;/span&gt; ~/Downloads/al2023-kvm-&lt;span class="k"&gt;*&lt;/span&gt;.qcow2 ~/VMs/templates/al2023-master.qcow2

&lt;span class="c"&gt;# Create a working copy for your first VM&lt;/span&gt;
&lt;span class="nb"&gt;cp&lt;/span&gt; ~/VMs/templates/al2023-master.qcow2 ~/VMs/active-vms/vm1/amzn-devops-vm1.qcow2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;💡&lt;br&gt;
&lt;strong&gt;Pro Tip: Linked Clones.&lt;/strong&gt; If you plan to spin up many VMs, use a linked clone instead of a full copy, saving gigabytes of disk space.&lt;br&gt;
&lt;/p&gt;
&lt;/blockquote&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;qemu-img create &lt;span class="nt"&gt;-f&lt;/span&gt; qcow2 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-b&lt;/span&gt; ~/VMs/templates/al2023-master.qcow2 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-F&lt;/span&gt; qcow2 &lt;span class="se"&gt;\&lt;/span&gt;
  ~/VMs/active-vms/vm1/amzn-devops-vm1.qcow2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 3: Create the Cloud-Init Configuration
&lt;/h2&gt;

&lt;p&gt;You need two plain text files. No file extensions. Name them exactly &lt;code&gt;user-data&lt;/code&gt; and &lt;code&gt;meta-data&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  File 1: &lt;code&gt;user-data&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;This configures your user account, SSH key, and a backup console password. Replace the SSH key placeholder with your actual public key (run &lt;code&gt;cat ~/.ssh/id_ed25519.pub&lt;/code&gt; to get it).&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;#cloud-config&lt;/span&gt;
&lt;span class="na"&gt;users&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;ec2-user&lt;/span&gt;
    &lt;span class="na"&gt;groups&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;wheel&lt;/span&gt;
    &lt;span class="na"&gt;sudo&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;ALL=(ALL)&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;NOPASSWD:ALL'&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;shell&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/bin/bash&lt;/span&gt;
    &lt;span class="na"&gt;ssh_authorized_keys&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA...YOUR_PUBLIC_KEY_HERE...&lt;/span&gt;
&lt;span class="na"&gt;chpasswd&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;users&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;ec2-user&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;amazon&lt;/span&gt;
      &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;text&lt;/span&gt;
  &lt;span class="na"&gt;expire&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;💡&lt;br&gt;
&lt;strong&gt;Using an older Cloud-Init version?&lt;/strong&gt; Amazon Linux 2023 ships with cloud-init &lt;strong&gt;v22.2.2&lt;/strong&gt;, which also supports the legacy multiline format shown below. Both formats work, but the list format above is recommended since the multiline format will be removed in a future version.&lt;br&gt;
&lt;/p&gt;
&lt;/blockquote&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Legacy format (deprecated, still works on v22.2.2)&lt;/span&gt;
&lt;span class="na"&gt;chpasswd&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;list&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
    &lt;span class="s"&gt;ec2-user:amazon&lt;/span&gt;
  &lt;span class="na"&gt;expire&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;False&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Field&lt;/th&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;#cloud-config&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Must be the first line. Tells Cloud-Init this is a valid config&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;name: ec2-user&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Creates the default Amazon Linux user&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;groups: wheel&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Adds the user to the sudo group&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;sudo: ['ALL=(ALL) NOPASSWD:ALL']&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Passwordless sudo access&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ssh_authorized_keys&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Injects your host's public SSH key for password-free login&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;chpasswd&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Sets a backup password (&lt;code&gt;amazon&lt;/code&gt;) for console access via Virt-Manager&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;expire: false&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Prevents forcing a password change on first login&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;blockquote&gt;
&lt;p&gt;💡&lt;br&gt;
&lt;strong&gt;Don't have an SSH key?&lt;/strong&gt; Generate one with the commands below.&lt;br&gt;
&lt;/p&gt;
&lt;/blockquote&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;"devops-local"&lt;/span&gt;
&lt;span class="nb"&gt;cat&lt;/span&gt; ~/.ssh/id_ed25519.pub  &lt;span class="c"&gt;# Copy this entire output into user-data&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  File 2: &lt;code&gt;meta-data&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;This sets the VM's hostname and a unique instance ID. &lt;strong&gt;Do NOT add &lt;code&gt;#cloud-config&lt;/code&gt; to this file&lt;/strong&gt; because that header is only for &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 yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;local-hostname&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;amzn-devops-vm1&lt;/span&gt;
&lt;span class="na"&gt;instance-id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;i-devopslab01&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;instance-id&lt;/code&gt; is &lt;strong&gt;mandatory&lt;/strong&gt;. Cloud-Init will reject the NoCloud datasource without it. It can be any string, but using the AWS-style &lt;code&gt;i-&lt;/code&gt; prefix keeps your mental model consistent with production EC2 instances.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;How instance-id actually works:&lt;/strong&gt; Cloud-Init does NOT check instance IDs across VMs; each VM is completely isolated. The check happens &lt;strong&gt;within a single VM only&lt;/strong&gt;: on every boot, Cloud-Init compares the &lt;code&gt;instance-id&lt;/code&gt; from the seed.iso against the one stored on the VM's own disk (&lt;code&gt;/var/lib/cloud/data/instance-id&lt;/code&gt;). If they match, it skips first-boot setup. If they differ, it treats it as a brand-new deployment and re-runs provisioning. This is why changing the &lt;code&gt;instance-id&lt;/code&gt; forces a fresh Cloud-Init run.&lt;/p&gt;

&lt;p&gt;We still use unique IDs per VM (e.g., &lt;code&gt;i-devopslab01&lt;/code&gt;, &lt;code&gt;i-devopslab02&lt;/code&gt;) primarily so each VM gets a unique &lt;code&gt;local-hostname&lt;/code&gt; on the network. Otherwise multiple VMs would show up with the same name, which gets confusing fast.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Step 4: Generate the Seed ISO
&lt;/h2&gt;

&lt;p&gt;Package your configuration files into a virtual CD-ROM image. The volume label &lt;strong&gt;must&lt;/strong&gt; be exactly &lt;code&gt;cidata&lt;/code&gt;. Cloud-Init ignores anything else.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Method A: The Modern Way (cloud-localds - Recommended)&lt;/strong&gt;&lt;br&gt;
The Cloud-Init developers provide a dedicated tool for this that handles volume labels automatically.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;Ubuntu/Debian:&lt;/strong&gt; &lt;code&gt;sudo apt install -y cloud-image-utils&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Fedora/RHEL:&lt;/strong&gt; &lt;code&gt;sudo dnf install -y cloud-utils&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;cloud-localds seed.iso user-data meta-data
&lt;span class="nb"&gt;mv &lt;/span&gt;seed.iso ~/VMs/active-vms/vm1/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;&lt;strong&gt;Method B: The Traditional Way (genisoimage - Alternative)&lt;/strong&gt;&lt;br&gt;
If you require strict ISO9660 formatting or cross-platform compatibility, use the traditional tool:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;Ubuntu/Debian:&lt;/strong&gt; &lt;code&gt;sudo apt install -y genisoimage&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Fedora/RHEL:&lt;/strong&gt; &lt;code&gt;sudo dnf install -y genisoimage&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&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;span class="nb"&gt;mv &lt;/span&gt;seed.iso ~/VMs/active-vms/vm1/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;&lt;em&gt;(Curious about which tool is right for you? See the &lt;strong&gt;Appendix&lt;/strong&gt; at the end of this article for a full breakdown of the differences.)&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Your &lt;code&gt;vm1&lt;/code&gt; folder should now contain:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;~/VMs/active-vms/vm1/
├── amzn-devops-vm1.qcow2    # The VM disk
└── seed.iso                  # Cloud-Init config disk
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Step 5: Import into Virt-Manager
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Create New VM:&lt;/strong&gt; Click the top-left icon → Select &lt;strong&gt;Import existing disk image&lt;/strong&gt; → Forward.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Select Disk:&lt;/strong&gt; Click &lt;strong&gt;Browse → Browse Local&lt;/strong&gt; → Choose your &lt;code&gt;.qcow2&lt;/code&gt; file.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Set OS Type:&lt;/strong&gt; Type &lt;strong&gt;Generic Linux&lt;/strong&gt; (or Red Hat Enterprise Linux 9) → Forward.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Allocate Resources:&lt;/strong&gt; Memory: &lt;strong&gt;1024 MiB&lt;/strong&gt;, CPUs: &lt;strong&gt;1&lt;/strong&gt; → Forward.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Customize:&lt;/strong&gt; Name your VM, &lt;strong&gt;check "Customize configuration before install"&lt;/strong&gt; → Finish.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Attach the Seed ISO (Critical Step)
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;Click &lt;strong&gt;Add Hardware&lt;/strong&gt; (bottom-left).&lt;/li&gt;
&lt;li&gt;Select &lt;strong&gt;Storage&lt;/strong&gt;, change &lt;strong&gt;Device type&lt;/strong&gt; to &lt;strong&gt;CDROM device&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Click &lt;strong&gt;Manage... → Browse Local&lt;/strong&gt; → Select your &lt;code&gt;seed.iso&lt;/code&gt; → Finish.&lt;/li&gt;
&lt;li&gt;Click &lt;strong&gt;Apply&lt;/strong&gt;, then &lt;strong&gt;Begin Installation&lt;/strong&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Your VM boots in text mode. Cloud-Init reads the seed ISO, creates &lt;code&gt;ec2-user&lt;/code&gt;, injects your SSH key, and sets the hostname within seconds.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 6: Connect via SSH
&lt;/h2&gt;

&lt;p&gt;Find the VM's IP from Virt-Manager (lightbulb icon → NIC → IP Address), then:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh ec2-user@192.168.122.X
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You should see the Amazon Linux welcome banner:&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="gp"&gt;   ,     #&lt;/span&gt;_
&lt;span class="gp"&gt;   ~\_  #&lt;/span&gt;&lt;span class="c"&gt;###_        Amazon Linux 2023&lt;/span&gt;
&lt;span class="gp"&gt;  ~~  \_#&lt;/span&gt;&lt;span class="c"&gt;####\&lt;/span&gt;
&lt;span class="gp"&gt;  ~~     \#&lt;/span&gt;&lt;span class="c"&gt;##|&lt;/span&gt;
&lt;span class="gp"&gt;  ~~       \#&lt;/span&gt;/ ___   https://aws.amazon.com/linux/amazon-linux-2023
&lt;span class="gp"&gt;   ~~       V~' '-&amp;gt;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="go"&gt;    ~~~         /
      ~~._.   _/
         _/ _/
       _/m/'
&lt;/span&gt;&lt;span class="gp"&gt;[ec2-user@amzn-devops-vm1 ~]$&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;p&gt;🎉 &lt;strong&gt;Success!&lt;/strong&gt; If you are just starting your DevOps journey, you can stop right here! You have a fully functional Amazon Linux 2023 environment, and you can start doing your practice and deploying applications.&lt;/p&gt;

&lt;p&gt;🔬 &lt;strong&gt;Advanced Reading:&lt;/strong&gt; The rest of this guide is a technical deep-dive into how Cloud-Init handles passwords under the hood. If you want to level up your Linux troubleshooting skills, keep reading.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Cloud-Init Deep Dive: What Really Happens to Your Password
&lt;/h2&gt;

&lt;p&gt;This is where most tutorials stop. But when I tried to clean up my VM by removing the &lt;code&gt;seed.iso&lt;/code&gt;, everything broke. I ran 8 controlled tests to map out exactly how the password-locking mechanics work under the hood.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Discovery: How Cloud-Init Manages Passwords
&lt;/h3&gt;

&lt;p&gt;By examining &lt;code&gt;/var/log/cloud-init.log&lt;/code&gt;, I found that when Cloud-Init detects a &lt;strong&gt;new instance&lt;/strong&gt; (a new &lt;code&gt;instance-id&lt;/code&gt; it hasn't seen before), it runs a two-step process:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Step 1: passwd -l ec2-user     ← LOCKS the account first (users-groups module)
Step 2: chpasswd               ← Sets password, which UNLOCKS it (set-passwords module)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The lock happens first as a security measure. Then &lt;code&gt;chpasswd&lt;/code&gt; runs using the password from your &lt;code&gt;user-data&lt;/code&gt; file to unlock and set the password. This design ensures that if anything goes wrong with provisioning, the account defaults to &lt;strong&gt;locked&lt;/strong&gt; rather than leaving a default password exposed.&lt;/p&gt;

&lt;p&gt;Both modules run with frequency &lt;code&gt;once-per-instance&lt;/code&gt;, meaning they only execute once per unique &lt;code&gt;instance-id&lt;/code&gt;, then create a semaphore file to remember they already ran. &lt;strong&gt;On normal reboots where the instance-id hasn't changed, both modules are skipped entirely&lt;/strong&gt;. Cloud-Init doesn't touch the password at all.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flhnbuf5mohm945mpgsal.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flhnbuf5mohm945mpgsal.png" alt="Flowchart explaining Cloud-Init's instance-id logic. If it is the same instance-id, modules are skipped and the password is safe. If it is a new instance-id, it checks for the seed.iso. If attached, the password resets successfully. If removed, chpasswd fails and the password remains locked." width="800" height="1269"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  The 8 Tests
&lt;/h3&gt;

&lt;p&gt;I ran each test on a &lt;strong&gt;fresh VM&lt;/strong&gt; to eliminate leftover state. Here's what I found:&lt;/p&gt;

&lt;h4&gt;
  
  
  Test 1 &amp;amp; 2: Normal Operation ✅
&lt;/h4&gt;

&lt;p&gt;&lt;strong&gt;Fresh boot with seed.iso&lt;/strong&gt; → Cloud-Init detects a new &lt;code&gt;instance-id&lt;/code&gt;, runs &lt;code&gt;passwd -l&lt;/code&gt; (locks account), then runs &lt;code&gt;chpasswd&lt;/code&gt; (sets password &lt;code&gt;amazon&lt;/code&gt;, unlocking the account). Password works on console. SSH key works.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reboot with seed.iso still attached&lt;/strong&gt; → Password survives. Cloud-Init sees the same &lt;code&gt;instance-id&lt;/code&gt; as the previous boot. The semaphore files exist, so &lt;strong&gt;all modules are skipped&lt;/strong&gt;: no &lt;code&gt;passwd -l&lt;/code&gt;, no &lt;code&gt;chpasswd&lt;/code&gt;, nothing. The password in &lt;code&gt;/etc/shadow&lt;/code&gt; stays exactly as it was.&lt;/p&gt;

&lt;h4&gt;
  
  
  Test 3: Removing the Seed ISO ❌
&lt;/h4&gt;

&lt;p&gt;&lt;strong&gt;Remove seed.iso from Virt-Manager, reboot.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The password is &lt;strong&gt;locked&lt;/strong&gt;. SSH key still works. Here's what the logs revealed:&lt;/p&gt;

&lt;p&gt;Cloud-Init can't find the seed.iso, so it falls back to &lt;code&gt;DataSourceNone&lt;/code&gt; and assigns a fallback instance-id: &lt;strong&gt;&lt;code&gt;iid-datasource-none&lt;/code&gt;&lt;/strong&gt;. Because this is a "new" instance-id, Cloud-Init treats it as a fresh deployment:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;passwd -l ec2-user&lt;/code&gt; runs → account &lt;strong&gt;locked&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;set-passwords&lt;/code&gt; runs under the new instance-id, but there's &lt;strong&gt;no &lt;code&gt;user-data&lt;/code&gt; to read&lt;/strong&gt; (the seed.iso is gone) → &lt;code&gt;chpasswd&lt;/code&gt; has no password to set&lt;/li&gt;
&lt;li&gt;Account stays locked&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The SSH key survives because it's a file (&lt;code&gt;~/.ssh/authorized_keys&lt;/code&gt;) sitting on the VM's disk. Nothing deletes it.&lt;/p&gt;

&lt;h4&gt;
  
  
  Test 4: The Semaphore Trap ❌
&lt;/h4&gt;

&lt;p&gt;&lt;strong&gt;Re-attach the same seed.iso (same instance-id), reboot.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Password is &lt;strong&gt;still locked&lt;/strong&gt;. The semaphore file from the original first boot (&lt;code&gt;/var/lib/cloud/instances/i-devopslab01/sem/config_set_passwords&lt;/code&gt;) still exists on disk. Cloud-Init sees the original &lt;code&gt;instance-id&lt;/code&gt;, finds the semaphore, and says:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;config-set-passwords already ran (freq=once-per-instance)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It skips &lt;code&gt;chpasswd&lt;/code&gt; entirely. The account remains locked from the previous boot (when Cloud-Init ran &lt;code&gt;passwd -l&lt;/code&gt; under the fallback instance-id) because no module runs to unlock it.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;ℹ️&lt;br&gt;
&lt;strong&gt;The Instance-ID Fix:&lt;/strong&gt; Changing the &lt;code&gt;instance-id&lt;/code&gt; in your &lt;code&gt;meta-data&lt;/code&gt; to a new value (e.g., &lt;code&gt;i-devopslab02&lt;/code&gt;) forces Cloud-Init to treat it as a brand-new deployment. It creates a fresh semaphore directory and re-runs &lt;code&gt;chpasswd&lt;/code&gt;, restoring your password.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h4&gt;
  
  
  Test 5: The &lt;code&gt;cloud-init.disabled&lt;/code&gt; File ❌
&lt;/h4&gt;

&lt;p&gt;&lt;strong&gt;Create &lt;code&gt;/etc/cloud/cloud-init.disabled&lt;/code&gt;, remove seed.iso, reboot.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Password is &lt;strong&gt;still locked&lt;/strong&gt;. Despite &lt;code&gt;cloud-init status&lt;/code&gt; reporting &lt;code&gt;disabled&lt;/code&gt;, all four systemd services &lt;strong&gt;still executed&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;cloud-init.service         → Active: active (exited)   ← Still ran!
cloud-init-local.service   → Active: active (exited)   ← Still ran!
cloud-config.service       → Active: active (exited)   ← Still ran!
cloud-final.service        → Active: active (exited)   ← Still ran!
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On Amazon Linux 2023 (cloud-init &lt;strong&gt;v22.2.2&lt;/strong&gt;), the &lt;code&gt;cloud-init.disabled&lt;/code&gt; file causes Cloud-Init to self-report as disabled, but the &lt;strong&gt;systemd services were still enabled&lt;/strong&gt; and still ran &lt;code&gt;passwd -l&lt;/code&gt;.&lt;/p&gt;

&lt;h4&gt;
  
  
  Test 6: &lt;code&gt;systemctl disable&lt;/code&gt; ❌
&lt;/h4&gt;

&lt;p&gt;&lt;strong&gt;Use both &lt;code&gt;cloud-init.disabled&lt;/code&gt; AND &lt;code&gt;systemctl disable&lt;/code&gt; for all four services, remove seed.iso, reboot.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Password is &lt;strong&gt;still locked&lt;/strong&gt;. Even with services marked as &lt;code&gt;disabled&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;cloud-init.service; disabled; preset: disabled
Active: active (exited)    ← STILL RAN despite being disabled!
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Cloud-Init uses a &lt;strong&gt;systemd generator&lt;/strong&gt; (&lt;code&gt;cloud-init-generator&lt;/code&gt;) that dynamically creates service dependencies at boot time. The generator overrides the static &lt;code&gt;disable&lt;/code&gt; setting. &lt;code&gt;systemctl disable&lt;/code&gt; only removes boot symlinks, but generators can recreate them.&lt;/p&gt;

&lt;h4&gt;
  
  
  Test 7: &lt;code&gt;systemctl mask&lt;/code&gt; ✅
&lt;/h4&gt;

&lt;p&gt;&lt;strong&gt;Use both &lt;code&gt;cloud-init.disabled&lt;/code&gt; AND &lt;code&gt;systemctl mask&lt;/code&gt; for all four services, remove seed.iso, reboot.&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="nb"&gt;sudo touch&lt;/span&gt; /etc/cloud/cloud-init.disabled
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl mask cloud-init-local.service cloud-init.service cloud-config.service cloud-final.service
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Password SURVIVES!&lt;/strong&gt; All services are truly dead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;cloud-init.service         → Loaded: masked    Active: inactive (dead)
cloud-init-local.service   → Loaded: masked    Active: inactive (dead)
cloud-config.service       → Loaded: masked    Active: inactive (dead)
cloud-final.service        → Loaded: masked    Active: inactive (dead)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;systemctl mask&lt;/code&gt; creates symlinks to &lt;code&gt;/dev/null&lt;/code&gt;, making it &lt;strong&gt;impossible&lt;/strong&gt; for any generator or dependency to start the service. No &lt;code&gt;passwd -l&lt;/code&gt; runs, the password in &lt;code&gt;/etc/shadow&lt;/code&gt; stays untouched, and the SSH host keys are also preserved (no more "REMOTE HOST IDENTIFICATION HAS CHANGED" errors).&lt;/p&gt;

&lt;p&gt;I &lt;strong&gt;reproduced this test twice&lt;/strong&gt; on separate fresh VMs to confirm.&lt;/p&gt;

&lt;h4&gt;
  
  
  Test 8: &lt;code&gt;lock_passwd: false&lt;/code&gt; in user-data ❌
&lt;/h4&gt;

&lt;p&gt;&lt;strong&gt;Add &lt;code&gt;lock_passwd: false&lt;/code&gt; to the users block in user-data, remove seed.iso, reboot.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Password is &lt;strong&gt;still locked&lt;/strong&gt;. The &lt;code&gt;lock_passwd: false&lt;/code&gt; directive lives inside the &lt;code&gt;user-data&lt;/code&gt; file, which lives on the &lt;code&gt;seed.iso&lt;/code&gt;. When you remove the ISO, Cloud-Init can't read that directive, so it falls back to Amazon Linux's default config which doesn't include it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Results Summary
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Test&lt;/th&gt;
&lt;th&gt;Method&lt;/th&gt;
&lt;th&gt;Password&lt;/th&gt;
&lt;th&gt;SSH Key&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;Fresh boot with seed.iso&lt;/td&gt;
&lt;td&gt;✅ Works&lt;/td&gt;
&lt;td&gt;✅ Works&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;Reboot, seed.iso attached&lt;/td&gt;
&lt;td&gt;✅ Works&lt;/td&gt;
&lt;td&gt;✅ Works&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;Remove seed.iso&lt;/td&gt;
&lt;td&gt;❌ Locked&lt;/td&gt;
&lt;td&gt;✅ Works&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;Re-attach same seed.iso&lt;/td&gt;
&lt;td&gt;❌ Locked&lt;/td&gt;
&lt;td&gt;✅ Works&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;cloud-init.disabled&lt;/code&gt; file&lt;/td&gt;
&lt;td&gt;❌ Locked&lt;/td&gt;
&lt;td&gt;✅ Works&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;disabled&lt;/code&gt; + &lt;code&gt;systemctl disable&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;❌ Locked&lt;/td&gt;
&lt;td&gt;✅ Works&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;7&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;&lt;code&gt;disabled&lt;/code&gt; + &lt;code&gt;systemctl mask&lt;/code&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;✅ Works&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;✅ Works&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;lock_passwd: false&lt;/code&gt; in user-data&lt;/td&gt;
&lt;td&gt;❌ Locked&lt;/td&gt;
&lt;td&gt;✅ Works&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  The Definitive Answer
&lt;/h3&gt;

&lt;p&gt;There are three ways to handle &lt;code&gt;seed.iso&lt;/code&gt; removal. Pick whichever fits your workflow:&lt;/p&gt;

&lt;h4&gt;
  
  
  Option 1: Just leave the seed.iso attached (zero effort)
&lt;/h4&gt;

&lt;p&gt;Don't remove it. It uses zero CPU, negligible disk space, and Cloud-Init stays happy on every reboot. This is the simplest option if you don't mind the ISO sitting in your VM's hardware list.&lt;/p&gt;

&lt;h4&gt;
  
  
  Option 2: Remove seed.iso, then reset the password (easiest fix)
&lt;/h4&gt;

&lt;p&gt;This is the simplest solution if you want a clean VM without the ISO attached:&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. Remove seed.iso from Virt-Manager, then boot the VM&lt;/span&gt;
&lt;span class="c"&gt;# 2. Password won't work on console, but SSH still works:&lt;/span&gt;
ssh ec2-user@192.168.122.X
&lt;span class="c"&gt;# 3. Manually reset the password:&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;passwd ec2-user
&lt;span class="c"&gt;# Enter your new password (e.g., 'amazon') twice&lt;/span&gt;
&lt;span class="c"&gt;# 4. Done. Password now survives all future reboots.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Why this works:&lt;/strong&gt; When you removed the seed.iso and rebooted, Cloud-Init assigned the fallback instance-id &lt;code&gt;iid-datasource-none&lt;/code&gt; and created semaphore files for it. From this point on, the instance-id never changes (there's no seed.iso to provide a different one). So on every future reboot, Cloud-Init sees the same &lt;code&gt;iid-datasource-none&lt;/code&gt;, finds the existing semaphores, and &lt;strong&gt;skips all modules&lt;/strong&gt;, including &lt;code&gt;passwd -l&lt;/code&gt;. Your manually set password in &lt;code&gt;/etc/shadow&lt;/code&gt; stays untouched.&lt;/p&gt;

&lt;h4&gt;
  
  
  Option 3: Mask Cloud-Init, then remove seed.iso (most thorough)
&lt;/h4&gt;

&lt;p&gt;If you want to completely prevent Cloud-Init from ever running again:&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 VM after first boot&lt;/span&gt;
&lt;span class="nb"&gt;sudo touch&lt;/span&gt; /etc/cloud/cloud-init.disabled
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl mask cloud-init-local.service cloud-init.service cloud-config.service cloud-final.service
&lt;span class="nb"&gt;sudo &lt;/span&gt;poweroff
&lt;span class="c"&gt;# Now remove seed.iso from Virt-Manager's CD-ROM drive&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the nuclear option: Cloud-Init is permanently dead. Password, SSH keys, and host keys all stay exactly as they were. Use this if you're converting the VM into a long-lived server where Cloud-Init has no purpose.&lt;/p&gt;




&lt;h2&gt;
  
  
  Creating Additional VMs
&lt;/h2&gt;

&lt;p&gt;For every new VM, you need three unique items:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Item&lt;/th&gt;
&lt;th&gt;VM 1&lt;/th&gt;
&lt;th&gt;VM 2&lt;/th&gt;
&lt;th&gt;VM 3&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Folder&lt;/td&gt;
&lt;td&gt;&lt;code&gt;~/VMs/active-vms/vm1/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;~/VMs/active-vms/vm2/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;~/VMs/active-vms/vm3/&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;local-hostname&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;amzn-devops-vm1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;amzn-devops-vm2&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;amzn-devops-vm3&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;instance-id&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;i-devopslab01&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;i-devopslab02&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;i-devopslab03&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Your &lt;code&gt;user-data&lt;/code&gt; stays the same since your SSH key doesn't change.&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;# Quick setup for a second VM&lt;/span&gt;
&lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; ~/VMs/active-vms/vm2
&lt;span class="nb"&gt;cp&lt;/span&gt; ~/VMs/templates/al2023-master.qcow2 ~/VMs/active-vms/vm2/amzn-devops-vm2.qcow2

&lt;span class="c"&gt;# Create meta-data with new hostname and instance-id&lt;/span&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="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;
local-hostname: amzn-devops-vm2
instance-id: i-devopslab02
&lt;/span&gt;&lt;span class="no"&gt;EOF

&lt;/span&gt;&lt;span class="c"&gt;# Regenerate seed.iso&lt;/span&gt;
genisoimage &lt;span class="nt"&gt;-output&lt;/span&gt; ~/VMs/active-vms/vm2/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;All VMs on Virt-Manager share the same virtual network bridge (&lt;code&gt;virbr0&lt;/code&gt;, subnet &lt;code&gt;192.168.122.0/24&lt;/code&gt;) by default, so they can talk to each other and your host machine out of the box.&lt;/p&gt;




&lt;h2&gt;
  
  
  Automating the Entire Process
&lt;/h2&gt;

&lt;p&gt;Here's a shell script that replaces the entire manual Virt-Manager workflow:&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;#!/bin/bash&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-e&lt;/span&gt;

&lt;span class="nv"&gt;TEMPLATE_DIR&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$HOME&lt;/span&gt;&lt;span class="s2"&gt;/VMs/templates"&lt;/span&gt;
&lt;span class="nv"&gt;MASTER_TEMPLATE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$TEMPLATE_DIR&lt;/span&gt;&lt;span class="s2"&gt;/al2023-master.qcow2"&lt;/span&gt;
&lt;span class="nv"&gt;USER_DATA_TEMPLATE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$TEMPLATE_DIR&lt;/span&gt;&lt;span class="s2"&gt;/user-data"&lt;/span&gt;
&lt;span class="nv"&gt;BASE_DIR&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$HOME&lt;/span&gt;&lt;span class="s2"&gt;/VMs/active-vms"&lt;/span&gt;
&lt;span class="nv"&gt;FULL_COPY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;false&lt;/span&gt;

&lt;span class="c"&gt;# Parse arguments&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;arg &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$@&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
    case&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$arg&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt;
        &lt;span class="nt"&gt;--full-copy&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nv"&gt;FULL_COPY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;shift&lt;/span&gt; &lt;span class="p"&gt;;;&lt;/span&gt;
    &lt;span class="k"&gt;esac&lt;/span&gt;
&lt;span class="k"&gt;done

if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"$#"&lt;/span&gt; &lt;span class="nt"&gt;-ne&lt;/span&gt; 2 &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Usage: &lt;/span&gt;&lt;span class="nv"&gt;$0&lt;/span&gt;&lt;span class="s2"&gt; [--full-copy] &amp;lt;vm-name&amp;gt; &amp;lt;instance-id&amp;gt;"&lt;/span&gt;
    &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Example: &lt;/span&gt;&lt;span class="nv"&gt;$0&lt;/span&gt;&lt;span class="s2"&gt; amzn-devops-vm2 devopslab02"&lt;/span&gt;
    &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;""&lt;/span&gt;
    &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Options:"&lt;/span&gt;
    &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"  --full-copy  Create a full disk copy (~1.8 GB) instead of a linked clone (~200 KB)"&lt;/span&gt;
    &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"               Linked clones are faster and save disk space, but depend on the master template."&lt;/span&gt;
    &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"               Use --full-copy if you plan to move or delete the master template later."&lt;/span&gt;
    &lt;span class="nb"&gt;exit &lt;/span&gt;1
&lt;span class="k"&gt;fi

&lt;/span&gt;&lt;span class="nv"&gt;VM_NAME&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nv"&gt;INSTANCE_ID&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$2&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nv"&gt;VM_DIR&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$BASE_DIR&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="nv"&gt;$VM_NAME&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="c"&gt;# Safety checks&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$MASTER_TEMPLATE&lt;/span&gt;&lt;span class="s2"&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;then
    &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"ERROR: Master template not found: &lt;/span&gt;&lt;span class="nv"&gt;$MASTER_TEMPLATE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
    &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Download from: https://cdn.amazonlinux.com/al2023/os-images/latest/kvm/"&lt;/span&gt;
    &lt;span class="nb"&gt;exit &lt;/span&gt;1
&lt;span class="k"&gt;fi

if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$USER_DATA_TEMPLATE&lt;/span&gt;&lt;span class="s2"&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;then
    &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"ERROR: user-data template not found: &lt;/span&gt;&lt;span class="nv"&gt;$USER_DATA_TEMPLATE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
    &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Create your user-data file at: &lt;/span&gt;&lt;span class="nv"&gt;$USER_DATA_TEMPLATE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
    &lt;span class="nb"&gt;exit &lt;/span&gt;1
&lt;span class="k"&gt;fi

if &lt;/span&gt;virsh dominfo &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$VM_NAME&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &amp;amp;&amp;gt;/dev/null&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"ERROR: VM '&lt;/span&gt;&lt;span class="nv"&gt;$VM_NAME&lt;/span&gt;&lt;span class="s2"&gt;' already exists. Choose a different name or remove it first:"&lt;/span&gt;
    &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"  virsh destroy &lt;/span&gt;&lt;span class="nv"&gt;$VM_NAME&lt;/span&gt;&lt;span class="s2"&gt; &amp;amp;&amp;amp; virsh undefine &lt;/span&gt;&lt;span class="nv"&gt;$VM_NAME&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
    &lt;span class="nb"&gt;exit &lt;/span&gt;1
&lt;span class="k"&gt;fi

&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"=== Provisioning &lt;/span&gt;&lt;span class="nv"&gt;$VM_NAME&lt;/span&gt;&lt;span class="s2"&gt; ==="&lt;/span&gt;

&lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$VM_DIR&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="c"&gt;# Generate unique meta-data&lt;/span&gt;
&lt;span class="nb"&gt;cat&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt; &amp;gt; "&lt;/span&gt;&lt;span class="nv"&gt;$VM_DIR&lt;/span&gt;&lt;span class="sh"&gt;/meta-data"
local-hostname: &lt;/span&gt;&lt;span class="nv"&gt;$VM_NAME&lt;/span&gt;&lt;span class="sh"&gt;
instance-id: i-&lt;/span&gt;&lt;span class="nv"&gt;$INSTANCE_ID&lt;/span&gt;&lt;span class="sh"&gt;
&lt;/span&gt;&lt;span class="no"&gt;EOF

&lt;/span&gt;&lt;span class="c"&gt;# Copy user-data template&lt;/span&gt;
&lt;span class="nb"&gt;cp&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$USER_DATA_TEMPLATE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$VM_DIR&lt;/span&gt;&lt;span class="s2"&gt;/user-data"&lt;/span&gt;

&lt;span class="c"&gt;# Generate seed ISO&lt;/span&gt;
genisoimage &lt;span class="nt"&gt;-output&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$VM_DIR&lt;/span&gt;&lt;span class="s2"&gt;/seed.iso"&lt;/span&gt; &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; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$VM_DIR&lt;/span&gt;&lt;span class="s2"&gt;/user-data"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$VM_DIR&lt;/span&gt;&lt;span class="s2"&gt;/meta-data"&lt;/span&gt;

&lt;span class="c"&gt;# Create VM disk (linked clone by default, full copy with --full-copy)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$FULL_COPY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;true&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Creating full disk copy (~1.8 GB)..."&lt;/span&gt;
    &lt;span class="nb"&gt;cp&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$MASTER_TEMPLATE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$VM_DIR&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="nv"&gt;$VM_NAME&lt;/span&gt;&lt;span class="s2"&gt;.qcow2"&lt;/span&gt;
&lt;span class="k"&gt;else
    &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Creating linked clone (~200 KB, depends on master template)..."&lt;/span&gt;
    qemu-img create &lt;span class="nt"&gt;-f&lt;/span&gt; qcow2 &lt;span class="nt"&gt;-b&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$MASTER_TEMPLATE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-F&lt;/span&gt; qcow2 &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$VM_DIR&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="nv"&gt;$VM_NAME&lt;/span&gt;&lt;span class="s2"&gt;.qcow2"&lt;/span&gt;
&lt;span class="k"&gt;fi&lt;/span&gt;

&lt;span class="c"&gt;# Deploy headless&lt;/span&gt;
virt-install &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--name&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$VM_NAME&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--memory&lt;/span&gt; 1024 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--vcpus&lt;/span&gt; 1 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--disk&lt;/span&gt; &lt;span class="nv"&gt;path&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$VM_DIR&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="nv"&gt;$VM_NAME&lt;/span&gt;&lt;span class="s2"&gt;.qcow2"&lt;/span&gt;,device&lt;span class="o"&gt;=&lt;/span&gt;disk,bus&lt;span class="o"&gt;=&lt;/span&gt;virtio &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--disk&lt;/span&gt; &lt;span class="nv"&gt;path&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$VM_DIR&lt;/span&gt;&lt;span class="s2"&gt;/seed.iso"&lt;/span&gt;,device&lt;span class="o"&gt;=&lt;/span&gt;cdrom &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--os-variant&lt;/span&gt; generic &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--network&lt;/span&gt; &lt;span class="nv"&gt;network&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;default &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--import&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--noautoconsole&lt;/span&gt;

&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"=== &lt;/span&gt;&lt;span class="nv"&gt;$VM_NAME&lt;/span&gt;&lt;span class="s2"&gt; deployed! ==="&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Find IP: virsh domifaddr &lt;/span&gt;&lt;span class="nv"&gt;$VM_NAME&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Connect: ssh ec2-user@&amp;lt;ip&amp;gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Save as &lt;code&gt;deploy.sh&lt;/code&gt;, run &lt;code&gt;chmod +x deploy.sh&lt;/code&gt;, and deploy VMs in seconds:&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;# Linked clone (default, fast, ~200 KB per VM)&lt;/span&gt;
./deploy.sh amzn-devops-vm2 devopslab02

&lt;span class="c"&gt;# Full disk copy (~1.8 GB per VM, independent of master template)&lt;/span&gt;
./deploy.sh &lt;span class="nt"&gt;--full-copy&lt;/span&gt; amzn-devops-vm3 devopslab03
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Common SSH Errors
&lt;/h2&gt;

&lt;h3&gt;
  
  
  "WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED!"
&lt;/h3&gt;

&lt;p&gt;Your host computer remembers a previous VM that used the same IP. 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-keygen &lt;span class="nt"&gt;-R&lt;/span&gt; 192.168.122.X
ssh ec2-user@192.168.122.X    &lt;span class="c"&gt;# Type 'yes' when prompted&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This happens frequently when you create and destroy VMs that reuse local IPs. For temporary lab VMs:&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;-o&lt;/span&gt; &lt;span class="nv"&gt;StrictHostKeyChecking&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;no ec2-user@192.168.122.X
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






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

&lt;p&gt;With your VM running:&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;# Update packages&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;dnf makecache &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;sudo &lt;/span&gt;dnf update &lt;span class="nt"&gt;-y&lt;/span&gt;

&lt;span class="c"&gt;# Install Docker&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;dnf &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; docker
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl &lt;span class="nb"&gt;enable&lt;/span&gt; &lt;span class="nt"&gt;--now&lt;/span&gt; docker
&lt;span class="nb"&gt;sudo &lt;/span&gt;usermod &lt;span class="nt"&gt;-aG&lt;/span&gt; docker ec2-user

&lt;span class="c"&gt;# Reconnect SSH, then test&lt;/span&gt;
docker run &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; 8080:80 nginx
&lt;span class="c"&gt;# Visit http://192.168.122.X:8080 from your host browser&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Quick Reference Card
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Task&lt;/th&gt;
&lt;th&gt;Command&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Generate SSH key&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ssh-keygen -t ed25519 -C "devops-local"&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;View your public key&lt;/td&gt;
&lt;td&gt;&lt;code&gt;cat ~/.ssh/id_ed25519.pub&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Create seed ISO&lt;/td&gt;
&lt;td&gt;&lt;code&gt;genisoimage -output seed.iso -volid cidata -joliet -rock user-data meta-data&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Clear stale SSH key&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ssh-keygen -R &amp;lt;ip-address&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Check password status&lt;/td&gt;
&lt;td&gt;&lt;code&gt;sudo passwd -S ec2-user&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Safely disable Cloud-Init&lt;/td&gt;
&lt;td&gt;&lt;code&gt;sudo touch /etc/cloud/cloud-init.disabled &amp;amp;&amp;amp; sudo systemctl mask cloud-init-local.service cloud-init.service cloud-config.service cloud-final.service&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Find VM IP address&lt;/td&gt;
&lt;td&gt;&lt;code&gt;virsh domifaddr &amp;lt;vm-name&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Destroy VM&lt;/td&gt;
&lt;td&gt;&lt;code&gt;virsh destroy &amp;lt;vm-name&amp;gt; &amp;amp;&amp;amp; virsh undefine &amp;lt;vm-name&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  Appendix: cloud-localds vs genisoimage
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;cloud-localds&lt;/code&gt; and &lt;code&gt;genisoimage&lt;/code&gt; serve the same core purpose for virtual machines: packaging configuration files (like user-data and meta-data) into an ISO image that cloud-init reads. However, &lt;code&gt;cloud-localds&lt;/code&gt; is an automated, high-level wrapper specifically for cloud-init, while &lt;code&gt;genisoimage&lt;/code&gt; is a low-level, traditional CD/DVD authoring tool.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;(Fun Fact: &lt;code&gt;cloud-localds&lt;/code&gt; is actually just a wrapper script! Under the hood, it automatically constructs and runs the &lt;code&gt;genisoimage&lt;/code&gt; or &lt;code&gt;mkisofs&lt;/code&gt; command for you, saving you from having to memorize the correct volume labels and ISO extensions).&lt;/em&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Key Differences
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Feature&lt;/th&gt;
&lt;th&gt;cloud-localds&lt;/th&gt;
&lt;th&gt;genisoimage&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Purpose&lt;/td&gt;
&lt;td&gt;Designed specifically to build NoCloud seed images.&lt;/td&gt;
&lt;td&gt;General-purpose filesystem (ISO9660/Joliet/Rock Ridge) creation.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Usage&lt;/td&gt;
&lt;td&gt;Requires minimal parameters; automatically sets the correct volume ID and file structure.&lt;/td&gt;
&lt;td&gt;Requires exact flags (-volid cidata, -joliet, -rock) to be properly recognized by cloud-init.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Flexibility&lt;/td&gt;
&lt;td&gt;Highly opinionated; outputs the ISO directly formatted for local hypervisors (like QEMU).&lt;/td&gt;
&lt;td&gt;Granular control over file placement, boot catalogs, and disk attributes.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  When to Use Each
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Use cloud-localds if:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  You are setting up a local testing environment in QEMU/KVM and need to quickly spin up a cloud image.&lt;/li&gt;
&lt;li&gt;  You want a simple command-line interface that handles volume labeling (cidata) automatically.&lt;/li&gt;
&lt;li&gt;  You are building a .img or .raw disk file directly, instead of strict ISO formats.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Use genisoimage if:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  You require cross-platform compatibility (e.g., building an ISO image on macOS using a wrapper like hdiutil, or using mkisofs on Linux).&lt;/li&gt;
&lt;li&gt;  You need an exact, standard .iso file format for hypervisors like VMware or Proxmox.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Examples
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Using cloud-localds:&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;cloud-localds &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="nt"&gt;--network-config&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;network-config.yaml seed.img user-data.yaml meta-data.yaml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Using genisoimage:&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;genisoimage &lt;span class="nt"&gt;-output&lt;/span&gt; seed.img &lt;span class="nt"&gt;-volid&lt;/span&gt; cidata &lt;span class="nt"&gt;-rational-rock&lt;/span&gt; &lt;span class="nt"&gt;-joliet&lt;/span&gt; user-data meta-data network-config
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  References &amp;amp; Documentation
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://cdn.amazonlinux.com/al2023/os-images/latest/kvm/" rel="noopener noreferrer"&gt;Amazon Linux 2023 KVM Images (Official Download)&lt;/a&gt; - Official Amazon CDN for QCOW2 images&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://docs.aws.amazon.com/linux/al2023/ug/outside-ec2.html" rel="noopener noreferrer"&gt;Using Amazon Linux 2023 Outside of Amazon EC2&lt;/a&gt; - AWS documentation for running AL2023 locally&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://docs.aws.amazon.com/linux/al2023/ug/kvm-requirements.html" rel="noopener noreferrer"&gt;AL2023 KVM Requirements&lt;/a&gt; - Official KVM hardware and software requirements&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://docs.cloud-init.io/en/latest/reference/datasources/nocloud.html" rel="noopener noreferrer"&gt;Cloud-Init NoCloud Datasource&lt;/a&gt; - Official NoCloud documentation covering &lt;code&gt;user-data&lt;/code&gt;, &lt;code&gt;meta-data&lt;/code&gt;, and &lt;code&gt;instance-id&lt;/code&gt; requirements&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://docs.cloud-init.io/en/latest/reference/modules.html#set-passwords" rel="noopener noreferrer"&gt;Cloud-Init Module Reference: Set Passwords&lt;/a&gt; - Documentation for the &lt;code&gt;chpasswd&lt;/code&gt; and &lt;code&gt;set-passwords&lt;/code&gt; module behavior&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://docs.cloud-init.io/en/latest/explanation/boot.html" rel="noopener noreferrer"&gt;Cloud-Init Boot Stages&lt;/a&gt; - How Cloud-Init's systemd services and generator work&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://wiki.archlinux.org/title/Systemd#Using_units" rel="noopener noreferrer"&gt;systemctl mask vs disable (Arch Wiki)&lt;/a&gt; - Explanation of why &lt;code&gt;mask&lt;/code&gt; prevents generators from overriding service state&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://manpages.debian.org/testing/cloud-image-utils/cloud-localds.1.en.html" rel="noopener noreferrer"&gt;cloud-localds Manual (Debian Manpage)&lt;/a&gt; - Official manual page for the &lt;code&gt;cloud-localds&lt;/code&gt; wrapper utility.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://linux.die.net/man/1/genisoimage" rel="noopener noreferrer"&gt;genisoimage man page&lt;/a&gt; - Manual for the legacy ISO generation tool.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Found this helpful? Follow me for more real-world Linux troubleshooting and DevOps content.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://khadirullah.com/blog/how-to-run-amazon-linux-2023-locally-with-qemu/kvm-and-cloud-init/" rel="noopener noreferrer"&gt;khadirullah.com&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>linux</category>
      <category>aws</category>
      <category>devops</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>How I Recovered My Linux GUI After a Full Disk Killed LightDM</title>
      <dc:creator>Khadirullah Mohammad</dc:creator>
      <pubDate>Fri, 03 Jul 2026 13:13:39 +0000</pubDate>
      <link>https://dev.to/khadirullah/how-i-recovered-my-linux-gui-after-a-full-disk-killed-lightdm-oin</link>
      <guid>https://dev.to/khadirullah/how-i-recovered-my-linux-gui-after-a-full-disk-killed-lightdm-oin</guid>
      <description>&lt;p&gt;I booted my Linux system and instead of the usual login screen — nothing. Just a blinking cursor on &lt;code&gt;tty1&lt;/code&gt;. No graphical desktop, no LightDM greeter, no error dialog. Just a text terminal.&lt;/p&gt;

&lt;p&gt;This is the story of how I diagnosed the problem from tty1, discovered the real root cause, and fixed it — all without reinstalling anything.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Setup:&lt;/strong&gt; Debian 13 (Trixie) with Cinnamon desktop, running as a KVM virtual machine with a 28 GB virtual disk and LightDM as the display manager.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;🚨 Spoiler:&lt;/strong&gt; It wasn't a display manager bug. It was a full disk pretending to be a GUI problem.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  The Symptom: Stuck on tty1
&lt;/h2&gt;

&lt;p&gt;After logging in on tty1, I tried switching to the graphical session with &lt;strong&gt;Ctrl+Alt+F7&lt;/strong&gt;. Instead of the desktop, I saw:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Failed to start postgresql@17-main.service
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;PostgreSQL failing was a red herring — it had nothing to do with the GUI. But it was a hint that something deeper was wrong.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 1: Check the Display Manager
&lt;/h2&gt;

&lt;p&gt;First, I checked whether the display manager service was running:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;systemctl status display-manager
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Result:&lt;/strong&gt; The service was &lt;strong&gt;failed&lt;/strong&gt; but &lt;strong&gt;enabled&lt;/strong&gt; — meaning it was supposed to start at boot, but something prevented it.&lt;/p&gt;

&lt;p&gt;Next, I confirmed which display manager was in use:&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;-l&lt;/span&gt; /etc/systemd/system/display-manager.service
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight systemd"&gt;&lt;code&gt;&lt;span class="err"&gt;...&lt;/span&gt; &lt;span class="err"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="err"&gt;/lib/systemd/system/lightdm.service&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So the system uses &lt;strong&gt;LightDM&lt;/strong&gt;. Let's dig deeper.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 2: Investigate LightDM
&lt;/h2&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;systemctl status lightdm
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;LightDM was in a failed state. I tried reconfiguring it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;dpkg-reconfigure lightdm
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then attempted a restart:&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;systemctl restart lightdm
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Still failing. The status output wasn't giving much detail, so I checked the full 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="nb"&gt;sudo &lt;/span&gt;systemctl status lightdm &lt;span class="nt"&gt;--no-pager&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;journalctl &lt;span class="nt"&gt;-xeu&lt;/span&gt; lightdm &lt;span class="nt"&gt;--no-pager&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The journal showed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight systemd"&gt;&lt;code&gt;&lt;span class="nt"&gt;lightdm.service: Triggering OnFailure&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;dependencies
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's a generic systemd message — it means the service failed, but the &lt;em&gt;reason&lt;/em&gt; could be anything. Time to look at the system itself.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 3: The Real Culprit — Disk Full
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;df&lt;/span&gt; &lt;span class="nt"&gt;-h&lt;/span&gt; /
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;Filesystem   Size   Used   Avail   Use%   Mounted on
/dev/vda1    28G    27G    0       100%   /
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The root filesystem was completely full.&lt;/strong&gt; Zero bytes available.&lt;/p&gt;

&lt;p&gt;This is the kind of failure that breaks everything silently. When Linux can't write to disk, services fail in confusing ways — no clear error, just "failed."&lt;/p&gt;

&lt;h3&gt;
  
  
  Why a Full Disk Breaks the GUI
&lt;/h3&gt;

&lt;p&gt;LightDM, Xorg, and the login session all need to write files at startup:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Component&lt;/th&gt;
&lt;th&gt;What It Writes&lt;/th&gt;
&lt;th&gt;Where&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;LightDM&lt;/td&gt;
&lt;td&gt;Session state, PID files&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;/var/run/&lt;/code&gt;, &lt;code&gt;/tmp/&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Xorg&lt;/td&gt;
&lt;td&gt;Display server logs, sockets&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;/var/log/&lt;/code&gt;, &lt;code&gt;/tmp/.X11-unix/&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;systemd&lt;/td&gt;
&lt;td&gt;Service status, runtime data&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/run/&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Login session&lt;/td&gt;
&lt;td&gt;Authentication tokens&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;/var/&lt;/code&gt;, &lt;code&gt;/tmp/&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;When any of these writes fail, the service crashes. But the error message rarely says "disk full" — it usually says something vague like "failed to start" or "OnFailure=dependencies."&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 4: Free Up Disk Space
&lt;/h2&gt;

&lt;p&gt;With the root cause identified, I needed to free space from the terminal. Here's what I did:&lt;/p&gt;

&lt;h3&gt;
  
  
  Clean the APT Package Cache
&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 clean
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This removes all cached &lt;code&gt;.deb&lt;/code&gt; files from &lt;code&gt;/var/cache/apt/archives/&lt;/code&gt;. On a system that's had many package installations, this directory can grow to &lt;strong&gt;several gigabytes&lt;/strong&gt; without you realizing it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Remove Unused Packages
&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 autoremove &lt;span class="nt"&gt;--purge&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This removes orphaned dependencies — packages that were installed automatically but are no longer needed by anything. The &lt;code&gt;--purge&lt;/code&gt; flag also removes their configuration files.&lt;/p&gt;

&lt;h3&gt;
  
  
  Check System Journal Size
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;journalctl &lt;span class="nt"&gt;--disk-usage&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;journalctl &lt;span class="nt"&gt;--disk-usage&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I checked how much space system logs were consuming. If the journal had been consuming significant space, I could have reduced it with &lt;code&gt;sudo journalctl --vacuum-size=200M&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Investigate Where Space Was Going
&lt;/h3&gt;

&lt;p&gt;I also ran a few diagnostic commands to understand the disk usage:&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 du&lt;/span&gt; &lt;span class="nt"&gt;-xh&lt;/span&gt; / &lt;span class="nt"&gt;--max-depth&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1 2&amp;gt;/dev/null | &lt;span class="nb"&gt;sort&lt;/span&gt; &lt;span class="nt"&gt;-h&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Results after cleanup:&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;/opt    704M
/var    3.2G
/home   5.9G
/usr    8.1G
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And checked for deleted files still held open by processes:&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;lsof +L1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h4&gt;
  
  
  What does this command actually do?
&lt;/h4&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;lsof&lt;/code&gt; = &lt;strong&gt;L&lt;/strong&gt;ist &lt;strong&gt;O&lt;/strong&gt;pen &lt;strong&gt;F&lt;/strong&gt;iles — shows every file currently opened by any running process&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;+L1&lt;/code&gt; = filter to show only files with a &lt;strong&gt;link count less than 1&lt;/strong&gt; — meaning the file has been &lt;strong&gt;deleted&lt;/strong&gt; from the filesystem but a process still has it open&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;sudo&lt;/code&gt; = run as root so we can see files opened by all processes, not just our own&lt;/li&gt;
&lt;/ul&gt;

&lt;h4&gt;
  
  
  Why this matters for disk space
&lt;/h4&gt;

&lt;p&gt;On Linux, deleting a file with &lt;code&gt;rm&lt;/code&gt; only removes the &lt;strong&gt;directory entry&lt;/strong&gt; (the name). If a process still has that file open, the &lt;strong&gt;actual data stays on disk&lt;/strong&gt; until the process closes it or is restarted.&lt;/p&gt;

&lt;p&gt;This creates a confusing gap between two tools:&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;What it reports&lt;/th&gt;
&lt;th&gt;Sees deleted-but-open files?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;df -h&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Filesystem-level usage&lt;/td&gt;
&lt;td&gt;✅ Yes — counts them as used space&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;du -sh /&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Directory-level usage&lt;/td&gt;
&lt;td&gt;❌ No — can't see files with no name&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;So if &lt;code&gt;df&lt;/code&gt; says 27 GB used but &lt;code&gt;du&lt;/code&gt; only adds up to 18 GB, the difference is likely ghost files — deleted but still consuming space because a process holds them open.&lt;/p&gt;

&lt;h4&gt;
  
  
  Real-world example
&lt;/h4&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# A log file grows to 5 GB&lt;/span&gt;
&lt;span class="c"&gt;# You delete it to free space:&lt;/span&gt;
&lt;span class="nb"&gt;rm&lt;/span&gt; /var/log/huge.log

&lt;span class="c"&gt;# But the service that was writing to it still has it open&lt;/span&gt;
&lt;span class="c"&gt;# df still shows 5 GB used — space NOT freed!&lt;/span&gt;

&lt;span class="c"&gt;# lsof +L1 reveals it:&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;lsof +L1
&lt;span class="c"&gt;# COMMAND   PID   USER       FD  TYPE  SIZE/OFF  NLINK  NAME&lt;/span&gt;
&lt;span class="c"&gt;# postgres  1234  postgres    5w  REG  5368709120  0    /var/log/huge.log (deleted)&lt;/span&gt;

&lt;span class="c"&gt;# Fix: restart the service to release the file handle&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl restart postgresql
&lt;span class="c"&gt;# NOW the 5 GB is actually freed&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h4&gt;
  
  
  How to read the output
&lt;/h4&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;COMMAND   PID   USER       FD   TYPE   SIZE/OFF  NLINK  NAME
postgres  1234  postgres    5w  REG    5368709120  0    /var/log/huge.log (deleted)
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Column&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;COMMAND&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The process holding the file open&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;PID&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Process ID — use this to restart or kill it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;SIZE/OFF&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;How much space the deleted file is still consuming&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;NLINK&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Link count — &lt;code&gt;0&lt;/code&gt; means deleted from filesystem&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;NAME&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Original path, tagged with &lt;code&gt;(deleted)&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h4&gt;
  
  
  How to fix it
&lt;/h4&gt;

&lt;p&gt;Restart the process that's holding the deleted file:&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;systemctl restart &amp;lt;service-name&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Once the process releases the file handle, the space is truly freed.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 5: Verify and Restart
&lt;/h2&gt;

&lt;p&gt;After cleanup:&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;df&lt;/span&gt; &lt;span class="nt"&gt;-h&lt;/span&gt; /
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;Filesystem   Size   Used   Avail   Use%   Mounted on
/dev/vda1    28G    19G    8.4G    69%    /
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;From &lt;strong&gt;100% → 69%&lt;/strong&gt;. That's roughly &lt;strong&gt;8 GB freed&lt;/strong&gt; — most of it from the APT cache.&lt;/p&gt;

&lt;p&gt;Now I restarted LightDM:&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;systemctl restart lightdm
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The GUI came back immediately.&lt;/strong&gt; LightDM loaded, the login screen appeared, and the desktop session started normally — no reboot needed.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Exact Commands I Used
&lt;/h2&gt;

&lt;p&gt;Here's the complete sequence from my shell history — nothing more, nothing less:&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. Diagnose the display manager&lt;/span&gt;
systemctl status display-manager                          &lt;span class="c"&gt;# 1941&lt;/span&gt;
&lt;span class="nb"&gt;ls&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt; /etc/systemd/system/display-manager.service          &lt;span class="c"&gt;# 1942&lt;/span&gt;

&lt;span class="c"&gt;# 2. Investigate LightDM&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl status lightdm                              &lt;span class="c"&gt;# 1943&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;dpkg-reconfigure lightdm                              &lt;span class="c"&gt;# 1944&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl restart lightdm                             &lt;span class="c"&gt;# 1945&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl status lightdm                              &lt;span class="c"&gt;# 1946&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl status lightdm &lt;span class="nt"&gt;--no-pager&lt;/span&gt;                   &lt;span class="c"&gt;# 1947&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;journalctl &lt;span class="nt"&gt;-xeu&lt;/span&gt; lightdm &lt;span class="nt"&gt;--no-pager&lt;/span&gt;                    &lt;span class="c"&gt;# 1948&lt;/span&gt;

&lt;span class="c"&gt;# 3. Discover the root cause&lt;/span&gt;
&lt;span class="nb"&gt;df&lt;/span&gt; &lt;span class="nt"&gt;-h&lt;/span&gt; /                                                    &lt;span class="c"&gt;# 1949&lt;/span&gt;

&lt;span class="c"&gt;# 4. Free disk space&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt clean                                             &lt;span class="c"&gt;# 1950&lt;/span&gt;
journalctl &lt;span class="nt"&gt;--disk-usage&lt;/span&gt;                                    &lt;span class="c"&gt;# 1951&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;journalctl &lt;span class="nt"&gt;--disk-usage&lt;/span&gt;                               &lt;span class="c"&gt;# 1952&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt autoremove &lt;span class="nt"&gt;--purge&lt;/span&gt;                                &lt;span class="c"&gt;# 1953&lt;/span&gt;

&lt;span class="c"&gt;# 5. Investigate disk usage&lt;/span&gt;
&lt;span class="nb"&gt;du&lt;/span&gt; &lt;span class="nt"&gt;-sh&lt;/span&gt; ~/.cache/pip                                        &lt;span class="c"&gt;# 1954&lt;/span&gt;
&lt;span class="nb"&gt;sudo du&lt;/span&gt; &lt;span class="nt"&gt;-xh&lt;/span&gt; / &lt;span class="nt"&gt;--max-depth&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1 2&amp;gt;/dev/null | &lt;span class="nb"&gt;sort&lt;/span&gt; &lt;span class="nt"&gt;-h&lt;/span&gt;          &lt;span class="c"&gt;# 1955&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;lsof +L1                                              &lt;span class="c"&gt;# 1956&lt;/span&gt;

&lt;span class="c"&gt;# 6. Verify and recover&lt;/span&gt;
&lt;span class="nb"&gt;df&lt;/span&gt; &lt;span class="nt"&gt;-h&lt;/span&gt; /                                                    &lt;span class="c"&gt;# 1957&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl restart lightdm                             &lt;span class="c"&gt;# 1958&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  What Actually Fixed It
&lt;/h2&gt;

&lt;p&gt;Let's be precise. The fix was &lt;strong&gt;not&lt;/strong&gt; restarting LightDM. The fix was &lt;strong&gt;freeing disk space&lt;/strong&gt; so that LightDM &lt;em&gt;could&lt;/em&gt; start.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Action&lt;/th&gt;
&lt;th&gt;Effect&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;sudo apt clean&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Removed cached &lt;code&gt;.deb&lt;/code&gt; files (likely several GB)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;sudo apt autoremove --purge&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Removed unused packages and configs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Disk: 100% → 69%&lt;/td&gt;
&lt;td&gt;~8 GB freed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;sudo systemctl restart lightdm&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Restarted the now-functional service&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;No reboot was needed. Once disk space was available, a simple service restart brought the GUI back immediately.&lt;/p&gt;

&lt;p&gt;The PostgreSQL failure I saw earlier? Also caused by the full disk — it just happened to display first because it starts before LightDM in the boot sequence.&lt;/p&gt;




&lt;h2&gt;
  
  
  Lessons Learned
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. "Service failed" Often Means "Disk Full"
&lt;/h3&gt;

&lt;p&gt;When multiple services fail at boot with vague errors, check &lt;code&gt;df -h /&lt;/code&gt; before anything else. A full disk is one of the most common — and most misleading — causes of service failures on Linux.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. APT Cache Can Grow Silently
&lt;/h3&gt;

&lt;p&gt;Every time you run &lt;code&gt;apt install&lt;/code&gt;, the downloaded &lt;code&gt;.deb&lt;/code&gt; file is cached in &lt;code&gt;/var/cache/apt/archives/&lt;/code&gt;. Over months of installing packages, this can consume gigabytes. Unlike pip or npm, APT doesn't auto-clean by default.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Prevention:&lt;/strong&gt; Add periodic cleanup to your routine:&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;# Clean APT cache&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt clean

&lt;span class="c"&gt;# Remove unused packages&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt autoremove &lt;span class="nt"&gt;--purge&lt;/span&gt;

&lt;span class="c"&gt;# Limit journal size&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;journalctl &lt;span class="nt"&gt;--vacuum-size&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;500M
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  3. &lt;code&gt;du&lt;/code&gt; and &lt;code&gt;df&lt;/code&gt; Can Disagree
&lt;/h3&gt;

&lt;p&gt;If &lt;code&gt;df&lt;/code&gt; shows more used space than &lt;code&gt;du&lt;/code&gt; reports, look for deleted-but-open files:&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;lsof +L1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This finds files that have been deleted from the filesystem but are still held open by a running process. The space won't be freed until the process is killed or restarted.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Keep a Safety Margin
&lt;/h3&gt;

&lt;p&gt;A Linux system needs free disk space to function. When the root filesystem hits 100%, things break in unpredictable ways — services fail, logs can't be written, even &lt;code&gt;apt&lt;/code&gt; can't install fixes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rule of thumb:&lt;/strong&gt; Keep at least &lt;strong&gt;2–5 GB free&lt;/strong&gt; on the root filesystem at all times.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Monitor Disk Usage
&lt;/h3&gt;

&lt;p&gt;A simple check you can run anytime:&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;df&lt;/span&gt; &lt;span class="nt"&gt;-h&lt;/span&gt; /
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For a more detailed breakdown:&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 du&lt;/span&gt; &lt;span class="nt"&gt;-xh&lt;/span&gt; / &lt;span class="nt"&gt;--max-depth&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1 2&amp;gt;/dev/null | &lt;span class="nb"&gt;sort&lt;/span&gt; &lt;span class="nt"&gt;-h&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Quick Reference: Emergency Disk Cleanup
&lt;/h2&gt;

&lt;p&gt;If you're stuck on tty with a full disk, here's a fast recovery checklist:&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. Confirm disk is full&lt;/span&gt;
&lt;span class="nb"&gt;df&lt;/span&gt; &lt;span class="nt"&gt;-h&lt;/span&gt; /

&lt;span class="c"&gt;# 2. Quick wins (usually frees 1-5+ GB)&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt clean
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt autoremove &lt;span class="nt"&gt;--purge&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;journalctl &lt;span class="nt"&gt;--vacuum-size&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;200M

&lt;span class="c"&gt;# 3. Find biggest directories&lt;/span&gt;
&lt;span class="nb"&gt;sudo du&lt;/span&gt; &lt;span class="nt"&gt;-xh&lt;/span&gt; / &lt;span class="nt"&gt;--max-depth&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1 2&amp;gt;/dev/null | &lt;span class="nb"&gt;sort&lt;/span&gt; &lt;span class="nt"&gt;-h&lt;/span&gt;

&lt;span class="c"&gt;# 4. Find biggest files&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;find / &lt;span class="nt"&gt;-xdev&lt;/span&gt; &lt;span class="nt"&gt;-type&lt;/span&gt; f &lt;span class="nt"&gt;-size&lt;/span&gt; +500M &lt;span class="nt"&gt;-ls&lt;/span&gt; 2&amp;gt;/dev/null | &lt;span class="nb"&gt;sort&lt;/span&gt; &lt;span class="nt"&gt;-k7&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt;

&lt;span class="c"&gt;# 5. Check for deleted-but-open files&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;lsof +L1

&lt;span class="c"&gt;# 6. Check common caches&lt;/span&gt;
&lt;span class="nb"&gt;du&lt;/span&gt; &lt;span class="nt"&gt;-sh&lt;/span&gt; ~/.cache/pip 2&amp;gt;/dev/null
&lt;span class="nb"&gt;du&lt;/span&gt; &lt;span class="nt"&gt;-sh&lt;/span&gt; ~/.cache/huggingface 2&amp;gt;/dev/null
&lt;span class="nb"&gt;du&lt;/span&gt; &lt;span class="nt"&gt;-sh&lt;/span&gt; ~/.ollama 2&amp;gt;/dev/null

&lt;span class="c"&gt;# 7. Verify space freed&lt;/span&gt;
&lt;span class="nb"&gt;df&lt;/span&gt; &lt;span class="nt"&gt;-h&lt;/span&gt; /

&lt;span class="c"&gt;# 8. Restart services or reboot&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl restart lightdm   &lt;span class="c"&gt;# or your display manager&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;reboot
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Final Thought
&lt;/h2&gt;

&lt;p&gt;This wasn't a LightDM bug. It wasn't a driver issue. It wasn't a broken configuration.&lt;/p&gt;

&lt;p&gt;It was a &lt;strong&gt;full disk&lt;/strong&gt; — and every confusing error message was just a downstream symptom of that one simple problem.&lt;/p&gt;

&lt;p&gt;In my case, just two commands did the job:&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 clean
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt autoremove &lt;span class="nt"&gt;--purge&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That freed ~8 GB of cached packages and unused dependencies — more than enough to get the system working again.&lt;/p&gt;

&lt;h3&gt;
  
  
  But What If Cleanup Isn't Enough?
&lt;/h3&gt;

&lt;p&gt;Not every full-disk situation can be solved by clearing caches. If your disk is genuinely running out of space because your data, applications, or workloads have outgrown it, cleanup will only buy you time. In those cases, you'll need to &lt;strong&gt;extend the disk itself&lt;/strong&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Scenario&lt;/th&gt;
&lt;th&gt;Solution&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;Cloud VM&lt;/strong&gt; (AWS, GCP, Azure, etc.)&lt;/td&gt;
&lt;td&gt;Expand the volume in the cloud console, then run &lt;code&gt;sudo growpart /dev/vda 1&lt;/code&gt; and &lt;code&gt;sudo resize2fs /dev/vda1&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;Local VM&lt;/strong&gt; (VirtualBox, KVM, Proxmox)&lt;/td&gt;
&lt;td&gt;Increase the virtual disk size in the hypervisor settings, then grow the partition from inside the VM&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;LVM&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Add a new physical volume or extend the logical volume with &lt;code&gt;lvextend&lt;/code&gt; + &lt;code&gt;resize2fs&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Physical server&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Add a new disk, mount it, and move large directories (like &lt;code&gt;/home&lt;/code&gt; or &lt;code&gt;/var&lt;/code&gt;) to it&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The key takeaway: &lt;strong&gt;always diagnose first.&lt;/strong&gt; Run &lt;code&gt;df -h /&lt;/code&gt; to confirm the disk is full, then decide — is this a cleanup problem or a capacity problem?&lt;/p&gt;

&lt;p&gt;If &lt;code&gt;apt clean&lt;/code&gt; and &lt;code&gt;autoremove&lt;/code&gt; free enough space, great. If not, it's time to give your system more room to breathe.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Found this helpful? Follow me for more real-world Linux troubleshooting and DevOps content.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://khadirullah.com/blog/how-i-recovered-my-linux-gui-after-a-full-disk-killed-lightdm/" rel="noopener noreferrer"&gt;khadirullah.com&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>linux</category>
      <category>tutorial</category>
      <category>sysadmin</category>
      <category>devops</category>
    </item>
    <item>
      <title>Charting New Waters: Building a Custom Coral Source Spec for Internal Enterprise APIs</title>
      <dc:creator>Khadirullah Mohammad</dc:creator>
      <pubDate>Sun, 31 May 2026 08:34:33 +0000</pubDate>
      <link>https://dev.to/khadirullah/charting-new-waters-building-a-custom-coral-source-spec-for-internal-enterprise-apis-160e</link>
      <guid>https://dev.to/khadirullah/charting-new-waters-building-a-custom-coral-source-spec-for-internal-enterprise-apis-160e</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;⚔️ This post is my submission for the &lt;strong&gt;"Chart New Waters"&lt;/strong&gt; bounty in the &lt;a href="https://wemakedevs.org/hackathons/coral" rel="noopener noreferrer"&gt;Pirates of the Coral-bean&lt;/a&gt; hackathon by &lt;a href="https://wemakedevs.org" rel="noopener noreferrer"&gt;WeMakeDevs&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Source Spec:&lt;/strong&gt; &lt;a href="https://github.com/khadirullah/devops-incident-investigator/blob/main/coral-config/payment-api.yaml" rel="noopener noreferrer"&gt;&lt;code&gt;payment-api.yaml&lt;/code&gt;&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  The Problem: Enterprises Don't Just Use Public SaaS
&lt;/h2&gt;

&lt;p&gt;When building an Enterprise Agent, there's one massive architectural challenge: &lt;strong&gt;enterprises don't just use GitHub, Sentry, and Slack.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A real enterprise relies on hundreds of &lt;strong&gt;internal, private microservices&lt;/strong&gt; — custom payment gateways, user management APIs, internal inventory systems, health monitoring dashboards. These services:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;❌ Have &lt;strong&gt;no public API documentation&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;❌ Are &lt;strong&gt;not listed in any marketplace&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;❌ Have &lt;strong&gt;no native Coral connector&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;❌ Sit behind &lt;strong&gt;VPNs and private networks&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If your incident correlation engine can only query public SaaS tools, &lt;strong&gt;you're missing half the picture.&lt;/strong&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;🔥 This is where &lt;strong&gt;Coral's Custom Source Specs&lt;/strong&gt; save the day. One YAML file turns any REST API into a SQL table.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Why Not Just Use Sentry? (Active vs Passive Monitoring)
&lt;/h2&gt;

&lt;p&gt;You might wonder: &lt;em&gt;"If the payment API already sends errors to Sentry, and Coral already reads Sentry, why do we need to query the API directly?"&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The answer highlights a critical DevOps distinction:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Passive Monitoring (Sentry):
  Error occurs → Sentry captures snapshot → "What BROKE 5 min ago?"

Active Monitoring (Custom Source):
  Coral queries /api/health → Real-time response → "Is the API UP right now?"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F0vyefojwxju6y0etjlxi.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F0vyefojwxju6y0etjlxi.png" alt="Active and Passive Monitoring" width="800" height="1783"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;Question It Answers&lt;/th&gt;
&lt;th&gt;When&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Passive&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Sentry&lt;/td&gt;
&lt;td&gt;"What broke 5 minutes ago?"&lt;/td&gt;
&lt;td&gt;After the fact&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Active&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Custom Source Spec&lt;/td&gt;
&lt;td&gt;"Is the service alive RIGHT NOW?"&lt;/td&gt;
&lt;td&gt;Real-time&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;During a major outage at 2AM, a DevOps engineer's first question is: &lt;em&gt;"Is the API completely dead, or is it recovering?"&lt;/em&gt; Sentry can't answer that — it only logs past errors. The custom source can.&lt;/p&gt;

&lt;p&gt;With our spec, the Incident Investigator can execute powerful logic:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;"I see the database error in Sentry from 5 minutes ago. Let me instantly query &lt;code&gt;payment_api.health&lt;/code&gt; to check if the service is currently online and what the response time is."&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  How Custom Source Specs Work
&lt;/h2&gt;

&lt;p&gt;Think of a custom source spec as a &lt;strong&gt;translator&lt;/strong&gt; between SQL and HTTP.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F2qb9ahy44o8ti3tmsphb.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F2qb9ahy44o8ti3tmsphb.jpg" alt="Custom Source Specs Sequence Diagram" width="800" height="492"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The YAML file maps:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;SQL table name&lt;/strong&gt; → &lt;strong&gt;HTTP endpoint&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SQL columns&lt;/strong&gt; → &lt;strong&gt;JSON response fields&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SQL query&lt;/strong&gt; → &lt;strong&gt;HTTP request&lt;/strong&gt; (method, path, headers)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;From the user's perspective, they just write SQL. Coral handles the HTTP call, JSON parsing, and column mapping automatically.&lt;/p&gt;




&lt;h2&gt;
  
  
  Building the Source Spec: Step by Step
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The API We're Connecting
&lt;/h3&gt;

&lt;p&gt;Our &lt;a href="https://github.com/khadirullah/demo-payment-api" rel="noopener noreferrer"&gt;&lt;code&gt;demo-payment-api&lt;/code&gt;&lt;/a&gt; is a Flask microservice with these endpoints:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Endpoint&lt;/th&gt;
&lt;th&gt;Returns&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;GET /api/health&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Service health, response times, endpoint status&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;GET /api/payments&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;List of processed and pending payments&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;In a real enterprise, this could be &lt;strong&gt;any&lt;/strong&gt; internal microservice — a user management API, an inventory system, a billing platform. The pattern is the same.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1: Define the Source Identity
&lt;/h3&gt;

&lt;p&gt;Every custom source needs a name, version, and the Coral DSL version:&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;payment_api&lt;/span&gt;          &lt;span class="c1"&gt;# This becomes the SQL schema: payment_api.health&lt;/span&gt;
&lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;0.1.0&lt;/span&gt;
&lt;span class="na"&gt;dsl_version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;3&lt;/span&gt;             &lt;span class="c1"&gt;# Current Coral DSL version&lt;/span&gt;
&lt;span class="na"&gt;backend&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;http&lt;/span&gt;              &lt;span class="c1"&gt;# We're connecting to an HTTP API&lt;/span&gt;
&lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Internal&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Payment&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;API&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;for&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;processing&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;and&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;tracking&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;mock&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;payments"&lt;/span&gt;
&lt;span class="na"&gt;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;http://localhost:5001"&lt;/span&gt;   &lt;span class="c1"&gt;# Where the API lives&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;ℹ️ The &lt;code&gt;name&lt;/code&gt; is critical — it becomes the SQL schema prefix. After registration, you'll query tables as &lt;code&gt;payment_api.health&lt;/code&gt;, &lt;code&gt;payment_api.payments&lt;/code&gt;, etc.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Step 2: Set Up Authentication
&lt;/h3&gt;

&lt;p&gt;Even internal APIs need authentication. We configure Bearer token auth:&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;inputs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;PAYMENT_API_TOKEN&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;secret&lt;/span&gt;
    &lt;span class="na"&gt;hint&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Bearer&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;token&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;for&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;the&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Payment&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;API"&lt;/span&gt;

&lt;span class="na"&gt;auth&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;HeaderAuth&lt;/span&gt;
  &lt;span class="na"&gt;headers&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;Authorization&lt;/span&gt;
      &lt;span class="na"&gt;from&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;template&lt;/span&gt;
      &lt;span class="na"&gt;template&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Bearer&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;{{input.PAYMENT_API_TOKEN}}"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;inputs&lt;/code&gt; section tells Coral to ask for a &lt;code&gt;PAYMENT_API_TOKEN&lt;/code&gt; environment variable when adding the source. This keeps secrets out of the YAML file itself.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: Map Endpoints to SQL Tables
&lt;/h3&gt;

&lt;p&gt;This is the magic part. For each API endpoint, we create a SQL table definition:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Table 1: &lt;code&gt;health&lt;/code&gt;&lt;/strong&gt; — Maps &lt;code&gt;GET /api/health&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;The API returns JSON like:&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="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"endpoint"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"/api/health"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"healthy"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"response_time_ms"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"timestamp"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-05-29T18:00:00Z"&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="nl"&gt;"endpoint"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"/api/payments"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"healthy"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"response_time_ms"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;45&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"timestamp"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-05-29T18:00:00Z"&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;We map it in YAML:&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;tables&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;health&lt;/span&gt;
    &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Payment&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;service&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;health&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;and&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;uptime&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;metrics"&lt;/span&gt;
    &lt;span class="na"&gt;request&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;GET&lt;/span&gt;
      &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/api/health&lt;/span&gt;
    &lt;span class="na"&gt;response&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;rows_path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[]&lt;/span&gt;          &lt;span class="c1"&gt;# Response IS the array (no wrapper key)&lt;/span&gt;
    &lt;span class="na"&gt;columns&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;endpoint&lt;/span&gt;
        &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Utf8&lt;/span&gt;
        &lt;span class="na"&gt;expr&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;endpoint&lt;/span&gt;&lt;span class="pi"&gt;]&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;status&lt;/span&gt;
        &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Utf8&lt;/span&gt;
        &lt;span class="na"&gt;expr&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;status&lt;/span&gt;&lt;span class="pi"&gt;]&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;response_time_ms&lt;/span&gt;
        &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Int64&lt;/span&gt;
        &lt;span class="na"&gt;expr&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;response_time_ms&lt;/span&gt;&lt;span class="pi"&gt;]&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;timestamp&lt;/span&gt;
        &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Utf8&lt;/span&gt;
        &lt;span class="na"&gt;expr&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;timestamp&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Key details:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;rows_path: []&lt;/code&gt; means the JSON response IS the array (not wrapped in a key like &lt;code&gt;{"data": [...]}&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Each column's &lt;code&gt;expr: { kind: path, path: [...] }&lt;/code&gt; extracts a specific JSON field&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;type&lt;/code&gt; maps JSON types to SQL types (&lt;code&gt;Utf8&lt;/code&gt; = string, &lt;code&gt;Int64&lt;/code&gt; = integer, &lt;code&gt;Float64&lt;/code&gt; = decimal)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Table 2: &lt;code&gt;payments&lt;/code&gt;&lt;/strong&gt; — Maps &lt;code&gt;GET /api/payments&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="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;payments&lt;/span&gt;
    &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;List&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;of&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;processed&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;and&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;pending&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;payments"&lt;/span&gt;
    &lt;span class="na"&gt;request&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;GET&lt;/span&gt;
      &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/api/payments&lt;/span&gt;
    &lt;span class="na"&gt;response&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;rows_path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;data&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;      &lt;span class="c1"&gt;# Rows are inside the "data" key&lt;/span&gt;
    &lt;span class="na"&gt;columns&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;id&lt;/span&gt;
        &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Utf8&lt;/span&gt;
        &lt;span class="na"&gt;expr&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;]&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;amount&lt;/span&gt;
        &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Float64&lt;/span&gt;
        &lt;span class="na"&gt;expr&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;amount&lt;/span&gt;&lt;span class="pi"&gt;]&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;currency&lt;/span&gt;
        &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Utf8&lt;/span&gt;
        &lt;span class="na"&gt;expr&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;currency&lt;/span&gt;&lt;span class="pi"&gt;]&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;status&lt;/span&gt;
        &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Utf8&lt;/span&gt;
        &lt;span class="na"&gt;expr&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;status&lt;/span&gt;&lt;span class="pi"&gt;]&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;customer&lt;/span&gt;
        &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Utf8&lt;/span&gt;
        &lt;span class="na"&gt;expr&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;customer&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice &lt;code&gt;rows_path: [data]&lt;/code&gt; — this tells Coral the rows are nested inside a &lt;code&gt;"data"&lt;/code&gt; key in the JSON response.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 4: Add Test Queries
&lt;/h3&gt;

&lt;p&gt;Good source specs include test queries that Coral runs during registration to validate everything works:&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;test_queries&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;SELECT status, endpoint FROM payment_api.health LIMIT &lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;SELECT id, amount, status FROM payment_api.payments LIMIT &lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 5: Register with Coral
&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;# 1. Lint the YAML to check for syntax errors&lt;/span&gt;
coral &lt;span class="nb"&gt;source &lt;/span&gt;lint coral-config/payment-api.yaml

&lt;span class="c"&gt;# 2. Add the source (providing the auth token)&lt;/span&gt;
&lt;span class="nv"&gt;PAYMENT_API_TOKEN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;mock_token_123 coral &lt;span class="nb"&gt;source &lt;/span&gt;add &lt;span class="nt"&gt;--file&lt;/span&gt; coral-config/payment-api.yaml

&lt;span class="c"&gt;# 3. Query it like a database!&lt;/span&gt;
coral sql &lt;span class="s2"&gt;"SELECT endpoint, status, response_time_ms FROM payment_api.health"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;✅ From this moment on, Coral treats your private microservice &lt;strong&gt;exactly the same&lt;/strong&gt; as GitHub or Sentry.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  The Complete Source Spec
&lt;/h2&gt;

&lt;p&gt;Here's the full &lt;a href="https://github.com/khadirullah/devops-incident-investigator/blob/main/coral-config/payment-api.yaml" rel="noopener noreferrer"&gt;&lt;code&gt;payment-api.yaml&lt;/code&gt;&lt;/a&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;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;payment_api&lt;/span&gt;
&lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;0.1.0&lt;/span&gt;
&lt;span class="na"&gt;dsl_version&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;backend&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;http&lt;/span&gt;
&lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Internal&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Payment&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;API&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;for&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;processing&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;and&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;tracking&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;mock&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;payments"&lt;/span&gt;
&lt;span class="na"&gt;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;http://localhost:5001"&lt;/span&gt;

&lt;span class="na"&gt;inputs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;PAYMENT_API_TOKEN&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;secret&lt;/span&gt;
    &lt;span class="na"&gt;hint&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Bearer&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;token&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;for&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;the&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Payment&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;API"&lt;/span&gt;

&lt;span class="na"&gt;auth&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;HeaderAuth&lt;/span&gt;
  &lt;span class="na"&gt;headers&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;Authorization&lt;/span&gt;
      &lt;span class="na"&gt;from&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;template&lt;/span&gt;
      &lt;span class="na"&gt;template&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Bearer&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;{{input.PAYMENT_API_TOKEN}}"&lt;/span&gt;

&lt;span class="na"&gt;test_queries&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;SELECT status, endpoint FROM payment_api.health LIMIT &lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;SELECT id, amount, status FROM payment_api.payments LIMIT &lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;

&lt;span class="na"&gt;tables&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;health&lt;/span&gt;
    &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Payment&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;service&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;health&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;and&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;uptime&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;metrics"&lt;/span&gt;
    &lt;span class="na"&gt;request&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;GET&lt;/span&gt;
      &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/api/health&lt;/span&gt;
    &lt;span class="na"&gt;response&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;rows_path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[]&lt;/span&gt;
    &lt;span class="na"&gt;columns&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;endpoint&lt;/span&gt;
        &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Utf8&lt;/span&gt;
        &lt;span class="na"&gt;expr&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;endpoint&lt;/span&gt;&lt;span class="pi"&gt;]&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;status&lt;/span&gt;
        &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Utf8&lt;/span&gt;
        &lt;span class="na"&gt;expr&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;status&lt;/span&gt;&lt;span class="pi"&gt;]&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;response_time_ms&lt;/span&gt;
        &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Int64&lt;/span&gt;
        &lt;span class="na"&gt;expr&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;response_time_ms&lt;/span&gt;&lt;span class="pi"&gt;]&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;timestamp&lt;/span&gt;
        &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Utf8&lt;/span&gt;
        &lt;span class="na"&gt;expr&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;timestamp&lt;/span&gt;&lt;span class="pi"&gt;]&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;payments&lt;/span&gt;
    &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;List&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;of&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;processed&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;and&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;pending&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;payments"&lt;/span&gt;
    &lt;span class="na"&gt;request&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;GET&lt;/span&gt;
      &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/api/payments&lt;/span&gt;
    &lt;span class="na"&gt;response&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;rows_path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;data&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;columns&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;id&lt;/span&gt;
        &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Utf8&lt;/span&gt;
        &lt;span class="na"&gt;expr&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;]&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;amount&lt;/span&gt;
        &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Float64&lt;/span&gt;
        &lt;span class="na"&gt;expr&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;amount&lt;/span&gt;&lt;span class="pi"&gt;]&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;currency&lt;/span&gt;
        &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Utf8&lt;/span&gt;
        &lt;span class="na"&gt;expr&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;currency&lt;/span&gt;&lt;span class="pi"&gt;]&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;status&lt;/span&gt;
        &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Utf8&lt;/span&gt;
        &lt;span class="na"&gt;expr&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;status&lt;/span&gt;&lt;span class="pi"&gt;]&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;customer&lt;/span&gt;
        &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Utf8&lt;/span&gt;
        &lt;span class="na"&gt;expr&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;customer&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  The Result: True Enterprise Correlation
&lt;/h2&gt;

&lt;p&gt;With this custom source, the DevOps Incident Investigator can now:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Query internal API health in real-time:&lt;/strong&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;   &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;endpoint&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;response_time_ms&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;payment_api&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;health&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Cross-reference with Sentry errors:&lt;/strong&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;   &lt;span class="c1"&gt;-- "Is the payment API healthy after this Sentry error appeared?"&lt;/span&gt;
   &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;response_time_ms&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;error&lt;/span&gt;
   &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;payment_api&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;health&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sentry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;issues&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;
   &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;level&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'error'&lt;/span&gt;
   &lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Extend to ANY internal service:&lt;/strong&gt;
The same YAML pattern works for user-management APIs, inventory systems, billing platforms — any service with an HTTP endpoint.&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  Why This Matters for Enterprises
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F7fzres2jv8bb1slipkyu.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F7fzres2jv8bb1slipkyu.jpg" alt="Enterprises Private and Public Apps" width="798" height="236"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Every enterprise has internal tools that are:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Private&lt;/strong&gt; — behind VPNs, not publicly accessible&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Undocumented&lt;/strong&gt; — no OpenAPI spec, no marketplace listing&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Critical&lt;/strong&gt; — the payment gateway, the user auth service, the config management system&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Without custom source specs, an incident investigator is blind to these services. With them, &lt;strong&gt;any REST API becomes a SQL table in minutes.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The pattern is always the same:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Write a YAML file mapping endpoints → tables&lt;/li&gt;
&lt;li&gt;Run &lt;code&gt;coral source add --file your-spec.yaml&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Query with SQL: &lt;code&gt;SELECT * FROM your_api.your_table&lt;/code&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;One YAML file. Any API. Full SQL access.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Reproduce It Yourself
&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;# 1. Clone both repos&lt;/span&gt;
git clone https://github.com/khadirullah/devops-incident-investigator
git clone https://github.com/khadirullah/demo-payment-api

&lt;span class="c"&gt;# 2. Start the payment API&lt;/span&gt;
&lt;span class="nb"&gt;cd &lt;/span&gt;demo-payment-api &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; requirements.txt &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; python3 app.py
&lt;span class="c"&gt;# Running on http://localhost:5001&lt;/span&gt;

&lt;span class="c"&gt;# 3. Register the custom source with Coral&lt;/span&gt;
&lt;span class="nb"&gt;cd&lt;/span&gt; ../devops-incident-investigator
&lt;span class="nv"&gt;PAYMENT_API_TOKEN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;mock_123 coral &lt;span class="nb"&gt;source &lt;/span&gt;add &lt;span class="nt"&gt;--file&lt;/span&gt; coral-config/payment-api.yaml

&lt;span class="c"&gt;# 4. Query your internal API with SQL!&lt;/span&gt;
coral sql &lt;span class="s2"&gt;"SELECT * FROM payment_api.health"&lt;/span&gt;
coral sql &lt;span class="s2"&gt;"SELECT id, amount, status FROM payment_api.payments LIMIT 5"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;ℹ️ &lt;strong&gt;Official Guide:&lt;/strong&gt; &lt;a href="https://withcoral.com/docs/guides/write-a-custom-source" rel="noopener noreferrer"&gt;How to write a custom source spec →&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;🔗 &lt;strong&gt;&lt;a href="https://github.com/khadirullah/devops-incident-investigator" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Built as part of the &lt;a href="https://khadirullah.com/blog/devops-incident-investigator/" rel="noopener noreferrer"&gt;DevOps Incident Investigator&lt;/a&gt; for the &lt;a href="https://wemakedevs.org/hackathons/coral" rel="noopener noreferrer"&gt;Pirates of the Coral-bean&lt;/a&gt; hackathon by WeMakeDevs.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;#ChartNewWaters #Coral #DevOps #CustomSourceSpec #PiratesOfTheCoralBean&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://khadirullah.com/blog/coral-custom-source-spec/" rel="noopener noreferrer"&gt;khadirullah.com&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>coral</category>
      <category>devops</category>
      <category>hackathon</category>
      <category>sql</category>
    </item>
    <item>
      <title>Building a DevOps Incident Investigator with Coral SQL — From 15 Minutes to 15 Seconds</title>
      <dc:creator>Khadirullah Mohammad</dc:creator>
      <pubDate>Sun, 31 May 2026 02:37:19 +0000</pubDate>
      <link>https://dev.to/khadirullah/building-a-devops-incident-investigator-with-coral-sql-from-15-minutes-to-15-seconds-3gcp</link>
      <guid>https://dev.to/khadirullah/building-a-devops-incident-investigator-with-coral-sql-from-15-minutes-to-15-seconds-3gcp</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;🏴‍☠️ Built for the &lt;a href="https://wemakedevs.org/hackathons/coral" rel="noopener noreferrer"&gt;Pirates of the Coral-bean&lt;/a&gt; hackathon by &lt;a href="https://wemakedevs.org" rel="noopener noreferrer"&gt;WeMakeDevs&lt;/a&gt; | May 25–31, 2026&lt;/p&gt;
&lt;/blockquote&gt;

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

&lt;p&gt;Built a DevOps Incident Investigator using Coral SQL that correlates &lt;strong&gt;GitHub PRs&lt;/strong&gt;, &lt;strong&gt;Sentry incidents&lt;/strong&gt;, and &lt;strong&gt;Slack incident context&lt;/strong&gt; using a single SQL query. &lt;/p&gt;

&lt;p&gt;&lt;em&gt;Coral turns operational debugging into a single SQL query across distributed systems.&lt;/em&gt;&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;📉 Incident triage reduced from ~15 minutes to ~15 seconds.&lt;/li&gt;
&lt;li&gt;🪸 Custom Coral source spec created for internal APIs.&lt;/li&gt;
&lt;li&gt;🤖 AI-generated root cause analysis and Slack alerts.&lt;/li&gt;
&lt;li&gt;💻 Includes both a CLI and a Web Dashboard.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Built in 4 days for the Pirates of the Coral-bean hackathon.&lt;/p&gt;

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

&lt;p&gt;As a DevOps engineer, I've experienced the pain of incident investigation firsthand — switching between GitHub, Sentry, and Slack at 2 AM trying to figure out what broke production.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;GitHub&lt;/strong&gt; — "Which PR was deployed last?"&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sentry&lt;/strong&gt; — "What errors are spiking?"&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Slack&lt;/strong&gt; — "What's the team saying?"&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's 3 tabs, 3 APIs, and &lt;strong&gt;15 minutes of context-switching&lt;/strong&gt; before you even understand what happened. This is what modern incident response looks like without unified observability.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Solution
&lt;/h2&gt;

&lt;p&gt;When I discovered &lt;a href="https://withcoral.com" rel="noopener noreferrer"&gt;Coral&lt;/a&gt;, an open-source tool that lets you query any API with SQL, I knew exactly what to build.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One command. Three sources. Full incident picture.&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;python3 investigator.py &lt;span class="nt"&gt;--query&lt;/span&gt; all &lt;span class="nt"&gt;--owner&lt;/span&gt; khadirullah &lt;span class="nt"&gt;--repo&lt;/span&gt; demo-payment-api
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  The Magic: Cross-Source JOINs
&lt;/h3&gt;

&lt;p&gt;Coral allows querying GitHub and Sentry together in a single statement. For this prototype, I use deployment timing to surface likely suspect PRs that were merged around the same time incidents first appeared.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;number&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;pr_number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;pr_title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user__login&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;pr_author&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;merged_at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;short_id&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;sentry_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;error_title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;level&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;error_level&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;first_seen&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;error_first_seen&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;github&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pulls&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;sentry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;issues&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;merged_at&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;owner&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'khadirullah'&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;repo&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'demo-payment-api'&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'closed'&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;first_seen&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;merged_at&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt; &lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;15&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fqbcpk2q7io5fly32gox3.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fqbcpk2q7io5fly32gox3.png" alt="Cross-source correlation — GitHub PRs joined with Sentry errors" width="800" height="256"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;🔥 &lt;strong&gt;This is the money shot.&lt;/strong&gt; PRs merged on the same day as errors appeared, side by side. "PR #820 merged at 12:18 PM, errors started at 12:20 PM" — instant suspect identification.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Why I Chose Coral (and Why It's a Game-Changer)
&lt;/h2&gt;

&lt;p&gt;Normally, if you want to pull data from GitHub, Sentry, and Slack, you end up installing multiple SDKs (&lt;code&gt;PyGithub&lt;/code&gt;, &lt;code&gt;sentry-sdk&lt;/code&gt;, &lt;code&gt;slack-sdk&lt;/code&gt;), learning different APIs, handling pagination and retries yourself, and writing glue code to correlate everything manually.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;(Note: SDKs like &lt;code&gt;sentry-sdk&lt;/code&gt; are excellent for emitting telemetry from applications, but they are still siloed when it comes to querying and correlating operational data across multiple platforms during an incident.)&lt;/em&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  What Coral Replaced
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Before Coral:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;GitHub SDK ──┐
Sentry SDK ──┤──→ Custom Glue Code + Pagination + Rate Limiting
Slack SDK  ──┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;After Coral:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="n"&gt;Coral&lt;/span&gt; &lt;span class="k"&gt;SQL&lt;/span&gt; &lt;span class="err"&gt;──→&lt;/span&gt; &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;github&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;sentry&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;slack&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F1gesdjqwsfx2ve8b7qfb.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F1gesdjqwsfx2ve8b7qfb.jpg" alt="Before and After Coral" width="800" height="297"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;By treating the entire operational stack as a unified query layer, I was able to build my agent using &lt;strong&gt;far fewer SDKs&lt;/strong&gt; and &lt;strong&gt;almost no glue code&lt;/strong&gt;. Coral handles the authentication, pagination, and schema mapping locally, allowing me to focus on the actual business logic: writing cross-source JOINs and generating AI analysis.&lt;/p&gt;

&lt;h2&gt;
  
  
  Day 1: Setting Up Coral &amp;amp; Connecting Sources
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Installing Coral
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-fsSL&lt;/span&gt; https://withcoral.com/install.sh | sh
coral &lt;span class="nt"&gt;--version&lt;/span&gt;
&lt;span class="c"&gt;# coral 0.3.0+96d61f7&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One command. That's it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Connecting GitHub
&lt;/h3&gt;

&lt;blockquote&gt;
&lt;p&gt;ℹ️ For the GitHub token, I created a classic PAT at &lt;a href="https://github.com/settings/tokens" rel="noopener noreferrer"&gt;github.com/settings/tokens&lt;/a&gt; with &lt;strong&gt;zero scopes selected&lt;/strong&gt;. For public repos, you don't need any permissions — the token just bumps your API rate limit from 60 to 5,000 requests per hour.&lt;br&gt;
&lt;/p&gt;
&lt;/blockquote&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;GITHUB_TOKEN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;ghp_XXXXX coral &lt;span class="nb"&gt;source &lt;/span&gt;add github
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F7mp4wrtvqu4py20w22qn.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F7mp4wrtvqu4py20w22qn.png" alt="Coral installation and GitHub source connection in terminal" width="800" height="658"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;362 tables&lt;/strong&gt; connected instantly — issues, pull requests, commits, repos, actions.&lt;/p&gt;

&lt;h3&gt;
  
  
  Connecting Sentry
&lt;/h3&gt;

&lt;p&gt;Finding the Sentry token wasn't obvious. Sentry doesn't have a simple "API Tokens" page — you create tokens through &lt;strong&gt;Custom Integrations&lt;/strong&gt;:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Settings → Custom Integrations → Create New Integration&lt;/strong&gt; (Internal Integration)&lt;/li&gt;
&lt;li&gt;Name it &lt;code&gt;coral-hackathon&lt;/code&gt; with minimal &lt;strong&gt;read-only permissions&lt;/strong&gt; (Project: Read, Issue &amp;amp; Event: Read, Organization: Read)&lt;/li&gt;
&lt;li&gt;Copy the generated token and connect:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;SENTRY_TOKEN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;sntrys_XXXXX &lt;span class="nv"&gt;SENTRY_ORG&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;my-org coral &lt;span class="nb"&gt;source &lt;/span&gt;add sentry
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fo6h3p1nkm0pffdubv9jm.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fo6h3p1nkm0pffdubv9jm.png" alt="Sentry source connected to Coral" width="800" height="288"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;12 tables&lt;/strong&gt; connected — events, issues, projects, deployments.&lt;/p&gt;

&lt;p&gt;I verified the tables were available with a quick &lt;code&gt;SELECT schema_name, table_name FROM coral.tables&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Connecting Slack
&lt;/h3&gt;

&lt;p&gt;Slack was the trickiest. Coral's &lt;a href="https://api.slack.com/apps" rel="noopener noreferrer"&gt;pre-filled link&lt;/a&gt; creates a Slack app, but it only sets up &lt;strong&gt;User Token Scopes&lt;/strong&gt; with PKCE — tokens aren't displayed in the UI.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;⚠️ &lt;strong&gt;The fix:&lt;/strong&gt; Add &lt;strong&gt;Bot Token Scopes&lt;/strong&gt; instead of User Token Scopes. In &lt;strong&gt;OAuth &amp;amp; Permissions&lt;/strong&gt;, scroll to &lt;strong&gt;Bot Token Scopes&lt;/strong&gt; and add: &lt;code&gt;channels:history&lt;/code&gt;, &lt;code&gt;channels:read&lt;/code&gt;, &lt;code&gt;groups:history&lt;/code&gt;, &lt;code&gt;groups:read&lt;/code&gt;, &lt;code&gt;users:read&lt;/code&gt;. Then &lt;strong&gt;reinstall&lt;/strong&gt; the app — a &lt;strong&gt;Bot User OAuth Token&lt;/strong&gt; (&lt;code&gt;xoxb-...&lt;/code&gt;) appears!&lt;br&gt;
&lt;/p&gt;
&lt;/blockquote&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;coral &lt;span class="nb"&gt;source &lt;/span&gt;add &lt;span class="nt"&gt;--interactive&lt;/span&gt; slack
&lt;span class="c"&gt;# Paste xoxb-... token when prompted&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fv3l1qixyroxguo22mywn.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fv3l1qixyroxguo22mywn.png" alt="Slack source connected" width="800" height="225"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  All Sources Connected! 🎉
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Source&lt;/th&gt;
&lt;th&gt;Tables&lt;/th&gt;
&lt;th&gt;What It Gives Us&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;GitHub&lt;/td&gt;
&lt;td&gt;362&lt;/td&gt;
&lt;td&gt;PRs, commits, issues, actions&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sentry&lt;/td&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;td&gt;Errors, events, projects&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Slack&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;Channels, users&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;376&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;All queryable with SQL&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  Architecture
&lt;/h2&gt;

&lt;p&gt;Here's how the entire system fits together:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fps2ozq0304ux26o21ne6.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fps2ozq0304ux26o21ne6.jpg" alt="Architecture" width="799" height="483"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Two interfaces&lt;/strong&gt; (CLI + Web Dashboard) sit on top of &lt;strong&gt;Coral SQL&lt;/strong&gt;, which unifies 4 data sources into a single query layer. The AI layer uses Gemini Flash for root cause analysis and natural language → SQL generation.&lt;/p&gt;




&lt;h2&gt;
  
  
  Day 2: Writing SQL Queries &amp;amp; Building the CLI
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Generating Test Data for Sentry
&lt;/h3&gt;

&lt;p&gt;First roadblock: Sentry was empty. I wrote &lt;code&gt;generate_errors.py&lt;/code&gt; to send 10 realistic DevOps errors.&lt;/p&gt;

&lt;p&gt;Errors include: &lt;code&gt;ConnectionError&lt;/code&gt; (PostgreSQL max connections), &lt;code&gt;MemoryError&lt;/code&gt; (OOM kill), &lt;code&gt;RuntimeError&lt;/code&gt; (K8s CrashLoopBackOff), &lt;code&gt;TimeoutError&lt;/code&gt; (30s timeout), and more.&lt;/p&gt;

&lt;h3&gt;
  
  
  The SQL Queries
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Query 1: Deployments&lt;/strong&gt; — "What was recently deployed?"&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;user__login&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;author&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;merged_at&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;github&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pulls&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="k"&gt;owner&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'khadirullah'&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;repo&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'demo-payment-api'&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;merged_at&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;merged_at&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt; &lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Query 2: Incidents&lt;/strong&gt; — "What errors are happening?"&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;short_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;level&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;count&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;event_count&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;first_seen&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;last_seen&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;sentry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;issues&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;last_seen&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt; &lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Query 3: Correlation&lt;/strong&gt; — "Suspect deployment identification"&lt;/p&gt;

&lt;p&gt;&lt;em&gt;(As shown in the introduction, this cross-source JOIN correlates GitHub PRs with Sentry errors based on timestamps).&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Query 4: Team Overview&lt;/strong&gt; — Another cross-source JOIN:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;username&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;real_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;is_admin&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;channel_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;num_members&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;slack&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;users&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt; &lt;span class="k"&gt;CROSS&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;slack&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;channels&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;deleted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;is_archived&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Building the CLI
&lt;/h3&gt;

&lt;p&gt;The CLI wraps everything in a clean, colorful interface with &lt;strong&gt;zero pip dependencies&lt;/strong&gt; — Python stdlib only (&lt;code&gt;subprocess&lt;/code&gt;, &lt;code&gt;argparse&lt;/code&gt;, &lt;code&gt;json&lt;/code&gt;, &lt;code&gt;urllib&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;python3 investigator.py &lt;span class="nt"&gt;--query&lt;/span&gt; all &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--owner&lt;/span&gt; khadirullah &lt;span class="nt"&gt;--repo&lt;/span&gt; demo-payment-api &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--slack-token&lt;/span&gt; &lt;span class="nv"&gt;$SLACK_TOKEN&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Troubleshooting Gotchas
&lt;/h3&gt;

&lt;blockquote&gt;
&lt;p&gt;⚠️ &lt;strong&gt;Sentry Project ID:&lt;/strong&gt; My first query failed with &lt;code&gt;"Invalid project parameter. Values must be numbers."&lt;/code&gt; I was using the slug (&lt;code&gt;python&lt;/code&gt;) but Sentry wants the numeric ID. Fixed with:&lt;/p&gt;


&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;sentry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;projects&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;


&lt;p&gt;⚠️ &lt;strong&gt;Slack Bot Permissions:&lt;/strong&gt; Got &lt;code&gt;not_in_channel&lt;/code&gt; error when fetching messages. The bot had &lt;code&gt;channels:history&lt;/code&gt; but wasn't &lt;em&gt;in&lt;/em&gt; the channel. Then hit &lt;code&gt;missing_scope&lt;/code&gt; — needed &lt;code&gt;channels:join&lt;/code&gt; scope. Quick fix: add scope → reinstall app → copy new token.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Day 3: Web Dashboard + AI Integration
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Why Build a Dashboard?
&lt;/h3&gt;

&lt;p&gt;A CLI is functional, but judges have 5 minutes. They need to &lt;em&gt;see&lt;/em&gt; the data. I built a Flask dashboard with a dark glassmorphism design.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Dashboard
&lt;/h3&gt;

&lt;p&gt;Features:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Stats Row&lt;/strong&gt; — Live counters for deployments, incidents, correlations, risky PRs&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Incident Timeline&lt;/strong&gt; — Horizontal scrolling event sequence&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deployments Table&lt;/strong&gt; — Merged PRs with authors and timestamps&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sentry Incidents&lt;/strong&gt; — Severity-colored cards with "🤖 Analyze" button&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Correlation View&lt;/strong&gt; — Visual PR ↔ Error mapping&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Slack Messages&lt;/strong&gt; — Chat-style from #incidents&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Risky PRs&lt;/strong&gt; — Risk-scored with green/yellow/red bars&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Team Overview&lt;/strong&gt; — Member cards from Slack users × channels JOIN&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  AI Root Cause Analysis
&lt;/h3&gt;

&lt;p&gt;Click &lt;strong&gt;🤖 Analyze&lt;/strong&gt; on any Sentry error.&lt;/p&gt;

&lt;p&gt;For example, PROD-41A (PostgreSQL max connections):&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Root Cause:&lt;/strong&gt; PR #487 introduced a new connection pooling layer that eagerly opens connections on pod startup. With 4 pods each opening 25 connections, the default max_connections=100 limit is immediately exhausted.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Immediate Fix:&lt;/strong&gt; 1) Roll back PR #487, 2) Increase max_connections to 200, 3) Restart affected pods&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Confidence: 94%&lt;/strong&gt; &lt;em&gt;(Coral system score)&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;em&gt;(Note: This example is generated from demo data and illustrates the style of analysis produced by the assistant.)&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;In live mode with a Gemini API key, it calls Gemini Flash in real-time. Pre-computed demos ensure instant results without API waits.&lt;/p&gt;

&lt;h3&gt;
  
  
  Settings &amp;amp; Token Security
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;All 4 API tokens encrypted with &lt;strong&gt;Fernet symmetric encryption&lt;/strong&gt; before writing to disk&lt;/li&gt;
&lt;li&gt;"Delete All Tokens" does a 3-pass random overwrite&lt;/li&gt;
&lt;li&gt;Demo → Live toggle with per-panel badges&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Demo Mode
&lt;/h3&gt;

&lt;p&gt;The dashboard starts in &lt;strong&gt;Demo Mode&lt;/strong&gt; — pre-loaded data, zero setup. Switching to live is 4 clicks:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;⚙️ Settings → enter tokens&lt;/li&gt;
&lt;li&gt;💾 Save&lt;/li&gt;
&lt;li&gt;Click DEMO badge → LIVE&lt;/li&gt;
&lt;li&gt;🔄 Refresh&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If a live call fails, panels gracefully fall back to demo data.&lt;/p&gt;




&lt;h2&gt;
  
  
  Day 4: Competitive Upgrades — Going Beyond
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Custom Source Spec: Querying Internal APIs
&lt;/h3&gt;

&lt;p&gt;Enterprises don't just use public SaaS tools. A real incident investigator needs to query &lt;strong&gt;internal microservices&lt;/strong&gt;. This is where Coral's &lt;strong&gt;Custom Source Specs&lt;/strong&gt; shine.&lt;/p&gt;

&lt;p&gt;I built a companion project (&lt;code&gt;demo-payment-api&lt;/code&gt;) and wrote a YAML spec to teach Coral how to talk to it:&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;payment_api&lt;/span&gt;
&lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;0.1.0&lt;/span&gt;
&lt;span class="na"&gt;dsl_version&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;backend&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;http&lt;/span&gt;
&lt;span class="na"&gt;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;http://localhost:5001"&lt;/span&gt;

&lt;span class="na"&gt;auth&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;HeaderAuth&lt;/span&gt;
  &lt;span class="na"&gt;headers&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;Authorization&lt;/span&gt;
      &lt;span class="na"&gt;template&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Bearer&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;{{input.PAYMENT_API_TOKEN}}"&lt;/span&gt;

&lt;span class="na"&gt;tables&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;health&lt;/span&gt;
    &lt;span class="na"&gt;request&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;GET&lt;/span&gt;
      &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/api/health&lt;/span&gt;
    &lt;span class="na"&gt;columns&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;status&lt;/span&gt;
        &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Utf8&lt;/span&gt;
        &lt;span class="na"&gt;expr&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;status&lt;/span&gt;&lt;span class="pi"&gt;]&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;response_time_ms&lt;/span&gt;
        &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Int64&lt;/span&gt;
        &lt;span class="na"&gt;expr&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;response_time_ms&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The YAML acts as a "translator" — it tells Coral how to map SQL concepts (tables, columns) to HTTP concepts (endpoints, JSON paths):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;User ──→ Coral: SELECT status FROM payment_api.health
Coral ──→ YAML: Look up "health" table
YAML ──→ Coral: GET /api/health
Coral ──→ API: HTTP GET http://localhost:5001/api/health
API ──→ Coral: {"status": "healthy", "response_time_ms": 42}
Coral ──→ User: | status  | response_time_ms |
                | healthy |       42         |
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Register and query:&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;# Lint the spec&lt;/span&gt;
coral &lt;span class="nb"&gt;source &lt;/span&gt;lint coral-config/payment-api.yaml

&lt;span class="c"&gt;# Add to Coral&lt;/span&gt;
&lt;span class="nv"&gt;PAYMENT_API_TOKEN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;mock_123 coral &lt;span class="nb"&gt;source &lt;/span&gt;add &lt;span class="nt"&gt;--file&lt;/span&gt; coral-config/payment-api.yaml

&lt;span class="c"&gt;# Query like a database!&lt;/span&gt;
coral sql &lt;span class="s2"&gt;"SELECT * FROM payment_api.health"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;ℹ️ This proves the tool can connect to &lt;strong&gt;any&lt;/strong&gt; internal enterprise service — not just the big SaaS providers. Read more in our dedicated &lt;a href="https://khadirullah.com/blog/coral-custom-source-spec/" rel="noopener noreferrer"&gt;Chart New Waters&lt;/a&gt; deep-dive.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Natural Language to SQL (&lt;code&gt;/api/ask&lt;/code&gt;)
&lt;/h3&gt;

&lt;p&gt;Type a question in plain English → AI generates Coral SQL → executes it → returns results:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;User: "Show me critical errors from last week"
  → Gemini Flash
  → SELECT * FROM sentry.issues WHERE level = 'error' LIMIT 10
  → Coral SQL Engine
  → Results Table
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The backend feeds the &lt;strong&gt;live Coral schema&lt;/strong&gt; (all tables + columns) to Gemini, so it generates accurate SQL every time.&lt;/p&gt;

&lt;h3&gt;
  
  
  Automated Slack Alerts
&lt;/h3&gt;

&lt;p&gt;Investigation is only half the battle — &lt;strong&gt;communication&lt;/strong&gt; is the other half. After AI generates a root cause analysis, users can click &lt;strong&gt;"📢 Send to Slack"&lt;/strong&gt; to push the report directly to &lt;code&gt;#incidents&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;🚨 Sentry Error → 🤖 AI Analysis → 📢 Send to Slack → #incidents channel → Team sees report
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the token only has read permissions, the UI catches the &lt;code&gt;missing_scope&lt;/code&gt; error and gracefully prompts the user to add &lt;code&gt;chat:write&lt;/code&gt; scope.&lt;/p&gt;

&lt;h3&gt;
  
  
  Handling Slack Messages (TVF vs Custom Fallback)
&lt;/h3&gt;

&lt;p&gt;Coral exposes Slack messages via a function-like table interface (TVF), which requires passing the exact channel ID directly into the SQL query: &lt;code&gt;SELECT * FROM slack.messages(channel =&amp;gt; 'C12345678')&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;However, I needed something more robust for an automated dashboard. If the bot isn't already in the incident channel, the Slack API returns a strict &lt;code&gt;not_in_channel&lt;/code&gt; error. Instead of failing, I deliberately built a custom Python fallback that catches this error, dynamically forces the bot to join the channel, fetches the messages, and then uses Coral to pull the &lt;code&gt;slack.users&lt;/code&gt; table to map the raw User IDs (e.g., &lt;code&gt;UXXXXXXX&lt;/code&gt;) to real human names. &lt;/p&gt;

&lt;h3&gt;
  
  
  Fetching the Team Overview
&lt;/h3&gt;

&lt;p&gt;With the messages handled resiliently, I still needed to display the active Incident Response team. Instead of making separate API calls for users and channels, I let Coral grab the entire team landscape in a single round-trip using a &lt;code&gt;CROSS JOIN&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;username&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;real_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;is_admin&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;channel_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;num_members&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;slack&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;users&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt; &lt;span class="k"&gt;CROSS&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;slack&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;channels&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;deleted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;is_archived&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A quick Python iteration separates the results, giving us the full team and channel rosters instantly without juggling multiple Slack SDK requests.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why Sentry (Not Just SonarQube &amp;amp; Trivy)
&lt;/h2&gt;

&lt;p&gt;A question I got: "Why do you need Sentry if you have SonarQube and Trivy?" The answer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Before Deploy:  SonarQube → "Code COULD break"
                Trivy     → "Has known CVEs"
                      ↓
                   Deploy
                      ↓
After Deploy:   Sentry    → "App JUST broke for 1,203 users"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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;Stage&lt;/th&gt;
&lt;th&gt;Catches&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;SonarQube&lt;/td&gt;
&lt;td&gt;Pre-deploy (CI)&lt;/td&gt;
&lt;td&gt;Code smells, potential bugs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Trivy&lt;/td&gt;
&lt;td&gt;Pre-deploy (CI)&lt;/td&gt;
&lt;td&gt;Known CVEs in dependencies&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Sentry&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Post-deploy (Runtime)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Real crashes, right now, with stack traces + user impact&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;The Incident Investigator bridges all three stages&lt;/strong&gt; — correlating code changes (GitHub) with runtime errors (Sentry) and team communication (Slack).&lt;/p&gt;




&lt;h2&gt;
  
  
  Setting Up Slack Alerts (Webhooks vs Bot Tokens)
&lt;/h2&gt;

&lt;p&gt;We use &lt;strong&gt;both&lt;/strong&gt; approaches:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Feature&lt;/th&gt;
&lt;th&gt;Incoming Webhook&lt;/th&gt;
&lt;th&gt;Bot Token (xoxb-)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Direction&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;App → Slack (one-way)&lt;/td&gt;
&lt;td&gt;App ↔ Slack (two-way)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Use case&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Post alerts&lt;/td&gt;
&lt;td&gt;Read messages + respond&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Setup&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Just a URL&lt;/td&gt;
&lt;td&gt;OAuth scopes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Best for&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Simple alerting&lt;/td&gt;
&lt;td&gt;Complex integrations&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Webhook&lt;/strong&gt; → Demo Payment API posts error alerts to &lt;code&gt;#incidents&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bot Token&lt;/strong&gt; → Incident Investigator &lt;em&gt;reads&lt;/em&gt; messages from &lt;code&gt;#incidents&lt;/code&gt; via Coral SQL&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The complete pipeline:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Error in Payment API
  ├──→ Sentry captures exception ──→ Coral SQL queries ──┐
  └──→ Slack webhook fires ──→ #incidents channel ──────┤
                                                         ├──→ Incident Investigator
  GitHub PRs ──────────────────────────────────────────┘       │
                                                               ▼
                                                        Gemini AI Analysis
                                                               │
                                                               ▼
                                                        📢 Push to Slack #incidents
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






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

&lt;h3&gt;
  
  
  About Coral
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Zero-scope GitHub tokens work&lt;/strong&gt; — just bumps your rate limit for public repos&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sentry wants numeric project IDs&lt;/strong&gt; — not slugs, query &lt;code&gt;sentry.projects&lt;/code&gt; first&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Slack's Coral source is limited&lt;/strong&gt; — only &lt;code&gt;channels&lt;/code&gt; and &lt;code&gt;users&lt;/code&gt;, no messages. But the bot token works with direct API calls&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cross-source JOINs are the killer feature&lt;/strong&gt; — GitHub × Sentry in one SQL statement is genuinely powerful&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  About DevOps Incident Response
&lt;/h3&gt;

&lt;p&gt;The hardest part of incident response isn't fixing the problem — &lt;strong&gt;it's finding the right information.&lt;/strong&gt; We spend more time context-switching than debugging. The investigator answers three questions fast:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;What was deployed recently?&lt;/li&gt;
&lt;li&gt;What errors appeared after deployment?&lt;/li&gt;
&lt;li&gt;What's the team saying about it?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That's 80% of the first 15 minutes of any incident.&lt;/p&gt;

&lt;h3&gt;
  
  
  About Hackathons
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Ship fast, iterate later&lt;/strong&gt; — the Discord advice was spot-on&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Document as you go&lt;/strong&gt; — writing the blog alongside the code was more efficient&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Errors are content&lt;/strong&gt; — every &lt;code&gt;missing_scope&lt;/code&gt; became a blog section&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep scope tight, then expand&lt;/strong&gt; — CLI first, dashboard second, AI third&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Limitations
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Correlation heuristic:&lt;/strong&gt; GitHub ↔ Sentry correlation is currently based on deployment timing, not deterministic tracing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AI as an assistant:&lt;/strong&gt; Root cause analysis is AI-assisted and should be treated as a hypothesis, not absolute truth.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Slack capabilities:&lt;/strong&gt; Slack messages are retrieved through a Python fallback integration because the native Coral Slack source currently exposes only users and channels.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;API rate limits:&lt;/strong&gt; In a high-traffic live incident, direct API calls to Slack and GitHub could hit rate limits, requiring an intermediate caching layer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Schema mismatches:&lt;/strong&gt; Edge cases in custom internal APIs may occasionally fail to map perfectly to Coral's static YAML types without custom data coercions.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  The Result
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;In testing, the workflow reduced incident investigation from roughly 15 minutes of manual context-switching to less than 15 seconds for an initial deployment-to-error correlation query.&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  The Impact
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Before (The Old Way):&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;❌ Open GitHub to check recent PRs&lt;/li&gt;
&lt;li&gt;❌ Open Sentry to check recent errors&lt;/li&gt;
&lt;li&gt;❌ Open Slack to read team discussions&lt;/li&gt;
&lt;li&gt;❌ Manually correlate timestamps across 3 tabs&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;After (The Coral Way):&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;✅ Run one command or view one dashboard&lt;/li&gt;
&lt;li&gt;✅ Instantly see PRs and Errors side-by-side&lt;/li&gt;
&lt;li&gt;✅ Get AI-generated root cause analysis&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Technical Achievements
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Feature&lt;/th&gt;
&lt;th&gt;Details&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;6 SQL Queries&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Deployments, incidents, correlation, risky PRs, team, health&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;2 Cross-Source JOINs&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;GitHub × Sentry, Slack users × channels&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;1 Custom Source Spec&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;payment-api.yaml&lt;/code&gt; for internal microservice&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;AI Analysis&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Gemini-powered root cause + fix suggestions&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;NL-to-SQL&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Ask questions in English, get Coral SQL results&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;2 Interfaces&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;CLI (zero deps) + Web Dashboard (Flask)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Security&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Fernet symmetric encryption at rest&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Docker + CI&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Production-ready packaging + GitHub Actions&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Try It
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/khadirullah/devops-incident-investigator
&lt;span class="nb"&gt;cd &lt;/span&gt;devops-incident-investigator
pip &lt;span class="nb"&gt;install &lt;/span&gt;flask google-generativeai cryptography
python3 app.py
&lt;span class="c"&gt;# Open http://localhost:5000 — works instantly with demo data!&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;🔗 &lt;strong&gt;&lt;a href="https://github.com/khadirullah/devops-incident-investigator" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Future Work
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Direct Release Correlation:&lt;/strong&gt; Correlate Sentry releases directly to GitHub commits using commit SHAs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;More Sources:&lt;/strong&gt; Add Grafana and Kubernetes log sources to the SQL engine.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Automated Postmortems:&lt;/strong&gt; Use the AI layer to generate and publish full incident postmortems automatically.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Built with &lt;a href="https://withcoral.com" rel="noopener noreferrer"&gt;Coral&lt;/a&gt; for the &lt;a href="https://wemakedevs.org/hackathons/coral" rel="noopener noreferrer"&gt;Pirates of the Coral-bean&lt;/a&gt; hackathon by WeMakeDevs.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Follow the journey: &lt;a href="https://khadirullah.com" rel="noopener noreferrer"&gt;khadirullah.com&lt;/a&gt; | &lt;a href="https://github.com/khadirullah" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://khadirullah.com/blog/devops-incident-investigator/" rel="noopener noreferrer"&gt;khadirullah.com&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>devops</category>
      <category>coral</category>
      <category>hackathon</category>
      <category>python</category>
    </item>
    <item>
      <title>Setting Up Custom Domain Email with SPF, DKIM, and DMARC</title>
      <dc:creator>Khadirullah Mohammad</dc:creator>
      <pubDate>Tue, 19 May 2026 13:30:00 +0000</pubDate>
      <link>https://dev.to/khadirullah/setting-up-custom-domain-email-with-spf-dkim-and-dmarc-m78</link>
      <guid>https://dev.to/khadirullah/setting-up-custom-domain-email-with-spf-dkim-and-dmarc-m78</guid>
      <description>&lt;p&gt;&lt;strong&gt;A complete guide to setting up professional email on your custom domain — with SPF, DKIM, and DMARC explained from the ground up.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;When I bought my domain, one of the first things I wanted was a professional email address — &lt;code&gt;contact@yourdomain.com&lt;/code&gt; instead of a generic Gmail address. But I also wanted to make sure nobody could spoof my domain to send fake emails pretending to be me.&lt;/p&gt;

&lt;p&gt;This post covers everything I did: setting up Zoho Mail, configuring DNS records in Cloudflare, and implementing SPF, DKIM, and DMARC — the three protocols that prove your emails are legitimate.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Custom Domain Email?
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Option&lt;/th&gt;
&lt;th&gt;What It Looks Like&lt;/th&gt;
&lt;th&gt;Impression&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Gmail&lt;/td&gt;
&lt;td&gt;@gmail.com&lt;/td&gt;
&lt;td&gt;"Just another person"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Custom domain&lt;/td&gt;
&lt;td&gt;&lt;a href="mailto:contact@yourdomain.com"&gt;contact@yourdomain.com&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;"Professional, owns their infrastructure"&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;For a DevOps engineer's website, having a custom domain email shows you understand DNS, mail infrastructure, and security — which is literally part of the job.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Zoho Mail?
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Provider&lt;/th&gt;
&lt;th&gt;Free Tier&lt;/th&gt;
&lt;th&gt;Why I Chose/Skipped&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Google Workspace&lt;/td&gt;
&lt;td&gt;No free tier anymore&lt;/td&gt;
&lt;td&gt;Costs $6/month per user&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Microsoft 365&lt;/td&gt;
&lt;td&gt;No free tier&lt;/td&gt;
&lt;td&gt;Costs $6/month per user&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ProtonMail&lt;/td&gt;
&lt;td&gt;Custom domain on paid plan only&lt;/td&gt;
&lt;td&gt;Costs $4/month&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Zoho Mail&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;✅ &lt;strong&gt;Free for 5 users, 5GB&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;Free, custom domain. Note: Web/App only (no IMAP)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cloudflare Email Routing&lt;/td&gt;
&lt;td&gt;Free&lt;/td&gt;
&lt;td&gt;Forwarding only — can't send FROM your domain&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Zoho Mail gives you a real mailbox on your custom domain for free for up to 5 users. You can send and receive emails as &lt;code&gt;anything@yourdomain.com&lt;/code&gt;, though the free plan requires using the Zoho Mail website or mobile app (IMAP/POP for third-party apps like Outlook or Apple Mail is not included). You can upgrade to a &lt;a href="https://www.zoho.com/en-in/mail/zohomail-pricing.html" rel="noopener noreferrer"&gt;paid plan&lt;/a&gt; to add IMAP/POP and other features.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: Add Your Domain to Zoho
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Go to &lt;a href="https://www.zoho.com/mail/" rel="noopener noreferrer"&gt;Zoho Mail&lt;/a&gt; → Sign up for the free plan&lt;/li&gt;
&lt;li&gt;Add your domain — Zoho will ask you to verify ownership&lt;/li&gt;
&lt;li&gt;Zoho gives you a &lt;strong&gt;TXT record&lt;/strong&gt; to add to your DNS — this proves you own the domain&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;In Cloudflare DNS:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;Name&lt;/th&gt;
&lt;th&gt;Content&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;TXT&lt;/td&gt;
&lt;td&gt;&lt;code&gt;@&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;zoho-verification=zb12345678.zmverify.zoho.com&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;After adding this record, click "Verify" in Zoho. Once verified, Zoho gives you the remaining DNS records.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: MX Records (Mail Exchange)
&lt;/h2&gt;

&lt;p&gt;MX records tell the internet &lt;strong&gt;where to deliver emails&lt;/strong&gt; for your domain. When someone sends an email to &lt;code&gt;contact@yourdomain.com&lt;/code&gt;, the sending mail server looks up the MX records for &lt;code&gt;yourdomain.com&lt;/code&gt; to find out which mail server should receive it.&lt;/p&gt;

&lt;p&gt;Zoho provides these MX records:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;Name&lt;/th&gt;
&lt;th&gt;Mail Server&lt;/th&gt;
&lt;th&gt;Priority&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;MX&lt;/td&gt;
&lt;td&gt;&lt;code&gt;@&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;mx.zoho.com&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MX&lt;/td&gt;
&lt;td&gt;&lt;code&gt;@&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;mx2.zoho.com&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;20&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MX&lt;/td&gt;
&lt;td&gt;&lt;code&gt;@&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;mx3.zoho.com&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;50&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Priority&lt;/strong&gt; determines the order — the sending server tries priority 10 first (&lt;code&gt;mx.zoho.com&lt;/code&gt;). If that's down, it tries priority 20, then 50. This gives you redundancy.&lt;/p&gt;

&lt;p&gt;Add all three in Cloudflare DNS.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;ℹ️ &lt;strong&gt;Note:&lt;/strong&gt; These are the MX records for Zoho's US/global data center (&lt;code&gt;zoho.com&lt;/code&gt;). If you signed up at &lt;code&gt;zoho.eu&lt;/code&gt; or &lt;code&gt;zoho.in&lt;/code&gt;, your MX servers will be different (e.g., &lt;code&gt;mx.zoho.eu&lt;/code&gt;). Always use the exact values Zoho provides during setup.&lt;/p&gt;

&lt;p&gt;⚠️ &lt;strong&gt;Important:&lt;/strong&gt; Make sure the proxy status for MX records is set to &lt;strong&gt;DNS only&lt;/strong&gt; (gray cloud), not &lt;strong&gt;Proxied&lt;/strong&gt; (orange cloud). Email traffic cannot go through Cloudflare's proxy.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Step 3: SPF (Sender Policy Framework)
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What Is SPF?
&lt;/h3&gt;

&lt;p&gt;SPF answers the question: &lt;strong&gt;"Which mail servers are allowed to send email on behalf of my domain?"&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Without SPF, anyone in the world could send an email that says &lt;code&gt;From: contact@yourdomain.com&lt;/code&gt; — and the receiving server would have no way to verify if it's real. SPF fixes this by publishing a list of authorized mail servers in your DNS.&lt;/p&gt;

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



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1. You send an email from contact@yourdomain.com via Zoho
2. Zoho sends it with a Return-Path (envelope sender) on your domain
3. The receiving mail server looks up the SPF record for that envelope domain
4. SPF record says: "Only Zoho's servers are allowed to send for this domain"
5. Mail server checks: Did this email actually come from Zoho's IP addresses?
   → Yes → SPF passes
   → No  → SPF fails (mark as suspicious or reject)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;ℹ️ &lt;strong&gt;Technical detail:&lt;/strong&gt; SPF validates the &lt;em&gt;envelope sender&lt;/em&gt; (&lt;code&gt;Return-Path&lt;/code&gt;), not the &lt;code&gt;From:&lt;/code&gt; header you see in your email client. For simple setups like Zoho, these match your domain — but the distinction matters when you add third-party senders and is the reason DMARC alignment exists as a separate check (explained below).&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  The Record
&lt;/h3&gt;

&lt;p&gt;Zoho provides this SPF record:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;Name&lt;/th&gt;
&lt;th&gt;Content&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;TXT&lt;/td&gt;
&lt;td&gt;&lt;code&gt;@&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;v=spf1 include:zoho.com ~all&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;blockquote&gt;
&lt;p&gt;ℹ️ &lt;strong&gt;Regional note:&lt;/strong&gt; Just like MX records, the SPF &lt;code&gt;include:&lt;/code&gt; domain varies by Zoho region. If you signed up at &lt;code&gt;zoho.eu&lt;/code&gt;, use &lt;code&gt;include:zoho.eu&lt;/code&gt;. If &lt;code&gt;zoho.in&lt;/code&gt;, use &lt;code&gt;include:zoho.in&lt;/code&gt;. Always use the exact SPF record Zoho provides during setup.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Breaking it down:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Part&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;v=spf1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;This is an SPF record (version 1)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;include:zoho.com&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Allow any server that Zoho authorizes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;~all&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Soft-fail everything else (mark as suspicious but don't reject)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;blockquote&gt;
&lt;p&gt;ℹ️ &lt;strong&gt;&lt;code&gt;~all&lt;/code&gt; vs &lt;code&gt;-all&lt;/code&gt;:&lt;/strong&gt; The &lt;code&gt;~&lt;/code&gt; (tilde) means "soft fail" — unauthorized emails are marked suspicious but still delivered. The &lt;code&gt;-&lt;/code&gt; (hyphen) means "hard fail" — unauthorized emails are rejected outright. I started with &lt;code&gt;~all&lt;/code&gt; to make sure legitimate emails weren't accidentally blocked. Once you're confident everything works, you can switch to &lt;code&gt;-all&lt;/code&gt; for stricter enforcement.&lt;/p&gt;

&lt;p&gt;ℹ️ &lt;strong&gt;DevOps gotcha: SPF has a 10 DNS lookup limit.&lt;/strong&gt; Each &lt;code&gt;include:&lt;/code&gt; in your SPF record triggers DNS lookups, and nested includes count too. Zoho's &lt;code&gt;include:zoho.com&lt;/code&gt; uses a few of those. If you later add services like SendGrid, Mailchimp, or AWS SES, you can easily exceed this limit — causing SPF to permanently fail. Use tools like &lt;a href="https://dmarcian.com/spf-survey/" rel="noopener noreferrer"&gt;dmarcian SPF Surveyor&lt;/a&gt; to audit your lookup count.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Step 4: DKIM (DomainKeys Identified Mail)
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What Is DKIM?
&lt;/h3&gt;

&lt;p&gt;DKIM answers the question: &lt;strong&gt;"Was this email actually sent by who it claims, and was it tampered with in transit?"&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;DKIM uses cryptographic signatures. When Zoho sends an email from your domain, it signs the email with a &lt;strong&gt;private key&lt;/strong&gt; that only Zoho has. The receiving server verifies the signature using a &lt;strong&gt;public key&lt;/strong&gt; that you publish in your DNS.&lt;/p&gt;

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



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1. You send an email from contact@yourdomain.com via Zoho
2. Zoho signs selected email headers (like From, Subject, Date) and a hash of the body with its private key
3. Zoho adds the signature to the email header as "DKIM-Signature"
4. Receiving mail server looks up the DKIM public key in your DNS
5. Server verifies: Does the signature match the email content?
   → Yes → Email is authentic and untampered
   → No  → Email was forged or modified in transit
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  The Record
&lt;/h3&gt;

&lt;p&gt;Zoho gives you a DKIM TXT record to add. It looks something like:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;Name&lt;/th&gt;
&lt;th&gt;Content&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;TXT&lt;/td&gt;
&lt;td&gt;&lt;code&gt;zmail._domainkey&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;v=DKIM1; k=rsa; p=MIGfMA0GCS...&lt;/code&gt; (long public key)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The &lt;code&gt;zmail._domainkey&lt;/code&gt; is the &lt;strong&gt;selector&lt;/strong&gt; — it tells receiving servers where to find the public key for Zoho-signed emails.&lt;/p&gt;

&lt;p&gt;You get this record from Zoho's admin panel: &lt;strong&gt;Email Admin → Domain → Email Authentication → DKIM&lt;/strong&gt;. Zoho generates the key pair and gives you the public key to put in DNS. You just copy-paste it into Cloudflare.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: DMARC (Domain-based Message Authentication, Reporting &amp;amp; Conformance)
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What Is DMARC?
&lt;/h3&gt;

&lt;p&gt;DMARC answers the question: &lt;strong&gt;"Does the domain in the &lt;code&gt;From:&lt;/code&gt; header actually match the domain verified by SPF or DKIM — and what should happen if it doesn't?"&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;DMARC ties SPF and DKIM together into a unified policy. It tells receiving servers:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Check SPF — did the email come from an authorized server?&lt;/li&gt;
&lt;li&gt;Check DKIM — is the signature valid?&lt;/li&gt;
&lt;li&gt;Check &lt;strong&gt;alignment&lt;/strong&gt; — does the authenticated domain match the &lt;code&gt;From:&lt;/code&gt; header domain?&lt;/li&gt;
&lt;li&gt;If &lt;strong&gt;neither&lt;/strong&gt; SPF nor DKIM passes with alignment, apply the policy (nothing, quarantine, or reject)&lt;/li&gt;
&lt;li&gt;Send me reports about pass/fail results&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Why Alignment Matters
&lt;/h3&gt;

&lt;p&gt;SPF and DKIM alone have a gap: an attacker could set up their &lt;em&gt;own&lt;/em&gt; domain with valid SPF and DKIM, but forge &lt;em&gt;your&lt;/em&gt; domain in the &lt;code&gt;From:&lt;/code&gt; header that the recipient sees. Both SPF and DKIM would pass — for the attacker's domain — but the recipient would still see your spoofed address.&lt;/p&gt;

&lt;p&gt;DMARC closes this gap by requiring &lt;strong&gt;alignment&lt;/strong&gt;: the domain authenticated by SPF or DKIM must match the domain in the visible &lt;code&gt;From:&lt;/code&gt; header.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Check&lt;/th&gt;
&lt;th&gt;What Must Align&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;SPF alignment&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Return-Path&lt;/code&gt; (envelope sender) domain must match &lt;code&gt;From:&lt;/code&gt; header domain&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DKIM alignment&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;d=&lt;/code&gt; domain in the DKIM signature must match &lt;code&gt;From:&lt;/code&gt; header domain&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;DMARC passes if &lt;strong&gt;at least one&lt;/strong&gt; of SPF or DKIM passes its check &lt;strong&gt;and&lt;/strong&gt; is aligned with the &lt;code&gt;From:&lt;/code&gt; domain. This means an email can fail SPF but still pass DMARC if DKIM passes and is aligned (or vice versa).&lt;/p&gt;

&lt;p&gt;For a simple Zoho setup, alignment works automatically — Zoho uses your domain for both the envelope sender and DKIM signature. But if you ever add third-party email services (like Mailchimp or SendGrid), you'll need to make sure they can send with proper alignment, or those emails will fail DMARC.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Record
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;Name&lt;/th&gt;
&lt;th&gt;Content&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;TXT&lt;/td&gt;
&lt;td&gt;&lt;code&gt;_dmarc&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;v=DMARC1; p=none; rua=mailto:dmarc@yourdomain.com&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Breaking it down:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Part&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;v=DMARC1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;This is a DMARC record (version 1)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;p=none&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Policy: don't take action on failures (just monitor)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;rua=mailto:dmarc@yourdomain.com&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Send aggregate reports to this email&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;blockquote&gt;
&lt;p&gt;⚠️ &lt;strong&gt;Heads up:&lt;/strong&gt; DMARC aggregate reports are not human-readable emails. They arrive as &lt;strong&gt;gzipped XML attachments&lt;/strong&gt; that look like gibberish if you open them directly. You'll need a tool to parse them — services like &lt;a href="https://www.dmarcanalyzer.com/" rel="noopener noreferrer"&gt;DMARC Analyzer&lt;/a&gt;, &lt;a href="https://dmarc.postmarkapp.com/" rel="noopener noreferrer"&gt;Postmark DMARC&lt;/a&gt;, or &lt;a href="https://dmarcian.com/" rel="noopener noreferrer"&gt;dmarcian&lt;/a&gt; can ingest these reports and turn them into dashboards you can actually read.&lt;/p&gt;

&lt;p&gt;ℹ️ &lt;strong&gt;Other useful DMARC tags you'll encounter:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;ruf=mailto:...&lt;/code&gt; — Forensic (per-failure) reports with details about individual failures. Few providers send these due to privacy concerns, but it doesn't hurt to include.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;adkim=r&lt;/code&gt; / &lt;code&gt;aspf=r&lt;/code&gt; — Alignment mode: &lt;code&gt;r&lt;/code&gt; for relaxed (subdomains can align, e.g., &lt;code&gt;mail.yourdomain.com&lt;/code&gt; matches &lt;code&gt;yourdomain.com&lt;/code&gt;), &lt;code&gt;s&lt;/code&gt; for strict. Defaults are relaxed, which is correct for most setups.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;pct=100&lt;/code&gt; — Percentage of messages the policy applies to. Useful for gradually rolling out a stricter policy (e.g., &lt;code&gt;pct=10&lt;/code&gt; to apply &lt;code&gt;p=quarantine&lt;/code&gt; to only 10% of failing messages at first).&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  DMARC Policy Levels
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Policy&lt;/th&gt;
&lt;th&gt;What Happens on Failure&lt;/th&gt;
&lt;th&gt;When to Use&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;p=none&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Nothing — just collect reports&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Start here.&lt;/strong&gt; Monitor for 2-4 weeks.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;p=quarantine&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Failed emails go to spam folder&lt;/td&gt;
&lt;td&gt;After confirming legitimate emails pass&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;p=reject&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Failed emails are rejected entirely&lt;/td&gt;
&lt;td&gt;Maximum protection — use after &lt;code&gt;quarantine&lt;/code&gt; works&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;blockquote&gt;
&lt;p&gt;💡 &lt;strong&gt;Tip:&lt;/strong&gt; I started with &lt;code&gt;p=none&lt;/code&gt; to monitor and make sure my legitimate emails from Zoho were passing SPF and DKIM checks. Once I confirmed everything was working, I could tighten the policy. Don't jump straight to &lt;code&gt;p=reject&lt;/code&gt; — you might accidentally block your own emails.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Step 6: Email Aliases and Routing
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The Setup
&lt;/h3&gt;

&lt;p&gt;Instead of creating multiple Zoho accounts (the free tier allows up to 5 users, but paid plans charge per user), I use &lt;strong&gt;aliases&lt;/strong&gt; to keep costs down and management simple:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Address&lt;/th&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;yourname@yourdomain.com&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Main email (admin account)&lt;/td&gt;
&lt;td&gt;Primary&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;contact@yourdomain.com&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Displayed on website&lt;/td&gt;
&lt;td&gt;Alias → forwards to primary&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  How Aliases Work
&lt;/h3&gt;

&lt;p&gt;When someone sends an email to &lt;code&gt;contact@yourdomain.com&lt;/code&gt;, Zoho delivers it to my primary inbox. When I reply, I can choose to reply &lt;strong&gt;as&lt;/strong&gt; &lt;code&gt;contact@yourdomain.com&lt;/code&gt; — so the person never sees my primary address.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Security Catch with Aliases
&lt;/h3&gt;

&lt;p&gt;There is one important detail to note: &lt;strong&gt;Zoho allows you to log into your account using any of your aliases as the username.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This can be both good and bad:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The Advantage:&lt;/strong&gt; It's convenient. You don't have to remember your primary admin email; you can just log in using your public alias.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The Security Risk:&lt;/strong&gt; If you created a private, hard-to-guess admin email to protect your account, that security is bypassed because attackers can simply use your public alias (like &lt;code&gt;contact@yourdomain.com&lt;/code&gt;) to attempt to log into your admin panel.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;🛡️ &lt;strong&gt;Security Tip: Use Group Aliases&lt;/strong&gt; — To solve this login vulnerability, you can use &lt;strong&gt;Group Aliases&lt;/strong&gt; instead of regular user aliases. Group aliases are strictly blocked from being used to sign in. With a bit of extra configuration in Zoho, you can still send and receive emails using the group alias, giving you the routing benefits without exposing your account to login attacks.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Folder-Based Rules
&lt;/h3&gt;

&lt;p&gt;I also set up rules in Zoho to automatically organize incoming email:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Rule&lt;/th&gt;
&lt;th&gt;Action&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Email to &lt;code&gt;contact@&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Move to "Website" folder&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Email from GitHub&lt;/td&gt;
&lt;td&gt;Move to "GitHub" folder&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Email from LinkedIn&lt;/td&gt;
&lt;td&gt;Move to "LinkedIn" folder&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;This keeps my inbox clean and organized without manual sorting.&lt;/p&gt;

&lt;h2&gt;
  
  
  How All the Records Work Together
&lt;/h2&gt;

&lt;p&gt;Here's what happens when someone receives an email "from" my domain:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fehbc52zof803t8hs22ny.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fehbc52zof803t8hs22ny.png" alt="A detailed flowchart illustrating the email authentication process. It shows how an incoming email is verified via SPF and DKIM checks, followed by a DMARC alignment check, resulting in either delivery to the inbox, monitoring, or rejection based on the domain's policy." width="800" height="1196"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Verifying Your Setup
&lt;/h2&gt;

&lt;p&gt;After adding all the records, verify everything works:&lt;/p&gt;

&lt;h3&gt;
  
  
  Check DNS Records
&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;# MX records&lt;/span&gt;
dig MX yourdomain.com +short
&lt;span class="c"&gt;# Expected: 10 mx.zoho.com, 20 mx2.zoho.com, 50 mx3.zoho.com&lt;/span&gt;

&lt;span class="c"&gt;# SPF&lt;/span&gt;
dig TXT yourdomain.com +short
&lt;span class="c"&gt;# Expected: "v=spf1 include:zoho.com ~all"&lt;/span&gt;

&lt;span class="c"&gt;# DKIM&lt;/span&gt;
dig TXT zmail._domainkey.yourdomain.com +short
&lt;span class="c"&gt;# Expected: "v=DKIM1; k=rsa; p=MIGf..."&lt;/span&gt;

&lt;span class="c"&gt;# DMARC&lt;/span&gt;
dig TXT _dmarc.yourdomain.com +short
&lt;span class="c"&gt;# Expected: "v=DMARC1; p=none; rua=mailto:..."&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Online Tools
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;🔗 &lt;strong&gt;&lt;a href="https://mxtoolbox.com/" rel="noopener noreferrer"&gt;MXToolbox&lt;/a&gt;&lt;/strong&gt; — checks MX, SPF, DKIM, DMARC, and blacklist status&lt;/li&gt;
&lt;li&gt;🔗 &lt;strong&gt;&lt;a href="https://www.mail-tester.com/" rel="noopener noreferrer"&gt;Mail Tester&lt;/a&gt;&lt;/strong&gt; — send a test email and get a deliverability score out of 10&lt;/li&gt;
&lt;li&gt;🔗 &lt;strong&gt;&lt;a href="https://www.dmarcanalyzer.com/" rel="noopener noreferrer"&gt;DMARC Analyzer&lt;/a&gt;&lt;/strong&gt; — parse DMARC aggregate reports&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Send a Test Email
&lt;/h3&gt;

&lt;p&gt;Send an email from your custom domain to a Gmail address. In Gmail, open the email → click the three dots → &lt;strong&gt;"Show original"&lt;/strong&gt;. Look for:&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="py"&gt;SPF&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;   &lt;span class="s"&gt;PASS&lt;/span&gt;
&lt;span class="py"&gt;DKIM&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="s"&gt;PASS&lt;/span&gt;
&lt;span class="py"&gt;DMARC&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;PASS&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;✅ &lt;strong&gt;If all three show PASS&lt;/strong&gt;, your authentication setup is complete. But read the next section — authentication alone doesn't guarantee inbox delivery.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  But Your Emails Can Still Land in Spam
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;⚠️ &lt;strong&gt;Important:&lt;/strong&gt; Setting up SPF, DKIM, and DMARC is essential — but it doesn't guarantee inbox delivery. These records prove &lt;strong&gt;authentication&lt;/strong&gt; (that you are who you say you are), but major email providers like Gmail and Outlook also evaluate &lt;strong&gt;reputation&lt;/strong&gt; and &lt;strong&gt;content&lt;/strong&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Here are the other factors that can send your perfectly authenticated emails straight to spam:&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Spammy Subject Lines or Body Content
&lt;/h3&gt;

&lt;p&gt;Email providers run your email through content filters. If your subject line looks like &lt;code&gt;FREE MONEY — ACT NOW!!!&lt;/code&gt; or your body is stuffed with sales language, excessive links, or all-caps text, it gets flagged regardless of your DNS setup. Write emails like a human, not a marketer.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. IP or Domain Reputation
&lt;/h3&gt;

&lt;p&gt;Every mail server has an IP address, and that IP has a reputation score. If the IP your emails are sent from has been used for spam in the past (even by other users on the same shared server), your emails inherit that bad reputation. You can check your IP's reputation using tools like &lt;a href="https://mxtoolbox.com/blacklists.aspx" rel="noopener noreferrer"&gt;MXToolbox Blacklist Check&lt;/a&gt; or &lt;a href="https://postmaster.google.com/" rel="noopener noreferrer"&gt;Google Postmaster Tools&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Domain and IP Warming
&lt;/h3&gt;

&lt;p&gt;This is the one most people don't know about. When you start sending emails from a brand new domain or IP address, email providers have &lt;strong&gt;zero trust&lt;/strong&gt; in you. You have no sending history, no reputation — you're an unknown.&lt;/p&gt;

&lt;p&gt;If you suddenly send 500 emails from a new domain, Gmail will almost certainly flag them. The solution is called &lt;strong&gt;warming&lt;/strong&gt; — you start by sending a small number of emails (5-10 per day) and gradually increase the volume over 2-4 weeks. This lets providers build trust in your sending patterns over time. On shared hosting like Zoho, the IP addresses are already established — it's your &lt;em&gt;domain&lt;/em&gt; reputation that starts from zero.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;ℹ️ &lt;strong&gt;For a personal domain like mine, IP warming isn't a big concern since I only send a few emails a day. But if you're setting up email for a **business or newsletter&lt;/strong&gt;, IP warming is critical.**&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  4. Recipients Marking You as Spam
&lt;/h3&gt;

&lt;p&gt;This is the most brutal one. If enough people who receive your emails click the &lt;strong&gt;"Report Spam"&lt;/strong&gt; button, email providers learn that people don't want your emails. Once your spam complaint rate crosses a threshold (Google's threshold is roughly 0.3%), your future emails start landing in spam for &lt;em&gt;everyone&lt;/em&gt; — even people who want them.&lt;/p&gt;

&lt;p&gt;This is why every newsletter has an unsubscribe link. It's better for someone to unsubscribe than to hit "Report Spam."&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;💡 &lt;strong&gt;Tip:&lt;/strong&gt; &lt;strong&gt;Bottom line:&lt;/strong&gt; SPF, DKIM, and DMARC get you through the &lt;strong&gt;authentication door&lt;/strong&gt;. But content quality, sender reputation, IP warming, and user engagement determine whether you make it to the &lt;strong&gt;inbox or the spam folder&lt;/strong&gt;. Think of DNS records as your ID card — they prove who you are, but they don't guarantee you'll be invited in.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Summary of All DNS Records
&lt;/h2&gt;

&lt;p&gt;Here's the complete set of DNS records I added in Cloudflare for email:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;Name&lt;/th&gt;
&lt;th&gt;Content&lt;/th&gt;
&lt;th&gt;Proxy&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;MX&lt;/td&gt;
&lt;td&gt;&lt;code&gt;@&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;mx.zoho.com&lt;/code&gt; (priority 10)&lt;/td&gt;
&lt;td&gt;DNS only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MX&lt;/td&gt;
&lt;td&gt;&lt;code&gt;@&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;mx2.zoho.com&lt;/code&gt; (priority 20)&lt;/td&gt;
&lt;td&gt;DNS only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MX&lt;/td&gt;
&lt;td&gt;&lt;code&gt;@&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;mx3.zoho.com&lt;/code&gt; (priority 50)&lt;/td&gt;
&lt;td&gt;DNS only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;TXT&lt;/td&gt;
&lt;td&gt;&lt;code&gt;@&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;v=spf1 include:zoho.com ~all&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;TXT&lt;/td&gt;
&lt;td&gt;&lt;code&gt;zmail._domainkey&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;v=DKIM1; k=rsa; p=MIGf...&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;TXT&lt;/td&gt;
&lt;td&gt;&lt;code&gt;_dmarc&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;v=DMARC1; p=none; rua=mailto:...&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;TXT&lt;/td&gt;
&lt;td&gt;&lt;code&gt;@&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;zoho-verification=...&lt;/code&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;
  
  
  What I Learned
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;SPF, DKIM, and DMARC are not optional&lt;/strong&gt; — without them, your emails land in spam or get rejected by Gmail/Outlook. Most people skip these and wonder why their emails aren't delivered.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Zoho gives you the records — you just paste them&lt;/strong&gt; — I didn't generate any keys manually. Zoho's admin panel provides every DNS record you need. Your job is to copy them into your DNS provider (Cloudflare in my case) correctly.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Start with &lt;code&gt;p=none&lt;/code&gt; for DMARC&lt;/strong&gt; — don't jump to &lt;code&gt;p=reject&lt;/code&gt; immediately. Monitor first, make sure legitimate emails pass, then tighten the policy.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Aliases are powerful&lt;/strong&gt; — one Zoho account can receive email at multiple addresses. No need to create separate accounts.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;DNS propagation takes time&lt;/strong&gt; — after adding records, wait 15-30 minutes (sometimes up to 48 hours) before testing. Don't panic if verification fails immediately.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;MX records must be DNS-only in Cloudflare&lt;/strong&gt; — if you accidentally proxy them (orange cloud), email delivery breaks. Always set MX records to gray cloud (DNS only).&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  A Quick Warning: My Personal Choice on Using Custom Domain Email for Logins
&lt;/h3&gt;

&lt;blockquote&gt;
&lt;p&gt;💀 &lt;strong&gt;Warning: This section is not a general recommendation&lt;/strong&gt; — it's my personal threat model and decision based on how I use custom domains. If you're only using your domain for professional or business email, you can skip this — but I recommend reading it anyway.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;When I first set all this up, my immediate thought was: &lt;em&gt;Awesome, I'm going to create a custom alias for every platform I use.&lt;/em&gt; &lt;code&gt;github@yourdomain.com&lt;/code&gt;, &lt;code&gt;linkedin@yourdomain.com&lt;/code&gt;... you get the idea. It felt super organized.&lt;/p&gt;

&lt;p&gt;But after thinking about it for a while, a darker thought crossed my mind: &lt;strong&gt;What happens if I die?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Or even if I just forget to renew the domain?&lt;/p&gt;

&lt;p&gt;A custom domain is basically a subscription. If I'm not around to keep paying for it, the domain will eventually expire. And once it expires, anyone on the internet can buy it. If someone else &lt;em&gt;does&lt;/em&gt; manage to buy my expired domain, they can set up an email server and start receiving all my messages. They could hit "Forgot Password" on my GitHub, get the reset link, and attempt to take over my account.&lt;/p&gt;

&lt;p&gt;This creates a dependency chain: &lt;code&gt;accounts → email → domain ownership&lt;/code&gt;. Break any link, and everything downstream is at risk.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Objections I Considered
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;"Can't I just pay for 10 years upfront or use auto-renew?"&lt;/strong&gt;&lt;br&gt;
You could! You can prepay for a decade or put a credit card on auto-renew. Those are good practices, but they still aren't bulletproof. Credit cards expire, banks block transactions, and 10 years isn't forever.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"What about leaving a Digital Will?"&lt;/strong&gt;&lt;br&gt;
You might think that leaving a "Digital Will" with instructions for your family to maintain and renew the domain solves the problem. While it's a nice thought, relying on it for your core security is a bad idea. Your family simply won't care about maintaining your infrastructure as much as you do.&lt;/p&gt;

&lt;p&gt;More importantly, they probably don't have the deep technical knowledge required to navigate domain registrars, DNS records, and email hosting. Expecting them to manage all of that — or expecting them to spend money hiring a professional to do it for them while they are grieving — is highly unrealistic.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"But I'll be dead, why do I care?"&lt;/strong&gt;&lt;br&gt;
You might be thinking this — and honestly, if you don't have anything important tied to the email, maybe you don't need to care! But if you are a developer distributing software that other people rely on, an attacker could push malware or spyware under your name. Furthermore, if you've used that email for government IDs or highly private, sensitive information, you probably don't want a random stranger gaining access to your digital life — because it could ultimately be used to scam, extort, or hurt your family. Who knows what a malicious actor might do with that kind of leverage?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"But what about MFA?"&lt;/strong&gt;&lt;br&gt;
You might argue that setting up Two-Factor Authentication (2FA/MFA) would stop an attacker from getting in, even if they have access to the email. And technically, you're right. But why even put yourself in that situation? Why rely on a secondary defense mechanism when your primary one (your email) is compromised?&lt;/p&gt;

&lt;p&gt;Furthermore, if an attacker is triggering password resets, your accounts will likely get flagged and locked down. Recovering a locked account through customer support is already an incredibly tedious and stressful process. Now imagine trying to prove to support that it's &lt;em&gt;really you&lt;/em&gt;, when you don't even own the email address associated with the account anymore! It's a nightmare waiting to happen.&lt;/p&gt;

&lt;h3&gt;
  
  
  Important Context
&lt;/h3&gt;

&lt;p&gt;This is not a flaw in custom domains themselves. They are widely used in companies, teams, and organizations safely because they have renewal processes, domain management policies, and ownership continuity. The risk is mainly relevant for &lt;strong&gt;individuals managing everything alone over long time periods&lt;/strong&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  What I Changed
&lt;/h3&gt;

&lt;p&gt;Because of this risk, I completely backtracked. I moved all my critical platform logins back to standard public emails like Gmail or Outlook, and now keep my custom domain strictly for professional use:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;th&gt;Email&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Public-facing (on this website)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;contact@yourdomain.com&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Professional impression&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Replies and correspondence&lt;/td&gt;
&lt;td&gt;&lt;code&gt;yourname@yourdomain.com&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Clean sender identity&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Account recovery and critical logins&lt;/td&gt;
&lt;td&gt;Gmail / Outlook&lt;/td&gt;
&lt;td&gt;Can't be bought out from under me&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;This gives me a balance between professionalism and long-term account safety.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;🛡️ &lt;strong&gt;Security Tip: If you still want to use your custom domain for everything:&lt;/strong&gt; That's completely valid — just make sure to add a secure public email (like ProtonMail, Outlook, or Gmail) as a &lt;strong&gt;secondary recovery email&lt;/strong&gt; on all your platforms, enable auto-renew on your domain, and keep your payment methods updated. That way, if your domain host goes down, or you forget to renew, or something bad happens, you still have a reliable backdoor to recover your accounts.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Further Reading
&lt;/h2&gt;

&lt;p&gt;If you want to go deeper into how email authentication actually works under the hood:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Topic&lt;/th&gt;
&lt;th&gt;Resource&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;SPF specification&lt;/td&gt;
&lt;td&gt;&lt;a href="https://www.rfc-editor.org/rfc/rfc7208" rel="noopener noreferrer"&gt;RFC 7208 — Sender Policy Framework&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DKIM specification&lt;/td&gt;
&lt;td&gt;&lt;a href="https://www.rfc-editor.org/rfc/rfc6376" rel="noopener noreferrer"&gt;RFC 6376 — DomainKeys Identified Mail&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DMARC specification&lt;/td&gt;
&lt;td&gt;&lt;a href="https://www.rfc-editor.org/rfc/rfc7489" rel="noopener noreferrer"&gt;RFC 7489 — Domain-based Message Authentication&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Beginner-friendly explainers&lt;/td&gt;
&lt;td&gt;&lt;a href="https://www.cloudflare.com/learning/email-security/" rel="noopener noreferrer"&gt;Cloudflare Learning Center — DNS Email Security&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Google's sender requirements&lt;/td&gt;
&lt;td&gt;&lt;a href="https://support.google.com/mail/answer/81126" rel="noopener noreferrer"&gt;Google Email Sender Guidelines&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Microsoft's sender requirements&lt;/td&gt;
&lt;td&gt;&lt;a href="https://learn.microsoft.com/en-us/microsoft-365/security/office-365-security/anti-spam-protection-about" rel="noopener noreferrer"&gt;Microsoft Anti-Spam Policies&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DMARC report analysis&lt;/td&gt;
&lt;td&gt;&lt;a href="https://dmarc.postmarkapp.com/" rel="noopener noreferrer"&gt;Postmark's Free DMARC Monitoring&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;p&gt;&lt;strong&gt;Setting up email authentication isn't difficult — it's just DNS records. But understanding &lt;em&gt;why&lt;/em&gt; each record exists and &lt;em&gt;what it does&lt;/em&gt; is what separates "I copy-pasted some records" from "I understand email infrastructure." And that understanding is exactly what DevOps is about.&lt;/strong&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://khadirullah.com/blog/setting-up-custom-domain-email-with-spf-dkim-and-dmarc/" rel="noopener noreferrer"&gt;khadirullah.com&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>dns</category>
      <category>email</category>
      <category>security</category>
      <category>cloudflare</category>
    </item>
    <item>
      <title>How to Block Internet Access for Any Linux App (While Keeping LAN)</title>
      <dc:creator>Khadirullah Mohammad</dc:creator>
      <pubDate>Wed, 08 Apr 2026 12:55:49 +0000</pubDate>
      <link>https://dev.to/khadirullah/how-to-block-internet-access-for-any-linux-app-while-keeping-lan-5g17</link>
      <guid>https://dev.to/khadirullah/how-to-block-internet-access-for-any-linux-app-while-keeping-lan-5g17</guid>
      <description>&lt;p&gt;Ever wanted Jellyfin to stay off the internet? Or Chromium to only work on your local network? Maybe you want to test how an app behaves offline — without actually pulling the Ethernet cable.&lt;/p&gt;

&lt;p&gt;This guide shows you how to &lt;strong&gt;block outbound internet for any specific app on Linux&lt;/strong&gt; while keeping localhost and your home LAN fully functional.&lt;/p&gt;

&lt;p&gt;I'll cover five approaches, from a quick 2-minute wrapper script to a production-hardened Chromium setup that survives apt upgrades. Then I'll show you the &lt;strong&gt;fundamental security flaw&lt;/strong&gt; that most guides never mention — and what to use instead when it actually matters.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;🔥 &lt;strong&gt;Safety First: Take a Snapshot!&lt;/strong&gt;&lt;br&gt;
You are modifying core network firewall rules. A simple typo can easily break your internet connection or lock you out of your server. &lt;strong&gt;It is highly recommended to take a VM/System Snapshot before starting.&lt;/strong&gt; If a snapshot is not possible, please &lt;a href="https://khadirullah.com/blog/block-internet-linux-apps/#before-you-start-back-up-everything" rel="noopener noreferrer"&gt;take a manual backup of your UFW rules&lt;/a&gt; first. Reverting a snapshot takes 10 seconds; troubleshooting a broken firewall can take hours.&lt;/p&gt;
&lt;/blockquote&gt;

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

&lt;p&gt;You might wonder why this guide uses UFW instead of raw nftables or iptables. The answer is simple: &lt;strong&gt;safety for beginners&lt;/strong&gt;. If something goes wrong — you accidentally lock yourself out of the network, or an app stops working — you can just run &lt;code&gt;sudo ufw disable&lt;/code&gt; or even &lt;code&gt;sudo apt remove ufw&lt;/code&gt; to instantly restore full connectivity. With raw nftables, one wrong rule can leave you debugging kernel tables for an hour. UFW is a thin wrapper over iptables/netfilter — same power, much easier to roll back.&lt;/p&gt;




&lt;h2&gt;
  
  
  How Does This Actually Work?
&lt;/h2&gt;

&lt;p&gt;Every time a process opens a network socket, the Linux kernel stamps it with the process's &lt;strong&gt;UID&lt;/strong&gt; (User ID) and &lt;strong&gt;GID&lt;/strong&gt; (Group ID). The firewall — specifically netfilter, which UFW sits on top of — can inspect those stamps on outgoing packets and decide: &lt;em&gt;accept&lt;/em&gt; or &lt;em&gt;reject&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;That's the entire trick:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Mark&lt;/strong&gt; the app's processes with a specific UID or GID&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Write firewall rules&lt;/strong&gt; that allow that UID/GID to reach LAN addresses but reject everything else&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;For &lt;strong&gt;services&lt;/strong&gt; (Jellyfin, Syncthing), we match by &lt;strong&gt;UID&lt;/strong&gt; because they already run as dedicated users. For &lt;strong&gt;desktop apps&lt;/strong&gt; (Firefox, Chromium), we match by &lt;strong&gt;GID&lt;/strong&gt; using a &lt;code&gt;no-internet&lt;/code&gt; group.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F11uoqgo66cj3qoysdbem.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F11uoqgo66cj3qoysdbem.webp" alt="flow-chart" width="800" height="1494"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Which Approach Should You Use?
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Your Situation&lt;/th&gt;
&lt;th&gt;Best Option&lt;/th&gt;
&lt;th&gt;Difficulty&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;"I just want to test this quickly"&lt;/td&gt;
&lt;td&gt;Option A — Wrapper script&lt;/td&gt;
&lt;td&gt;⭐ Easy&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Desktop GUI app (Firefox, KeePassXC)&lt;/td&gt;
&lt;td&gt;Option B — setgid on ELF&lt;/td&gt;
&lt;td&gt;⭐⭐ Medium&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;System service (Jellyfin, Syncthing)&lt;/td&gt;
&lt;td&gt;Option C — UID owner-match&lt;/td&gt;
&lt;td&gt;⭐ Easy&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Chromium or Electron apps&lt;/td&gt;
&lt;td&gt;Option D — dpkg-divert&lt;/td&gt;
&lt;td&gt;⭐⭐⭐ Advanced&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;You don't use UFW&lt;/td&gt;
&lt;td&gt;Option E — Direct iptables/nftables&lt;/td&gt;
&lt;td&gt;⭐⭐ Medium&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Need real enforcement&lt;/td&gt;
&lt;td&gt;Bypass-Proof Alternatives — Firejail / namespaces&lt;/td&gt;
&lt;td&gt;⭐⭐ Medium&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  Quick Glossary
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Term&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;th&gt;How to check&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;UID&lt;/td&gt;
&lt;td&gt;User Identifier (numeric)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;id -u username&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GID&lt;/td&gt;
&lt;td&gt;Group Identifier (numeric)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;getent group groupname&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;EGID&lt;/td&gt;
&lt;td&gt;Effective GID — the runtime GID the kernel actually uses for socket ownership&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ps -eo egid,egroup,cmd&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;UFW&lt;/td&gt;
&lt;td&gt;Uncomplicated Firewall — Debian/Ubuntu frontend for iptables&lt;/td&gt;
&lt;td&gt;&lt;code&gt;sudo ufw status&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;sg&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Run a command with a different primary group&lt;/td&gt;
&lt;td&gt;&lt;code&gt;sg groupname command&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;dpkg-divert&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Debian tool to relocate a package-managed file so your file can sit at the original path&lt;/td&gt;
&lt;td&gt;&lt;code&gt;dpkg-divert --list&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;conntrack&lt;/td&gt;
&lt;td&gt;Connection tracking — lets the firewall allow replies to established connections&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;owner-match&lt;/td&gt;
&lt;td&gt;iptables module that matches packets by the UID/GID of the process that created the socket&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  Before You Start: Back Up Everything
&lt;/h2&gt;

&lt;p&gt;If you didn't take a VM or system snapshot, you &lt;strong&gt;must&lt;/strong&gt; back up your current firewall state. Take 30 seconds to save your current rules so you can easily revert them later:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;iptables-save &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; ~/iptables.before
&lt;span class="nb"&gt;sudo mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; ~/ufw_rules_backup
&lt;span class="nb"&gt;sudo cp&lt;/span&gt; /etc/ufw/before.rules ~/ufw_rules_backup/before.rules.backup
&lt;span class="nb"&gt;sudo cp&lt;/span&gt; /etc/ufw/before6.rules ~/ufw_rules_backup/before6.rules.backup
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If anything goes wrong:&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 cp&lt;/span&gt; ~/ufw_rules_backup/before.rules.backup /etc/ufw/before.rules
&lt;span class="nb"&gt;sudo cp&lt;/span&gt; ~/ufw_rules_backup/before6.rules.backup /etc/ufw/before6.rules
&lt;span class="nb"&gt;sudo &lt;/span&gt;ufw reload
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F3xxho2fzktb09yytl0gq.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F3xxho2fzktb09yytl0gq.webp" alt="Backing up UFW configuration files in the terminal" width="798" height="44"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The Firewall Rules (The Core of Everything)
&lt;/h2&gt;

&lt;p&gt;Every option below ends up using the same firewall rules. The only difference is &lt;em&gt;how&lt;/em&gt; you mark the app. Here's what the rules look like — you'll paste these into &lt;code&gt;/etc/ufw/before.rules&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Where Exactly to Paste
&lt;/h3&gt;

&lt;p&gt;Open the file and look for the &lt;code&gt;*filter&lt;/code&gt; section at the top:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;*&lt;span class="n"&gt;filter&lt;/span&gt;
:&lt;span class="n"&gt;ufw&lt;/span&gt;-&lt;span class="n"&gt;before&lt;/span&gt;-&lt;span class="n"&gt;input&lt;/span&gt; - [&lt;span class="m"&gt;0&lt;/span&gt;:&lt;span class="m"&gt;0&lt;/span&gt;]
:&lt;span class="n"&gt;ufw&lt;/span&gt;-&lt;span class="n"&gt;before&lt;/span&gt;-&lt;span class="n"&gt;output&lt;/span&gt; - [&lt;span class="m"&gt;0&lt;/span&gt;:&lt;span class="m"&gt;0&lt;/span&gt;]
:&lt;span class="n"&gt;ufw&lt;/span&gt;-&lt;span class="n"&gt;before&lt;/span&gt;-&lt;span class="n"&gt;forward&lt;/span&gt; - [&lt;span class="m"&gt;0&lt;/span&gt;:&lt;span class="m"&gt;0&lt;/span&gt;]
← &lt;span class="n"&gt;YOUR&lt;/span&gt; &lt;span class="n"&gt;RULES&lt;/span&gt; &lt;span class="n"&gt;GO&lt;/span&gt; &lt;span class="n"&gt;HERE&lt;/span&gt;, &lt;span class="n"&gt;right&lt;/span&gt; &lt;span class="n"&gt;after&lt;/span&gt; &lt;span class="n"&gt;these&lt;/span&gt; &lt;span class="n"&gt;lines&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fcr44ko3wvyak7y68mbb2.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fcr44ko3wvyak7y68mbb2.webp" alt="Opening /etc/ufw/before.rules with sudo vim" width="618" height="52"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Your file should initially look like this:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F38fc4q0qh4syn1jliog0.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F38fc4q0qh4syn1jliog0.webp" alt="The default /etc/ufw/before.rules filter section before any custom rules" width="799" height="411"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  For Desktop Apps (GID Match)
&lt;/h3&gt;

&lt;p&gt;Once you paste your rules into the editor, it should look exactly like this:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fla1arwm5bykwlhu27tkr.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fla1arwm5bykwlhu27tkr.webp" alt="Inserting the no-internet GID block into the UFW config" width="800" height="382"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Replace &lt;code&gt;GID&lt;/code&gt; with your actual numeric group ID:&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;# --- BEGIN no-internet block (IPv4) ---&lt;/span&gt;
&lt;span class="nt"&gt;-A&lt;/span&gt; ufw-before-output &lt;span class="nt"&gt;-m&lt;/span&gt; owner &lt;span class="nt"&gt;--gid-owner&lt;/span&gt; GID &lt;span class="nt"&gt;-m&lt;/span&gt; conntrack &lt;span class="nt"&gt;--ctstate&lt;/span&gt; RELATED,ESTABLISHED &lt;span class="nt"&gt;-j&lt;/span&gt; ACCEPT
&lt;span class="nt"&gt;-A&lt;/span&gt; ufw-before-output &lt;span class="nt"&gt;-m&lt;/span&gt; owner &lt;span class="nt"&gt;--gid-owner&lt;/span&gt; GID &lt;span class="nt"&gt;-d&lt;/span&gt; 127.0.0.0/8 &lt;span class="nt"&gt;-j&lt;/span&gt; ACCEPT
&lt;span class="nt"&gt;-A&lt;/span&gt; ufw-before-output &lt;span class="nt"&gt;-m&lt;/span&gt; owner &lt;span class="nt"&gt;--gid-owner&lt;/span&gt; GID &lt;span class="nt"&gt;-d&lt;/span&gt; 10.0.0.0/8 &lt;span class="nt"&gt;-j&lt;/span&gt; ACCEPT
&lt;span class="nt"&gt;-A&lt;/span&gt; ufw-before-output &lt;span class="nt"&gt;-m&lt;/span&gt; owner &lt;span class="nt"&gt;--gid-owner&lt;/span&gt; GID &lt;span class="nt"&gt;-d&lt;/span&gt; 172.16.0.0/12 &lt;span class="nt"&gt;-j&lt;/span&gt; ACCEPT
&lt;span class="nt"&gt;-A&lt;/span&gt; ufw-before-output &lt;span class="nt"&gt;-m&lt;/span&gt; owner &lt;span class="nt"&gt;--gid-owner&lt;/span&gt; GID &lt;span class="nt"&gt;-d&lt;/span&gt; 192.168.0.0/16 &lt;span class="nt"&gt;-j&lt;/span&gt; ACCEPT
&lt;span class="nt"&gt;-A&lt;/span&gt; ufw-before-output &lt;span class="nt"&gt;-m&lt;/span&gt; owner &lt;span class="nt"&gt;--gid-owner&lt;/span&gt; GID &lt;span class="nt"&gt;-j&lt;/span&gt; LOG &lt;span class="nt"&gt;--log-prefix&lt;/span&gt; &lt;span class="s2"&gt;"Blocked noinet: "&lt;/span&gt;
&lt;span class="nt"&gt;-A&lt;/span&gt; ufw-before-output &lt;span class="nt"&gt;-m&lt;/span&gt; owner &lt;span class="nt"&gt;--gid-owner&lt;/span&gt; GID &lt;span class="nt"&gt;-j&lt;/span&gt; REJECT
&lt;span class="c"&gt;# --- END no-internet block (IPv4) ---&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Do the same in &lt;code&gt;/etc/ufw/before6.rules&lt;/code&gt; (use &lt;code&gt;ufw6-before-output&lt;/code&gt;, allow &lt;code&gt;::1&lt;/code&gt; and &lt;code&gt;fe80::/10&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="c"&gt;# --- BEGIN no-internet block (IPv6) ---&lt;/span&gt;
&lt;span class="nt"&gt;-A&lt;/span&gt; ufw6-before-output &lt;span class="nt"&gt;-m&lt;/span&gt; owner &lt;span class="nt"&gt;--gid-owner&lt;/span&gt; GID &lt;span class="nt"&gt;-m&lt;/span&gt; conntrack &lt;span class="nt"&gt;--ctstate&lt;/span&gt; RELATED,ESTABLISHED &lt;span class="nt"&gt;-j&lt;/span&gt; ACCEPT
&lt;span class="nt"&gt;-A&lt;/span&gt; ufw6-before-output &lt;span class="nt"&gt;-m&lt;/span&gt; owner &lt;span class="nt"&gt;--gid-owner&lt;/span&gt; GID &lt;span class="nt"&gt;-d&lt;/span&gt; ::1 &lt;span class="nt"&gt;-j&lt;/span&gt; ACCEPT
&lt;span class="nt"&gt;-A&lt;/span&gt; ufw6-before-output &lt;span class="nt"&gt;-m&lt;/span&gt; owner &lt;span class="nt"&gt;--gid-owner&lt;/span&gt; GID &lt;span class="nt"&gt;-d&lt;/span&gt; fe80::/10 &lt;span class="nt"&gt;-j&lt;/span&gt; ACCEPT
&lt;span class="c"&gt;# Optional: uncomment for mDNS / DLNA / SSDP LAN service discovery&lt;/span&gt;
&lt;span class="c"&gt;# -A ufw6-before-output -m owner --gid-owner GID -d ff00::/8 -j ACCEPT&lt;/span&gt;
&lt;span class="nt"&gt;-A&lt;/span&gt; ufw6-before-output &lt;span class="nt"&gt;-m&lt;/span&gt; owner &lt;span class="nt"&gt;--gid-owner&lt;/span&gt; GID &lt;span class="nt"&gt;-j&lt;/span&gt; LOG &lt;span class="nt"&gt;--log-prefix&lt;/span&gt; &lt;span class="s2"&gt;"Blocked noinet v6: "&lt;/span&gt;
&lt;span class="nt"&gt;-A&lt;/span&gt; ufw6-before-output &lt;span class="nt"&gt;-m&lt;/span&gt; owner &lt;span class="nt"&gt;--gid-owner&lt;/span&gt; GID &lt;span class="nt"&gt;-j&lt;/span&gt; REJECT
&lt;span class="c"&gt;# --- END no-internet block (IPv6) ---&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  For Services (UID Match — &lt;code&gt;/etc/ufw/before.rules&lt;/code&gt;)
&lt;/h3&gt;

&lt;p&gt;Same structure, but use &lt;code&gt;--uid-owner&lt;/code&gt; with the service's numeric UID:&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;# --- BEGIN service UID block (IPv4) ---&lt;/span&gt;
&lt;span class="nt"&gt;-A&lt;/span&gt; ufw-before-output &lt;span class="nt"&gt;-m&lt;/span&gt; owner &lt;span class="nt"&gt;--uid-owner&lt;/span&gt; UID &lt;span class="nt"&gt;-m&lt;/span&gt; conntrack &lt;span class="nt"&gt;--ctstate&lt;/span&gt; RELATED,ESTABLISHED &lt;span class="nt"&gt;-j&lt;/span&gt; ACCEPT
&lt;span class="nt"&gt;-A&lt;/span&gt; ufw-before-output &lt;span class="nt"&gt;-m&lt;/span&gt; owner &lt;span class="nt"&gt;--uid-owner&lt;/span&gt; UID &lt;span class="nt"&gt;-d&lt;/span&gt; 127.0.0.0/8 &lt;span class="nt"&gt;-j&lt;/span&gt; ACCEPT
&lt;span class="nt"&gt;-A&lt;/span&gt; ufw-before-output &lt;span class="nt"&gt;-m&lt;/span&gt; owner &lt;span class="nt"&gt;--uid-owner&lt;/span&gt; UID &lt;span class="nt"&gt;-d&lt;/span&gt; 10.0.0.0/8 &lt;span class="nt"&gt;-j&lt;/span&gt; ACCEPT
&lt;span class="nt"&gt;-A&lt;/span&gt; ufw-before-output &lt;span class="nt"&gt;-m&lt;/span&gt; owner &lt;span class="nt"&gt;--uid-owner&lt;/span&gt; UID &lt;span class="nt"&gt;-d&lt;/span&gt; 172.16.0.0/12 &lt;span class="nt"&gt;-j&lt;/span&gt; ACCEPT
&lt;span class="nt"&gt;-A&lt;/span&gt; ufw-before-output &lt;span class="nt"&gt;-m&lt;/span&gt; owner &lt;span class="nt"&gt;--uid-owner&lt;/span&gt; UID &lt;span class="nt"&gt;-d&lt;/span&gt; 192.168.0.0/16 &lt;span class="nt"&gt;-j&lt;/span&gt; ACCEPT
&lt;span class="nt"&gt;-A&lt;/span&gt; ufw-before-output &lt;span class="nt"&gt;-m&lt;/span&gt; owner &lt;span class="nt"&gt;--uid-owner&lt;/span&gt; UID &lt;span class="nt"&gt;-j&lt;/span&gt; LOG &lt;span class="nt"&gt;--log-prefix&lt;/span&gt; &lt;span class="s2"&gt;"Blocked uid: "&lt;/span&gt;
&lt;span class="nt"&gt;-A&lt;/span&gt; ufw-before-output &lt;span class="nt"&gt;-m&lt;/span&gt; owner &lt;span class="nt"&gt;--uid-owner&lt;/span&gt; UID &lt;span class="nt"&gt;-j&lt;/span&gt; REJECT
&lt;span class="c"&gt;# --- END service UID block (IPv4) ---&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And in &lt;code&gt;/etc/ufw/before6.rules&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="c"&gt;# --- BEGIN service UID block (IPv6) ---&lt;/span&gt;
&lt;span class="nt"&gt;-A&lt;/span&gt; ufw6-before-output &lt;span class="nt"&gt;-m&lt;/span&gt; owner &lt;span class="nt"&gt;--uid-owner&lt;/span&gt; UID &lt;span class="nt"&gt;-m&lt;/span&gt; conntrack &lt;span class="nt"&gt;--ctstate&lt;/span&gt; RELATED,ESTABLISHED &lt;span class="nt"&gt;-j&lt;/span&gt; ACCEPT
&lt;span class="nt"&gt;-A&lt;/span&gt; ufw6-before-output &lt;span class="nt"&gt;-m&lt;/span&gt; owner &lt;span class="nt"&gt;--uid-owner&lt;/span&gt; UID &lt;span class="nt"&gt;-d&lt;/span&gt; ::1 &lt;span class="nt"&gt;-j&lt;/span&gt; ACCEPT
&lt;span class="nt"&gt;-A&lt;/span&gt; ufw6-before-output &lt;span class="nt"&gt;-m&lt;/span&gt; owner &lt;span class="nt"&gt;--uid-owner&lt;/span&gt; UID &lt;span class="nt"&gt;-d&lt;/span&gt; fe80::/10 &lt;span class="nt"&gt;-j&lt;/span&gt; ACCEPT
&lt;span class="nt"&gt;-A&lt;/span&gt; ufw6-before-output &lt;span class="nt"&gt;-m&lt;/span&gt; owner &lt;span class="nt"&gt;--uid-owner&lt;/span&gt; UID &lt;span class="nt"&gt;-j&lt;/span&gt; LOG &lt;span class="nt"&gt;--log-prefix&lt;/span&gt; &lt;span class="s2"&gt;"Blocked uid v6: "&lt;/span&gt;
&lt;span class="nt"&gt;-A&lt;/span&gt; ufw6-before-output &lt;span class="nt"&gt;-m&lt;/span&gt; owner &lt;span class="nt"&gt;--uid-owner&lt;/span&gt; UID &lt;span class="nt"&gt;-j&lt;/span&gt; REJECT
&lt;span class="c"&gt;# --- END service UID block (IPv6) ---&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Why This Order?
&lt;/h3&gt;

&lt;p&gt;The rules are evaluated top-to-bottom, first match wins:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;RELATED,ESTABLISHED&lt;/strong&gt; — Don't break existing connections mid-stream&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Loopback&lt;/strong&gt; (127.x) — App can still talk to localhost&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;LAN ranges&lt;/strong&gt; (10.x, 172.16.x, 192.168.x) — App can reach your home network&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;LOG&lt;/strong&gt; — Audit blocked attempts in &lt;code&gt;/var/log/kern.log&lt;/code&gt; or journalctl&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;REJECT&lt;/strong&gt; — Everything else (the actual internet) gets blocked&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Safe Way to Edit
&lt;/h3&gt;

&lt;blockquote&gt;
&lt;p&gt;⚠️ &lt;strong&gt;Warning:&lt;/strong&gt; Always backup your original firewall rules to a safe, persistent location (like your root directory) before editing. Temporary files in &lt;code&gt;/tmp/&lt;/code&gt; are wiped upon every reboot!&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Don't edit the live file directly. Backup, copy to a temp file, edit, test, then apply:&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. Create a permanent backup&lt;/span&gt;
&lt;span class="nb"&gt;sudo cp&lt;/span&gt; /etc/ufw/before.rules /root/before.rules.backup

&lt;span class="c"&gt;# 2. Copy to a temporary file for editing&lt;/span&gt;
&lt;span class="nb"&gt;sudo cp&lt;/span&gt; /etc/ufw/before.rules /tmp/before.rules.edit
&lt;span class="nb"&gt;sudo &lt;/span&gt;nano /tmp/before.rules.edit                          &lt;span class="c"&gt;# paste your rules&lt;/span&gt;

&lt;span class="c"&gt;# 3. Syntax check (safe, doesn't apply)&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;iptables-restore &lt;span class="nt"&gt;--test&lt;/span&gt; &amp;lt; /tmp/before.rules.edit     

&lt;span class="c"&gt;# 4. Apply the rules&lt;/span&gt;
&lt;span class="nb"&gt;sudo mv&lt;/span&gt; /tmp/before.rules.edit /etc/ufw/before.rules
&lt;span class="nb"&gt;sudo chown &lt;/span&gt;root:root /etc/ufw/before.rules
&lt;span class="nb"&gt;sudo chmod &lt;/span&gt;644 /etc/ufw/before.rules
&lt;span class="nb"&gt;sudo &lt;/span&gt;ufw reload
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Option A: Quick Wrapper Script
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Time:&lt;/strong&gt; 2 minutes · &lt;strong&gt;Best for:&lt;/strong&gt; Testing, quick experiments&lt;/p&gt;

&lt;p&gt;This is the fastest way. You create a tiny script that launches any app under a &lt;code&gt;no-internet&lt;/code&gt; group.&lt;/p&gt;

&lt;h3&gt;
  
  
  Setup
&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;# Create the group&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;groupadd &lt;span class="nt"&gt;-f&lt;/span&gt; no-internet
getent group no-internet    &lt;span class="c"&gt;# note the GID (e.g., 1001)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fzn1lnpambwa0sjaaxzkl.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fzn1lnpambwa0sjaaxzkl.webp" alt="Creating the 'no-internet' group and verifying its GID" width="800" height="195"&gt;&lt;/a&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;# Add your user to the group so 'sg' doesn't prompt for a password&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;usermod &lt;span class="nt"&gt;-aG&lt;/span&gt; no-internet &lt;span class="nv"&gt;$USER&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fv22uoo738q4iosfzvjzw.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fv22uoo738q4iosfzvjzw.webp" alt="Adding the current user to the 'no-internet' group" width="658" height="87"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Create the wrapper script
&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 tee&lt;/span&gt; /usr/local/bin/no-internet &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;'
#!/bin/bash
exec sg no-internet "&lt;/span&gt;&lt;span class="nv"&gt;$@&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;sudo chmod &lt;/span&gt;755 /usr/local/bin/no-internet
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add the GID firewall rules to UFW and reload.&lt;/p&gt;

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



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;no-internet firefox &amp;amp;
no-internet steam &amp;amp;
no-internet keepassxc &amp;amp;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fyzamn62mo0vv5m7ia9p3.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fyzamn62mo0vv5m7ia9p3.webp" alt="Launching Firefox using our new no-internet wrapper script" width="800" height="46"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Verify It Works
&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;# Should be BLOCKED:&lt;/span&gt;
sg no-internet &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'curl -I -m 10 https://example.com'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"FAIL"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"BLOCKED ✓"&lt;/span&gt;

&lt;span class="c"&gt;# Should still work:&lt;/span&gt;
sg no-internet &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'curl -I -m 10 http://192.168.1.1'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"LAN works ✓"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"FAIL"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F57we61s3w3rupp9ztr89.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F57we61s3w3rupp9ztr89.webp" alt="Proof: Firefox trying to reach Google and failing with 'Unable to connect'" width="799" height="409"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Downside:&lt;/strong&gt; If you launch the app from the desktop menu, it won't use the wrapper. You'd need to edit the &lt;code&gt;.desktop&lt;/code&gt; file:&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; /usr/share/applications/firefox.desktop ~/.local/share/applications/
nano ~/.local/share/applications/firefox.desktop
&lt;span class="c"&gt;# Change: Exec=firefox %u&lt;/span&gt;
&lt;span class="c"&gt;# To:     Exec=/usr/local/bin/no-internet firefox %u&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Option B: setgid on the Binary
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Time:&lt;/strong&gt; 5 minutes · &lt;strong&gt;Best for:&lt;/strong&gt; Desktop apps you always want restricted&lt;/p&gt;

&lt;p&gt;Instead of a wrapper, you set the GID flag directly on the app's binary. Every time it runs — from the menu, terminal, wherever — it automatically gets the &lt;code&gt;no-internet&lt;/code&gt; group.&lt;/p&gt;

&lt;h3&gt;
  
  
  Find the Real Binary
&lt;/h3&gt;

&lt;p&gt;This is important. Many apps have wrapper scripts. You need the actual ELF binary (Executable and Linkable Format — the compiled program file that Linux actually runs):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;which firefox                              &lt;span class="c"&gt;# might be /usr/bin/firefox&lt;/span&gt;
&lt;span class="nb"&gt;readlink&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;which firefox&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;             &lt;span class="c"&gt;# resolves symlinks&lt;/span&gt;
file &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;readlink&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;which firefox&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;   &lt;span class="c"&gt;# should say "ELF 64-bit"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If &lt;code&gt;file&lt;/code&gt; says "shell script" or "Python script", dig deeper — that script calls the real binary somewhere.&lt;/p&gt;

&lt;h3&gt;
  
  
  Apply setgid
&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 chown &lt;/span&gt;root:no-internet /path/to/real/elf/binary
&lt;span class="nb"&gt;sudo chmod &lt;/span&gt;750 /path/to/real/elf/binary
&lt;span class="nb"&gt;sudo chmod &lt;/span&gt;g+s /path/to/real/elf/binary    &lt;span class="c"&gt;# the magic: setgid bit&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now every process spawned from this binary inherits EGID = &lt;code&gt;no-internet&lt;/code&gt;, which the firewall matches.&lt;/p&gt;

&lt;h3&gt;
  
  
  Verify
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;firefox &amp;amp; &lt;span class="nb"&gt;sleep &lt;/span&gt;1
ps &lt;span class="nt"&gt;-eo&lt;/span&gt; pid,uid,egid,cmd | &lt;span class="nb"&gt;grep &lt;/span&gt;firefox
&lt;span class="c"&gt;# EGID column should show your no-internet GID number&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Rollback
&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 chmod &lt;/span&gt;g-s /path/to/real/elf/binary
&lt;span class="nb"&gt;sudo chown &lt;/span&gt;root:root /path/to/real/elf/binary
&lt;span class="nb"&gt;sudo chmod &lt;/span&gt;755 /path/to/real/elf/binary
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;⚠️ &lt;strong&gt;Caveat:&lt;/strong&gt; This doesn't work on Snap or Flatpak apps — they run in sandboxes with their own network stack. For &lt;strong&gt;Flatpak&lt;/strong&gt;, use &lt;a href="https://flathub.org/apps/com.github.tchx84.Flatseal" rel="noopener noreferrer"&gt;Flatseal&lt;/a&gt; (GUI) to toggle off "Network" permissions, or run &lt;code&gt;flatpak override --user --unshare=network com.app.Name&lt;/code&gt;. For &lt;strong&gt;Snap&lt;/strong&gt;, use &lt;code&gt;snap connections app-name&lt;/code&gt; and &lt;code&gt;snap disconnect app-name:network&lt;/code&gt; to revoke the network plug. Or install the app as a native &lt;code&gt;.deb&lt;/code&gt;.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Option C: Service UID Match
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Time:&lt;/strong&gt; 3 minutes · &lt;strong&gt;Best for:&lt;/strong&gt; Daemons like Jellyfin, Syncthing, qBittorrent&lt;/p&gt;

&lt;p&gt;Services already run as dedicated system users. You just match their UID in the firewall. This is the &lt;strong&gt;strongest&lt;/strong&gt; of the five options because a service can't change its own UID.&lt;/p&gt;

&lt;h3&gt;
  
  
  Find the UID
&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;id&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; jellyfin    &lt;span class="c"&gt;# e.g., 112&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fmz5ndtbxb7jmwxi3is8u.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fmz5ndtbxb7jmwxi3is8u.webp" alt="Checking the numeric UID of the jellyfin service user" width="441" height="94"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Add UID Rules to UFW
&lt;/h3&gt;

&lt;p&gt;Same as the GID rules above, but use &lt;code&gt;--uid-owner 112&lt;/code&gt; instead of &lt;code&gt;--gid-owner&lt;/code&gt;. Paste into &lt;code&gt;before.rules&lt;/code&gt; and &lt;code&gt;before6.rules&lt;/code&gt;, then:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;ufw reload
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ff6sxxh26sxat6upejs0z.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ff6sxxh26sxat6upejs0z.webp" alt="Firewall successfully reloaded after configuration changes" width="664" height="77"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fyo9gvv6jr0mk695w9zmt.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fyo9gvv6jr0mk695w9zmt.webp" alt="The final UFW before.rules file with both GID and UID blocks implemented" width="800" height="383"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Test
&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;# Internet should be blocked:&lt;/span&gt;
&lt;span class="nb"&gt;sudo&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; jellyfin curl &lt;span class="nt"&gt;-I&lt;/span&gt; &lt;span class="nt"&gt;-m&lt;/span&gt; 10 https://example.com &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"FAIL"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"BLOCKED ✓"&lt;/span&gt;

&lt;span class="c"&gt;# LAN should work (reaches a local Python HTTP server):&lt;/span&gt;
&lt;span class="nb"&gt;sudo&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; jellyfin curl &lt;span class="nt"&gt;-I&lt;/span&gt; &lt;span class="nt"&gt;-m&lt;/span&gt; 10 http://192.168.1.10 &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"LAN works ✓"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"FAIL"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fwbtg30mjgtf621zxsqxs.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fwbtg30mjgtf621zxsqxs.gif" alt="Jellyfin service verification: Internet requests are blocked while LAN requests succeed." width="760" height="428"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  The Ultimate Proof: LAN vs Internet
&lt;/h3&gt;

&lt;p&gt;One of the best ways to verify your setup is to try reaching an external site and a local IP in the same process. Here is the result of that test:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F1h4jgor6d0d6aynt1c36.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F1h4jgor6d0d6aynt1c36.gif" alt="Technical Proof: Internet access (Google) is blocked, while local network access remains fully accessible." width="600" height="338"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h3&gt;
  
  
  Don't Forget: Allow Incoming on the Service Port
&lt;/h3&gt;

&lt;p&gt;If your UFW default is "deny incoming" (it should be), LAN clients can't reach your service unless you explicitly allow the port:&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;ufw allow from 192.168.0.0/16 to any port 8096 proto tcp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  For Custom Services Without a Dedicated User
&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;adduser &lt;span class="nt"&gt;--system&lt;/span&gt; &lt;span class="nt"&gt;--group&lt;/span&gt; &lt;span class="nt"&gt;--no-create-home&lt;/span&gt; &lt;span class="nt"&gt;--shell&lt;/span&gt; /usr/sbin/nologin myservice
&lt;span class="nb"&gt;sudo &lt;/span&gt;passwd &lt;span class="nt"&gt;-l&lt;/span&gt; myservice
&lt;span class="nb"&gt;id&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; myservice    &lt;span class="c"&gt;# use this UID in rules&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Option D: dpkg-divert + Wrapper
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Time:&lt;/strong&gt; 15 minutes · &lt;strong&gt;Best for:&lt;/strong&gt; Chromium, Electron, multi-process apps&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note:&lt;/strong&gt; &lt;code&gt;dpkg-divert&lt;/code&gt; is a Debian/Ubuntu tool. If you're on Fedora, Arch, or another distro, you'll need to manually relocate the binary instead — the firewall rules themselves are distro-agnostic.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Chromium is special. It spawns renderer processes, GPU processes, utility processes — all from different code paths. A simple setgid on one binary won't catch them all.&lt;/p&gt;

&lt;p&gt;The solution: use Debian's &lt;code&gt;dpkg-divert&lt;/code&gt; to relocate the real binary, then put a wrapper at the original path. Every invocation — menu, terminal, child processes — goes through your wrapper.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Full Setup
&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;# 1. Create the group&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;groupadd &lt;span class="nt"&gt;-f&lt;/span&gt; no-internet
getent group no-internet    &lt;span class="c"&gt;# note the GID&lt;/span&gt;

&lt;span class="c"&gt;# Add your user to the group so 'sg' doesn't prompt for a password&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;usermod &lt;span class="nt"&gt;-aG&lt;/span&gt; no-internet &lt;span class="nv"&gt;$USER&lt;/span&gt;

&lt;span class="c"&gt;# 2. Divert the real binary to a new location&lt;/span&gt;
&lt;span class="nb"&gt;sudo mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; /usr/lib/chromium
&lt;span class="nb"&gt;sudo &lt;/span&gt;dpkg-divert &lt;span class="nt"&gt;--local&lt;/span&gt; &lt;span class="nt"&gt;--add&lt;/span&gt; &lt;span class="nt"&gt;--rename&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--divert&lt;/span&gt; /usr/lib/chromium/chromium.distrib /usr/bin/chromium

&lt;span class="c"&gt;# 3. Reinstall so the diverted file lands at the new path&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--reinstall&lt;/span&gt; chromium

&lt;span class="c"&gt;# 4. Lock down the real binary&lt;/span&gt;
&lt;span class="nb"&gt;sudo chown &lt;/span&gt;root:no-internet /usr/lib/chromium/chromium.distrib
&lt;span class="nb"&gt;sudo chmod &lt;/span&gt;0750 /usr/lib/chromium/chromium.distrib

&lt;span class="c"&gt;# 5. Put a shell wrapper at the original path&lt;/span&gt;
&lt;span class="nb"&gt;sudo tee&lt;/span&gt; /usr/bin/chromium &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;'
#!/bin/bash
exec sg no-internet /usr/lib/chromium/chromium.distrib "&lt;/span&gt;&lt;span class="nv"&gt;$@&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;sudo chmod &lt;/span&gt;0755 /usr/bin/chromium
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add the GID firewall rules, reload UFW, and test.&lt;/p&gt;

&lt;h3&gt;
  
  
  Option D Variant: Compiled C Wrapper
&lt;/h3&gt;

&lt;p&gt;Instead of a shell wrapper, you can compile a minimal C binary. It avoids spawning an extra bash process and the binary isn't human-readable (though &lt;code&gt;strings&lt;/code&gt; will still reveal the path — see Security Limitations below).&lt;/p&gt;

&lt;p&gt;Save as &lt;code&gt;/tmp/sg-wrapper.c&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="cm"&gt;/* sg-wrapper.c — execv /bin/sg no-internet -- /usr/lib/chromium/chromium.distrib */&lt;/span&gt;
&lt;span class="cp"&gt;#define _GNU_SOURCE
#include&lt;/span&gt; &lt;span class="cpf"&gt;&amp;lt;errno.h&amp;gt;&lt;/span&gt;&lt;span class="cp"&gt;
#include&lt;/span&gt; &lt;span class="cpf"&gt;&amp;lt;stdio.h&amp;gt;&lt;/span&gt;&lt;span class="cp"&gt;
#include&lt;/span&gt; &lt;span class="cpf"&gt;&amp;lt;stdlib.h&amp;gt;&lt;/span&gt;&lt;span class="cp"&gt;
#include&lt;/span&gt; &lt;span class="cpf"&gt;&amp;lt;string.h&amp;gt;&lt;/span&gt;&lt;span class="cp"&gt;
#include&lt;/span&gt; &lt;span class="cpf"&gt;&amp;lt;unistd.h&amp;gt;&lt;/span&gt;&lt;span class="cp"&gt;
&lt;/span&gt;
&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;argc&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;char&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;argv&lt;/span&gt;&lt;span class="p"&gt;[])&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="kt"&gt;char&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;group&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"no-internet"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="kt"&gt;char&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;sg_path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"/bin/sg"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="kt"&gt;char&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;real_binary&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"/usr/lib/chromium/chromium.distrib"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;extra&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;argc&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="cm"&gt;/* count: sg_path + group + "--" + real_binary + extra_args + NULL */&lt;/span&gt;
    &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;sg_argc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;extra&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kt"&gt;char&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;sg_argv&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;calloc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sg_argc&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;sizeof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;char&lt;/span&gt; &lt;span class="o"&gt;*&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="n"&gt;sg_argv&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;fprintf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;stderr&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"calloc failed&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;127&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;i&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;sg_argv&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&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="kt"&gt;char&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="n"&gt;sg_path&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="n"&gt;sg_argv&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&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="kt"&gt;char&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="n"&gt;group&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="n"&gt;sg_argv&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&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="kt"&gt;char&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="s"&gt;"--"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="n"&gt;sg_argv&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&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="kt"&gt;char&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="n"&gt;real_binary&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;j&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;j&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;argc&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="n"&gt;j&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;sg_argv&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;argv&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;j&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
    &lt;span class="n"&gt;sg_argv&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="n"&gt;execv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sg_path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sg_argv&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;fprintf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;stderr&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"execv(%s) failed: %s&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sg_path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;strerror&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;errno&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="cm"&gt;/* free is technically unreachable if execv succeeds, but kept for completeness */&lt;/span&gt;
    &lt;span class="n"&gt;free&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sg_argv&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;126&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;Compile and install:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gcc &lt;span class="nt"&gt;-O2&lt;/span&gt; &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /tmp/sg-wrapper /tmp/sg-wrapper.c
&lt;span class="nb"&gt;sudo mv&lt;/span&gt; /tmp/sg-wrapper /usr/bin/chromium
&lt;span class="nb"&gt;sudo chown &lt;/span&gt;root:no-internet /usr/bin/chromium
&lt;span class="nb"&gt;sudo chmod &lt;/span&gt;2751 /usr/bin/chromium    &lt;span class="c"&gt;# setgid(2) + rwx(7) + r-x(5) + --x(1)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Surviving &lt;code&gt;apt upgrade&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;Package updates can overwrite your changes. Protect 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;# Tell dpkg to enforce ownership/permissions&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;dpkg-statoverride &lt;span class="nt"&gt;--add&lt;/span&gt; root no-internet 0750 /usr/lib/chromium/chromium.distrib

&lt;span class="c"&gt;# Create a script that reapplies permissions&lt;/span&gt;
&lt;span class="nb"&gt;sudo tee&lt;/span&gt; /usr/local/sbin/reapply-noinet.sh &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;'
#!/usr/bin/env bash
set -euo pipefail
GROUP=no-internet
[ -e /usr/bin/chromium ] &amp;amp;&amp;amp; chown root:&lt;/span&gt;&lt;span class="nv"&gt;$GROUP&lt;/span&gt;&lt;span class="sh"&gt; /usr/bin/chromium &amp;amp;&amp;amp; chmod 2751 /usr/bin/chromium || true
[ -e /usr/lib/chromium/chromium.distrib ] &amp;amp;&amp;amp; chown root:&lt;/span&gt;&lt;span class="nv"&gt;$GROUP&lt;/span&gt;&lt;span class="sh"&gt; /usr/lib/chromium/chromium.distrib &amp;amp;&amp;amp; chmod 0750 /usr/lib/chromium/chromium.distrib || true
&lt;/span&gt;&lt;span class="no"&gt;EOF
&lt;/span&gt;&lt;span class="nb"&gt;sudo chmod &lt;/span&gt;755 /usr/local/sbin/reapply-noinet.sh

&lt;span class="c"&gt;# Hook it into APT so it runs after every package update&lt;/span&gt;
&lt;span class="nb"&gt;sudo tee&lt;/span&gt; /etc/apt/apt.conf.d/99-reapply-noinet &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;'
DPkg::Post-Invoke {"[ -x /usr/local/sbin/reapply-noinet.sh ] &amp;amp;&amp;amp; /usr/local/sbin/reapply-noinet.sh";};
&lt;/span&gt;&lt;span class="no"&gt;EOF
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Rollback
&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 rm&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; /usr/bin/chromium
&lt;span class="nb"&gt;sudo &lt;/span&gt;dpkg-divert &lt;span class="nt"&gt;--remove&lt;/span&gt; &lt;span class="nt"&gt;--rename&lt;/span&gt; /usr/bin/chromium
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--reinstall&lt;/span&gt; chromium
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Option E: Raw iptables / nftables
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Best for:&lt;/strong&gt; Systems that don't use UFW, or if you prefer direct control.&lt;/p&gt;

&lt;h3&gt;
  
  
  iptables
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;GID&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1001  &lt;span class="c"&gt;# your no-internet group ID&lt;/span&gt;

&lt;span class="nb"&gt;sudo &lt;/span&gt;iptables &lt;span class="nt"&gt;-I&lt;/span&gt; OUTPUT 1 &lt;span class="nt"&gt;-m&lt;/span&gt; owner &lt;span class="nt"&gt;--gid-owner&lt;/span&gt; &lt;span class="nv"&gt;$GID&lt;/span&gt; &lt;span class="nt"&gt;-m&lt;/span&gt; conntrack &lt;span class="nt"&gt;--ctstate&lt;/span&gt; RELATED,ESTABLISHED &lt;span class="nt"&gt;-j&lt;/span&gt; ACCEPT
&lt;span class="nb"&gt;sudo &lt;/span&gt;iptables &lt;span class="nt"&gt;-I&lt;/span&gt; OUTPUT 2 &lt;span class="nt"&gt;-m&lt;/span&gt; owner &lt;span class="nt"&gt;--gid-owner&lt;/span&gt; &lt;span class="nv"&gt;$GID&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; 127.0.0.0/8 &lt;span class="nt"&gt;-j&lt;/span&gt; ACCEPT
&lt;span class="nb"&gt;sudo &lt;/span&gt;iptables &lt;span class="nt"&gt;-I&lt;/span&gt; OUTPUT 3 &lt;span class="nt"&gt;-m&lt;/span&gt; owner &lt;span class="nt"&gt;--gid-owner&lt;/span&gt; &lt;span class="nv"&gt;$GID&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; 10.0.0.0/8 &lt;span class="nt"&gt;-j&lt;/span&gt; ACCEPT
&lt;span class="nb"&gt;sudo &lt;/span&gt;iptables &lt;span class="nt"&gt;-I&lt;/span&gt; OUTPUT 4 &lt;span class="nt"&gt;-m&lt;/span&gt; owner &lt;span class="nt"&gt;--gid-owner&lt;/span&gt; &lt;span class="nv"&gt;$GID&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; 172.16.0.0/12 &lt;span class="nt"&gt;-j&lt;/span&gt; ACCEPT
&lt;span class="nb"&gt;sudo &lt;/span&gt;iptables &lt;span class="nt"&gt;-I&lt;/span&gt; OUTPUT 5 &lt;span class="nt"&gt;-m&lt;/span&gt; owner &lt;span class="nt"&gt;--gid-owner&lt;/span&gt; &lt;span class="nv"&gt;$GID&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; 192.168.0.0/16 &lt;span class="nt"&gt;-j&lt;/span&gt; ACCEPT
&lt;span class="nb"&gt;sudo &lt;/span&gt;iptables &lt;span class="nt"&gt;-A&lt;/span&gt; OUTPUT &lt;span class="nt"&gt;-m&lt;/span&gt; owner &lt;span class="nt"&gt;--gid-owner&lt;/span&gt; &lt;span class="nv"&gt;$GID&lt;/span&gt; &lt;span class="nt"&gt;-j&lt;/span&gt; LOG &lt;span class="nt"&gt;--log-prefix&lt;/span&gt; &lt;span class="s2"&gt;"NOINTERNET: "&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;iptables &lt;span class="nt"&gt;-A&lt;/span&gt; OUTPUT &lt;span class="nt"&gt;-m&lt;/span&gt; owner &lt;span class="nt"&gt;--gid-owner&lt;/span&gt; &lt;span class="nv"&gt;$GID&lt;/span&gt; &lt;span class="nt"&gt;-j&lt;/span&gt; REJECT
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Persist with:&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 &lt;span class="nb"&gt;install &lt;/span&gt;iptables-persistent
&lt;span class="nb"&gt;sudo &lt;/span&gt;netfilter-persistent save
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  nftables
&lt;/h3&gt;

&lt;p&gt;Add to &lt;code&gt;/etc/nftables.conf&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;table inet lanlock {
  chain output {
    type filter hook output priority 0;
    meta skgid 1001 ct state related,established accept
    meta skgid 1001 ip daddr 127.0.0.0/8 accept
    meta skgid 1001 ip daddr 10.0.0.0/8 accept
    meta skgid 1001 ip daddr 172.16.0.0/12 accept
    meta skgid 1001 ip daddr 192.168.0.0/16 accept
    meta skgid 1001 ip6 daddr ::1 accept
    meta skgid 1001 ip6 daddr fe80::/10 accept
    meta skgid 1001 counter log prefix "NOINTERNET: "
    meta skgid 1001 drop
  }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;nft &lt;span class="nt"&gt;-f&lt;/span&gt; /etc/nftables.conf
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl &lt;span class="nb"&gt;enable&lt;/span&gt; &lt;span class="nt"&gt;--now&lt;/span&gt; nftables
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  The Security Flaw Nobody Talks About
&lt;/h2&gt;

&lt;p&gt;Now that you know how to set this up, let's talk about when it's actually enough — because the GID-based approach (Options A, B, and D) has a &lt;strong&gt;fundamental bypass&lt;/strong&gt; that most guides never mention.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Problem: EGID vs Supplementary Groups
&lt;/h3&gt;

&lt;p&gt;The firewall's &lt;code&gt;--gid-owner&lt;/code&gt; match checks the process's &lt;strong&gt;EGID&lt;/strong&gt; (Effective Group ID) — not its supplementary group list. Here's what that means in practice:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;How the app is launched&lt;/th&gt;
&lt;th&gt;Process EGID&lt;/th&gt;
&lt;th&gt;Firewall matches?&lt;/th&gt;
&lt;th&gt;Internet?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Via wrapper (&lt;code&gt;sg no-internet ...&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;no-internet&lt;/code&gt; (1001)&lt;/td&gt;
&lt;td&gt;✅ Yes&lt;/td&gt;
&lt;td&gt;❌ Blocked&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Directly (&lt;code&gt;/usr/lib/chromium/chromium.distrib&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;User's primary group (1000)&lt;/td&gt;
&lt;td&gt;❌ No&lt;/td&gt;
&lt;td&gt;✅ &lt;strong&gt;Full access&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;When a user runs a binary directly, their &lt;strong&gt;primary group&lt;/strong&gt; becomes the EGID. The &lt;code&gt;no-internet&lt;/code&gt; supplementary group membership is irrelevant to the firewall.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fwxm53qmxwg5dvsz1rty0.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fwxm53qmxwg5dvsz1rty0.png" alt="flow-chart-1" width="800" height="1000"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;And there's a catch-22: &lt;code&gt;sg&lt;/code&gt; (which the wrapper uses) requires the user to be a &lt;strong&gt;member&lt;/strong&gt; of the &lt;code&gt;no-internet&lt;/code&gt; group. But if they're a member, they also have permission to execute the &lt;code&gt;chmod 0750&lt;/code&gt; binary directly — bypassing the wrapper entirely.&lt;/p&gt;

&lt;h3&gt;
  
  
  "What If I Hide the Binary Path?"
&lt;/h3&gt;

&lt;p&gt;You might think: "I'll compile the wrapper as a C binary so users can't read the script to find the real path." That doesn't work either:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Attempt&lt;/th&gt;
&lt;th&gt;Why it fails&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Compiled C wrapper&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;strings /usr/bin/chromium&lt;/code&gt; reveals the embedded path&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Random filename&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;ps aux&lt;/code&gt; and &lt;code&gt;/proc/PID/exe&lt;/code&gt; expose it at runtime&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;setgid on the binary itself&lt;/td&gt;
&lt;td&gt;Chromium and Firefox &lt;strong&gt;refuse to run with setgid&lt;/strong&gt; (browser security feature)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  So When IS the GID Approach Good Enough?
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;✅ &lt;strong&gt;Self-discipline&lt;/strong&gt; — you want YOUR OWN app to stop phoning home (telemetry, metadata downloads, auto-updates)&lt;/li&gt;
&lt;li&gt;✅ &lt;strong&gt;Services and daemons&lt;/strong&gt; — Option C uses UID matching, which IS unbypassable since processes can't change their own UID&lt;/li&gt;
&lt;li&gt;✅ &lt;strong&gt;Non-technical users&lt;/strong&gt; — people who won't think to look for the diverted binary&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  When You Need Something Stronger
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;❌ Technical users who actively want to bypass your restrictions&lt;/li&gt;
&lt;li&gt;❌ Multi-user machines where you're enforcing policy&lt;/li&gt;
&lt;li&gt;❌ Any scenario where "security through obscurity" isn't acceptable&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For those cases, keep reading.&lt;/p&gt;




&lt;h2&gt;
  
  
  Bypass-Proof Alternatives (Not-Tested By Me)
&lt;/h2&gt;

&lt;p&gt;When the GID approach isn't enough, here are three methods that provide real, kernel-enforced isolation.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note:&lt;/strong&gt; I haven't personally tested these alternatives end-to-end. They're included for completeness based on documentation and community guides. If you try any of these and find issues (or get them working), feel free to reach out.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Alternative 1: Separate User + UID Match
&lt;/h3&gt;

&lt;p&gt;Run the app as a completely separate user. UID matching &lt;strong&gt;cannot be bypassed&lt;/strong&gt; — a user can't change their own UID.&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;# Create a restricted user&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;adduser &lt;span class="nt"&gt;--disabled-password&lt;/span&gt; &lt;span class="nt"&gt;--gecos&lt;/span&gt; &lt;span class="s2"&gt;""&lt;/span&gt; &lt;span class="nt"&gt;--shell&lt;/span&gt; /usr/sbin/nologin chromium-user
&lt;span class="nb"&gt;sudo &lt;/span&gt;passwd &lt;span class="nt"&gt;-l&lt;/span&gt; chromium-user
&lt;span class="nb"&gt;id&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; chromium-user    &lt;span class="c"&gt;# use this UID in UFW rules (same format as Option C)&lt;/span&gt;

&lt;span class="c"&gt;# Allow X11 display access&lt;/span&gt;
xhost +SI:localuser:chromium-user

&lt;span class="c"&gt;# Launch&lt;/span&gt;
&lt;span class="nb"&gt;sudo&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; chromium-user chromium
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Tradeoffs:&lt;/strong&gt; You lose your keyring, D-Bus session, bookmarks, and cookies from your main user. Wayland compositors may block other users entirely. But the network restriction is absolute.&lt;/p&gt;

&lt;h3&gt;
  
  
  Alternative 2: Firejail (Easiest True Isolation)
&lt;/h3&gt;

&lt;p&gt;Firejail uses kernel network namespaces under the hood. No firewall rules needed — the app physically cannot see the external network.&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 &lt;span class="nb"&gt;install &lt;/span&gt;firejail

&lt;span class="c"&gt;# No network at all — this works reliably&lt;/span&gt;
firejail &lt;span class="nt"&gt;--net&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;none chromium
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;⚠️ &lt;strong&gt;My experience:&lt;/strong&gt; &lt;code&gt;firejail --net=none&lt;/code&gt; works perfectly — the app has zero network access. However, I was &lt;strong&gt;unable to get LAN-only mode working&lt;/strong&gt; using the theoretical setup for reference, but your mileage may vary.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;LAN-only (theoretical):&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;firejail &lt;span class="nt"&gt;--netfilter&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/etc/firejail/lan-only.net chromium
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Create &lt;code&gt;/etc/firejail/lan-only.net&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;*&lt;span class="n"&gt;filter&lt;/span&gt;
:&lt;span class="n"&gt;INPUT&lt;/span&gt; &lt;span class="n"&gt;ACCEPT&lt;/span&gt; [&lt;span class="m"&gt;0&lt;/span&gt;:&lt;span class="m"&gt;0&lt;/span&gt;]
:&lt;span class="n"&gt;FORWARD&lt;/span&gt; &lt;span class="n"&gt;ACCEPT&lt;/span&gt; [&lt;span class="m"&gt;0&lt;/span&gt;:&lt;span class="m"&gt;0&lt;/span&gt;]
:&lt;span class="n"&gt;OUTPUT&lt;/span&gt; &lt;span class="n"&gt;DROP&lt;/span&gt; [&lt;span class="m"&gt;0&lt;/span&gt;:&lt;span class="m"&gt;0&lt;/span&gt;]
-&lt;span class="n"&gt;A&lt;/span&gt; &lt;span class="n"&gt;OUTPUT&lt;/span&gt; -&lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="m"&gt;127&lt;/span&gt;.&lt;span class="m"&gt;0&lt;/span&gt;.&lt;span class="m"&gt;0&lt;/span&gt;.&lt;span class="m"&gt;0&lt;/span&gt;/&lt;span class="m"&gt;8&lt;/span&gt; -&lt;span class="n"&gt;j&lt;/span&gt; &lt;span class="n"&gt;ACCEPT&lt;/span&gt;
-&lt;span class="n"&gt;A&lt;/span&gt; &lt;span class="n"&gt;OUTPUT&lt;/span&gt; -&lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="m"&gt;10&lt;/span&gt;.&lt;span class="m"&gt;0&lt;/span&gt;.&lt;span class="m"&gt;0&lt;/span&gt;.&lt;span class="m"&gt;0&lt;/span&gt;/&lt;span class="m"&gt;8&lt;/span&gt; -&lt;span class="n"&gt;j&lt;/span&gt; &lt;span class="n"&gt;ACCEPT&lt;/span&gt;
-&lt;span class="n"&gt;A&lt;/span&gt; &lt;span class="n"&gt;OUTPUT&lt;/span&gt; -&lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="m"&gt;172&lt;/span&gt;.&lt;span class="m"&gt;16&lt;/span&gt;.&lt;span class="m"&gt;0&lt;/span&gt;.&lt;span class="m"&gt;0&lt;/span&gt;/&lt;span class="m"&gt;12&lt;/span&gt; -&lt;span class="n"&gt;j&lt;/span&gt; &lt;span class="n"&gt;ACCEPT&lt;/span&gt;
-&lt;span class="n"&gt;A&lt;/span&gt; &lt;span class="n"&gt;OUTPUT&lt;/span&gt; -&lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="m"&gt;192&lt;/span&gt;.&lt;span class="m"&gt;168&lt;/span&gt;.&lt;span class="m"&gt;0&lt;/span&gt;.&lt;span class="m"&gt;0&lt;/span&gt;/&lt;span class="m"&gt;16&lt;/span&gt; -&lt;span class="n"&gt;j&lt;/span&gt; &lt;span class="n"&gt;ACCEPT&lt;/span&gt;
-&lt;span class="n"&gt;A&lt;/span&gt; &lt;span class="n"&gt;OUTPUT&lt;/span&gt; -&lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt; --&lt;span class="n"&gt;state&lt;/span&gt; &lt;span class="n"&gt;RELATED&lt;/span&gt;,&lt;span class="n"&gt;ESTABLISHED&lt;/span&gt; -&lt;span class="n"&gt;j&lt;/span&gt; &lt;span class="n"&gt;ACCEPT&lt;/span&gt;
&lt;span class="n"&gt;COMMIT&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Alternative 3: Network Namespaces (Manual, Full Control)
&lt;/h3&gt;

&lt;p&gt;For maximum control, create a network namespace directly. No extra packages needed.&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;# Create a namespace with no external network&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;ip netns add no-inet

&lt;span class="c"&gt;# Run the app inside it&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;ip netns &lt;span class="nb"&gt;exec &lt;/span&gt;no-inet &lt;span class="nb"&gt;sudo&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; &lt;span class="nv"&gt;$USER&lt;/span&gt; chromium

&lt;span class="c"&gt;# Optional: Add LAN-only access via a veth pair&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;ip &lt;span class="nb"&gt;link &lt;/span&gt;add veth-host &lt;span class="nb"&gt;type &lt;/span&gt;veth peer name veth-jail
&lt;span class="nb"&gt;sudo &lt;/span&gt;ip &lt;span class="nb"&gt;link set &lt;/span&gt;veth-jail netns no-inet
&lt;span class="nb"&gt;sudo &lt;/span&gt;ip addr add 192.168.100.1/24 dev veth-host
&lt;span class="nb"&gt;sudo &lt;/span&gt;ip &lt;span class="nb"&gt;link set &lt;/span&gt;veth-host up
&lt;span class="nb"&gt;sudo &lt;/span&gt;ip netns &lt;span class="nb"&gt;exec &lt;/span&gt;no-inet ip addr add 192.168.100.2/24 dev veth-jail
&lt;span class="nb"&gt;sudo &lt;/span&gt;ip netns &lt;span class="nb"&gt;exec &lt;/span&gt;no-inet ip &lt;span class="nb"&gt;link set &lt;/span&gt;veth-jail up
&lt;span class="nb"&gt;sudo &lt;/span&gt;ip netns &lt;span class="nb"&gt;exec &lt;/span&gt;no-inet ip &lt;span class="nb"&gt;link set &lt;/span&gt;lo up
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Quick Comparison
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Threat Model&lt;/th&gt;
&lt;th&gt;Best Solution&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Block your own apps from phoning home&lt;/td&gt;
&lt;td&gt;GID wrapper (Option A/D) — simple, good enough&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Block a daemon/service&lt;/td&gt;
&lt;td&gt;UID owner-match (Option C) — unbypassable&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Restrict technical/untrusted users&lt;/td&gt;
&lt;td&gt;Separate user + UID match (Alt 1)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;True network sandbox, easy setup&lt;/td&gt;
&lt;td&gt;Firejail (Alt 2)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Full manual control, no dependencies&lt;/td&gt;
&lt;td&gt;Network namespace (Alt 3)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Enterprise/production&lt;/td&gt;
&lt;td&gt;AppArmor + containers&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  Troubleshooting
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;"sg: no such group"&lt;/strong&gt;&lt;br&gt;
→ Group doesn't exist yet. Run &lt;code&gt;sudo groupadd -f no-internet&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Internet is still working after adding rules&lt;/strong&gt;&lt;br&gt;
→ Double-check the numeric UID/GID in your rules matches reality. Make sure you pasted the block right after the &lt;code&gt;:ufw-before-output&lt;/code&gt; line, not at the bottom. Run &lt;code&gt;sudo ufw reload&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;UFW reload fails&lt;/strong&gt;&lt;br&gt;
→ Syntax error in your rules. Test before applying: &lt;code&gt;sudo iptables-restore --test &amp;lt; /etc/ufw/before.rules&lt;/code&gt;. If it fails, restore your backup.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It works, but breaks after reboot&lt;/strong&gt;&lt;br&gt;
→ You might have &lt;code&gt;iptables-persistent&lt;/code&gt; installed, which conflicts with UFW. Remove it: &lt;code&gt;sudo apt remove iptables-persistent&lt;/code&gt;. Let UFW handle everything.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;setgid isn't working&lt;/strong&gt;&lt;br&gt;
→ You probably applied it to a shell script wrapper, not the real ELF binary. Use &lt;code&gt;readlink -f $(which app)&lt;/code&gt; and &lt;code&gt;file&lt;/code&gt; to find the actual binary.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Snap/Flatpak apps are unaffected&lt;/strong&gt;&lt;br&gt;
→ They run in sandboxes with their own network stack. &lt;strong&gt;Flatpak:&lt;/strong&gt; Use Flatseal (GUI) to toggle off "Network" permissions, or run &lt;code&gt;flatpak override --user --unshare=network com.app.Name&lt;/code&gt;. &lt;strong&gt;Snap:&lt;/strong&gt; Use &lt;code&gt;snap connections app-name&lt;/code&gt; and &lt;code&gt;snap disconnect app-name:network&lt;/code&gt; to revoke the network plug. Or install the app as a native &lt;code&gt;.deb&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;DNS seems to leak&lt;/strong&gt;&lt;br&gt;
→ &lt;code&gt;systemd-resolved&lt;/code&gt; runs on &lt;code&gt;127.0.0.53&lt;/code&gt;. Since we allow &lt;code&gt;127.0.0.0/8&lt;/code&gt;, DNS resolves even for blocked apps — but the actual connections still get rejected.&lt;/p&gt;


&lt;h2&gt;
  
  
  Testing Checklist
&lt;/h2&gt;

&lt;p&gt;After setting up any option, run through 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;# 1. Group exists and GID is correct?&lt;/span&gt;
getent group no-internet
&lt;span class="c"&gt;# Expected: no-internet:x:&amp;lt;GID&amp;gt;:&lt;/span&gt;

&lt;span class="c"&gt;# 2. Service UID correct? (Option C only)&lt;/span&gt;
&lt;span class="nb"&gt;id&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; jellyfin
&lt;span class="c"&gt;# Expected: numeric UID, e.g., 107&lt;/span&gt;

&lt;span class="c"&gt;# 3. File ownership and permissions correct? (Options B/D)&lt;/span&gt;
&lt;span class="nb"&gt;stat&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"%n: %U %G %a"&lt;/span&gt; /usr/lib/chromium/chromium.distrib /usr/bin/chromium
&lt;span class="c"&gt;# Expected: real binary → root:no-internet 0750, wrapper → per your policy&lt;/span&gt;

&lt;span class="c"&gt;# 4. Running processes have correct EGID/UID?&lt;/span&gt;
ps &lt;span class="nt"&gt;-eo&lt;/span&gt; pid,ppid,uid,euid,gid,egid,cmd | egrep &lt;span class="s1"&gt;'chromium|jellyfin|firefox'&lt;/span&gt;
&lt;span class="c"&gt;# Look for: EGID == no-internet GID (Options A/B/D) or UID == service UID (Option C)&lt;/span&gt;

&lt;span class="c"&gt;# 5. Internet blocked?&lt;/span&gt;
sg no-internet &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'curl -I -m 10 https://example.com'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"FAIL"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"BLOCKED ✓"&lt;/span&gt;
&lt;span class="c"&gt;# For services:&lt;/span&gt;
&lt;span class="nb"&gt;sudo&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; jellyfin curl &lt;span class="nt"&gt;-I&lt;/span&gt; &lt;span class="nt"&gt;-m&lt;/span&gt; 10 https://example.com &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"FAIL"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"BLOCKED ✓"&lt;/span&gt;

&lt;span class="c"&gt;# 6. LAN still works?&lt;/span&gt;
sg no-internet &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'curl -I -m 10 http://192.168.1.1'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"LAN works ✓"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"FAIL"&lt;/span&gt;

&lt;span class="c"&gt;# 7. Check firewall logs (if LOG rules added)&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;journalctl &lt;span class="nt"&gt;-k&lt;/span&gt; &lt;span class="nt"&gt;--since&lt;/span&gt; &lt;span class="s2"&gt;"10 minutes ago"&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s1"&gt;'Blocked\|NOINTERNET'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Emergency Rollback
&lt;/h2&gt;

&lt;p&gt;If something goes wrong, these commands restore everything:&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;# Restore UFW backups&lt;/span&gt;
&lt;span class="nb"&gt;sudo cp&lt;/span&gt; /root/before.rules.bak /etc/ufw/before.rules
&lt;span class="nb"&gt;sudo cp&lt;/span&gt; /root/before6.rules.bak /etc/ufw/before6.rules
&lt;span class="nb"&gt;sudo &lt;/span&gt;ufw reload

&lt;span class="c"&gt;# If you need immediate connectivity recovery&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;iptables &lt;span class="nt"&gt;-I&lt;/span&gt; OUTPUT 1 &lt;span class="nt"&gt;-m&lt;/span&gt; owner &lt;span class="nt"&gt;--gid-owner&lt;/span&gt; &amp;lt;GID&amp;gt; &lt;span class="nt"&gt;-j&lt;/span&gt; ACCEPT
&lt;span class="c"&gt;# Remove when fixed:&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;iptables &lt;span class="nt"&gt;-D&lt;/span&gt; OUTPUT &lt;span class="nt"&gt;-m&lt;/span&gt; owner &lt;span class="nt"&gt;--gid-owner&lt;/span&gt; &amp;lt;GID&amp;gt; &lt;span class="nt"&gt;-j&lt;/span&gt; ACCEPT

&lt;span class="c"&gt;# Last resort — disable the entire firewall&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;ufw disable
&lt;span class="c"&gt;# Fix your rules, then: sudo ufw enable&lt;/span&gt;

&lt;span class="c"&gt;# Undo dpkg-divert (Option D)&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;dpkg-divert &lt;span class="nt"&gt;--remove&lt;/span&gt; &lt;span class="nt"&gt;--rename&lt;/span&gt; /usr/bin/chromium
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--reinstall&lt;/span&gt; chromium
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  🎬 Watch it in Action: Full GUI Demo
&lt;/h2&gt;

&lt;p&gt;This walkthrough puts the system to the test using a real-world browser (&lt;strong&gt;Google Chrome&lt;/strong&gt;). Here’s exactly what you’ll see:&lt;/p&gt;

&lt;p&gt;🚫 &lt;strong&gt;The Block&lt;/strong&gt;&lt;br&gt;
Chrome tries to reach Google—and fails instantly while the firewall rules are active.&lt;/p&gt;

&lt;p&gt;🌐 &lt;strong&gt;LAN Routing&lt;/strong&gt;&lt;br&gt;
Despite the block, Chrome successfully loads a local dashboard on your LAN, proving internal traffic still works flawlessly.&lt;/p&gt;

&lt;p&gt;🎛️ &lt;strong&gt;The Control&lt;/strong&gt;&lt;br&gt;
With a simple toggle:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;ufw disable&lt;/code&gt; → restores full internet access&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ufw enable&lt;/code&gt; → locks everything down again&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;Curious to see it all in action? Watch the full high-resolution 75-second demo:&lt;/p&gt;

&lt;p&gt;🔗 &lt;a href="https://khadirullah.com/blog/block-internet-linux-apps/#watch-it-in-action-full-gui-demo" rel="noopener noreferrer"&gt;https://khadirullah.com/blog/block-internet-linux-apps/#watch-it-in-action-full-gui-demo&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;✨ A complete visual walkthrough of the interface, behavior, and control flow—from block to restore.&lt;/p&gt;




&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;The GID-based approach (Options A–E) is a clean, elegant way to restrict app networking — and it's &lt;strong&gt;good enough for most personal use cases&lt;/strong&gt;. If you want to stop Jellyfin from downloading metadata, or prevent a game from phoning home, it works perfectly.&lt;/p&gt;

&lt;p&gt;But if you need real enforcement against users who know their way around Linux, the GID approach has a fundamental EGID bypass. For those cases, use &lt;strong&gt;UID matching&lt;/strong&gt; (unbypassable for services), &lt;strong&gt;Firejail&lt;/strong&gt; (easiest for desktop apps), or &lt;strong&gt;network namespaces&lt;/strong&gt; (maximum control).&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Tested on Debian 13 (Trixie) with UFW. Should work on any Debian/Ubuntu-based distro with kernel 4.x+.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>linux</category>
      <category>security</category>
      <category>devops</category>
      <category>tutorial</category>
    </item>
  </channel>
</rss>
