<?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.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3346735%2F45651a40-8081-49c1-a384-9a4fc420add0.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>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="800" 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="800" 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="800" 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>
    <item>
      <title>I Built an Interactive SVG Viewer Because Static Diagrams Deserve Better</title>
      <dc:creator>Khadirullah Mohammad</dc:creator>
      <pubDate>Wed, 11 Feb 2026 13:23:28 +0000</pubDate>
      <link>https://dev.to/khadirullah/i-built-an-interactive-svg-viewer-because-static-diagrams-deserve-better-2aa7</link>
      <guid>https://dev.to/khadirullah/i-built-an-interactive-svg-viewer-because-static-diagrams-deserve-better-2aa7</guid>
      <description>&lt;p&gt;As developers, we love clear documentation. Use Case diagrams, Cloud Architectures, Flowcharts — they are the lifeblood of understanding complex systems. Tools like Mermaid.js, PlantUML, and Draw.io are fantastic for &lt;em&gt;creating&lt;/em&gt; them.&lt;/p&gt;

&lt;p&gt;But &lt;strong&gt;viewing&lt;/strong&gt; them? That experience is often stuck in the past.&lt;/p&gt;

&lt;p&gt;If you export a complex architecture diagram as an SVG and embed it on your docs site, it's just a static image. The text is too small to read, you can't search for that one specific microservice, and if you zoom with your browser, the whole page breaks.&lt;/p&gt;

&lt;p&gt;I looked for a library to solve this. I found &lt;strong&gt;D3.js&lt;/strong&gt; (too complex for just viewing) and &lt;strong&gt;Leaflet&lt;/strong&gt; (too heavy for a diagram). I didn't want to write hundreds of lines of code just to let a user zoom into a flowchart.&lt;/p&gt;

&lt;p&gt;So, I built &lt;strong&gt;DiagView&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Demo
&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%2F9r3v9wzv69qkb2b8w14b.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%2F9r3v9wzv69qkb2b8w14b.gif" title="Zoom, pan, and search in action" alt="DiagView Demo" width="720" height="405"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What is DiagView?
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;DiagView&lt;/strong&gt; is a feature-rich, interactive wrapper that gives your static SVGs superpowers.&lt;/p&gt;

&lt;p&gt;It is built on top of the excellent &lt;a href="https://github.com/timmywil/panzoom" rel="noopener noreferrer"&gt;panzoom&lt;/a&gt; library, which handles the low-level matrix math for smooth 60fps zooming and panning. But while panzoom gives you the engine, DiagView gives you the entire car.&lt;/p&gt;

&lt;h3&gt;
  
  
  Feature Overview
&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;Description&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;🔍 &lt;strong&gt;Deep Search&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;Traverses the SVG DOM to find and highlight matching nodes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;📤 &lt;strong&gt;Multi-Format Export&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;PNG, SVG, PDF, WebP, or copy to clipboard&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;🎯 &lt;strong&gt;Meeting Mode&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;Built-in laser pointer for remote presentations&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;🔗 &lt;strong&gt;Share Links&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;Generate URLs that preserve zoom/pan state&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;⌨️ &lt;strong&gt;Keyboard Navigation&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;Arrows to pan, +/- to zoom, F to search&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;🌗 &lt;strong&gt;Auto-Theming&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;Detects light/dark mode automatically&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;📱 &lt;strong&gt;Mobile-First Touch&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;Pinch-to-zoom, double-tap to reset&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  The Landscape: Why Wasn't This Already Solved?
&lt;/h2&gt;

&lt;p&gt;Before writing any code, I scoured npm and GitHub. Here's what I found:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;D3.js&lt;/strong&gt; — The titan of data visualization. But D3 is for &lt;em&gt;creating&lt;/em&gt; graphics from data, not for &lt;em&gt;viewing&lt;/em&gt; pre-made SVGs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;svg-pan-zoom&lt;/strong&gt; — A focused library for adding pan/zoom to SVGs. But it's just the engine — no UI, no search, no export.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Leaflet.js&lt;/strong&gt; — The standard for interactive maps. Overkill for a simple flowchart.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The gap was clear:&lt;/strong&gt; I needed a batteries-included solution — something that would just &lt;em&gt;work&lt;/em&gt; with a single &lt;code&gt;init()&lt;/code&gt; call.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quick Start
&lt;/h2&gt;

