[{"content":"","date":"30 May 2026","externalUrl":null,"permalink":"/tags/ai/","section":"Tags","summary":"","title":"Ai","type":"tags"},{"content":"","date":"30 May 2026","externalUrl":null,"permalink":"/tags/api/","section":"Tags","summary":"","title":"Api","type":"tags"},{"content":"","date":"30 May 2026","externalUrl":null,"permalink":"/blog/","section":"Blog","summary":"","title":"Blog","type":"blog"},{"content":" 🏴‍☠️ Built for the Pirates of the Coral-bean hackathon by WeMakeDevs | May 25–31, 2026 TL;DR # Built a DevOps Incident Investigator using Coral SQL that correlates GitHub PRs, Sentry incidents, and Slack incident context using a single SQL query.\nCoral turns operational debugging into a single SQL query across distributed systems.\nResults:\n📉 Incident triage reduced from ~15 minutes to ~15 seconds. 🪸 Custom Coral source spec created for internal APIs. 🤖 AI-generated root cause analysis and Slack alerts. 💻 Includes both a CLI and a Web Dashboard. Built in 4 days for the Pirates of the Coral-bean hackathon.\nReal-time DevOps intelligence dashboard correlating deployments, incidents, Slack activity, and payment health.\n📖 The Story (what this tool does) # Imagine this: a developer merges a Pull Request on GitHub. That PR automatically shows up in the Incident Investigator dashboard under Deployments.\nMinutes later, something breaks — a new error starts firing in Sentry. The tool instantly correlates that error back to the exact PR that caused it, showing you: \u0026ldquo;PR #42 was merged 15 minutes before this error first appeared.\u0026rdquo;\nNow the on-call engineer clicks the incident → Gemini AI analyzes the root cause in seconds, gives immediate fix steps and long-term prevention advice.\nOne click on 📢 Send to Slack and the full incident report is posted to your team\u0026rsquo;s #incidents channel.\nAnd if you just want a quick answer? Type plain English like \u0026ldquo;Which errors have the most events?\u0026rdquo; → Gemini writes the Coral SQL → executes it live → shows you the results.\nThat\u0026rsquo;s the DevOps Incident Investigator — from PR merge to root cause to team notification, all in one tool powered by Coral SQL + Gemini AI.\nThe Problem # As a DevOps engineer, I\u0026rsquo;ve experienced the pain of incident investigation firsthand — switching between GitHub, Sentry, and Slack at 2 AM trying to figure out what broke production.\nGitHub — \u0026ldquo;Which PR was deployed last?\u0026rdquo; Sentry — \u0026ldquo;What errors are spiking?\u0026rdquo; Slack — \u0026ldquo;What\u0026rsquo;s the team saying?\u0026rdquo; That\u0026rsquo;s 3 tabs, 3 APIs, and 15 minutes of context-switching before you even understand what happened. This is what modern incident response looks like without unified observability.\nThe Solution # When I discovered Coral, an open-source tool that lets you query any API with SQL, I knew exactly what to build.\nOne command. Three sources. Full incident picture.\npython3 investigator.py --query all --owner khadirullah --repo demo-payment-api The Magic: Cross-Source JOINs # Coral allows querying GitHub and Sentry together in a single statement. For this prototype, I use deployment timing to surface likely suspect PRs that were merged around the same time incidents first appeared.\nSELECT p.number AS pr_number, p.title AS pr_title, p.user__login AS pr_author, p.merged_at, i.short_id AS sentry_id, i.title AS error_title, i.level AS error_level, i.first_seen AS error_first_seen FROM github.pulls p JOIN sentry.issues i ON p.merged_at IS NOT NULL WHERE p.owner = \u0026#39;khadirullah\u0026#39; AND p.repo = \u0026#39;demo-payment-api\u0026#39; AND p.state = \u0026#39;closed\u0026#39; ORDER BY i.first_seen DESC, p.merged_at DESC LIMIT 15 This is the money shot. PRs merged on the same day as errors appeared, side by side. \u0026ldquo;PR #820 merged at 12:18 PM, errors started at 12:20 PM\u0026rdquo; — instant suspect identification. Why I Chose Coral (and Why It\u0026rsquo;s a Game-Changer) # Normally, if you want to pull data from GitHub, Sentry, and Slack, you end up installing multiple SDKs (PyGithub, sentry-sdk, slack-sdk), learning different APIs, handling pagination and retries yourself, and writing glue code to correlate everything manually.\n(Note: SDKs like sentry-sdk are excellent for emitting telemetry from applications, but they are still siloed when it comes to querying and correlating operational data across multiple platforms during an incident.)\nWhat Coral Replaced # flowchart TD subgraph \"Before Coral\" GH[GitHub SDK] S[Sentry SDK] SL[Slack SDK] GLUE[Custom Glue Code+ Pagination+ Rate Limiting] GH --\u003e GLUE S --\u003e GLUE SL --\u003e GLUE end subgraph \"After Coral\" C[Coral SQL] Q[SELECT * FROM githubJOIN sentryJOIN slack] C --\u003e Q end By treating the entire operational stack as a unified query layer, I was able to build my agent using far fewer SDKs and almost no glue code. Coral handles the authentication, pagination, and schema mapping locally, allowing me to focus on the actual business logic: writing cross-source JOINs and generating AI analysis.\nDay 1: Setting Up Coral \u0026amp; Connecting Sources # Installing Coral # curl -fsSL https://withcoral.com/install.sh | sh coral --version # coral 0.3.0+96d61f7 One command. That\u0026rsquo;s it.\nConnecting GitHub # For the GitHub token, I created a classic PAT at github.com/settings/tokens with zero scopes selected. For public repos, you don\u0026rsquo;t need any permissions — the token just bumps your API rate limit from 60 to 5,000 requests per hour. GITHUB_TOKEN=ghp_XXXXX coral source add github 362 tables connected instantly — issues, pull requests, commits, repos, actions.\nConnecting Sentry # Finding the Sentry token wasn\u0026rsquo;t obvious. Sentry doesn\u0026rsquo;t have a simple \u0026ldquo;API Tokens\u0026rdquo; page — you create tokens through Custom Integrations:\nSettings → Custom Integrations → Create New Integration (Internal Integration) Name it coral-hackathon with minimal read-only permissions (Project: Read, Issue \u0026amp; Event: Read, Organization: Read): Copy the generated token and connect: SENTRY_TOKEN=sntrys_XXXXX SENTRY_ORG=my-org coral source add sentry 12 tables connected — events, issues, projects, deployments.\nI verified the tables were available with a quick SELECT schema_name, table_name FROM coral.tables.\nConnecting Slack # Slack was the trickiest. Coral\u0026rsquo;s pre-filled link creates a Slack app, but it only sets up User Token Scopes with PKCE — tokens aren\u0026rsquo;t displayed in the UI.\nThe fix: Add Bot Token Scopes instead of User Token Scopes. In OAuth \u0026amp; Permissions, scroll to Bot Token Scopes and add: channels:history, channels:read, groups:history, groups:read, users:read. Then reinstall the app — a Bot User OAuth Token (xoxb-...) appears! coral source add --interactive slack # Paste xoxb-... token when prompted All Sources Connected! 🎉 # Source Tables What It Gives Us GitHub 362 PRs, commits, issues, actions Sentry 12 Errors, events, projects Slack 2 Channels, users Total 376 All queryable with SQL Architecture # Here\u0026rsquo;s how the entire system fits together:\ngraph TB subgraph \"User Interfaces\" CLI[\"🖥️ CLIinvestigator.py\"] WEB[\"🌐 Web DashboardFlask + Vanilla JS\"] end subgraph \"Backend - app.py\" API[\"Flask API15 endpoints\"] SEC[\"🔒 Token SecuritySymmetric encryption\"] DEMO[\"📦 Demo DataPre-loaded scenarios\"] end subgraph \"AI Layer\" GEMINI[\"🤖 Gemini FlashRoot Cause Analysis\"] NL2SQL[\"💬 NL-to-SQLNatural Language Queries\"] end subgraph \"Coral SQL Layer\" CORAL[\"🪸 Coral CLIcoral sql\"] end subgraph \"Data Sources\" GH[\"GitHub API362 tables\"] SENTRY[\"Sentry API12 tables\"] SLACK[\"Slack API2 tables\"] PAY[\"Payment APICustom Source Spec\"] end subgraph \"Alerting\" SLACKALERT[\"📢 Slack #incidentsAutomated Alerts\"] end CLI --\u003e CORAL WEB --\u003e API API --\u003e CORAL API --\u003e SEC API --\u003e DEMO API --\u003e GEMINI API --\u003e NL2SQL NL2SQL --\u003e CORAL CORAL --\u003e GH CORAL --\u003e SENTRY CORAL --\u003e SLACK CORAL --\u003e PAY GEMINI --\u003e SLACKALERT Two interfaces (CLI + Web Dashboard) sit on top of Coral SQL, which unifies 4 data sources into a single query layer. The AI layer uses Gemini Flash for root cause analysis and natural language → SQL generation.\nDay 2: Writing SQL Queries \u0026amp; Building the CLI # Generating Test Data for Sentry # First roadblock: Sentry was empty. I wrote generate_errors.py to send 10 realistic DevOps errors:\nErrors include: ConnectionError (PostgreSQL max connections), MemoryError (OOM kill), RuntimeError (K8s CrashLoopBackOff), TimeoutError (30s timeout), and more.\nThe SQL Queries # Query 1: Deployments — \u0026ldquo;What was recently deployed?\u0026rdquo;\nSELECT number, title, user__login AS author, merged_at FROM github.pulls WHERE owner = \u0026#39;khadirullah\u0026#39; AND repo = \u0026#39;demo-payment-api\u0026#39; AND merged_at IS NOT NULL ORDER BY merged_at DESC LIMIT 10 Query 2: Incidents — \u0026ldquo;What errors are happening?\u0026rdquo;\nSELECT short_id, title, level, count AS event_count, first_seen, last_seen FROM sentry.issues ORDER BY last_seen DESC LIMIT 10 Query 3: Correlation — \u0026ldquo;Suspect deployment identification\u0026rdquo;\n(As shown in the introduction, this cross-source JOIN correlates GitHub PRs with Sentry errors based on timestamps).\nQuery 4: Team Overview — Another cross-source JOIN:\nSELECT u.name AS username, u.real_name, u.is_admin, c.name AS channel_name, c.num_members FROM slack.users u CROSS JOIN slack.channels c WHERE u.deleted = false AND c.is_archived = false Building the CLI # The CLI wraps everything in a clean, colorful interface with zero pip dependencies — Python stdlib only (subprocess, argparse, json, urllib):\npython3 investigator.py --query all \\ --owner khadirullah --repo demo-payment-api \\ --slack-token $SLACK_TOKEN Troubleshooting Gotchas # Sentry Project ID: My first query failed with \u0026quot;Invalid project parameter. Values must be numbers.\u0026quot; I was using the slug (python) but Sentry wants the numeric ID. Fixed with:\nSELECT id, slug, name FROM sentry.projects Slack Bot Permissions: Got not_in_channel error when fetching messages. The bot had channels:history but wasn\u0026rsquo;t in the channel. Then hit missing_scope — needed channels:join scope. Quick fix: add scope → reinstall app → copy new token. Day 3: Web Dashboard + AI Integration # Why Build a Dashboard? # A CLI is functional, but judges have 5 minutes. They need to see the data. I built a Flask dashboard with a dark glassmorphism design.\nThe Dashboard # Features:\nStats Row — Live counters for deployments, incidents, correlations, risky PRs Incident Timeline — Horizontal scrolling event sequence Deployments Table — Merged PRs with authors and timestamps Sentry Incidents — Severity-colored cards with \u0026ldquo;🤖 Analyze\u0026rdquo; button Correlation View — Visual PR ↔ Error mapping Slack Messages — Chat-style from #incidents Risky PRs — Risk-scored with green/yellow/red bars Team Overview — Member cards from Slack users × channels JOIN AI Root Cause Analysis # Click 🤖 Analyze on any Sentry error:\nFor example, PROD-41A (PostgreSQL max connections):\nRoot Cause: PR #487 introduced a new connection pooling layer that eagerly opens connections on pod startup. With 4 pods each opening 25 connections, the default max_connections=100 limit is immediately exhausted.\nImmediate Fix: 1) Roll back PR #487, 2) Increase max_connections to 200, 3) Restart affected pods\nConfidence: 94% (Coral system score)\n(Note: This example is generated from demo data and illustrates the style of analysis produced by the assistant.)\nIn live mode with a Gemini API key, it calls Gemini Flash in real-time. Pre-computed demos ensure instant results without API waits.\nSettings \u0026amp; Token Security # All 4 API tokens encrypted with Fernet symmetric encryption before writing to disk \u0026ldquo;Delete All Tokens\u0026rdquo; does a 3-pass random overwrite Demo → Live toggle with per-panel badges Demo Mode # The dashboard starts in Demo Mode — pre-loaded data, zero setup. Switching to live is 4 clicks:\n⚙️ Settings → enter tokens 💾 Save Click DEMO badge → LIVE 🔄 Refresh If a live call fails, panels gracefully fall back to demo data.\nDay 4: Competitive Upgrades — Going Beyond # Custom Source Spec: Querying Internal APIs # Enterprises don\u0026rsquo;t just use public SaaS tools. A real incident investigator needs to query internal microservices. This is where Coral\u0026rsquo;s Custom Source Specs shine.\nI built a companion project (demo-payment-api) and wrote a YAML spec to teach Coral how to talk to it:\nname: payment_api version: 0.1.0 dsl_version: 3 backend: http base_url: \u0026#34;http://localhost:5001\u0026#34; auth: type: HeaderAuth headers: - name: Authorization template: \u0026#34;Bearer {{input.PAYMENT_API_TOKEN}}\u0026#34; tables: - name: health request: method: GET path: /api/health columns: - name: status type: Utf8 expr: { kind: path, path: [status] } - name: response_time_ms type: Int64 expr: { kind: path, path: [response_time_ms] } The YAML acts as a \u0026ldquo;translator\u0026rdquo; — it tells Coral how to map SQL concepts (tables, columns) to HTTP concepts (endpoints, JSON paths):\nsequenceDiagram participant User participant Coral participant YAML as payment-api.yaml participant API as Payment API User-\u003e\u003eCoral: SELECT status FROM payment_api.health Coral-\u003e\u003eYAML: Look up \"health\" table YAML--\u003e\u003eCoral: GET /api/health Coral-\u003e\u003eAPI: HTTP GET http://localhost:5001/api/health API--\u003e\u003eCoral: {\"status\": \"healthy\", \"response_time_ms\": 42} Coral--\u003e\u003eUser: | status | response_time_ms | Note over Coral,User: | healthy | 42 | Register and query:\n# Lint the spec coral source lint coral-config/payment-api.yaml # Add to Coral PAYMENT_API_TOKEN=mock_123 coral source add --file coral-config/payment-api.yaml # Query like a database! coral sql \u0026#34;SELECT * FROM payment_api.health\u0026#34; This proves the tool can connect to any internal enterprise service — not just the big SaaS providers. Read more in our dedicated Chart New Waters deep-dive. Natural Language to SQL (/api/ask) # Type a question in plain English → AI generates Coral SQL → executes it → returns results:\nflowchart LR A[\"User: 'Show me criticalerrors from last week'\"] --\u003e B[\"Gemini Flash\"] B --\u003e C[\"SELECT * FROM sentry.issuesWHERE level = 'error'LIMIT 10\"] C --\u003e D[\"Coral SQL Engine\"] D --\u003e E[\"Results Table\"] The backend feeds the live Coral schema (all tables + columns) to Gemini, so it generates accurate SQL every time.\nAutomated Slack Alerts # Investigation is only half the battle — communication is the other half. After AI generates a root cause analysis, users can click \u0026quot;📢 Send to Slack\u0026quot; to push the report directly to #incidents:\nflowchart LR A[\"🚨 Sentry Error\"] --\u003e B[\"🤖 AI Analysis\"] B --\u003e C[\"📢 Send to Slack\"] C --\u003e D[\"#incidents channel\"] D --\u003e E[\"Team sees report\"] If the token only has read permissions, the UI catches the missing_scope error and gracefully prompts the user to add chat:write scope.\nHandling Slack Messages (TVF vs Custom Fallback) # Coral exposes Slack messages via a function-like table interface (TVF), which requires passing the exact channel ID directly into the SQL query: SELECT * FROM slack.messages(channel =\u0026gt; 'C12345678').\nHowever, I needed something more robust for an automated dashboard. If the bot isn\u0026rsquo;t already in the incident channel, the Slack API returns a strict not_in_channel error. Instead of failing, I deliberately built a custom Python fallback that catches this error, dynamically forces the bot to join the channel, fetches the messages, and then uses Coral to pull the slack.users table to map the raw User IDs (e.g., UXXXXXXX) to real human names.\nFetching the Team Overview # With the messages handled resiliently, I still needed to display the active Incident Response team. Instead of making separate API calls for users and channels, I let Coral grab the entire team landscape in a single round-trip using a CROSS JOIN:\nSELECT u.name AS username, u.real_name, u.is_admin, c.name AS channel_name, c.num_members FROM slack.users u CROSS JOIN slack.channels c WHERE u.deleted = false AND c.is_archived = false A quick Python iteration separates the results, giving us the full team and channel rosters instantly without juggling multiple Slack SDK requests.\nWhy Sentry (Not Just SonarQube \u0026amp; Trivy) # A question I got: \u0026ldquo;Why do you need Sentry if you have SonarQube and Trivy?\u0026rdquo; The answer:\nflowchart LR subgraph \"Before Deploy\" SQ[\"SonarQube'Code COULD break'\"] TV[\"Trivy'Has known CVEs'\"] end subgraph \"After Deploy\" SE[\"Sentry'App JUST brokefor 1,203 users'\"] end SQ --\u003e DEPLOY[\"Deploy\"] TV --\u003e DEPLOY DEPLOY --\u003e SE Tool Stage Catches SonarQube Pre-deploy (CI) Code smells, potential bugs Trivy Pre-deploy (CI) Known CVEs in dependencies Sentry Post-deploy (Runtime) Real crashes, right now, with stack traces + user impact The Incident Investigator bridges all three stages — correlating code changes (GitHub) with runtime errors (Sentry) and team communication (Slack).\nSetting Up Slack Alerts (Webhooks vs Bot Tokens) # We use both approaches:\nFeature Incoming Webhook Bot Token (xoxb-) Direction App → Slack (one-way) App ↔ Slack (two-way) Use case Post alerts Read messages + respond Setup Just a URL OAuth scopes Best for Simple alerting Complex integrations Webhook → Demo Payment API posts error alerts to #incidents Bot Token → Incident Investigator reads messages from #incidents via Coral SQL The complete pipeline:\nflowchart TB ERR[\"Error in Payment API\"] --\u003e SENTRY[\"Sentry captures exception\"] ERR --\u003e WEBHOOK[\"Slack webhook fires\"] WEBHOOK --\u003e INCIDENTS[\"#incidents channel\"] SENTRY --\u003e CORAL[\"Coral SQL queries\"] INCIDENTS --\u003e CORAL GH[\"GitHub PRs\"] --\u003e CORAL CORAL --\u003e INVESTIGATOR[\"Incident Investigator\"] INVESTIGATOR --\u003e AI[\"Gemini AI Analysis\"] AI --\u003e ALERT[\"📢 Push to Slack\"] ALERT --\u003e INCIDENTS What I Learned # About Coral # Zero-scope GitHub tokens work — just bumps your rate limit for public repos Sentry wants numeric project IDs — not slugs, query sentry.projects first Slack\u0026rsquo;s Coral source is limited — only channels and users, no messages. But the bot token works with direct API calls Cross-source JOINs are the killer feature — GitHub × Sentry in one SQL statement is genuinely powerful About DevOps Incident Response # The hardest part of incident response isn\u0026rsquo;t fixing the problem — it\u0026rsquo;s finding the right information. We spend more time context-switching than debugging. The investigator answers three questions fast:\nWhat was deployed recently? What errors appeared after deployment? What\u0026rsquo;s the team saying about it? That\u0026rsquo;s 80% of the first 15 minutes of any incident.\nAbout Hackathons # Ship fast, iterate later — the Discord advice was spot-on Document as you go — writing the blog alongside the code was more efficient Errors are content — every missing_scope became a blog section Keep scope tight, then expand — CLI first, dashboard second, AI third Limitations # Correlation heuristic: GitHub ↔ Sentry correlation is currently based on deployment timing, not deterministic tracing. AI as an assistant: Root cause analysis is AI-assisted and should be treated as a hypothesis, not absolute truth. Slack capabilities: Slack messages are retrieved through a Python fallback integration because the native Coral Slack source currently exposes only users and channels. API rate limits: In a high-traffic live incident, direct API calls to Slack and GitHub could hit rate limits, requiring an intermediate caching layer. Schema mismatches: Edge cases in custom internal APIs may occasionally fail to map perfectly to Coral\u0026rsquo;s static YAML types without custom data coercions. The Result # In testing, the workflow reduced incident investigation from roughly 15 minutes of manual context-switching to less than 15 seconds for an initial deployment-to-error correlation query.\nThe Impact # Before (The Old Way):\n❌ Open GitHub to check recent PRs ❌ Open Sentry to check recent errors ❌ Open Slack to read team discussions ❌ Manually correlate timestamps across 3 tabs After (The Coral Way):\n✅ Run one command or view one dashboard ✅ Instantly see PRs and Errors side-by-side ✅ Get AI-generated root cause analysis Technical Achievements # Feature Details 6 SQL Queries Deployments, incidents, correlation, risky PRs, team, health 2 Cross-Source JOINs GitHub × Sentry, Slack users × channels 1 Custom Source Spec payment-api.yaml for internal microservice AI Analysis Gemini-powered root cause + fix suggestions NL-to-SQL Ask questions in English, get Coral SQL results 2 Interfaces CLI (zero deps) + Web Dashboard (Flask) Security Fernet symmetric encryption at rest Docker + CI Production-ready packaging + GitHub Actions Try It # git clone https://github.com/khadirullah/devops-incident-investigator cd devops-incident-investigator pip install flask google-generativeai cryptography python3 app.py # Open http://localhost:5000 — works instantly with demo data! View on GitHub Future Work # Direct Release Correlation: Correlate Sentry releases directly to GitHub commits using commit SHAs. More Sources: Add Grafana and Kubernetes log sources to the SQL engine. Automated Postmortems: Use the AI layer to generate and publish full incident postmortems automatically. Built with Coral for the Pirates of the Coral-bean hackathon by WeMakeDevs.\nFollow the journey: khadirullah.com | GitHub\n","date":"30 May 2026","externalUrl":null,"permalink":"/blog/devops-incident-investigator/","section":"Blog","summary":"How I built a DevOps Incident Investigator that correlates GitHub PRs, Sentry errors, and Slack messages using Coral SQL — reducing incident triage from 15 minutes of tab-switching to 15 seconds with one command.","title":"Building a DevOps Incident Investigator with Coral SQL — From 15 Minutes to 15 Seconds","type":"blog"},{"content":"","date":"30 May 2026","externalUrl":null,"permalink":"/categories/","section":"Categories","summary":"","title":"Categories","type":"categories"},{"content":" ⚔️ This post is my submission for the \u0026ldquo;Chart New Waters\u0026rdquo; bounty in the Pirates of the Coral-bean hackathon by WeMakeDevs.\nSource Spec: payment-api.yaml\nThe Problem: Enterprises Don\u0026rsquo;t Just Use Public SaaS # When building an Enterprise Agent, there\u0026rsquo;s one massive architectural challenge: enterprises don\u0026rsquo;t just use GitHub, Sentry, and Slack.\nA real enterprise relies on hundreds of internal, private microservices — custom payment gateways, user management APIs, internal inventory systems, health monitoring dashboards. These services:\n❌ Have no public API documentation ❌ Are not listed in any marketplace ❌ Have no native Coral connector ❌ Sit behind VPNs and private networks If your incident correlation engine can only query public SaaS tools, you\u0026rsquo;re missing half the picture.\nThis is where Coral\u0026rsquo;s Custom Source Specs save the day. One YAML file turns any REST API into a SQL table. Why Not Just Use Sentry? (Active vs Passive Monitoring) # You might wonder: \u0026ldquo;If the payment API already sends errors to Sentry, and Coral already reads Sentry, why do we need to query the API directly?\u0026rdquo;\nThe answer highlights a critical DevOps distinction:\nflowchart LR subgraph \"Passive Monitoring via Sentry\" A[\"Error occurs\"] --\u003e B[\"Sentry captures snapshot\"] B --\u003e C[\"'What BROKE 5 min ago?'\"] end subgraph \"Active Monitoring via Custom Source\" D[\"Coral queries /api/health\"] --\u003e E[\"Real-time response\"] E --\u003e F[\"'Is the API UP right now?'\"] end Type Tool Question It Answers When Passive Sentry \u0026ldquo;What broke 5 minutes ago?\u0026rdquo; After the fact Active Custom Source Spec \u0026ldquo;Is the service alive RIGHT NOW?\u0026rdquo; Real-time During a major outage at 2AM, a DevOps engineer\u0026rsquo;s first question is: \u0026ldquo;Is the API completely dead, or is it recovering?\u0026rdquo; Sentry can\u0026rsquo;t answer that — it only logs past errors. The custom source can.\nWith our spec, the Incident Investigator can execute powerful logic:\n\u0026ldquo;I see the database error in Sentry from 5 minutes ago. Let me instantly query payment_api.health to check if the service is currently online and what the response time is.\u0026rdquo;\nHow Custom Source Specs Work # Think of a custom source spec as a translator between SQL and HTTP.\nsequenceDiagram participant Dev as DevOps Engineer participant Coral as Coral SQL Engine participant YAML as payment-api.yaml(Translator) participant API as Internal Payment API Dev-\u003e\u003eCoral: SELECT status, response_time_msFROM payment_api.health Coral-\u003e\u003eYAML: Look up table \"health\" YAML--\u003e\u003eCoral: Method: GETPath: /api/healthColumns: status → $.status Coral-\u003e\u003eAPI: HTTP GET http://localhost:5001/api/healthAuthorization: Bearer mock_123 API--\u003e\u003eCoral: {\"status\": \"healthy\",\"response_time_ms\": 42} Coral--\u003e\u003eDev: | status | response_time_ms || healthy | 42 | The YAML file maps:\nSQL table name → HTTP endpoint SQL columns → JSON response fields SQL query → HTTP request (method, path, headers) From the user\u0026rsquo;s perspective, they just write SQL. Coral handles the HTTP call, JSON parsing, and column mapping automatically.\nBuilding the Source Spec: Step by Step # The API We\u0026rsquo;re Connecting # Our demo-payment-api is a Flask microservice with these endpoints:\nEndpoint Returns GET /api/health Service health, response times, endpoint status GET /api/payments List of processed and pending payments In a real enterprise, this could be any internal microservice — a user management API, an inventory system, a billing platform. The pattern is the same.\nStep 1: Define the Source Identity # Every custom source needs a name, version, and the Coral DSL version:\nname: payment_api # This becomes the SQL schema: payment_api.health version: 0.1.0 dsl_version: 3 # Current Coral DSL version backend: http # We\u0026#39;re connecting to an HTTP API description: \u0026#34;Internal Payment API for processing and tracking mock payments\u0026#34; base_url: \u0026#34;http://localhost:5001\u0026#34; # Where the API lives The name is critical — it becomes the SQL schema prefix. After registration, you\u0026rsquo;ll query tables as payment_api.health, payment_api.payments, etc. Step 2: Set Up Authentication # Even internal APIs need authentication. We configure Bearer token auth:\ninputs: PAYMENT_API_TOKEN: kind: secret hint: \u0026#34;Bearer token for the Payment API\u0026#34; auth: type: HeaderAuth headers: - name: Authorization from: template template: \u0026#34;Bearer {{input.PAYMENT_API_TOKEN}}\u0026#34; The inputs section tells Coral to ask for a PAYMENT_API_TOKEN environment variable when adding the source. This keeps secrets out of the YAML file itself.\nStep 3: Map Endpoints to SQL Tables # This is the magic part. For each API endpoint, we create a SQL table definition:\nTable 1: health — Maps GET /api/health\nThe API returns JSON like:\n[ {\u0026#34;endpoint\u0026#34;: \u0026#34;/api/health\u0026#34;, \u0026#34;status\u0026#34;: \u0026#34;healthy\u0026#34;, \u0026#34;response_time_ms\u0026#34;: 12, \u0026#34;timestamp\u0026#34;: \u0026#34;2026-05-29T18:00:00Z\u0026#34;}, {\u0026#34;endpoint\u0026#34;: \u0026#34;/api/payments\u0026#34;, \u0026#34;status\u0026#34;: \u0026#34;healthy\u0026#34;, \u0026#34;response_time_ms\u0026#34;: 45, \u0026#34;timestamp\u0026#34;: \u0026#34;2026-05-29T18:00:00Z\u0026#34;} ] We map it in YAML:\ntables: - name: health description: \u0026#34;Payment service health status and uptime metrics\u0026#34; request: method: GET path: /api/health response: rows_path: [] # Response IS the array (no wrapper key) columns: - name: endpoint type: Utf8 expr: { kind: path, path: [endpoint] } - name: status type: Utf8 expr: { kind: path, path: [status] } - name: response_time_ms type: Int64 expr: { kind: path, path: [response_time_ms] } - name: timestamp type: Utf8 expr: { kind: path, path: [timestamp] } Key details:\nrows_path: [] means the JSON response IS the array (not wrapped in a key like {\u0026quot;data\u0026quot;: [...]}) Each column\u0026rsquo;s expr: { kind: path, path: [...] } extracts a specific JSON field type maps JSON types to SQL types (Utf8 = string, Int64 = integer, Float64 = decimal) Table 2: payments — Maps GET /api/payments\n- name: payments description: \u0026#34;List of processed and pending payments\u0026#34; request: method: GET path: /api/payments response: rows_path: [data] # Rows are inside the \u0026#34;data\u0026#34; key columns: - name: id type: Utf8 expr: { kind: path, path: [id] } - name: amount type: Float64 expr: { kind: path, path: [amount] } - name: currency type: Utf8 expr: { kind: path, path: [currency] } - name: status type: Utf8 expr: { kind: path, path: [status] } - name: customer type: Utf8 expr: { kind: path, path: [customer] } Notice rows_path: [data] — this tells Coral the rows are nested inside a \u0026quot;data\u0026quot; key in the JSON response.\nStep 4: Add Test Queries # Good source specs include test queries that Coral runs during registration to validate everything works:\ntest_queries: - SELECT status, endpoint FROM payment_api.health LIMIT 1 - SELECT id, amount, status FROM payment_api.payments LIMIT 1 Step 5: Register with Coral # # 1. Lint the YAML to check for syntax errors coral source lint coral-config/payment-api.yaml # 2. Add the source (providing the auth token) PAYMENT_API_TOKEN=mock_token_123 coral source add --file coral-config/payment-api.yaml # 3. Query it like a database! coral sql \u0026#34;SELECT endpoint, status, response_time_ms FROM payment_api.health\u0026#34; From this moment on, Coral treats your private microservice exactly the same as GitHub or Sentry. The Complete Source Spec # Here\u0026rsquo;s the full payment-api.yaml:\nname: payment_api version: 0.1.0 dsl_version: 3 backend: http description: \u0026#34;Internal Payment API for processing and tracking mock payments\u0026#34; base_url: \u0026#34;http://localhost:5001\u0026#34; inputs: PAYMENT_API_TOKEN: kind: secret hint: \u0026#34;Bearer token for the Payment API\u0026#34; auth: type: HeaderAuth headers: - name: Authorization from: template template: \u0026#34;Bearer {{input.PAYMENT_API_TOKEN}}\u0026#34; test_queries: - SELECT status, endpoint FROM payment_api.health LIMIT 1 - SELECT id, amount, status FROM payment_api.payments LIMIT 1 tables: - name: health description: \u0026#34;Payment service health status and uptime metrics\u0026#34; request: method: GET path: /api/health response: rows_path: [] columns: - name: endpoint type: Utf8 expr: { kind: path, path: [endpoint] } - name: status type: Utf8 expr: { kind: path, path: [status] } - name: response_time_ms type: Int64 expr: { kind: path, path: [response_time_ms] } - name: timestamp type: Utf8 expr: { kind: path, path: [timestamp] } - name: payments description: \u0026#34;List of processed and pending payments\u0026#34; request: method: GET path: /api/payments response: rows_path: [data] columns: - name: id type: Utf8 expr: { kind: path, path: [id] } - name: amount type: Float64 expr: { kind: path, path: [amount] } - name: currency type: Utf8 expr: { kind: path, path: [currency] } - name: status type: Utf8 expr: { kind: path, path: [status] } - name: customer type: Utf8 expr: { kind: path, path: [customer] } The Result: True Enterprise Correlation # With this custom source, the DevOps Incident Investigator can now:\nQuery internal API health in real-time:\nSELECT endpoint, status, response_time_ms FROM payment_api.health Cross-reference with Sentry errors:\n-- \u0026#34;Is the payment API healthy after this Sentry error appeared?\u0026#34; SELECT h.status, h.response_time_ms, i.title AS error FROM payment_api.health h, sentry.issues i WHERE i.level = \u0026#39;error\u0026#39; LIMIT 5 Extend to ANY internal service: The same YAML pattern works for user-management APIs, inventory systems, billing platforms — any service with an HTTP endpoint.\nWhy This Matters for Enterprises # flowchart TB subgraph \"Public SaaS - Native Connectors\" GH[\"GitHub362 tables\"] SE[\"Sentry12 tables\"] SL[\"Slack2 tables\"] end subgraph \"Internal Services - Custom Source Specs\" PAY[\"Payment APIpayment-api.yaml\"] USER[\"User Management APIuser-api.yaml\"] INV[\"Inventory Systeminventory-api.yaml\"] BILL[\"Billing Platformbilling-api.yaml\"] end GH --\u003e CORAL[\"🪸 Coral SQL\"] SE --\u003e CORAL SL --\u003e CORAL PAY --\u003e CORAL USER --\u003e CORAL INV --\u003e CORAL BILL --\u003e CORAL CORAL --\u003e SQL[\"One SQL queryacross ALL sources\"] Every enterprise has internal tools that are:\nPrivate — behind VPNs, not publicly accessible Undocumented — no OpenAPI spec, no marketplace listing Critical — the payment gateway, the user auth service, the config management system Without custom source specs, an incident investigator is blind to these services. With them, any REST API becomes a SQL table in minutes.\nThe pattern is always the same:\nWrite a YAML file mapping endpoints → tables Run coral source add --file your-spec.yaml Query with SQL: SELECT * FROM your_api.your_table One YAML file. Any API. Full SQL access.\nReproduce It Yourself # # 1. Clone both repos git clone https://github.com/khadirullah/devops-incident-investigator git clone https://github.com/khadirullah/demo-payment-api # 2. Start the payment API cd demo-payment-api \u0026amp;\u0026amp; pip install -r requirements.txt \u0026amp;\u0026amp; python3 app.py # Running on http://localhost:5001 # 3. Register the custom source with Coral cd ../devops-incident-investigator PAYMENT_API_TOKEN=mock_123 coral source add --file coral-config/payment-api.yaml # 4. Query your internal API with SQL! coral sql \u0026#34;SELECT * FROM payment_api.health\u0026#34; coral sql \u0026#34;SELECT id, amount, status FROM payment_api.payments LIMIT 5\u0026#34; Official Guide: How to write a custom source spec → View on GitHub Built as part of the DevOps Incident Investigator for the Pirates of the Coral-bean hackathon by WeMakeDevs.\n#ChartNewWaters #Coral #DevOps #CustomSourceSpec #PiratesOfTheCoralBean\n","date":"30 May 2026","externalUrl":null,"permalink":"/blog/coral-custom-source-spec/","section":"Blog","summary":"A step-by-step guide to building a custom Coral source spec that turns any internal REST API into a queryable SQL table — no SDK, no glue code, just one YAML file.","title":"Charting New Waters: Building a Custom Coral Source Spec for Internal Enterprise APIs","type":"blog"},{"content":"","date":"30 May 2026","externalUrl":null,"permalink":"/tags/coral/","section":"Tags","summary":"","title":"Coral","type":"tags"},{"content":"","date":"30 May 2026","externalUrl":null,"permalink":"/tags/devops/","section":"Tags","summary":"","title":"Devops","type":"tags"},{"content":"","date":"30 May 2026","externalUrl":null,"permalink":"/tags/enterprise/","section":"Tags","summary":"","title":"Enterprise","type":"tags"},{"content":"","date":"30 May 2026","externalUrl":null,"permalink":"/tags/github/","section":"Tags","summary":"","title":"Github","type":"tags"},{"content":"","date":"30 May 2026","externalUrl":null,"permalink":"/tags/hackathon/","section":"Tags","summary":"","title":"Hackathon","type":"tags"},{"content":"I build CI/CD pipelines, manage cloud infrastructure, and automate everything I can — with Docker, Kubernetes, Terraform, and AWS. Recently built an AI-powered incident correlation tool that JOINs data across GitHub, Sentry, and Slack using cross-source SQL.\nThis blog is where I document it all — real tutorials and practical notes from the field.\n📬 I\u0026rsquo;m actively looking for DevOps \u0026amp; Cloud Engineering roles — get in touch.\n","date":"30 May 2026","externalUrl":null,"permalink":"/","section":"Home","summary":"I build CI/CD pipelines, manage cloud infrastructure, and automate everything I can — with Docker, Kubernetes, Terraform, and AWS. Recently built an AI-powered incident correlation tool that JOINs data across GitHub, Sentry, and Slack using cross-source SQL.\n","title":"Home","type":"page"},{"content":"","date":"30 May 2026","externalUrl":null,"permalink":"/categories/projects/","section":"Categories","summary":"","title":"Projects","type":"categories"},{"content":"","date":"30 May 2026","externalUrl":null,"permalink":"/tags/python/","section":"Tags","summary":"","title":"Python","type":"tags"},{"content":"","date":"30 May 2026","externalUrl":null,"permalink":"/tags/sentry/","section":"Tags","summary":"","title":"Sentry","type":"tags"},{"content":"","date":"30 May 2026","externalUrl":null,"permalink":"/tags/slack/","section":"Tags","summary":"","title":"Slack","type":"tags"},{"content":"","date":"30 May 2026","externalUrl":null,"permalink":"/tags/sql/","section":"Tags","summary":"","title":"Sql","type":"tags"},{"content":"","date":"30 May 2026","externalUrl":null,"permalink":"/tags/","section":"Tags","summary":"","title":"Tags","type":"tags"},{"content":"","date":"30 May 2026","externalUrl":null,"permalink":"/categories/tutorials/","section":"Categories","summary":"","title":"Tutorials","type":"categories"},{"content":"","date":"30 May 2026","externalUrl":null,"permalink":"/tags/yaml/","section":"Tags","summary":"","title":"Yaml","type":"tags"},{"content":"","date":"19 May 2026","externalUrl":null,"permalink":"/tags/cloudflare/","section":"Tags","summary":"","title":"Cloudflare","type":"tags"},{"content":"","date":"19 May 2026","externalUrl":null,"permalink":"/tags/deployment/","section":"Tags","summary":"","title":"Deployment","type":"tags"},{"content":"","date":"19 May 2026","externalUrl":null,"permalink":"/tags/dns/","section":"Tags","summary":"","title":"Dns","type":"tags"},{"content":"I wanted a personal website — a place to publish blog posts, share my projects, and have a professional online presence. I didn\u0026rsquo;t want WordPress, I didn\u0026rsquo;t want to pay for hosting, and I didn\u0026rsquo;t want to manage a server just for a blog.\nAfter some research, I landed on Hugo (static site generator) + Blowfish (theme) + Cloudflare Pages (free hosting). This post walks through exactly how I set it all up.\nWhy Hugo? # Option Why I Skipped It WordPress Needs a server, database, PHP. Overkill for a blog. Security headaches. Ghost Needs hosting and a database. Paid plans for managed hosting. Jekyll Slower build times. Ruby dependency. Next.js Overkill — I don\u0026rsquo;t need React for static blog posts. Hugo ✅ Fast, no dependencies, single binary, massive theme ecosystem, Markdown-based. Hugo generates static HTML files from Markdown. No database, no server-side code, no PHP. Just HTML, CSS, and JS files that any CDN can serve.\nWhy Blowfish? # I tried several Hugo themes before settling on Blowfish. Here\u0026rsquo;s what sold me:\nDark mode by default — looks professional, easy on the eyes Profile layout — homepage shows my photo, bio, and links Built-in features — search, table of contents, code copy, breadcrumbs, related content Shortcodes — timeline, alerts, GitHub repo cards, mermaid diagrams, buttons Responsive — works well on mobile without extra effort Active maintenance — regularly updated with new features Project Setup # Installing Hugo # Hugo is a single binary. On Fedora:\nsudo dnf install hugo On Debian/Ubuntu:\nsudo apt install hugo On macOS (Homebrew):\nbrew install hugo Verify:\nhugo version Many Hugo themes, including Blowfish, require the extended version of Hugo for SCSS/SASS support.\nCreating the Site # hugo new site khadirullah.com cd khadirullah.com git init Adding Blowfish as a Git Submodule # git submodule add -b main https://github.com/nunocoracao/blowfish.git themes/blowfish Using a Git submodule means the theme stays in its own repository. When Blowfish gets updated, I can pull the latest version without modifying my site\u0026rsquo;s code.\nBlowfish also supports Hugo Modules, but I chose Git submodules because they\u0026rsquo;re simpler to understand and work well with standard Git workflows.\nConfiguration # Hugo + Blowfish uses a multi-file configuration system inside config/_default/. Here\u0026rsquo;s what each file does:\nhugo.toml — Core Site Settings # theme = \u0026#34;blowfish\u0026#34; baseURL = \u0026#34;https://khadirullah.com/\u0026#34; defaultContentLanguage = \u0026#34;en\u0026#34; enableRobotsTXT = true enableEmoji = true buildDrafts = false [outputs] home = [\u0026#34;HTML\u0026#34;, \u0026#34;RSS\u0026#34;, \u0026#34;JSON\u0026#34;] # JSON enables search [sitemap] changefreq = \u0026#39;weekly\u0026#39; filename = \u0026#39;sitemap.xml\u0026#39; Key decisions:\nenableRobotsTXT = true — generates a robots.txt for search engines JSON output — enables Blowfish\u0026rsquo;s built-in search feature buildDrafts = false — draft posts don\u0026rsquo;t get published languages.en.toml — Author and Site Info # title = \u0026#34;Khadirullah Mohammad\u0026#34; [params.author] name = \u0026#34;Khadirullah Mohammad\u0026#34; email = \u0026#34;contact@khadirullah.com\u0026#34; headline = \u0026#34;DevOps \u0026amp; Cloud Engineer\u0026#34; bio = \u0026#34;Former IT fixer turned DevOps Engineer...\u0026#34; links = [ { github = \u0026#34;https://github.com/khadirullah\u0026#34; }, { linkedin = \u0026#34;https://linkedin.com/in/khadirullah\u0026#34; }, { email = \u0026#34;mailto:contact@khadirullah.com\u0026#34; }, ] params.toml — Theme Customization # colorScheme = \u0026#34;ocean\u0026#34; defaultAppearance = \u0026#34;dark\u0026#34; autoSwitchAppearance = false enableSearch = true enableCodeCopy = true smartTOC = true [homepage] layout = \u0026#34;profile\u0026#34; showRecent = true showRecentItems = 5 cardView = true [article] showHero = true heroStyle = \u0026#34;big\u0026#34; showTableOfContents = true showReadingTime = true sharingLinks = [\u0026#34;linkedin\u0026#34;, \u0026#34;x-twitter\u0026#34;, \u0026#34;reddit\u0026#34;, \u0026#34;email\u0026#34;] Key decisions:\ncolorScheme = \u0026quot;ocean\u0026quot; — a blue-toned dark theme layout = \u0026quot;profile\u0026quot; — homepage shows my avatar, bio, and social links heroStyle = \u0026quot;big\u0026quot; — blog posts get a large featured image at the top sharingLinks — readers can share posts directly to LinkedIn, X, Reddit, or email Content Structure # Hugo uses a specific directory structure:\ncontent/ ├── _index.md # Homepage content ├── about/ │ └── index.md # About page └── blog/ ├── _index.md # Blog listing page ├── block-internet-linux-apps/ │ ├── index.md # The blog post (Markdown) │ ├── featured.svg # Hero image for the post │ └── media/ # Screenshots and videos └── introducing-diagview/ ├── index.md ├── featured.webp └── demo.webm Each blog post lives in its own directory. This keeps images, videos, and the post itself together — no messy /static/images/post-name/ paths.\nWriting a Blog Post # Every post starts with front matter — metadata in YAML format:\n--- title: \u0026#34;How to Block Internet Access for Any Linux App\u0026#34; date: 2026-03-25 draft: false description: \u0026#34;A deep-dive guide to restricting internet...\u0026#34; summary: \u0026#34;Block outbound internet for specific Linux apps...\u0026#34; tags: [\u0026#34;linux\u0026#34;, \u0026#34;ufw\u0026#34;, \u0026#34;firewall\u0026#34;, \u0026#34;security\u0026#34;] categories: [\u0026#34;Tutorials\u0026#34;] --- draft: true → post exists locally but doesn\u0026rsquo;t get published tags → appear at the bottom of the post, help readers find related content categories → used for grouping (I use \u0026ldquo;Tutorials\u0026rdquo; and \u0026ldquo;Projects\u0026rdquo;) description → used for SEO \u0026lt;meta\u0026gt; tag summary → shown on the blog listing page Then the content is just standard Markdown — headings, code blocks, tables, links. Blowfish also supports shortcodes for richer content like alerts, timelines, mermaid diagrams, and GitHub repo cards.\nDeployment on Cloudflare Pages # Why Cloudflare Pages? # Option Cost Why I Chose/Skipped GitHub Pages Free Good, Simple and reliable, but less flexible than Cloudflare Pages for build configuration and edge/CDN features Netlify Free tier Good, but 100GB bandwidth limit on free tier Vercel Free tier Designed for Next.js/React, overkill for static HTML AWS S3 + CloudFront Pay per use Too complex for a blog, billing anxiety Cloudflare Pages ✅ Free tier Global CDN, generous bandwidth limits, automatic HTTPS, custom domains, and Git-based deployments Setting Up Cloudflare Pages # Push your Hugo site to GitHub — the entire project, including content and config Go to Cloudflare Dashboard → Pages → Create a project Connect your GitHub repository Set the build settings: Setting Value Framework preset Hugo Build command hugo Build output directory public Environment variable HUGO_VERSION = 0.147.6 (or your version) Environment variable HUGO_ENV = production Important: Set the HUGO_VERSION environment variable to match your local Hugo version. Cloudflare\u0026rsquo;s default Hugo version is old and may not support newer features. Check your version with hugo version. Click \u0026ldquo;Save and Deploy\u0026rdquo; — Cloudflare builds your site and gives you a .pages.dev URL Custom Domain Setup # After the initial deploy:\nGo to your Cloudflare Pages project → Custom domains Add khadirullah.com — Cloudflare automatically creates the DNS record (since I already use Cloudflare DNS) Add www.khadirullah.com — redirects to the root domain HTTPS — automatic, no configuration needed Automated Deployments # This is the best part: every time I push to the main branch, Cloudflare automatically builds and deploys the updated site. No manual steps, no SSH, no FTP.\n# Write a new blog post hugo new content blog/my-new-post/index.md # Edit the post... # Deploy git add . git commit -m \u0026#34;Add new blog post\u0026#34; git push origin main # Done — Cloudflare builds and deploys automatically Build time is usually under 30 seconds. The site is live globally on Cloudflare\u0026rsquo;s CDN within a minute.\nThe Resume Page # My resume is a standalone HTML page inside static/resume/index.html. It doesn\u0026rsquo;t use Hugo\u0026rsquo;s templating — it\u0026rsquo;s pure HTML + CSS with:\nPrint-optimized CSS — window.print() generates a clean PDF Inter font from Google Fonts Responsive design — works on mobile A \u0026ldquo;Save as PDF\u0026rdquo; button — uses the browser\u0026rsquo;s print dialog Since it\u0026rsquo;s in the static/ directory, Hugo copies it as-is to the output — no Markdown processing.\nWhat I Learned # Static sites are enough for blogs — I don\u0026rsquo;t need a database, a CMS, or server-side code. Markdown → HTML → CDN. Simple. Git submodules for themes — keeps the theme updatable without mixing it into my code Cloudflare Pages free tier is genuinely usable — generous bandwidth limits, automatic HTTPS, and Git-push deploys. For a static personal site, the free tier has been more than enough. Content next to assets — Hugo\u0026rsquo;s page bundle structure (post-name/index.md + images in the same folder) is much cleaner than maintaining a separate global images directory. Write in Markdown, publish everywhere — the same Markdown files can be rendered by Hugo, GitHub, VS Code, or any Markdown reader The Stack # Layer Tool Cost Static site generator Hugo Free Theme Blowfish Free (open source) Hosting Cloudflare Pages Free DNS Cloudflare Free Domain khadirullah.com ~$10/year Version control Git + GitHub Free Email Zoho Mail Free tier Total ~$10/year (domain only) Resources \u0026amp; Documentation # If you\u0026rsquo;re interested in building your own setup like this, here are the official docs that helped me along the way:\nHugo Documentation — The official docs for the Hugo static site generator. Blowfish Theme Docs — Excellent documentation for configuring the Blowfish theme, including all shortcodes and layout options. Cloudflare Pages — Guide on deploying frameworks and static sites on Cloudflare Pages. The source code for this website is public: khadirullah/khadirullah.com My personal portfolio and blog. Built with Hugo and the Blowfish theme, hosted on Cloudflare Pages. HTML 0 0 ","date":"19 May 2026","externalUrl":null,"permalink":"/blog/how-i-built-and-deployed-this-website/","section":"Blog","summary":"How I built khadirullah.com with Hugo and the Blowfish theme, configured it, and deployed it to Cloudflare Pages for free — with automated Git-push deployments.","title":"How I Built and Deployed This Website","type":"blog"},{"content":"","date":"19 May 2026","externalUrl":null,"permalink":"/tags/hugo/","section":"Tags","summary":"","title":"Hugo","type":"tags"},{"content":"","date":"19 May 2026","externalUrl":null,"permalink":"/tags/tutorial/","section":"Tags","summary":"","title":"Tutorial","type":"tags"},{"content":"","date":"15 May 2026","externalUrl":null,"permalink":"/tags/email/","section":"Tags","summary":"","title":"Email","type":"tags"},{"content":"","date":"15 May 2026","externalUrl":null,"permalink":"/tags/security/","section":"Tags","summary":"","title":"Security","type":"tags"},{"content":" A complete guide to setting up professional email on your custom domain — with SPF, DKIM, and DMARC explained from the ground up. When I bought my domain, one of the first things I wanted was a professional email address — contact@yourdomain.com 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.\nThis 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.\nWhy Custom Domain Email? # Option What It Looks Like Impression Gmail @gmail.com \u0026ldquo;Just another person\u0026rdquo; Custom domain contact@yourdomain.com \u0026ldquo;Professional, owns their infrastructure\u0026rdquo; For a DevOps engineer\u0026rsquo;s website, having a custom domain email shows you understand DNS, mail infrastructure, and security — which is literally part of the job.\nWhy Zoho Mail? # Provider Free Tier Why I Chose/Skipped Google Workspace No free tier anymore Costs $6/month per user Microsoft 365 No free tier Costs $6/month per user ProtonMail Custom domain on paid plan only Costs $4/month Zoho Mail ✅ Free for 5 users, 5GB Free, custom domain. Note: Web/App only (no IMAP) Cloudflare Email Routing Free Forwarding only — can\u0026rsquo;t send FROM your domain 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 anything@yourdomain.com, 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 paid plan to add IMAP/POP and other features.\nStep 1: Add Your Domain to Zoho # Go to Zoho Mail → Sign up for the free plan Add your domain — Zoho will ask you to verify ownership Zoho gives you a TXT record to add to your DNS — this proves you own the domain In Cloudflare DNS:\nType Name Content TXT @ zoho-verification=zb12345678.zmverify.zoho.com After adding this record, click \u0026ldquo;Verify\u0026rdquo; in Zoho. Once verified, Zoho gives you the remaining DNS records.\nStep 2: MX Records (Mail Exchange) # MX records tell the internet where to deliver emails for your domain. When someone sends an email to contact@yourdomain.com, the sending mail server looks up the MX records for yourdomain.com to find out which mail server should receive it.\nZoho provides these MX records:\nType Name Mail Server Priority MX @ mx.zoho.com 10 MX @ mx2.zoho.com 20 MX @ mx3.zoho.com 50 Priority determines the order — the sending server tries priority 10 first (mx.zoho.com). If that\u0026rsquo;s down, it tries priority 20, then 50. This gives you redundancy.\nAdd all three in Cloudflare DNS.\nNote: These are the MX records for Zoho\u0026rsquo;s US/global data center (zoho.com). If you signed up at zoho.eu or zoho.in, your MX servers will be different (e.g., mx.zoho.eu). Always use the exact values Zoho provides during setup. Important: Make sure the proxy status for MX records is set to DNS only (gray cloud), not Proxied (orange cloud). Email traffic cannot go through Cloudflare\u0026rsquo;s proxy. Step 3: SPF (Sender Policy Framework) # What Is SPF? # SPF answers the question: \u0026ldquo;Which mail servers are allowed to send email on behalf of my domain?\u0026rdquo;\nWithout SPF, anyone in the world could send an email that says From: contact@yourdomain.com — and the receiving server would have no way to verify if it\u0026rsquo;s real. SPF fixes this by publishing a list of authorized mail servers in your DNS.\nHow It Works # 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: \u0026#34;Only Zoho\u0026#39;s servers are allowed to send for this domain\u0026#34; 5. Mail server checks: Did this email actually come from Zoho\u0026#39;s IP addresses? → Yes → SPF passes → No → SPF fails (mark as suspicious or reject) Technical detail: SPF validates the envelope sender (Return-Path), not the From: 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). The Record # Zoho provides this SPF record:\nType Name Content TXT @ v=spf1 include:zoho.com ~all Regional note: Just like MX records, the SPF include: domain varies by Zoho region. If you signed up at zoho.eu, use include:zoho.eu. If zoho.in, use include:zoho.in. Always use the exact SPF record Zoho provides during setup. Breaking it down:\nPart Meaning v=spf1 This is an SPF record (version 1) include:zoho.com Allow any server that Zoho authorizes ~all Soft-fail everything else (mark as suspicious but don\u0026rsquo;t reject) ~all vs -all: The ~ (tilde) means \u0026ldquo;soft fail\u0026rdquo; — unauthorized emails are marked suspicious but still delivered. The - (hyphen) means \u0026ldquo;hard fail\u0026rdquo; — unauthorized emails are rejected outright. I started with ~all to make sure legitimate emails weren\u0026rsquo;t accidentally blocked. Once you\u0026rsquo;re confident everything works, you can switch to -all for stricter enforcement. DevOps gotcha: SPF has a 10 DNS lookup limit. Each include: in your SPF record triggers DNS lookups, and nested includes count too. Zoho\u0026rsquo;s include:zoho.com 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 dmarcian SPF Surveyor to audit your lookup count. Step 4: DKIM (DomainKeys Identified Mail) # What Is DKIM? # DKIM answers the question: \u0026ldquo;Was this email actually sent by who it claims, and was it tampered with in transit?\u0026rdquo;\nDKIM uses cryptographic signatures. When Zoho sends an email from your domain, it signs the email with a private key that only Zoho has. The receiving server verifies the signature using a public key that you publish in your DNS.\nHow It Works # 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 \u0026#34;DKIM-Signature\u0026#34; 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 The Record # Zoho gives you a DKIM TXT record to add. It looks something like:\nType Name Content TXT zmail._domainkey v=DKIM1; k=rsa; p=MIGfMA0GCS... (long public key) The zmail._domainkey is the selector — it tells receiving servers where to find the public key for Zoho-signed emails.\nYou get this record from Zoho\u0026rsquo;s admin panel: Email Admin → Domain → Email Authentication → DKIM. Zoho generates the key pair and gives you the public key to put in DNS. You just copy-paste it into Cloudflare.\nStep 5: DMARC (Domain-based Message Authentication, Reporting \u0026amp; Conformance) # What Is DMARC? # DMARC answers the question: \u0026ldquo;Does the domain in the From: header actually match the domain verified by SPF or DKIM — and what should happen if it doesn\u0026rsquo;t?\u0026rdquo;\nDMARC ties SPF and DKIM together into a unified policy. It tells receiving servers:\nCheck SPF — did the email come from an authorized server? Check DKIM — is the signature valid? Check alignment — does the authenticated domain match the From: header domain? If neither SPF nor DKIM passes with alignment, apply the policy (nothing, quarantine, or reject) Send me reports about pass/fail results Why Alignment Matters # SPF and DKIM alone have a gap: an attacker could set up their own domain with valid SPF and DKIM, but forge your domain in the From: header that the recipient sees. Both SPF and DKIM would pass — for the attacker\u0026rsquo;s domain — but the recipient would still see your spoofed address.\nDMARC closes this gap by requiring alignment: the domain authenticated by SPF or DKIM must match the domain in the visible From: header.\nCheck What Must Align SPF alignment Return-Path (envelope sender) domain must match From: header domain DKIM alignment d= domain in the DKIM signature must match From: header domain DMARC passes if at least one of SPF or DKIM passes its check and is aligned with the From: domain. This means an email can fail SPF but still pass DMARC if DKIM passes and is aligned (or vice versa).\nFor 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\u0026rsquo;ll need to make sure they can send with proper alignment, or those emails will fail DMARC.\nThe Record # Type Name Content TXT _dmarc v=DMARC1; p=none; rua=mailto:dmarc@yourdomain.com Breaking it down:\nPart Meaning v=DMARC1 This is a DMARC record (version 1) p=none Policy: don\u0026rsquo;t take action on failures (just monitor) rua=mailto:dmarc@yourdomain.com Send aggregate reports to this email Heads up: DMARC aggregate reports are not human-readable emails. They arrive as gzipped XML attachments that look like gibberish if you open them directly. You\u0026rsquo;ll need a tool to parse them — services like DMARC Analyzer, Postmark DMARC, or dmarcian can ingest these reports and turn them into dashboards you can actually read. Other useful DMARC tags you\u0026rsquo;ll encounter:\nruf=mailto:... — Forensic (per-failure) reports with details about individual failures. Few providers send these due to privacy concerns, but it doesn\u0026rsquo;t hurt to include. adkim=r / aspf=r — Alignment mode: r for relaxed (subdomains can align, e.g., mail.yourdomain.com matches yourdomain.com), s for strict. Defaults are relaxed, which is correct for most setups. pct=100 — Percentage of messages the policy applies to. Useful for gradually rolling out a stricter policy (e.g., pct=10 to apply p=quarantine to only 10% of failing messages at first). DMARC Policy Levels # Policy What Happens on Failure When to Use p=none Nothing — just collect reports Start here. Monitor for 2-4 weeks. p=quarantine Failed emails go to spam folder After confirming legitimate emails pass p=reject Failed emails are rejected entirely Maximum protection — use after quarantine works Tip: I started with p=none 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\u0026rsquo;t jump straight to p=reject — you might accidentally block your own emails. Step 6: Email Aliases and Routing # The Setup # Instead of creating multiple Zoho accounts (the free tier allows up to 5 users, but paid plans charge per user), I use aliases to keep costs down and management simple:\nAddress Purpose Type yourname@yourdomain.com Main email (admin account) Primary contact@yourdomain.com Displayed on website Alias → forwards to primary How Aliases Work # When someone sends an email to contact@yourdomain.com, Zoho delivers it to my primary inbox. When I reply, I can choose to reply as contact@yourdomain.com — so the person never sees my primary address.\nThe Security Catch with Aliases # There is one important detail to note: Zoho allows you to log into your account using any of your aliases as the username.\nThis can be both good and bad:\nThe Advantage: It\u0026rsquo;s convenient. You don\u0026rsquo;t have to remember your primary admin email; you can just log in using your public alias. The Security Risk: 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 contact@yourdomain.com) to attempt to log into your admin panel. Security Tip: Use Group Aliases — To solve this login vulnerability, you can use Group Aliases 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. Folder-Based Rules # I also set up rules in Zoho to automatically organize incoming email:\nRule Action Email to contact@ Move to \u0026ldquo;Website\u0026rdquo; folder Email from GitHub Move to \u0026ldquo;GitHub\u0026rdquo; folder Email from LinkedIn Move to \u0026ldquo;LinkedIn\u0026rdquo; folder This keeps my inbox clean and organized without manual sorting.\nHow All the Records Work Together # Here\u0026rsquo;s what happens when someone receives an email \u0026ldquo;from\u0026rdquo; my domain:\ngraph TD A[\"📧 Email arrives claiming to be fromcontact@yourdomain.com\"] --\u003e B{\"1️⃣ SPF Check\"} A --\u003e D{\"2️⃣ DKIM Check\"} B --\u003e|\"Look up TXT recordfor yourdomain.com\"| C{\"Did it come fromZoho's servers?\"} C --\u003e|\"✅ Yes\"| SPF_PASS[\"✅ SPF Pass\"] C --\u003e|\"❌ No\"| SPF_FAIL[\"⚠️ SPF Fail\"] D --\u003e|\"Look up zmail._domainkeyTXT record\"| E{\"Does the signaturematch?\"} E --\u003e|\"✅ Yes\"| DKIM_PASS[\"✅ DKIM Pass\"] E --\u003e|\"❌ No\"| DKIM_FAIL[\"⚠️ DKIM Fail\"] SPF_PASS --\u003e I{\"3️⃣ DMARC CheckAligned + passed?\"} SPF_FAIL --\u003e I DKIM_PASS --\u003e I DKIM_FAIL --\u003e I I --\u003e|\"At least one passedwith alignment\"| J[\"📬 Delivered to Inbox\"] I --\u003e|\"Neither passed +p=none\"| K[\"📬 Still delivered(just monitored)\"] I --\u003e|\"Neither passed +p=reject\"| L[\"🚫 Rejected\"] classDef pass fill:#22c55e,color:black,stroke:#166534; classDef fail fill:#ef4444,color:white,stroke:#991b1b; classDef warn fill:#f59e0b,color:black,stroke:#b45309; class SPF_PASS,DKIM_PASS,J pass; class L fail; class SPF_FAIL,DKIM_FAIL,K warn; Verifying Your Setup # After adding all the records, verify everything works:\nCheck DNS Records # # MX records dig MX yourdomain.com +short # Expected: 10 mx.zoho.com, 20 mx2.zoho.com, 50 mx3.zoho.com # SPF dig TXT yourdomain.com +short # Expected: \u0026#34;v=spf1 include:zoho.com ~all\u0026#34; # DKIM dig TXT zmail._domainkey.yourdomain.com +short # Expected: \u0026#34;v=DKIM1; k=rsa; p=MIGf...\u0026#34; # DMARC dig TXT _dmarc.yourdomain.com +short # Expected: \u0026#34;v=DMARC1; p=none; rua=mailto:...\u0026#34; Online Tools # MXToolbox — checks MX, SPF, DKIM, DMARC, and blacklist status Mail Tester — send a test email and get a deliverability score out of 10 DMARC Analyzer — parse DMARC aggregate reports Send a Test Email # Send an email from your custom domain to a Gmail address. In Gmail, open the email → click the three dots → \u0026ldquo;Show original\u0026rdquo;. Look for:\nSPF: PASS DKIM: PASS DMARC: PASS If all three show PASS, your authentication setup is complete. But read the next section — authentication alone doesn\u0026rsquo;t guarantee inbox delivery. But Your Emails Can Still Land in Spam # Setting up SPF, DKIM, and DMARC is essential — but it doesn\u0026rsquo;t guarantee inbox delivery. These records prove authentication (that you are who you say you are), but major email providers like Gmail and Outlook also evaluate reputation and content. Here are the other factors that can send your perfectly authenticated emails straight to spam:\n1. Spammy Subject Lines or Body Content # Email providers run your email through content filters. If your subject line looks like FREE MONEY — ACT NOW!!! 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.\n2. IP or Domain Reputation # 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\u0026rsquo;s reputation using tools like MXToolbox Blacklist Check or Google Postmaster Tools.\n3. Domain and IP Warming # This is the one most people don\u0026rsquo;t know about. When you start sending emails from a brand new domain or IP address, email providers have zero trust in you. You have no sending history, no reputation — you\u0026rsquo;re an unknown.\nIf you suddenly send 500 emails from a new domain, Gmail will almost certainly flag them. The solution is called warming — 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\u0026rsquo;s your domain reputation that starts from zero.\nFor a personal domain like mine, IP warming isn\u0026rsquo;t a big concern since I only send a few emails a day. But if you\u0026rsquo;re setting up email for a business or newsletter, IP warming is critical. 4. Recipients Marking You as Spam # This is the most brutal one. If enough people who receive your emails click the \u0026ldquo;Report Spam\u0026rdquo; button, email providers learn that people don\u0026rsquo;t want your emails. Once your spam complaint rate crosses a threshold (Google\u0026rsquo;s threshold is roughly 0.3%), your future emails start landing in spam for everyone — even people who want them.\nThis is why every newsletter has an unsubscribe link. It\u0026rsquo;s better for someone to unsubscribe than to hit \u0026ldquo;Report Spam.\u0026rdquo;\nBottom line: SPF, DKIM, and DMARC get you through the authentication door. But content quality, sender reputation, IP warming, and user engagement determine whether you make it to the inbox or the spam folder. Think of DNS records as your ID card — they prove who you are, but they don\u0026rsquo;t guarantee you\u0026rsquo;ll be invited in. Summary of All DNS Records # Here\u0026rsquo;s the complete set of DNS records I added in Cloudflare for email:\nType Name Content Proxy MX @ mx.zoho.com (priority 10) DNS only MX @ mx2.zoho.com (priority 20) DNS only MX @ mx3.zoho.com (priority 50) DNS only TXT @ v=spf1 include:zoho.com ~all — TXT zmail._domainkey v=DKIM1; k=rsa; p=MIGf... — TXT _dmarc v=DMARC1; p=none; rua=mailto:... — TXT @ zoho-verification=... — What I Learned # SPF, DKIM, and DMARC are not optional — without them, your emails land in spam or get rejected by Gmail/Outlook. Most people skip these and wonder why their emails aren\u0026rsquo;t delivered.\nZoho gives you the records — you just paste them — I didn\u0026rsquo;t generate any keys manually. Zoho\u0026rsquo;s admin panel provides every DNS record you need. Your job is to copy them into your DNS provider (Cloudflare in my case) correctly.\nStart with p=none for DMARC — don\u0026rsquo;t jump to p=reject immediately. Monitor first, make sure legitimate emails pass, then tighten the policy.\nAliases are powerful — one Zoho account can receive email at multiple addresses. No need to create separate accounts.\nDNS propagation takes time — after adding records, wait 15-30 minutes (sometimes up to 48 hours) before testing. Don\u0026rsquo;t panic if verification fails immediately.\nMX records must be DNS-only in Cloudflare — if you accidentally proxy them (orange cloud), email delivery breaks. Always set MX records to gray cloud (DNS only).\nA Quick Warning: My Personal Choice on Using Custom Domain Email for Logins # This section is not a general recommendation — it\u0026rsquo;s my personal threat model and decision based on how I use custom domains. If you\u0026rsquo;re only using your domain for professional or business email, you can skip this — but I recommend reading it anyway. When I first set all this up, my immediate thought was: Awesome, I\u0026rsquo;m going to create a custom alias for every platform I use. github@yourdomain.com, linkedin@yourdomain.com\u0026hellip; you get the idea. It felt super organized.\nBut after thinking about it for a while, a darker thought crossed my mind: What happens if I die?\nOr even if I just forget to renew the domain?\nA custom domain is basically a subscription. If I\u0026rsquo;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 does manage to buy my expired domain, they can set up an email server and start receiving all my messages. They could hit \u0026ldquo;Forgot Password\u0026rdquo; on my GitHub, get the reset link, and attempt to take over my account.\nThis creates a dependency chain: accounts → email → domain ownership. Break any link, and everything downstream is at risk.\nThe Objections I Considered # \u0026ldquo;Can\u0026rsquo;t I just pay for 10 years upfront or use auto-renew?\u0026rdquo; You could! You can prepay for a decade or put a credit card on auto-renew. Those are good practices, but they still aren\u0026rsquo;t bulletproof. Credit cards expire, banks block transactions, and 10 years isn\u0026rsquo;t forever.\n\u0026ldquo;What about leaving a Digital Will?\u0026rdquo; You might think that leaving a \u0026ldquo;Digital Will\u0026rdquo; with instructions for your family to maintain and renew the domain solves the problem. While it\u0026rsquo;s a nice thought, relying on it for your core security is a bad idea. Your family simply won\u0026rsquo;t care about maintaining your infrastructure as much as you do.\nMore importantly, they probably don\u0026rsquo;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.\n\u0026ldquo;But I\u0026rsquo;ll be dead, why do I care?\u0026rdquo; You might be thinking this — and honestly, if you don\u0026rsquo;t have anything important tied to the email, maybe you don\u0026rsquo;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\u0026rsquo;ve used that email for government IDs or highly private, sensitive information, you probably don\u0026rsquo;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?\n\u0026ldquo;But what about MFA?\u0026rdquo; 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\u0026rsquo;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?\nFurthermore, 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\u0026rsquo;s really you, when you don\u0026rsquo;t even own the email address associated with the account anymore! It\u0026rsquo;s a nightmare waiting to happen.\nImportant Context # 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 individuals managing everything alone over long time periods.\nWhat I Changed # 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:\nPurpose Email Why Public-facing (on this website) contact@yourdomain.com Professional impression Replies and correspondence yourname@yourdomain.com Clean sender identity Account recovery and critical logins Gmail / Outlook Can\u0026rsquo;t be bought out from under me This gives me a balance between professionalism and long-term account safety.\nIf you still want to use your custom domain for everything: That\u0026rsquo;s completely valid — just make sure to add a secure public email (like ProtonMail, Outlook, or Gmail) as a secondary recovery email 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. Further Reading # If you want to go deeper into how email authentication actually works under the hood:\nTopic Resource SPF specification RFC 7208 — Sender Policy Framework DKIM specification RFC 6376 — DomainKeys Identified Mail DMARC specification RFC 7489 — Domain-based Message Authentication Beginner-friendly explainers Cloudflare Learning Center — DNS Email Security Google\u0026rsquo;s sender requirements Google Email Sender Guidelines Microsoft\u0026rsquo;s sender requirements Microsoft Anti-Spam Policies DMARC report analysis Postmark\u0026rsquo;s Free DMARC Monitoring Setting up email authentication isn\u0026rsquo;t difficult — it\u0026rsquo;s just DNS records. But understanding why each record exists and what it does is what separates \u0026ldquo;I copy-pasted some records\u0026rdquo; from \u0026ldquo;I understand email infrastructure.\u0026rdquo; And that understanding is exactly what DevOps is about. ","date":"15 May 2026","externalUrl":null,"permalink":"/blog/setting-up-custom-domain-email-with-spf-dkim-and-dmarc/","section":"Blog","summary":"How I set up professional email on my custom domain using Zoho Mail and Cloudflare DNS — complete with SPF, DKIM, DMARC authentication, alias routing, and folder-based filtering.","title":"Setting Up Custom Domain Email with SPF, DKIM, and DMARC","type":"blog"},{"content":"","date":"25 March 2026","externalUrl":null,"permalink":"/tags/debian/","section":"Tags","summary":"","title":"Debian","type":"tags"},{"content":"","date":"25 March 2026","externalUrl":null,"permalink":"/tags/firejail/","section":"Tags","summary":"","title":"Firejail","type":"tags"},{"content":"","date":"25 March 2026","externalUrl":null,"permalink":"/tags/firewall/","section":"Tags","summary":"","title":"Firewall","type":"tags"},{"content":"","date":"25 March 2026","externalUrl":null,"permalink":"/tags/homelab/","section":"Tags","summary":"","title":"Homelab","type":"tags"},{"content":"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.\nThis guide shows you how to block outbound internet for any specific app on Linux while keeping localhost and your home LAN fully functional.\nI\u0026rsquo;ll cover five approaches, from a quick 2-minute wrapper script to a production-hardened Chromium setup that survives apt upgrades. Then I\u0026rsquo;ll show you the fundamental security flaw that most guides never mention — and what to use instead when it actually matters.\nSafety First: Take a Snapshot! You are modifying core network firewall rules. A simple typo can easily break your internet connection or lock you out of your server. It is highly recommended to take a VM/System Snapshot before starting. If a snapshot is not possible, please take a manual backup of your UFW rules first. Reverting a snapshot takes 10 seconds; troubleshooting a broken firewall can take hours. Why UFW? # You might wonder why this guide uses UFW instead of raw nftables or iptables. The answer is simple: safety for beginners. If something goes wrong — you accidentally lock yourself out of the network, or an app stops working — you can just run sudo ufw disable or even sudo apt remove ufw 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.\nHow Does This Actually Work? # Every time a process opens a network socket, the Linux kernel stamps it with the process\u0026rsquo;s UID (User ID) and GID (Group ID). The firewall — specifically netfilter, which UFW sits on top of — can inspect those stamps on outgoing packets and decide: accept or reject.\nThat\u0026rsquo;s the entire trick:\nMark the app\u0026rsquo;s processes with a specific UID or GID Write firewall rules that allow that UID/GID to reach LAN addresses but reject everything else For services (Jellyfin, Syncthing), we match by UID because they already run as dedicated users. For desktop apps (Firefox, Chromium), we match by GID using a no-internet group.\ngraph TD A[Linux App] --\u003e|Opens network socket| B(Linux Kernel) B --\u003e|Stamps packet with UID \u0026 GID| C{Firewall UFW / netfilter} C --\u003e|Matches 'no-internet' stamp?| D{Yes, it matches!} C --\u003e|Normal app stamp?| E((Allowed to Internet)) D --\u003e|Going to Local LAN?| F((Yes: Allowed)) D --\u003e|Going to External IP?| G((No: REJECTED)) classDef allow fill:#22c55e,color:black,stroke:#166534; classDef block fill:#ef4444,color:white,stroke:#991b1b; class E,F allow; class G block; Which Approach Should You Use? # Your Situation Best Option Difficulty \u0026ldquo;I just want to test this quickly\u0026rdquo; Option A — Wrapper script ⭐ Easy Desktop GUI app (Firefox, KeePassXC) Option B — setgid on ELF ⭐⭐ Medium System service (Jellyfin, Syncthing) Option C — UID owner-match ⭐ Easy Chromium or Electron apps Option D — dpkg-divert ⭐⭐⭐ Advanced You don\u0026rsquo;t use UFW Option E — Direct iptables/nftables ⭐⭐ Medium Need real enforcement Bypass-Proof Alternatives — Firejail / namespaces ⭐⭐ Medium Quick Glossary # Term Meaning How to check UID User Identifier (numeric) id -u username GID Group Identifier (numeric) getent group groupname EGID Effective GID — the runtime GID the kernel actually uses for socket ownership ps -eo egid,egroup,cmd UFW Uncomplicated Firewall — Debian/Ubuntu frontend for iptables sudo ufw status sg Run a command with a different primary group sg groupname command dpkg-divert Debian tool to relocate a package-managed file so your file can sit at the original path dpkg-divert --list conntrack Connection tracking — lets the firewall allow replies to established connections — owner-match iptables module that matches packets by the UID/GID of the process that created the socket — Before You Start: Back Up Everything # If you didn\u0026rsquo;t take a VM or system snapshot, you must back up your current firewall state. Take 30 seconds to save your current rules so you can easily revert them later:\nsudo iptables-save \u0026gt; ~/iptables.before sudo mkdir -p ~/ufw_rules_backup sudo cp /etc/ufw/before.rules ~/ufw_rules_backup/before.rules.backup sudo cp /etc/ufw/before6.rules ~/ufw_rules_backup/before6.rules.backup If anything goes wrong:\nsudo cp ~/ufw_rules_backup/before.rules.backup /etc/ufw/before.rules sudo cp ~/ufw_rules_backup/before6.rules.backup /etc/ufw/before6.rules sudo ufw reload The Firewall Rules (The Core of Everything) # Every option below ends up using the same firewall rules. The only difference is how you mark the app. Here\u0026rsquo;s what the rules look like — you\u0026rsquo;ll paste these into /etc/ufw/before.rules.\nWhere Exactly to Paste # Open the file and look for the *filter section at the top:\n*filter :ufw-before-input - [0:0] :ufw-before-output - [0:0] :ufw-before-forward - [0:0] ← YOUR RULES GO HERE, right after these lines Your file should initially look like this:\nFor Desktop Apps (GID Match) # Once you paste your rules into the editor, it should look exactly like this:\nReplace GID with your actual numeric group ID:\n# --- BEGIN no-internet block (IPv4) --- -A ufw-before-output -m owner --gid-owner GID -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT -A ufw-before-output -m owner --gid-owner GID -d 127.0.0.0/8 -j ACCEPT -A ufw-before-output -m owner --gid-owner GID -d 10.0.0.0/8 -j ACCEPT -A ufw-before-output -m owner --gid-owner GID -d 172.16.0.0/12 -j ACCEPT -A ufw-before-output -m owner --gid-owner GID -d 192.168.0.0/16 -j ACCEPT -A ufw-before-output -m owner --gid-owner GID -j LOG --log-prefix \u0026#34;Blocked noinet: \u0026#34; -A ufw-before-output -m owner --gid-owner GID -j REJECT # --- END no-internet block (IPv4) --- Do the same in /etc/ufw/before6.rules (use ufw6-before-output, allow ::1 and fe80::/10):\n# --- BEGIN no-internet block (IPv6) --- -A ufw6-before-output -m owner --gid-owner GID -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT -A ufw6-before-output -m owner --gid-owner GID -d ::1 -j ACCEPT -A ufw6-before-output -m owner --gid-owner GID -d fe80::/10 -j ACCEPT # Optional: uncomment for mDNS / DLNA / SSDP LAN service discovery # -A ufw6-before-output -m owner --gid-owner GID -d ff00::/8 -j ACCEPT -A ufw6-before-output -m owner --gid-owner GID -j LOG --log-prefix \u0026#34;Blocked noinet v6: \u0026#34; -A ufw6-before-output -m owner --gid-owner GID -j REJECT # --- END no-internet block (IPv6) --- For Services (UID Match — /etc/ufw/before.rules) # Same structure, but use --uid-owner with the service\u0026rsquo;s numeric UID:\n# --- BEGIN service UID block (IPv4) --- -A ufw-before-output -m owner --uid-owner UID -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT -A ufw-before-output -m owner --uid-owner UID -d 127.0.0.0/8 -j ACCEPT -A ufw-before-output -m owner --uid-owner UID -d 10.0.0.0/8 -j ACCEPT -A ufw-before-output -m owner --uid-owner UID -d 172.16.0.0/12 -j ACCEPT -A ufw-before-output -m owner --uid-owner UID -d 192.168.0.0/16 -j ACCEPT -A ufw-before-output -m owner --uid-owner UID -j LOG --log-prefix \u0026#34;Blocked uid: \u0026#34; -A ufw-before-output -m owner --uid-owner UID -j REJECT # --- END service UID block (IPv4) --- And in /etc/ufw/before6.rules:\n# --- BEGIN service UID block (IPv6) --- -A ufw6-before-output -m owner --uid-owner UID -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT -A ufw6-before-output -m owner --uid-owner UID -d ::1 -j ACCEPT -A ufw6-before-output -m owner --uid-owner UID -d fe80::/10 -j ACCEPT -A ufw6-before-output -m owner --uid-owner UID -j LOG --log-prefix \u0026#34;Blocked uid v6: \u0026#34; -A ufw6-before-output -m owner --uid-owner UID -j REJECT # --- END service UID block (IPv6) --- Why This Order? # The rules are evaluated top-to-bottom, first match wins:\nRELATED,ESTABLISHED — Don\u0026rsquo;t break existing connections mid-stream Loopback (127.x) — App can still talk to localhost LAN ranges (10.x, 172.16.x, 192.168.x) — App can reach your home network LOG — Audit blocked attempts in /var/log/kern.log or journalctl REJECT — Everything else (the actual internet) gets blocked Safe Way to Edit # Warning Always backup your original firewall rules to a safe, persistent location (like your root directory) before editing. Temporary files in /tmp/ are wiped upon every reboot!\nDon\u0026rsquo;t edit the live file directly. Backup, copy to a temp file, edit, test, then apply:\n# 1. Create a permanent backup sudo cp /etc/ufw/before.rules /root/before.rules.backup # 2. Copy to a temporary file for editing sudo cp /etc/ufw/before.rules /tmp/before.rules.edit sudo nano /tmp/before.rules.edit # paste your rules # 3. Syntax check (safe, doesn\u0026#39;t apply) sudo iptables-restore --test \u0026lt; /tmp/before.rules.edit # 4. Apply the rules sudo mv /tmp/before.rules.edit /etc/ufw/before.rules sudo chown root:root /etc/ufw/before.rules sudo chmod 644 /etc/ufw/before.rules sudo ufw reload Option A: Quick Wrapper Script # Time: 2 minutes · Best for: Testing, quick experiments\nThis is the fastest way. You create a tiny script that launches any app under a no-internet group.\nSetup # # Create the group sudo groupadd -f no-internet getent group no-internet # note the GID (e.g., 1001) # Add your user to the group so \u0026#39;sg\u0026#39; doesn\u0026#39;t prompt for a password sudo usermod -aG no-internet $USER 3. Create the wrapper script # sudo tee /usr/local/bin/no-internet \u0026gt; /dev/null \u0026lt;\u0026lt;\u0026#39;EOF\u0026#39; #!/bin/bash exec sg no-internet \u0026#34;$@\u0026#34; EOF sudo chmod 755 /usr/local/bin/no-internet Add the GID firewall rules to UFW and reload.\nUsage # no-internet firefox \u0026amp; no-internet steam \u0026amp; no-internet keepassxc \u0026amp; Verify It Works # # Should be BLOCKED: sg no-internet -c \u0026#39;curl -I -m 10 https://example.com\u0026#39; \u0026amp;\u0026amp; echo \u0026#34;FAIL\u0026#34; || echo \u0026#34;BLOCKED ✓\u0026#34; # Should still work: sg no-internet -c \u0026#39;curl -I -m 10 http://192.168.1.1\u0026#39; \u0026amp;\u0026amp; echo \u0026#34;LAN works ✓\u0026#34; || echo \u0026#34;FAIL\u0026#34; Downside: If you launch the app from the desktop menu, it won\u0026rsquo;t use the wrapper. You\u0026rsquo;d need to edit the .desktop file:\ncp /usr/share/applications/firefox.desktop ~/.local/share/applications/ nano ~/.local/share/applications/firefox.desktop # Change: Exec=firefox %u # To: Exec=/usr/local/bin/no-internet firefox %u Option B: setgid on the Binary # Time: 5 minutes · Best for: Desktop apps you always want restricted\nInstead of a wrapper, you set the GID flag directly on the app\u0026rsquo;s binary. Every time it runs — from the menu, terminal, wherever — it automatically gets the no-internet group.\nFind the Real Binary # 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):\nwhich firefox # might be /usr/bin/firefox readlink -f \u0026#34;$(which firefox)\u0026#34; # resolves symlinks file \u0026#34;$(readlink -f \u0026#34;$(which firefox)\u0026#34;)\u0026#34; # should say \u0026#34;ELF 64-bit\u0026#34; If file says \u0026ldquo;shell script\u0026rdquo; or \u0026ldquo;Python script\u0026rdquo;, dig deeper — that script calls the real binary somewhere.\nApply setgid # sudo chown root:no-internet /path/to/real/elf/binary sudo chmod 750 /path/to/real/elf/binary sudo chmod g+s /path/to/real/elf/binary # the magic: setgid bit Now every process spawned from this binary inherits EGID = no-internet, which the firewall matches.\nVerify # firefox \u0026amp; sleep 1 ps -eo pid,uid,egid,cmd | grep firefox # EGID column should show your no-internet GID number Rollback # sudo chmod g-s /path/to/real/elf/binary sudo chown root:root /path/to/real/elf/binary sudo chmod 755 /path/to/real/elf/binary ⚠️ Caveat: This doesn\u0026rsquo;t work on Snap or Flatpak apps — they run in sandboxes with their own network stack. For Flatpak, use Flatseal (GUI) to toggle off \u0026ldquo;Network\u0026rdquo; permissions, or run flatpak override --user --unshare=network com.app.Name. For Snap, use snap connections app-name and snap disconnect app-name:network to revoke the network plug. Or install the app as a native .deb.\nOption C: Service UID Match # Time: 3 minutes · Best for: Daemons like Jellyfin, Syncthing, qBittorrent\nServices already run as dedicated system users. You just match their UID in the firewall. This is the strongest of the five options because a service can\u0026rsquo;t change its own UID.\nFind the UID # id -u jellyfin # e.g., 112 Add UID Rules to UFW # Same as the GID rules above, but use --uid-owner 112 instead of --gid-owner. Paste into before.rules and before6.rules, then:\nsudo ufw reload Test # # Internet should be blocked: sudo -u jellyfin curl -I -m 10 https://example.com \u0026amp;\u0026amp; echo \u0026#34;FAIL\u0026#34; || echo \u0026#34;BLOCKED ✓\u0026#34; # LAN should work (reaches a local Python HTTP server): sudo -u jellyfin curl -I -m 10 http://192.168.1.10 \u0026amp;\u0026amp; echo \u0026#34;LAN works ✓\u0026#34; || echo \u0026#34;FAIL\u0026#34; Your browser cannot play this video. Download video.\nJellyfin service verification: Internet requests are blocked while LAN requests (Python server) succeed. The Ultimate Proof: LAN vs Internet # 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:\nYour browser cannot play this video. Download video.\nTechnical Proof: Internet access (Google) is blocked, while local network access (Python HTTP server) remains fully accessible. Don\u0026rsquo;t Forget: Allow Incoming on the Service Port # If your UFW default is \u0026ldquo;deny incoming\u0026rdquo; (it should be), LAN clients can\u0026rsquo;t reach your service unless you explicitly allow the port:\nsudo ufw allow from 192.168.0.0/16 to any port 8096 proto tcp For Custom Services Without a Dedicated User # sudo adduser --system --group --no-create-home --shell /usr/sbin/nologin myservice sudo passwd -l myservice id -u myservice # use this UID in rules Option D: dpkg-divert + Wrapper # Time: 15 minutes · Best for: Chromium, Electron, multi-process apps\nNote: dpkg-divert is a Debian/Ubuntu tool. If you\u0026rsquo;re on Fedora, Arch, or another distro, you\u0026rsquo;ll need to manually relocate the binary instead — the firewall rules themselves are distro-agnostic.\nChromium is special. It spawns renderer processes, GPU processes, utility processes — all from different code paths. A simple setgid on one binary won\u0026rsquo;t catch them all.\nThe solution: use Debian\u0026rsquo;s dpkg-divert to relocate the real binary, then put a wrapper at the original path. Every invocation — menu, terminal, child processes — goes through your wrapper.\nThe Full Setup # # 1. Create the group sudo groupadd -f no-internet getent group no-internet # note the GID # Add your user to the group so \u0026#39;sg\u0026#39; doesn\u0026#39;t prompt for a password sudo usermod -aG no-internet $USER # 2. Divert the real binary to a new location sudo mkdir -p /usr/lib/chromium sudo dpkg-divert --local --add --rename \\ --divert /usr/lib/chromium/chromium.distrib /usr/bin/chromium # 3. Reinstall so the diverted file lands at the new path sudo apt install --reinstall chromium # 4. Lock down the real binary sudo chown root:no-internet /usr/lib/chromium/chromium.distrib sudo chmod 0750 /usr/lib/chromium/chromium.distrib # 5. Put a shell wrapper at the original path sudo tee /usr/bin/chromium \u0026gt; /dev/null \u0026lt;\u0026lt;\u0026#39;EOF\u0026#39; #!/bin/bash exec sg no-internet /usr/lib/chromium/chromium.distrib \u0026#34;$@\u0026#34; EOF sudo chmod 0755 /usr/bin/chromium Add the GID firewall rules, reload UFW, and test.\nOption D Variant: Compiled C Wrapper # Instead of a shell wrapper, you can compile a minimal C binary. It avoids spawning an extra bash process and the binary isn\u0026rsquo;t human-readable (though strings will still reveal the path — see Security Limitations below).\nSave as /tmp/sg-wrapper.c:\n/* sg-wrapper.c — execv /bin/sg no-internet -- /usr/lib/chromium/chromium.distrib */ #define _GNU_SOURCE #include \u0026lt;errno.h\u0026gt; #include \u0026lt;stdio.h\u0026gt; #include \u0026lt;stdlib.h\u0026gt; #include \u0026lt;string.h\u0026gt; #include \u0026lt;unistd.h\u0026gt; int main(int argc, char *argv[]) { const char *group = \u0026#34;no-internet\u0026#34;; const char *sg_path = \u0026#34;/bin/sg\u0026#34;; const char *real_binary = \u0026#34;/usr/lib/chromium/chromium.distrib\u0026#34;; int extra = argc - 1; /* count: sg_path + group + \u0026#34;--\u0026#34; + real_binary + extra_args + NULL */ int sg_argc = 1 + 1 + 1 + 1 + extra + 1; char **sg_argv = calloc(sg_argc, sizeof(char *)); if (!sg_argv) { fprintf(stderr, \u0026#34;calloc failed\\n\u0026#34;); return 127; } int i = 0; sg_argv[i++] = (char *)sg_path; sg_argv[i++] = (char *)group; sg_argv[i++] = (char *)\u0026#34;--\u0026#34;; sg_argv[i++] = (char *)real_binary; for (int j = 1; j \u0026lt; argc; ++j) sg_argv[i++] = argv[j]; sg_argv[i] = NULL; execv(sg_path, sg_argv); fprintf(stderr, \u0026#34;execv(%s) failed: %s\\n\u0026#34;, sg_path, strerror(errno)); /* free is technically unreachable if execv succeeds, but kept for completeness */ free(sg_argv); return 126; } Compile and install:\ngcc -O2 -s -o /tmp/sg-wrapper /tmp/sg-wrapper.c sudo mv /tmp/sg-wrapper /usr/bin/chromium sudo chown root:no-internet /usr/bin/chromium sudo chmod 2751 /usr/bin/chromium # setgid(2) + rwx(7) + r-x(5) + --x(1) Surviving apt upgrade # Package updates can overwrite your changes. Protect them:\n# Tell dpkg to enforce ownership/permissions sudo dpkg-statoverride --add root no-internet 0750 /usr/lib/chromium/chromium.distrib # Create a script that reapplies permissions sudo tee /usr/local/sbin/reapply-noinet.sh \u0026gt; /dev/null \u0026lt;\u0026lt;\u0026#39;EOF\u0026#39; #!/usr/bin/env bash set -euo pipefail GROUP=no-internet [ -e /usr/bin/chromium ] \u0026amp;\u0026amp; chown root:$GROUP /usr/bin/chromium \u0026amp;\u0026amp; chmod 2751 /usr/bin/chromium || true [ -e /usr/lib/chromium/chromium.distrib ] \u0026amp;\u0026amp; chown root:$GROUP /usr/lib/chromium/chromium.distrib \u0026amp;\u0026amp; chmod 0750 /usr/lib/chromium/chromium.distrib || true EOF sudo chmod 755 /usr/local/sbin/reapply-noinet.sh # Hook it into APT so it runs after every package update sudo tee /etc/apt/apt.conf.d/99-reapply-noinet \u0026gt; /dev/null \u0026lt;\u0026lt;\u0026#39;EOF\u0026#39; DPkg::Post-Invoke {\u0026#34;[ -x /usr/local/sbin/reapply-noinet.sh ] \u0026amp;\u0026amp; /usr/local/sbin/reapply-noinet.sh\u0026#34;;}; EOF Rollback # sudo rm -f /usr/bin/chromium sudo dpkg-divert --remove --rename /usr/bin/chromium sudo apt install --reinstall chromium Option E: Raw iptables / nftables # Best for: Systems that don\u0026rsquo;t use UFW, or if you prefer direct control.\niptables # GID=1001 # your no-internet group ID sudo iptables -I OUTPUT 1 -m owner --gid-owner $GID -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT sudo iptables -I OUTPUT 2 -m owner --gid-owner $GID -d 127.0.0.0/8 -j ACCEPT sudo iptables -I OUTPUT 3 -m owner --gid-owner $GID -d 10.0.0.0/8 -j ACCEPT sudo iptables -I OUTPUT 4 -m owner --gid-owner $GID -d 172.16.0.0/12 -j ACCEPT sudo iptables -I OUTPUT 5 -m owner --gid-owner $GID -d 192.168.0.0/16 -j ACCEPT sudo iptables -A OUTPUT -m owner --gid-owner $GID -j LOG --log-prefix \u0026#34;NOINTERNET: \u0026#34; sudo iptables -A OUTPUT -m owner --gid-owner $GID -j REJECT Persist with:\nsudo apt install iptables-persistent sudo netfilter-persistent save nftables # Add to /etc/nftables.conf:\ntable 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 \u0026#34;NOINTERNET: \u0026#34; meta skgid 1001 drop } } sudo nft -f /etc/nftables.conf sudo systemctl enable --now nftables The Security Flaw Nobody Talks About # Now that you know how to set this up, let\u0026rsquo;s talk about when it\u0026rsquo;s actually enough — because the GID-based approach (Options A, B, and D) has a fundamental bypass that most guides never mention.\nThe Problem: EGID vs Supplementary Groups # The firewall\u0026rsquo;s --gid-owner match checks the process\u0026rsquo;s EGID (Effective Group ID) — not its supplementary group list. Here\u0026rsquo;s what that means in practice:\nHow the app is launched Process EGID Firewall matches? Internet? Via wrapper (sg no-internet ...) no-internet (1001) ✅ Yes ❌ Blocked Directly (/usr/lib/chromium/chromium.distrib) User\u0026rsquo;s primary group (1000) ❌ No ✅ Full access When a user runs a binary directly, their primary group becomes the EGID. The no-internet supplementary group membership is irrelevant to the firewall.\ngraph TD A[User wants to run Chromium] A --\u003e|Path 1: Uses Wrapper Script'sg no-internet'| B[Process EGID becomes 1001'no-internet' group] A --\u003e|Path 2: Runs Binary Directly| C[Process EGID remains 1000User's primary group] B --\u003e|Traffic hits Firewall| D{Firewall sees GID 1001} C --\u003e|Traffic hits Firewall| E{Firewall sees GID 1000} D --\u003e F[Internet BLOCKED] E --\u003e G[Internet ALLOWED Bypass Successful!] style F fill:#ef4444,color:white,stroke:#991b1b; style G fill:#f59e0b,color:black,stroke:#b45309; And there\u0026rsquo;s a catch-22: sg (which the wrapper uses) requires the user to be a member of the no-internet group. But if they\u0026rsquo;re a member, they also have permission to execute the chmod 0750 binary directly — bypassing the wrapper entirely.\n\u0026ldquo;What If I Hide the Binary Path?\u0026rdquo; # You might think: \u0026ldquo;I\u0026rsquo;ll compile the wrapper as a C binary so users can\u0026rsquo;t read the script to find the real path.\u0026rdquo; That doesn\u0026rsquo;t work either:\nAttempt Why it fails Compiled C wrapper strings /usr/bin/chromium reveals the embedded path Random filename ps aux and /proc/PID/exe expose it at runtime setgid on the binary itself Chromium and Firefox refuse to run with setgid (browser security feature) So When IS the GID Approach Good Enough? # ✅ Self-discipline — you want YOUR OWN app to stop phoning home (telemetry, metadata downloads, auto-updates) ✅ Services and daemons — Option C uses UID matching, which IS unbypassable since processes can\u0026rsquo;t change their own UID ✅ Non-technical users — people who won\u0026rsquo;t think to look for the diverted binary When You Need Something Stronger # ❌ Technical users who actively want to bypass your restrictions ❌ Multi-user machines where you\u0026rsquo;re enforcing policy ❌ Any scenario where \u0026ldquo;security through obscurity\u0026rdquo; isn\u0026rsquo;t acceptable For those cases, keep reading.\nBypass-Proof Alternatives (Not-Tested By Me) # When the GID approach isn\u0026rsquo;t enough, here are three methods that provide real, kernel-enforced isolation.\nNote: I haven\u0026rsquo;t personally tested these alternatives end-to-end. They\u0026rsquo;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.\nAlternative 1: Separate User + UID Match # Run the app as a completely separate user. UID matching cannot be bypassed — a user can\u0026rsquo;t change their own UID.\n# Create a restricted user sudo adduser --disabled-password --gecos \u0026#34;\u0026#34; --shell /usr/sbin/nologin chromium-user sudo passwd -l chromium-user id -u chromium-user # use this UID in UFW rules (same format as Option C) # Allow X11 display access xhost +SI:localuser:chromium-user # Launch sudo -u chromium-user chromium Tradeoffs: 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.\nAlternative 2: Firejail (Easiest True Isolation) # Firejail uses kernel network namespaces under the hood. No firewall rules needed — the app physically cannot see the external network.\nsudo apt install firejail # No network at all — this works reliably firejail --net=none chromium ⚠️ My experience: firejail --net=none works perfectly — the app has zero network access. However, I was unable to get LAN-only mode working using the --netfilter approach below. The app either had full internet or no network at all. I\u0026rsquo;m including the theoretical setup for reference, but your mileage may vary.\nLAN-only (theoretical — did not work for me):\nfirejail --netfilter=/etc/firejail/lan-only.net chromium Create /etc/firejail/lan-only.net:\n*filter :INPUT ACCEPT [0:0] :FORWARD ACCEPT [0:0] :OUTPUT DROP [0:0] -A OUTPUT -d 127.0.0.0/8 -j ACCEPT -A OUTPUT -d 10.0.0.0/8 -j ACCEPT -A OUTPUT -d 172.16.0.0/12 -j ACCEPT -A OUTPUT -d 192.168.0.0/16 -j ACCEPT -A OUTPUT -m state --state RELATED,ESTABLISHED -j ACCEPT COMMIT In theory, Firejail runs the app as your own user, so bookmarks, cookies, and desktop integration should all work normally. The network restriction is enforced at the kernel level and cannot be bypassed from inside the sandbox.\nAlternative 3: Network Namespaces (Manual, Full Control) # For maximum control, create a network namespace directly. No extra packages needed.\n# Create a namespace with no external network sudo ip netns add no-inet # Run the app inside it sudo ip netns exec no-inet sudo -u $USER chromium # Optional: Add LAN-only access via a veth pair sudo ip link add veth-host type veth peer name veth-jail sudo ip link set veth-jail netns no-inet sudo ip addr add 192.168.100.1/24 dev veth-host sudo ip link set veth-host up sudo ip netns exec no-inet ip addr add 192.168.100.2/24 dev veth-jail sudo ip netns exec no-inet ip link set veth-jail up sudo ip netns exec no-inet ip link set lo up Quick Comparison # Threat Model Best Solution Block your own apps from phoning home GID wrapper (Option A/D) — simple, good enough Block a daemon/service UID owner-match (Option C) — unbypassable Restrict technical/untrusted users Separate user + UID match (Alt 1) True network sandbox, easy setup Firejail (Alt 2) Full manual control, no dependencies Network namespace (Alt 3) Enterprise/production AppArmor + containers Troubleshooting # \u0026ldquo;sg: no such group\u0026rdquo; → Group doesn\u0026rsquo;t exist yet. Run sudo groupadd -f no-internet.\nInternet is still working after adding rules → Double-check the numeric UID/GID in your rules matches reality. Make sure you pasted the block right after the :ufw-before-output line, not at the bottom. Run sudo ufw reload.\nUFW reload fails → Syntax error in your rules. Test before applying: sudo iptables-restore --test \u0026lt; /etc/ufw/before.rules. If it fails, restore your backup.\nIt works, but breaks after reboot → You might have iptables-persistent installed, which conflicts with UFW. Remove it: sudo apt remove iptables-persistent. Let UFW handle everything.\nsetgid isn\u0026rsquo;t working → You probably applied it to a shell script wrapper, not the real ELF binary. Use readlink -f $(which app) and file to find the actual binary.\nSnap/Flatpak apps are unaffected → They run in sandboxes with their own network stack. Flatpak: Use Flatseal (GUI) to toggle off \u0026ldquo;Network\u0026rdquo; permissions, or run flatpak override --user --unshare=network com.app.Name. Snap: Use snap connections app-name and snap disconnect app-name:network to revoke the network plug. Or install the app as a native .deb.\nDNS seems to leak → systemd-resolved runs on 127.0.0.53. Since we allow 127.0.0.0/8, DNS resolves even for blocked apps — but the actual connections still get rejected. If you want to block DNS too, remove the loopback allow rule and add -d YOUR_LAN_DNS_IP -j ACCEPT instead.\nTesting Checklist # After setting up any option, run through this:\n# 1. Group exists and GID is correct? getent group no-internet # Expected: no-internet:x:\u0026lt;GID\u0026gt;: # 2. Service UID correct? (Option C only) id -u jellyfin # Expected: numeric UID, e.g., 107 # 3. File ownership and permissions correct? (Options B/D) stat -c \u0026#34;%n: %U %G %a\u0026#34; /usr/lib/chromium/chromium.distrib /usr/bin/chromium # Expected: real binary → root:no-internet 0750, wrapper → per your policy # 4. Running processes have correct EGID/UID? ps -eo pid,ppid,uid,euid,gid,egid,cmd | egrep \u0026#39;chromium|jellyfin|firefox\u0026#39; # Look for: EGID == no-internet GID (Options A/B/D) or UID == service UID (Option C) # 5. Internet blocked? sg no-internet -c \u0026#39;curl -I -m 10 https://example.com\u0026#39; \u0026amp;\u0026amp; echo \u0026#34;FAIL\u0026#34; || echo \u0026#34;BLOCKED ✓\u0026#34; # For services: sudo -u jellyfin curl -I -m 10 https://example.com \u0026amp;\u0026amp; echo \u0026#34;FAIL\u0026#34; || echo \u0026#34;BLOCKED ✓\u0026#34; # 6. LAN still works? sg no-internet -c \u0026#39;curl -I -m 10 http://192.168.1.1\u0026#39; \u0026amp;\u0026amp; echo \u0026#34;LAN works ✓\u0026#34; || echo \u0026#34;FAIL\u0026#34; # 7. Check firewall logs (if LOG rules added) sudo journalctl -k --since \u0026#34;10 minutes ago\u0026#34; | grep -i \u0026#39;Blocked\\|NOINTERNET\u0026#39; Emergency Rollback # If something goes wrong, these commands restore everything:\n# Restore UFW backups sudo cp /root/before.rules.bak /etc/ufw/before.rules sudo cp /root/before6.rules.bak /etc/ufw/before6.rules sudo ufw reload # If you need immediate connectivity recovery sudo iptables -I OUTPUT 1 -m owner --gid-owner \u0026lt;GID\u0026gt; -j ACCEPT # Remove when fixed: sudo iptables -D OUTPUT -m owner --gid-owner \u0026lt;GID\u0026gt; -j ACCEPT # Last resort — disable the entire firewall sudo ufw disable # Fix your rules, then: sudo ufw enable # Undo dpkg-divert (Option D) sudo dpkg-divert --remove --rename /usr/bin/chromium sudo apt install --reinstall chromium Standalone Rollback Script # Save as /usr/local/sbin/rollback-noinet.sh for emergencies:\n#!/usr/bin/env bash set -euo pipefail # Restore UFW before.rules backups [ -f /root/before.rules.bak ] \u0026amp;\u0026amp; sudo cp /root/before.rules.bak /etc/ufw/before.rules [ -f /root/before6.rules.bak ] \u0026amp;\u0026amp; sudo cp /root/before6.rules.bak /etc/ufw/before6.rules # Reload UFW sudo ufw reload || true # Optional: temporarily allow marker GID (uncomment and replace \u0026lt;GID\u0026gt;) # sudo iptables -I OUTPUT 1 -m owner --gid-owner \u0026lt;GID\u0026gt; -j ACCEPT echo \u0026#34;Rollback applied. Verify with: sudo ufw status \u0026amp;\u0026amp; curl -I https://example.com\u0026#34; sudo chmod 700 /usr/local/sbin/rollback-noinet.sh Watch it in Action: Full GUI Demo # This 75-second walkthrough shows the system handling a real-world browser (Google Chrome). You\u0026rsquo;ll see:\nThe Block: Chrome attempting to reach Google and failing while the firewall is active. LAN Routing: Chrome successfully loading a local technical dashboard (LAN) while all external traffic remains blocked. The Control: Toggling ufw disable to instantly restore access and ufw enable to re-lock the app. Your browser cannot play this video. Download video.\nFull walkthrough: Isolating a browser from the internet while maintaining access to a local Python HTTP dashboard. Summary # The GID-based approach (Options A–E) is a clean, elegant way to restrict app networking — and it\u0026rsquo;s good enough for most personal use cases. If you want to stop Jellyfin from downloading metadata, or prevent a game from phoning home, it works perfectly.\nBut 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 UID matching (unbypassable for services), Firejail (easiest for desktop apps), or network namespaces (maximum control).\nThe approach you choose depends on your threat model. Be honest about what you\u0026rsquo;re defending against, and pick accordingly.\nTested on Debian 13 (Trixie) with UFW. Should work on any Debian/Ubuntu-based distro with kernel 4.x+.\n","date":"25 March 2026","externalUrl":null,"permalink":"/blog/how-to-block-internet-access-for-any-linux-app-while-keeping-lan/","section":"Blog","summary":"Block outbound internet for specific Linux apps using UFW while keeping LAN access. Five approaches from quick wrapper scripts to production-hardened setups — plus the security flaw nobody talks about.","title":"How to Block Internet Access for Any Linux App (While Keeping LAN)","type":"blog"},{"content":"","date":"25 March 2026","externalUrl":null,"permalink":"/tags/iptables/","section":"Tags","summary":"","title":"Iptables","type":"tags"},{"content":"","date":"25 March 2026","externalUrl":null,"permalink":"/tags/linux/","section":"Tags","summary":"","title":"Linux","type":"tags"},{"content":"","date":"25 March 2026","externalUrl":null,"permalink":"/tags/networking/","section":"Tags","summary":"","title":"Networking","type":"tags"},{"content":"","date":"25 March 2026","externalUrl":null,"permalink":"/tags/ufw/","section":"Tags","summary":"","title":"Ufw","type":"tags"},{"content":"","date":"11 February 2026","externalUrl":null,"permalink":"/tags/ci-cd/","section":"Tags","summary":"","title":"Ci-Cd","type":"tags"},{"content":"","date":"11 February 2026","externalUrl":null,"permalink":"/tags/github-actions/","section":"Tags","summary":"","title":"Github-Actions","type":"tags"},{"content":"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 creating them.\nBut viewing them? That experience is often stuck in the past.\nIf you export a complex architecture diagram as an SVG and embed it on your docs site, it\u0026rsquo;s just a static image. The text is too small to read, you can\u0026rsquo;t search for that one specific microservice, and if you zoom with your browser, the whole page breaks.\nI looked for a library to solve this. I found D3.js (too complex for just viewing) and Leaflet (too heavy for a diagram). I didn\u0026rsquo;t want to write hundreds of lines of code just to let a user zoom into a flowchart.\nSo, I built DiagView.\nDemo # Your browser cannot play this video. Download video.\nZoom, pan, search, minimap, and export in action What is DiagView? # DiagView is a feature-rich, interactive wrapper that gives your static SVGs superpowers.\nIt is built on top of the excellent panzoom 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.\nFeature Overview # Feature Description 🔍 Deep Search Traverses the SVG DOM to find and highlight matching nodes with pulsing glow 📤 Multi-Format Export PNG, SVG, PDF, JPEG, WebP, or copy to clipboard 🗺️ Smart Minimap Accurate portrait/landscape scaling; click-to-navigate 🔄 Rotation 90° rotation steps with correct Panzoom recalibration 📝 Text Select Mode Toggle SVG text selection for copying node labels (press T) 🎯 Meeting Mode Built-in laser pointer for remote presentations 🔗 Precision Share Links Generate URLs that preserve exact zoom/pan position ⌨️ Keyboard Navigation Full keyboard control — zoom, pan, search, rotate, share 🌗 Auto-Theming Detects Tailwind, Bootstrap, and system dark/light mode 📱 Mobile-First Touch Pinch-to-zoom, double-tap to reset, Visual Viewport sync 🔒 SVG Sanitization Three-tier security model (strict/permissive/off) 🎭 3 Layout Modes Header toolbar, floating FAB, or invisible click-to-open 🔧 Per-Diagram Overrides Set layout, accent, scale per diagram via data-* attributes 🌐 Shadow DOM Support Works inside Shadow DOM roots 🔄 Remember Zoom Persist zoom/pan state per diagram across modal opens 🏷️ Watermarks Customizable watermarks on export (corner, background, four-sides) 📦 4 Button Styles Transparent, accent, solid, neutral — match any UI The Landscape: Why Wasn\u0026rsquo;t This Already Solved? # Before writing any code, I scoured npm and GitHub. Here\u0026rsquo;s what I found:\nD3.js — The titan of data visualization. But D3 is for creating graphics from data, not for viewing pre-made SVGs.\nsvg-pan-zoom — A focused library for adding pan/zoom to SVGs. But it\u0026rsquo;s just the engine — no UI, no search, no export.\nLeaflet.js — The standard for interactive maps. Overkill for a simple flowchart.\nThe gap was clear: I needed a batteries-included solution — something that would just work with a single init() call.\nQuick Start # CDN (Fastest) # \u0026lt;!-- Panzoom (required for zoom/pan) --\u0026gt; \u0026lt;script src=\u0026#34;https://cdn.jsdelivr.net/npm/@panzoom/panzoom@4.5.1/dist/panzoom.min.js\u0026#34;\u0026gt;\u0026lt;/script\u0026gt; \u0026lt;!-- DiagView --\u0026gt; \u0026lt;script src=\u0026#34;https://cdn.jsdelivr.net/npm/diagview@1.0.6/dist/diagview.umd.min.js\u0026#34;\u0026gt;\u0026lt;/script\u0026gt; \u0026lt;!-- Your diagram --\u0026gt; \u0026lt;div class=\u0026#34;diagram\u0026#34;\u0026gt; \u0026lt;svg\u0026gt;\u0026lt;!-- Your SVG content --\u0026gt;\u0026lt;/svg\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;!-- Initialize --\u0026gt; \u0026lt;script\u0026gt; DiagView.init(); \u0026lt;/script\u0026gt; NPM # npm install diagview @panzoom/panzoom import DiagView from \u0026#39;diagview\u0026#39;; DiagView.init({ layout: \u0026#39;floating\u0026#39;, accentColor: \u0026#39;#3b82f6\u0026#39;, }); Flexible Layouts # DiagView supports three layout modes to fit your design:\nHeader, Floating, and Off layout modes Layout Best For Header Classic top-bar controls, documentation sites Floating Clean HUD-style buttons on hover, minimal UIs Off Invisible UI, the diagram itself is the trigger Header Layout # A full-width toolbar is always visible above the diagram. Best for documentation sites and dashboards where discoverability matters.\nHeader layout — toolbar always visible Floating Layout # A circular FAB button appears at the bottom-right. Controls hover in at the bottom of the diagram card. Clean and minimal.\nFloating layout — minimal FAB button Off Layout # No controls are rendered. The diagram itself is the trigger — clicking it opens the fullscreen viewer.\nOff layout — click anywhere on the diagram to open Per-Diagram Overrides # Any diagram can override the global configuration using data-diagview-* attributes. This lets you mix layout modes and accent colors on a single page:\n\u0026lt;!-- Purple accent with header layout for this diagram only --\u0026gt; \u0026lt;div class=\u0026#34;diagram\u0026#34; data-diagview-layout=\u0026#34;header\u0026#34; data-diagview-accent=\u0026#34;#8b5cf6\u0026#34; data-diagview-scale=\u0026#34;6\u0026#34; data-title=\u0026#34;My Architecture\u0026#34;\u0026gt; \u0026lt;svg\u0026gt;...\u0026lt;/svg\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;!-- This diagram uses the global defaults --\u0026gt; \u0026lt;div class=\u0026#34;diagram\u0026#34;\u0026gt; \u0026lt;svg\u0026gt;...\u0026lt;/svg\u0026gt; \u0026lt;/div\u0026gt; Attribute Values Description data-diagview-layout header | floating | off Layout for this diagram only data-diagview-accent Any CSS color Accent color for this diagram only data-diagview-scale 1–10 Export resolution for this diagram only data-diagview-sanitize strict | permissive | off SVG sanitization mode data-diagview-allow-remote true | false Allow remote CSS/fonts in SVG data-diagview-watermark true | false Enable watermark for this diagram data-diagview-watermark-text Any string Custom watermark text data-title Any string Title shown in header layout Keyboard Shortcuts # All shortcuts are active when the fullscreen modal is open:\nKey Action Esc Close fullscreen Space / 0 Reset zoom — fit to screen + / = Zoom in - / _ Zoom out ↑ ↓ ← → Pan diagram Shift + arrows Fast pan (3× speed) F Focus search input T Toggle text-select mode R Rotate 90° clockwise M Toggle meeting mode (laser pointer) L Copy share link to clipboard ? Show/hide keyboard shortcuts panel Under the Hood: Technical Decisions # The Search Engine # This was the feature I was most proud of. The search system:\nPre-Caches Candidates — On first open, queries all text elements and stores them in a WeakMap Uses Dirty Checking — Before writing to the DOM, checks if values have changed Batches Updates — All DOM mutations are wrapped in requestAnimationFrame The result? Searching through diagrams with 2,500+ nodes is instant.\nFullscreen View # Clicking any diagram opens it in a fullscreen modal with all controls — zoom, pan, search, export, rotate, share, and minimap:\nFullscreen modal with all controls Text Select Mode # Press T in fullscreen and all SVG text nodes become selectable. You can highlight and copy node labels, edge text, or any text content inside the diagram. Press T again to disable. This is useful when you need to copy a specific service name or ID from a complex architecture diagram.\nMinimap # When a diagram is zoomed in so that parts of it are outside the viewport, a minimap automatically appears in the corner. It shows your current viewport position within the full diagram, and you can click anywhere on the minimap to jump to that area. The minimap correctly handles both portrait and landscape diagrams.\nSmart minimap with click-to-navigate Rotation # Press R to rotate the diagram 90° clockwise. This is particularly useful for tall diagrams (like vertical flowcharts) that would be easier to read horizontally. The rotation recalibrates the Panzoom instance so zoom and pan continue to work correctly after rotation.\nSVG Sanitization # DiagView includes a three-tier security model for SVG content:\nMode What It Blocks When to Use strict (default) \u0026lt;script\u0026gt;, \u0026lt;iframe\u0026gt;, \u0026lt;foreignObject\u0026gt;, \u0026lt;animate\u0026gt;, inline event handlers, \u0026lt;style\u0026gt; injection Untrusted SVGs (user-uploaded, third-party) permissive \u0026lt;script\u0026gt;, \u0026lt;iframe\u0026gt;, \u0026lt;object\u0026gt; only Semi-trusted SVGs (your own diagrams with animations) off Nothing Fully trusted SVGs only Watermarks # Watermarks are applied only during export/download — they never appear in the interactive viewer. You can configure them globally or per-diagram:\nDiagView.init({ watermark: { enabled: true, text: \u0026#34;Confidential\u0026#34;, style: \u0026#34;corner\u0026#34;, // \u0026#34;corner\u0026#34; | \u0026#34;background\u0026#34; | \u0026#34;both\u0026#34; position: \u0026#34;bottom-right\u0026#34;, // 6 positions + \u0026#34;four-sides\u0026#34; opacity: 0.2, }, }); The Export System # The export module handles edge cases:\nRobust Dimension Calculation — Uses getBBox() to find actual content area Cross-Origin Font Handling — Inlines Google Fonts for consistent exports High-DPI Scaling — Up to 10x resolution for print-quality images Transparent Background — PNG and WebP support transparent backgrounds PDF Export — Lazy-loads jsPDF from CDN only when needed Export Formats # Format Transparent Notes PNG ✅ High-res raster; default 4× scale SVG ✅ Fully scalable vector JPEG ❌ Smallest file size WebP ✅ Modern format; good compression PDF ❌ Requires jsPDF (lazy-loaded from CDN) Copy ❌ Copies PNG to system clipboard Optional Panzoom Dependency # I made panzoom an optional peer dependency:\nWith panzoom: Full zoom, pan, touch gestures Without panzoom: Fullscreen, search, and export still work This keeps DiagView usable even in constrained environments.\nMobile Support # DiagView is fully optimized for mobile — pinch-to-zoom, double-tap to reset, and Visual Viewport sync for stability on iOS and Android:\nMobile-optimized touch interface Button Styles # Four built-in styles for the diagram card buttons:\nStyle Look accent (default) Colored with your accent color transparent Transparent with subtle hover solid Solid background neutral Muted, blends into the background DiagView.init({ ui: { buttons: { style: \u0026#34;transparent\u0026#34; } } }); CI/CD Pipeline # One thing I invested heavily in was the automation pipeline. Every push to the repository triggers a GitHub Actions workflow that:\nLints the code with ESLint Runs 169 unit tests with Jest Builds the UMD and ESM bundles with Rollup Publishes to npm on tagged releases (semantic versioning) Deploys documentation to GitHub Pages # Simplified GitHub Actions workflow on: push: tags: [\u0026#39;v*\u0026#39;] # Triggers on version tags like v1.0.6 jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - run: npm ci - run: npm run lint - run: npm test # 169 tests - run: npm run build publish: needs: test runs-on: ubuntu-latest steps: - run: npm publish # Pushes to npm registry The pipeline ensures that no broken code gets published. If any test fails, the publish step never runs. This is the same pattern used in production CI/CD — just applied to an open-source package.\nThe project also uses:\nHusky — pre-commit hooks that run lint-staged lint-staged — runs ESLint + Prettier only on changed files release-it — automated version bumping, changelog generation, and npm publishing size-limit — CI fails if bundle size exceeds the budget (34 KB UMD, 38 KB ESM) Bundle Size # Metric Size UMD Minified ~34 KB ESM Minified ~38 KB Gzipped (Transfer) ~19 KB For context, that\u0026rsquo;s smaller than a single hero image. And it includes all CSS, SVG icons, and the entire UI framework.\nFramework Support # DiagView is framework-agnostic — it works with plain HTML, React, Vue, Svelte, Angular, or any framework that renders SVGs to the DOM. It also supports Shadow DOM and Mermaid.js integration.\nFull Configuration # DiagView.init({ // Layout layout: \u0026#39;floating\u0026#39;, // \u0026#39;header\u0026#39; | \u0026#39;floating\u0026#39; | \u0026#39;off\u0026#39; // Theme (null = auto-detect) accentColor: null, backgroundColor: null, textColor: null, // UI ui: { buttons: { style: \u0026#39;accent\u0026#39; } }, showKeyboardHelp: true, showBranding: true, showMinimap: true, animateOpen: true, // Interaction naturalPanning: false, immersiveMode: false, rememberZoom: false, printFriendly: true, // Zoom limits maxZoomScale: 25, minZoomScale: 0.05, // Export highResScale: 4, // 1–10 mobileScale: 2, // 1–5 maxPixels: 16777216, // 16MP safety cap // Security security: { mode: \u0026#39;strict\u0026#39;, allowOverrides: true, allowRemoteResources: false, }, // Watermarks (export only) watermark: { enabled: false, text: \u0026#39;\u0026#39;, style: \u0026#39;corner\u0026#39;, position: \u0026#39;bottom-right\u0026#39;, opacity: 0.2, }, // Callbacks onExport: null, onError: null, onZoomChange: null, onOpen: null, onClose: null, }); Try It Out # I built this to scratch my own itch. If you write technical documentation for a living, I think you\u0026rsquo;ll find it useful too.\n🧪 Live Demo: khadirullah.github.io/diagview ⭐ GitHub: github.com/khadirullah/diagview 📦 NPM: npmjs.com/package/diagview Have feedback or found a bug? Open an issue on GitHub.\n","date":"11 February 2026","externalUrl":null,"permalink":"/blog/introducing-diagview/","section":"Blog","summary":"I built a lightweight library that gives static SVGs superpowers — zoom, pan, search, minimap, rotation, text-select mode, watermarks, and more — with a CI/CD pipeline that automates linting, 169 tests, building, and npm publishing.","title":"Introducing DiagView","type":"blog"},{"content":"","date":"11 February 2026","externalUrl":null,"permalink":"/tags/javascript/","section":"Tags","summary":"","title":"Javascript","type":"tags"},{"content":"","date":"11 February 2026","externalUrl":null,"permalink":"/tags/open-source/","section":"Tags","summary":"","title":"Open-Source","type":"tags"},{"content":"","date":"11 February 2026","externalUrl":null,"permalink":"/tags/project/","section":"Tags","summary":"","title":"Project","type":"tags"},{"content":" Hey, I\u0026rsquo;m Khadirullah 👋 # DevOps \u0026amp; Cloud Engineer with 4+ years of professional IT experience and a B.Tech in Computer Science. I build CI/CD pipelines, manage cloud infrastructure on AWS, and automate deployments using Docker, Kubernetes, and Terraform. Recently built an AI-powered incident correlation tool that JOINs data across GitHub, Sentry, and Slack using cross-source SQL. My Journey # Hands-On IT Work Where It Started Assembling PCs, installing operating systems, configuring routers, optimizing WiFi networks, and setting up CCTV systems. I was the go-to person for anything hardware or networking related. B.Tech in Computer Science 2022 Graduated from Sir C R Reddy College of Engineering (Andhra University). Explored full-stack development but realized I enjoyed the infrastructure side more than building web applications. Engineer — Programmer, Network \u0026amp; System Admin Jul 2019 – Nov 2023 · 4y 5m Sole engineer managing end-to-end IT operations for a 100-person startup — system provisioning, network administration, hardware maintenance, and troubleshooting across the organization. DevOps \u0026amp; Cloud Engineering Dec 2023 – Present Building reliable CI/CD pipelines with Jenkins, managing Kubernetes clusters, provisioning infrastructure as code with Terraform, and working towards AWS certifications. Skills # Cloud \u0026amp; Infrastructure\nAWS — EC2, S3, VPC, ELB, IAM, Route 53, CloudFormation Terraform — Infrastructure as Code Ansible — Configuration Management \u0026amp; Playbooks Containers \u0026amp; Orchestration\nDocker — Dockerfiles, custom images, multi-stage builds, Docker Compose Kubernetes — Deployments, Services, RBAC, Network Policies, Ingress, Security Contexts Git CI/CD \u0026amp; Tooling\nJenkins — Pipelines with SonarQube, Trivy, Docker, and Kubernetes integration GitHub Actions — Automated testing, publishing, and deployment Git, Maven, npm, Bash scripting Linux Systems \u0026amp; Networking\nLinux — Administration, systemd, networking, troubleshooting DNS — SPF, DKIM, DMARC configuration Networking — Router configuration, WiFi optimization, TCP/IP, iptables 🐍 Scripting \u0026amp; Automation\nPython — API integration, automation scripts, Flask SQL — Cross-source queries with Coral for incident correlation Bash — System administration and deployment automation 🔒 Monitoring \u0026amp; Security\nPrometheus — Metrics collection and alerting Grafana — Dashboard visualization and monitoring Sentry — Error tracking and incident monitoring Slack — Incident alerting and team notifications Coral SQL — Cross-source API queries (GitHub + Sentry + Slack) Fernet AES encryption, SSL/TLS, IAM, Cloudflare Projects # 🔍 DevOps Incident Investigator # Hackathon: Pirates of the Coral-bean (WeMakeDevs, May 2026)\nAn AI-powered incident correlation tool that JOINs data from GitHub PRs, Sentry errors, and Slack messages using cross-source SQL via Coral — reducing incident triage from 15 minutes of tab-switching to a single unified view. Features a web dashboard with real-time incident timeline, Gemini AI root-cause analysis, one-click Slack alerting, natural-language-to-SQL queries, and encrypted token management.\nTech: Python, Flask, Coral SQL, Gemini 2.0 Flash, Docker, GitHub Actions, Slack API, Sentry API\nkhadirullah/devops-incident-investigator 🔍 AI-powered DevOps incident correlation using Coral SQL — queries GitHub, Sentry \u0026amp; Slack as database tables Python 0 0 CI/CD Pipelines # Built end-to-end Jenkins pipelines:\ncode commit → static analysis (SonarQube) → Docker build → vulnerability scanning (Trivy) → push to registry → deploy on Kubernetes with AWS ELB\nKubernetes Deployments # Set up Kubernetes clusters with Deployments, Services, RBAC, Network Policies, Ingress controllers, and security contexts — on both local VMs and AWS.\nInfrastructure as Code # Provisioned AWS infrastructure using Terraform — VPCs, EC2 instances, load balancers, and security groups.\nDiagView # A lightweight interactive SVG/Mermaid diagram viewer with search, export, and deep linking. Published to npm with 169 unit tests and fully automated CI/CD.\nkhadirullah/diagview A lightweight framework-agnostic viewer for SVG diagrams (Mermaid, Graphviz, etc). Adds interactive Zoom, Pan, Search, Export, and Presentation Mode. JavaScript 1 0 This Website # Hugo + Blowfish theme, deployed on Cloudflare Pages with custom domain, DNS records, and email authentication (SPF/DKIM/DMARC). → khadirullah.com\nkhadirullah/khadirullah.com My personal portfolio and blog. Built with Hugo and the Blowfish theme, hosted on Cloudflare Pages. HTML 0 0 Get In Touch # I\u0026rsquo;m actively looking for DevOps Engineer, Cloud Engineer, and Infrastructure Engineer roles. Email: contact@khadirullah.com GitHub: github.com/khadirullah LinkedIn: linkedin.com/in/khadirullah 📄 Resume: View Resume \u0026nbsp; Contact Me ","externalUrl":null,"permalink":"/about/","section":"Home","summary":"Hey, I’m Khadirullah 👋 # DevOps \u0026 Cloud Engineer with 4+ years of professional IT experience and a B.Tech in Computer Science. I build CI/CD pipelines, manage cloud infrastructure on AWS, and automate deployments using Docker, Kubernetes, and Terraform. Recently built an AI-powered incident correlation tool that JOINs data across GitHub, Sentry, and Slack using cross-source SQL. My Journey # Hands-On IT Work Where It Started Assembling PCs, installing operating systems, configuring routers, optimizing WiFi networks, and setting up CCTV systems. I was the go-to person for anything hardware or networking related. B.Tech in Computer Science 2022 Graduated from Sir C R Reddy College of Engineering (Andhra University). Explored full-stack development but realized I enjoyed the infrastructure side more than building web applications. Engineer — Programmer, Network \u0026 System Admin Jul 2019 – Nov 2023 · 4y 5m Sole engineer managing end-to-end IT operations for a 100-person startup — system provisioning, network administration, hardware maintenance, and troubleshooting across the organization. DevOps \u0026 Cloud Engineering Dec 2023 – Present Building reliable CI/CD pipelines with Jenkins, managing Kubernetes clusters, provisioning infrastructure as code with Terraform, and working towards AWS certifications. Skills # Cloud \u0026 Infrastructure\n","title":"About","type":"page"}]