&lt;h3&gt;
  
  
  CDN (Fastest)
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="c"&gt;&amp;lt;!-- Panzoom (optional, for zoom/pan) --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;script &lt;/span&gt;&lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"https://cdn.jsdelivr.net/npm/@panzoom/panzoom@4.5.1/dist/panzoom.min.js"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/script&amp;gt;&lt;/span&gt;

&lt;span class="c"&gt;&amp;lt;!-- DiagView --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;script &lt;/span&gt;&lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"https://cdn.jsdelivr.net/npm/diagview@1.0.0/dist/diagview.umd.min.js"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/script&amp;gt;&lt;/span&gt;

&lt;span class="c"&gt;&amp;lt;!-- Your diagram --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"diagram"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;svg&amp;gt;&lt;/span&gt;&lt;span class="c"&gt;&amp;lt;!-- Your SVG content --&amp;gt;&lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;/svg&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;

&lt;span class="c"&gt;&amp;lt;!-- Initialize --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;script&amp;gt;&lt;/span&gt;
  &lt;span class="nx"&gt;DiagView&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;init&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/script&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  NPM
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install &lt;/span&gt;diagview @panzoom/panzoom
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;DiagView&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;diagview&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nx"&gt;DiagView&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;init&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;layout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;floating&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;accentColor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#3b82f6&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Flexible Layouts
&lt;/h2&gt;

&lt;p&gt;DiagView supports three layout modes to fit your design:&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%2Fa5kwsj319ogdgd5lyqww.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%2Fa5kwsj319ogdgd5lyqww.png" title="Header, Floating, and Off layout modes" alt="Layout Options" width="800" height="330"&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;Layout&lt;/th&gt;
&lt;th&gt;Best For&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Header&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Classic top-bar controls, documentation sites&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Floating&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Clean HUD-style buttons on hover, minimal UIs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Off&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Invisible UI, the diagram itself is the trigger&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Under the Hood: Technical Decisions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The Search Engine
&lt;/h3&gt;

&lt;p&gt;This was the feature I was most proud of. The search system:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Pre-Caches Candidates&lt;/strong&gt; — On first open, queries all text elements and stores them in a WeakMap&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Uses Dirty Checking&lt;/strong&gt; — Before writing to the DOM, checks if values have changed&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Batches Updates&lt;/strong&gt; — All DOM mutations are wrapped in requestAnimationFrame&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The result? Searching through diagrams with &lt;strong&gt;2,500+ nodes&lt;/strong&gt; is instant.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Export System
&lt;/h3&gt;

&lt;p&gt;The export module handles edge cases:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Robust Dimension Calculation&lt;/strong&gt; — Uses getBBox() to find actual content area&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cross-Origin Font Handling&lt;/strong&gt; — Inlines Google Fonts for consistent exports&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;High-DPI Scaling&lt;/strong&gt; — Up to 6x resolution for print-quality images&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Optional Panzoom Dependency
&lt;/h3&gt;

&lt;p&gt;I made panzoom an &lt;strong&gt;optional&lt;/strong&gt; peer dependency:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;With panzoom:&lt;/strong&gt; Full zoom, pan, touch gestures&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Without panzoom:&lt;/strong&gt; Fullscreen, search, and export still work&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This keeps DiagView usable even in constrained environments.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bundle Size
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Size&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Raw Minified&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;~70 KB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Gzipped (Transfer)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~19 KB&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;For context, that's smaller than a single hero image. And it includes all CSS, SVG icons, and the entire UI framework.&lt;/p&gt;

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

&lt;p&gt;I built this to scratch my own itch. If you write technical documentation for a living, I think you'll find it useful too.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;🧪 &lt;strong&gt;Live Demo:&lt;/strong&gt; &lt;a href="https://khadirullah.github.io/diagview/" rel="noopener noreferrer"&gt;khadirullah.github.io/diagview&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;⭐ &lt;strong&gt;GitHub:&lt;/strong&gt; &lt;a href="https://github.com/khadirullah/diagview" rel="noopener noreferrer"&gt;github.com/khadirullah/diagview&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;📦 &lt;strong&gt;NPM:&lt;/strong&gt; &lt;a href="https://www.npmjs.com/package/diagview" rel="noopener noreferrer"&gt;npmjs.com/package/diagview&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Have feedback or found a bug? &lt;a href="https://github.com/khadirullah/diagview/issues" rel="noopener noreferrer"&gt;Open an issue on GitHub&lt;/a&gt;.&lt;/p&gt;




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

</description>
      <category>javascript</category>
      <category>webdev</category>
      <category>opensource</category>
      <category>svg</category>
    </item>
  </channel>
</rss>
