<?xml version='1.0' encoding='UTF-8'?>
<rss xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/" version="2.0">
  <channel>
    <title>Rich Gibbs</title>
    <link>https://blog.richgibbs.dev/feed.xml</link>
    <description>Practical, opinionated notes on Linux server security, AWS hygiene, and indie-founder operations from Rich Gibbs.</description>
    <atom:link href="https://blog.richgibbs.dev/feed.xml" rel="self"/>
    <docs>http://www.rssboard.org/rss-specification</docs>
    <generator>python-feedgen</generator>
    <language>en</language>
    <lastBuildDate>Tue, 12 May 2026 14:45:00 +0000</lastBuildDate>
    <item>
      <title>Ubuntu/Debian EC2 hardening checklist (2026)</title>
      <link>https://blog.richgibbs.dev/ubuntu-debian-ec2-hardening-checklist-2026/</link>
      <description>A practical 2026 hardening checklist for Ubuntu and Debian EC2 instances: SSH, UFW, IMDSv2, updates, logging, backups, and Docker basics.</description>
      <content:encoded>&lt;p&gt;You spun up an EC2 instance, pointed a domain at it, and now real traffic — and real bots — can reach it. Most &amp;ldquo;hardening guides&amp;rdquo; online are either copy-paste cargo cult from 2014 or vendor whitepapers selling a SIEM. This is the version I actually run on Ubuntu 22.04, Ubuntu 24.04, and Debian 12 boxes, written for solo founders and small teams who don&amp;rsquo;t have a dedicated security person.&lt;/p&gt;
&lt;p&gt;Work through it top to bottom on a fresh box. On an existing box, treat it as a diff: read each section, run the audit command, fix the gap, move on.&lt;/p&gt;
&lt;h2 id="why-this-checklist"&gt;Why this checklist&lt;/h2&gt;
&lt;p&gt;The threats most small EC2 fleets actually get hit by aren&amp;rsquo;t APTs. They&amp;rsquo;re:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;SSH brute force from random botnets&lt;/li&gt;
&lt;li&gt;Exposed services you forgot were listening (Redis, Postgres, Docker API, an old admin panel)&lt;/li&gt;
&lt;li&gt;Stolen IAM credentials via SSRF on a misconfigured app reaching the EC2 metadata service&lt;/li&gt;
&lt;li&gt;An unpatched kernel or library with a known CVE&lt;/li&gt;
&lt;li&gt;A compromised dependency or container image that opens a reverse shell&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Everything below is aimed at those concrete risks. There&amp;rsquo;s no checklist on earth that makes you &amp;ldquo;secure&amp;rdquo; — but a tight baseline closes the cheap, automated attack paths so an attacker has to actually work.&lt;/p&gt;
&lt;h2 id="threat-model-assumptions"&gt;Threat model assumptions&lt;/h2&gt;
&lt;p&gt;Before any commands, make these explicit:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;This is a single-tenant Linux server (or small fleet) on AWS EC2.&lt;/li&gt;
&lt;li&gt;You are the only admin, or there&amp;rsquo;s a tiny ops team with shared SSH keys.&lt;/li&gt;
&lt;li&gt;The instance runs a public-facing web app and/or some background workers.&lt;/li&gt;
&lt;li&gt;You&amp;rsquo;re not in a regulated environment yet (PCI/HIPAA/SOC 2 controls are &lt;em&gt;not&lt;/em&gt; what this checklist gives you).&lt;/li&gt;
&lt;li&gt;You can tolerate a few minutes of downtime to reboot for kernel updates.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;If any of those don&amp;rsquo;t match, adjust before applying.&lt;/p&gt;
&lt;h2 id="1-ssh"&gt;1. SSH&lt;/h2&gt;
&lt;p&gt;SSH is still the single biggest &amp;ldquo;front door&amp;rdquo; on a Linux server.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Use keys, not passwords. Disable root login. Limit who can log in.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Edit &lt;code&gt;/etc/ssh/sshd_config&lt;/code&gt; (or drop a file in &lt;code&gt;/etc/ssh/sshd_config.d/&lt;/code&gt; on Ubuntu 22.04+):&lt;/p&gt;
&lt;div class="codehilite"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
ChallengeResponseAuthentication no
PubkeyAuthentication yes
PermitEmptyPasswords no
X11Forwarding no
MaxAuthTries 3
LoginGraceTime 20
ClientAliveInterval 300
ClientAliveCountMax 2
AllowUsers ubuntu deploy
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;Replace &lt;code&gt;ubuntu deploy&lt;/code&gt; with the actual non-root accounts you use. Then validate and reload:&lt;/p&gt;
&lt;div class="codehilite"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;sudo&lt;span class="w"&gt; &lt;/span&gt;sshd&lt;span class="w"&gt; &lt;/span&gt;-t
sudo&lt;span class="w"&gt; &lt;/span&gt;systemctl&lt;span class="w"&gt; &lt;/span&gt;reload&lt;span class="w"&gt; &lt;/span&gt;ssh
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;Optional but worth it on small boxes:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Move SSH off port 22. It doesn&amp;rsquo;t stop a determined attacker, but it cuts log noise from internet-wide scanners by ~95%. If you do this, update the EC2 security group too.&lt;/li&gt;
&lt;li&gt;Restrict the SSH security group to your office/VPN IP, your home IP, or a bastion. &lt;code&gt;0.0.0.0/0&lt;/code&gt; on port 22 is a choice, not a default.&lt;/li&gt;
&lt;li&gt;Install &lt;code&gt;fail2ban&lt;/code&gt; for cheap brute-force throttling:&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;code&gt;bash
  sudo apt-get update &amp;amp;&amp;amp; sudo apt-get install -y fail2ban
  sudo systemctl enable --now fail2ban&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Audit:&lt;/strong&gt;&lt;/p&gt;
&lt;div class="codehilite"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;sudo&lt;span class="w"&gt; &lt;/span&gt;sshd&lt;span class="w"&gt; &lt;/span&gt;-T&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;grep&lt;span class="w"&gt; &lt;/span&gt;-Ei&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;permitrootlogin|passwordauth|pubkeyauth|allowusers|port&amp;#39;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;h2 id="2-firewall-and-listeners"&gt;2. Firewall and listeners&lt;/h2&gt;
&lt;p&gt;The cheapest mistake on EC2 is a service binding to &lt;code&gt;0.0.0.0&lt;/code&gt; that you thought was on &lt;code&gt;127.0.0.1&lt;/code&gt;. Defense in depth: lock it down at the OS &lt;em&gt;and&lt;/em&gt; at the security group.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;See what&amp;rsquo;s actually listening:&lt;/strong&gt;&lt;/p&gt;
&lt;div class="codehilite"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;sudo&lt;span class="w"&gt; &lt;/span&gt;ss&lt;span class="w"&gt; &lt;/span&gt;-tulpn
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;Anything bound to &lt;code&gt;0.0.0.0&lt;/code&gt; or &lt;code&gt;::&lt;/code&gt; that isn&amp;rsquo;t your web server, SSH, or something you explicitly want public is a finding. Common offenders: Redis (6379), Postgres (5432), MySQL (3306), Docker API (2375/2376), Elasticsearch (9200), Memcached (11211), &lt;code&gt;node&lt;/code&gt; dev servers.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Bind to localhost&lt;/strong&gt; in the service config (e.g. &lt;code&gt;bind 127.0.0.1&lt;/code&gt; in &lt;code&gt;/etc/redis/redis.conf&lt;/code&gt;, &lt;code&gt;listen_addresses = 'localhost'&lt;/code&gt; in &lt;code&gt;postgresql.conf&lt;/code&gt;).&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Then layer UFW&lt;/strong&gt; on top:&lt;/p&gt;
&lt;div class="codehilite"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;sudo&lt;span class="w"&gt; &lt;/span&gt;apt-get&lt;span class="w"&gt; &lt;/span&gt;install&lt;span class="w"&gt; &lt;/span&gt;-y&lt;span class="w"&gt; &lt;/span&gt;ufw
sudo&lt;span class="w"&gt; &lt;/span&gt;ufw&lt;span class="w"&gt; &lt;/span&gt;default&lt;span class="w"&gt; &lt;/span&gt;deny&lt;span class="w"&gt; &lt;/span&gt;incoming
sudo&lt;span class="w"&gt; &lt;/span&gt;ufw&lt;span class="w"&gt; &lt;/span&gt;default&lt;span class="w"&gt; &lt;/span&gt;allow&lt;span class="w"&gt; &lt;/span&gt;outgoing
sudo&lt;span class="w"&gt; &lt;/span&gt;ufw&lt;span class="w"&gt; &lt;/span&gt;allow&lt;span class="w"&gt; &lt;/span&gt;OpenSSH
sudo&lt;span class="w"&gt; &lt;/span&gt;ufw&lt;span class="w"&gt; &lt;/span&gt;allow&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;80&lt;/span&gt;/tcp
sudo&lt;span class="w"&gt; &lt;/span&gt;ufw&lt;span class="w"&gt; &lt;/span&gt;allow&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;443&lt;/span&gt;/tcp
sudo&lt;span class="w"&gt; &lt;/span&gt;ufw&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;enable&lt;/span&gt;
sudo&lt;span class="w"&gt; &lt;/span&gt;ufw&lt;span class="w"&gt; &lt;/span&gt;status&lt;span class="w"&gt; &lt;/span&gt;verbose
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;On the AWS side&lt;/strong&gt;, the security group is your real perimeter. Rules of thumb:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;One SG per role (web, db, worker), not one giant SG that allows everything internally.&lt;/li&gt;
&lt;li&gt;DB and cache SGs accept traffic &lt;em&gt;only&lt;/em&gt; from the app SG, never from &lt;code&gt;0.0.0.0/0&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;SSH SG limited to known IPs or a bastion/VPN SG.&lt;/li&gt;
&lt;li&gt;No &lt;code&gt;0.0.0.0/0&lt;/code&gt; on anything except 80/443 on the public web tier.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Audit:&lt;/strong&gt;&lt;/p&gt;
&lt;div class="codehilite"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;sudo&lt;span class="w"&gt; &lt;/span&gt;ss&lt;span class="w"&gt; &lt;/span&gt;-tulpn&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;awk&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;$5 ~ /0\.0\.0\.0|\[::\]/&amp;#39;&lt;/span&gt;
sudo&lt;span class="w"&gt; &lt;/span&gt;ufw&lt;span class="w"&gt; &lt;/span&gt;status&lt;span class="w"&gt; &lt;/span&gt;numbered
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;Cross-check the AWS console / CLI:&lt;/p&gt;
&lt;div class="codehilite"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;aws&lt;span class="w"&gt; &lt;/span&gt;ec2&lt;span class="w"&gt; &lt;/span&gt;describe-security-groups&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;--query&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;SecurityGroups[].{Name:GroupName,Ingress:IpPermissions}&amp;#39;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;--output&lt;span class="w"&gt; &lt;/span&gt;json
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;h2 id="3-os-updates-and-reboots"&gt;3. OS updates and reboots&lt;/h2&gt;
&lt;p&gt;Unpatched kernels and OpenSSL/libc libraries are the most boring and most common way servers get owned.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Enable unattended security upgrades:&lt;/strong&gt;&lt;/p&gt;
&lt;div class="codehilite"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;sudo&lt;span class="w"&gt; &lt;/span&gt;apt-get&lt;span class="w"&gt; &lt;/span&gt;install&lt;span class="w"&gt; &lt;/span&gt;-y&lt;span class="w"&gt; &lt;/span&gt;unattended-upgrades&lt;span class="w"&gt; &lt;/span&gt;apt-listchanges
sudo&lt;span class="w"&gt; &lt;/span&gt;dpkg-reconfigure&lt;span class="w"&gt; &lt;/span&gt;-plow&lt;span class="w"&gt; &lt;/span&gt;unattended-upgrades
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;Check &lt;code&gt;/etc/apt/apt.conf.d/50unattended-upgrades&lt;/code&gt; includes the security pocket and that &lt;code&gt;Unattended-Upgrade::Automatic-Reboot&lt;/code&gt; is set deliberately. On a single box with a real user, automatic reboots at 3am can be fine; on production-critical workers, prefer notification + manual.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Patch now:&lt;/strong&gt;&lt;/p&gt;
&lt;div class="codehilite"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;sudo&lt;span class="w"&gt; &lt;/span&gt;apt-get&lt;span class="w"&gt; &lt;/span&gt;update
sudo&lt;span class="w"&gt; &lt;/span&gt;apt-get&lt;span class="w"&gt; &lt;/span&gt;-y&lt;span class="w"&gt; &lt;/span&gt;dist-upgrade
sudo&lt;span class="w"&gt; &lt;/span&gt;apt-get&lt;span class="w"&gt; &lt;/span&gt;-y&lt;span class="w"&gt; &lt;/span&gt;autoremove&lt;span class="w"&gt; &lt;/span&gt;--purge
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Detect a needed reboot:&lt;/strong&gt;&lt;/p&gt;
&lt;div class="codehilite"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;-f&lt;span class="w"&gt; &lt;/span&gt;/var/run/reboot-required&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;cat&lt;span class="w"&gt; &lt;/span&gt;/var/run/reboot-required.pkgs
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;If the kernel was updated, schedule a reboot. Live-patching (Ubuntu Pro / Livepatch) is great if you&amp;rsquo;re paying for it, but it doesn&amp;rsquo;t cover everything — you&amp;rsquo;ll still need occasional reboots.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Audit:&lt;/strong&gt;&lt;/p&gt;
&lt;div class="codehilite"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;apt&lt;span class="w"&gt; &lt;/span&gt;list&lt;span class="w"&gt; &lt;/span&gt;--upgradable&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;2&lt;/span&gt;&amp;gt;/dev/null
uname&lt;span class="w"&gt; &lt;/span&gt;-r
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;h2 id="4-admin-surface"&gt;4. Admin surface&lt;/h2&gt;
&lt;p&gt;Every account that can &lt;code&gt;sudo&lt;/code&gt; is part of your admin surface. Trim it.&lt;/p&gt;
&lt;div class="codehilite"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;getent&lt;span class="w"&gt; &lt;/span&gt;group&lt;span class="w"&gt; &lt;/span&gt;sudo
getent&lt;span class="w"&gt; &lt;/span&gt;group&lt;span class="w"&gt; &lt;/span&gt;adm
awk&lt;span class="w"&gt; &lt;/span&gt;-F:&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;($3 == 0) {print}&amp;#39;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;/etc/passwd&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="c1"&gt;# any extra UID 0 account is a finding&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;Rules:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;One sudo user per real human, no shared logins where avoidable.&lt;/li&gt;
&lt;li&gt;Service accounts (&lt;code&gt;www-data&lt;/code&gt;, &lt;code&gt;postgres&lt;/code&gt;, &lt;code&gt;deploy&lt;/code&gt;) should not have shell or sudo. Use &lt;code&gt;usermod -s /usr/sbin/nologin &amp;lt;user&amp;gt;&lt;/code&gt; if needed.&lt;/li&gt;
&lt;li&gt;Rotate or remove SSH keys when someone leaves the team. &lt;code&gt;~/.ssh/authorized_keys&lt;/code&gt; for every login user is your source of truth — review it.&lt;/li&gt;
&lt;li&gt;Disable cloud-init&amp;rsquo;s default password if any (&lt;code&gt;cloud-init&lt;/code&gt; shouldn&amp;rsquo;t set one on official AMIs, but check).&lt;/li&gt;
&lt;li&gt;If you must allow &lt;code&gt;sudo&lt;/code&gt; without a password for automation, scope it to specific commands in &lt;code&gt;/etc/sudoers.d/&lt;/code&gt;, not blanket &lt;code&gt;NOPASSWD: ALL&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Audit:&lt;/strong&gt;&lt;/p&gt;
&lt;div class="codehilite"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="k"&gt;for&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;u&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;in&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;$(&lt;/span&gt;awk&lt;span class="w"&gt; &lt;/span&gt;-F:&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;$7 ~ /sh$/ {print $1}&amp;#39;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;/etc/passwd&lt;span class="k"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;do&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;== &lt;/span&gt;&lt;span class="nv"&gt;$u&lt;/span&gt;&lt;span class="s2"&gt; ==&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;sudo&lt;span class="w"&gt; &lt;/span&gt;cat&lt;span class="w"&gt; &lt;/span&gt;/home/&lt;span class="nv"&gt;$u&lt;/span&gt;/.ssh/authorized_keys&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;2&lt;/span&gt;&amp;gt;/dev/null
&lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;h2 id="5-ec2-metadata-service-imdsv2"&gt;5. EC2 metadata service (IMDSv2)&lt;/h2&gt;
&lt;p&gt;This one is non-negotiable in 2026. The EC2 instance metadata service hands out IAM role credentials. With IMDSv1 enabled, any server-side request forgery (SSRF) bug in your app can pop those credentials and walk into your AWS account.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Force IMDSv2 only&lt;/strong&gt;, with a low hop limit:&lt;/p&gt;
&lt;div class="codehilite"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="nv"&gt;TOKEN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;$(&lt;/span&gt;curl&lt;span class="w"&gt; &lt;/span&gt;-sX&lt;span class="w"&gt; &lt;/span&gt;PUT&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;http://169.254.169.254/latest/api/token&amp;quot;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;-H&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;X-aws-ec2-metadata-token-ttl-seconds: 60&amp;quot;&lt;/span&gt;&lt;span class="k"&gt;)&lt;/span&gt;
curl&lt;span class="w"&gt; &lt;/span&gt;-sH&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;X-aws-ec2-metadata-token: &lt;/span&gt;&lt;span class="nv"&gt;$TOKEN&lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;http://169.254.169.254/latest/meta-data/instance-id
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;If that works but the same call without a token also works, you&amp;rsquo;re still on IMDSv1. For old AMIs, containers, or ASGs you cannot blindly rotate, follow the &lt;a href="/aws-imdsv2-migration-without-breaking-things/"&gt;IMDSv2 migration sequence&lt;/a&gt; before making it mandatory.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Enforce v2 on the instance&lt;/strong&gt; (run from your laptop with the AWS CLI):&lt;/p&gt;
&lt;div class="codehilite"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;aws&lt;span class="w"&gt; &lt;/span&gt;ec2&lt;span class="w"&gt; &lt;/span&gt;modify-instance-metadata-options&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;--instance-id&lt;span class="w"&gt; &lt;/span&gt;i-xxxxxxxxxxxxxxxxx&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;--http-tokens&lt;span class="w"&gt; &lt;/span&gt;required&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;--http-endpoint&lt;span class="w"&gt; &lt;/span&gt;enabled&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;--http-put-response-hop-limit&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;hop-limit 1&lt;/code&gt; means a container or proxy can&amp;rsquo;t trivially relay a request to the metadata service. If you run Docker with bridge networking, you may need &lt;code&gt;2&lt;/code&gt; — but start at &lt;code&gt;1&lt;/code&gt;, raise only if needed, and never to &lt;code&gt;64&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Also: the IAM role attached to the instance should be &lt;strong&gt;least privilege&lt;/strong&gt;. &amp;ldquo;Read this one S3 bucket and write to this one log group&amp;rdquo; beats &lt;code&gt;AdministratorAccess&lt;/code&gt; every time.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Audit:&lt;/strong&gt;&lt;/p&gt;
&lt;div class="codehilite"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;aws&lt;span class="w"&gt; &lt;/span&gt;ec2&lt;span class="w"&gt; &lt;/span&gt;describe-instances&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;--query&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;Reservations[].Instances[].[InstanceId,MetadataOptions.HttpTokens,MetadataOptions.HttpPutResponseHopLimit]&amp;#39;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;--output&lt;span class="w"&gt; &lt;/span&gt;table
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;Anything where &lt;code&gt;HttpTokens&lt;/code&gt; is not &lt;code&gt;required&lt;/code&gt; is a finding.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id="mid-article-cta"&gt;Mid-article CTA&lt;/h2&gt;
&lt;p&gt;If you&amp;rsquo;d rather have someone else go through this list on your servers and hand you back a clear report, that&amp;rsquo;s exactly what &lt;strong&gt;&lt;a href="https://richgibbs.dev/quickcheck/"&gt;Tuck Sentinel QuickCheck&lt;/a&gt;&lt;/strong&gt; does: a one-shot, read-only audit of a single Linux box with prioritized findings and copy-pasteable fixes. You can see what the output looks like in this &lt;strong&gt;&lt;a href="https://richgibbs.dev/quickcheck/sample-report.html"&gt;sample report&lt;/a&gt;&lt;/strong&gt; before deciding.&lt;/p&gt;
&lt;p&gt;Back to the checklist.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id="6-logging-and-time-sync"&gt;6. Logging and time sync&lt;/h2&gt;
&lt;p&gt;You can&amp;rsquo;t investigate what you didn&amp;rsquo;t record, and you can&amp;rsquo;t correlate logs that disagree on what time it is.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Time sync.&lt;/strong&gt; Ubuntu 22.04+ and Debian 12 ship &lt;code&gt;systemd-timesyncd&lt;/code&gt; or &lt;code&gt;chrony&lt;/code&gt;. Either is fine, just make sure one is running:&lt;/p&gt;
&lt;div class="codehilite"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;timedatectl
&lt;span class="c1"&gt;# or&lt;/span&gt;
chronyc&lt;span class="w"&gt; &lt;/span&gt;tracking
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;If you&amp;rsquo;re on AWS, the local time source &lt;code&gt;169.254.169.123&lt;/code&gt; is reliable and low-latency. &lt;code&gt;chrony&lt;/code&gt; config example:&lt;/p&gt;
&lt;div class="codehilite"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;server 169.254.169.123 prefer iburst minpoll 4 maxpoll 4
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Logging.&lt;/strong&gt; &lt;code&gt;journald&lt;/code&gt; is the default. A few sane settings in &lt;code&gt;/etc/systemd/journald.conf&lt;/code&gt;:&lt;/p&gt;
&lt;div class="codehilite"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;Storage=persistent
SystemMaxUse=1G
SystemMaxFileSize=128M
ForwardToSyslog=no
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;Then:&lt;/p&gt;
&lt;div class="codehilite"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;sudo&lt;span class="w"&gt; &lt;/span&gt;systemctl&lt;span class="w"&gt; &lt;/span&gt;restart&lt;span class="w"&gt; &lt;/span&gt;systemd-journald
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;For anything beyond a single box, ship logs off the instance — CloudWatch Logs, a Loki/Grafana stack, or any hosted log service. The reason isn&amp;rsquo;t compliance, it&amp;rsquo;s that the first thing an attacker tries to do is &lt;code&gt;rm /var/log/*&lt;/code&gt; and &lt;code&gt;journalctl --rotate --vacuum-time=1s&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Auditd&lt;/strong&gt; is worth installing if you want a record of which user ran which command:&lt;/p&gt;
&lt;div class="codehilite"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;sudo&lt;span class="w"&gt; &lt;/span&gt;apt-get&lt;span class="w"&gt; &lt;/span&gt;install&lt;span class="w"&gt; &lt;/span&gt;-y&lt;span class="w"&gt; &lt;/span&gt;auditd
sudo&lt;span class="w"&gt; &lt;/span&gt;systemctl&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;enable&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;--now&lt;span class="w"&gt; &lt;/span&gt;auditd
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;You don&amp;rsquo;t need elaborate rules to start; the defaults plus shipping &lt;code&gt;/var/log/audit/audit.log&lt;/code&gt; off-box is already a huge upgrade.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Audit:&lt;/strong&gt;&lt;/p&gt;
&lt;div class="codehilite"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;journalctl&lt;span class="w"&gt; &lt;/span&gt;--disk-usage
timedatectl&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;grep&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;System clock synchronized&amp;#39;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;h2 id="7-backups-and-restore-drills"&gt;7. Backups and restore drills&lt;/h2&gt;
&lt;p&gt;A backup you&amp;rsquo;ve never restored is a wish, not a backup.&lt;/p&gt;
&lt;p&gt;For a small EC2 setup:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Use &lt;strong&gt;AWS Backup&lt;/strong&gt; or scheduled &lt;strong&gt;EBS snapshots&lt;/strong&gt; for the volume(s).&lt;/li&gt;
&lt;li&gt;For databases, also take &lt;strong&gt;logical&lt;/strong&gt; backups (&lt;code&gt;pg_dump&lt;/code&gt;, &lt;code&gt;mysqldump&lt;/code&gt;) on a schedule and copy them to S3 with versioning + lifecycle to Glacier.&lt;/li&gt;
&lt;li&gt;Encrypt at rest (EBS encryption + S3 SSE-KMS). On modern AWS regions/accounts, EBS encryption-by-default should be on — check it.&lt;/li&gt;
&lt;li&gt;Keep at least one backup copy in a &lt;strong&gt;different AWS account or region&lt;/strong&gt;. Ransomware-style attackers will delete in-region snapshots if they get the chance.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Restore drill&lt;/strong&gt; — once a quarter, on a throwaway instance:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Pick a recent snapshot/dump.&lt;/li&gt;
&lt;li&gt;Spin up a new instance/volume from it.&lt;/li&gt;
&lt;li&gt;Verify the app starts and recent data is present.&lt;/li&gt;
&lt;li&gt;Time how long it took. That&amp;rsquo;s your real RTO.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;If you&amp;rsquo;ve never done step 4, you don&amp;rsquo;t know your RTO; you have a hope.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Audit:&lt;/strong&gt;&lt;/p&gt;
&lt;div class="codehilite"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;aws&lt;span class="w"&gt; &lt;/span&gt;ec2&lt;span class="w"&gt; &lt;/span&gt;describe-snapshots&lt;span class="w"&gt; &lt;/span&gt;--owner-ids&lt;span class="w"&gt; &lt;/span&gt;self&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;--query&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;Snapshots[?StartTime&amp;gt;=`2026-01-01`].[SnapshotId,StartTime,VolumeSize,Description]&amp;#39;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;--output&lt;span class="w"&gt; &lt;/span&gt;table
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;h2 id="8-docker-basics-if-applicable"&gt;8. Docker basics (if applicable)&lt;/h2&gt;
&lt;p&gt;If you don&amp;rsquo;t run Docker on the box, skip this. If you do, the most common foot-guns:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Don&amp;rsquo;t expose the Docker daemon over TCP.&lt;/strong&gt; &lt;code&gt;2375&lt;/code&gt; unauthenticated is root-on-box for anyone who can reach it. Use the local socket (&lt;code&gt;/var/run/docker.sock&lt;/code&gt;) and SSH for remote control.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Mind the &lt;code&gt;-p&lt;/code&gt; flag.&lt;/strong&gt; &lt;code&gt;-p 5432:5432&lt;/code&gt; binds to &lt;code&gt;0.0.0.0&lt;/code&gt; and bypasses UFW on most Docker setups (Docker writes its own iptables rules). If you only need the port locally, use &lt;code&gt;-p 127.0.0.1:5432:5432&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Run containers as non-root&lt;/strong&gt; where possible. &lt;code&gt;USER&lt;/code&gt; directive in your Dockerfile, or &lt;code&gt;--user 1000:1000&lt;/code&gt; at runtime.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Pin base images&lt;/strong&gt; to a digest (&lt;code&gt;FROM ubuntu:24.04@sha256:...&lt;/code&gt;) for production, and rebuild on a schedule to pick up CVE fixes.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Don&amp;rsquo;t bind-mount the Docker socket into containers&lt;/strong&gt; unless you fully understand that&amp;rsquo;s equivalent to giving that container root on the host.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Set &lt;code&gt;--read-only&lt;/code&gt; and &lt;code&gt;--cap-drop=ALL&lt;/code&gt;&lt;/strong&gt; for containers that don&amp;rsquo;t need to write to their filesystem or hold extra capabilities; add back only what&amp;rsquo;s needed.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;A useful audit one-liner:&lt;/p&gt;
&lt;div class="codehilite"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;docker&lt;span class="w"&gt; &lt;/span&gt;ps&lt;span class="w"&gt; &lt;/span&gt;--format&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;{{.Names}} {{.Ports}}&amp;#39;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;grep&lt;span class="w"&gt; &lt;/span&gt;-E&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;0\.0\.0\.0|:::&amp;#39;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;Anything in that list is reachable from the public internet (modulo the security group). Decide if that&amp;rsquo;s intentional.&lt;/p&gt;
&lt;p&gt;For containerd/k8s setups this barely scratches the surface — but on a single EC2 box running a few containers, those bullets close ~80% of the cheap holes.&lt;/p&gt;
&lt;h2 id="what-this-is-not"&gt;What this is not&lt;/h2&gt;
&lt;p&gt;Be honest with yourself about what a checklist like this does and doesn&amp;rsquo;t do.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;It is not a penetration test.&lt;/strong&gt; Nobody is exploiting your application logic, your auth flows, or your business rules here. A pentest is a different (and more expensive) thing.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;It is not compliance.&lt;/strong&gt; SOC 2, HIPAA, PCI, ISO 27001 all require documented policies, evidence collection, access reviews, vendor management, and a lot more. A hardened box is &lt;em&gt;part&lt;/em&gt; of that, not a substitute.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;It is not a guarantee.&lt;/strong&gt; New CVEs ship every week. Your application code changes. Someone leaks a key on GitHub. Hardening is a continuous practice, not a one-time event.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;It is not opinionated about your app stack.&lt;/strong&gt; TLS configuration, WAF rules, secrets management, dependency scanning, CI/CD security — all out of scope here.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;What it &lt;em&gt;does&lt;/em&gt; do: dramatically reduce the set of &amp;ldquo;stupid ways your server gets owned by a bot at 3am&amp;rdquo; and give you a baseline you can re-run on every new instance.&lt;/p&gt;
&lt;h2 id="end-article-cta"&gt;End-article CTA&lt;/h2&gt;
&lt;p&gt;If you got this far and want to skip the manual audit, that&amp;rsquo;s exactly what I built &lt;strong&gt;&lt;a href="https://richgibbs.dev/quickcheck/"&gt;Tuck Sentinel QuickCheck&lt;/a&gt;&lt;/strong&gt; for: a single-instance, read-only Linux audit that runs the kind of checks above and produces a prioritized report with concrete fixes — no agent left behind, no ongoing access. Take a look at the &lt;strong&gt;&lt;a href="https://richgibbs.dev/quickcheck/sample-report.html"&gt;sample report&lt;/a&gt;&lt;/strong&gt; to see exactly what you&amp;rsquo;d get.&lt;/p&gt;
&lt;p&gt;Either way: run the checklist. Future-you will thank present-you.&lt;/p&gt;
&lt;h2 id="about-tuck-sentinel"&gt;About Tuck Sentinel&lt;/h2&gt;
&lt;p&gt;Tuck Sentinel is a small, focused security tooling project from indie operator Rich Gibbs. It produces practical, no-nonsense audits and content for solo founders and small teams running their own Linux infrastructure — the kind of work most SOC platforms ignore because the deal size is too small. Start with QuickCheck if you want a one-shot review of a single server.&lt;/p&gt;
&lt;div class="codehilite"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;@context&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;https://schema.org&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;@type&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;Article&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;headline&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;Ubuntu/Debian EC2 hardening checklist (2026)&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;description&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;A practical 2026 hardening checklist for Ubuntu and Debian EC2 instances: SSH, UFW, IMDSv2, updates, logging, backups, and Docker basics.&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;author&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;@type&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;Person&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;name&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;Rich Gibbs&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;url&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;https://richgibbs.dev/&amp;quot;&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;publisher&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;@type&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;Organization&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;name&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;Tuck Sentinel&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;url&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;https://richgibbs.dev/&amp;quot;&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;mainEntityOfPage&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;@type&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;WebPage&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;@id&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;https://richgibbs.dev/blog/ubuntu-debian-ec2-hardening-checklist-2026/&amp;quot;&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;image&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;https://richgibbs.dev/og/ubuntu-debian-ec2-hardening-2026.png&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;datePublished&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;2026-05-10&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;dateModified&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;2026-05-10&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;keywords&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;ubuntu, debian, ec2, hardening, security, devops, sysadmin, aws, imdsv2, ssh, ufw&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;inLanguage&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;en&amp;quot;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;</content:encoded>
      <guid isPermaLink="true">https://blog.richgibbs.dev/ubuntu-debian-ec2-hardening-checklist-2026/</guid>
      <category>ubuntu</category>
      <category>debian</category>
      <category>ec2</category>
      <category>hardening</category>
      <category>security</category>
      <category>devops</category>
      <category>sysadmin</category>
      <category>aws</category>
      <pubDate>Sun, 10 May 2026 00:20:00 +0000</pubDate>
    </item>
    <item>
      <title>The Indie Founder's VPS Security 101</title>
      <link>https://blog.richgibbs.dev/indie-founder-vps-security-101/</link>
      <description>A practical, no-nonsense guide for solo founders running one Linux VPS. Lock the doors, watch the right things, and skip the security theater.</description>
      <content:encoded>&lt;p&gt;You shipped the thing. It runs on one Linux box at DigitalOcean or Hetzner or wherever. Customers are starting to show up, and somewhere in the back of your head a little voice is asking: &lt;em&gt;is this thing actually safe?&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;This guide is for that voice.&lt;/p&gt;
&lt;p&gt;It&amp;rsquo;s written for solo founders and very small teams who are not security professionals but can copy a command into a terminal. The goal is &amp;ldquo;secure enough that you can sleep&amp;rdquo; — not &amp;ldquo;audit-grade fortress.&amp;rdquo; Those are different jobs, and treating one like the other is how you waste a weekend installing seven intrusion detection tools and shipping nothing for a month.&lt;/p&gt;
&lt;h2 id="what-secure-enough-looks-like-for-one-box"&gt;What &amp;ldquo;secure enough&amp;rdquo; looks like for one box&lt;/h2&gt;
&lt;p&gt;For a single VPS running your SaaS, &amp;ldquo;secure enough&amp;rdquo; is a short list:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Nobody can log in as root from the internet.&lt;/li&gt;
&lt;li&gt;Logging in requires a key you have, not a password someone could guess.&lt;/li&gt;
&lt;li&gt;Only the ports you actually use are open.&lt;/li&gt;
&lt;li&gt;The OS gets security patches automatically.&lt;/li&gt;
&lt;li&gt;You&amp;rsquo;d notice if something obviously bad started happening.&lt;/li&gt;
&lt;li&gt;If the disk caught fire tomorrow, you could rebuild from a backup before the end of the day.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;That&amp;rsquo;s the whole bar. Everything else is optimization. Hit those six and you&amp;rsquo;ve already done more than the majority of small-team production servers I&amp;rsquo;ve seen.&lt;/p&gt;
&lt;h2 id="first-day-setup"&gt;First-day setup&lt;/h2&gt;
&lt;p&gt;Do these once, when the server is fresh. They take about twenty minutes.&lt;/p&gt;
&lt;h3 id="1-create-a-non-root-user-with-sudo"&gt;1. Create a non-root user with sudo&lt;/h3&gt;
&lt;p&gt;Logging in as root is a footgun. One typo and you&amp;rsquo;ve nuked the box. Make a normal user instead.&lt;/p&gt;
&lt;div class="codehilite"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="c1"&gt;# As root, on a fresh server&lt;/span&gt;
adduser&lt;span class="w"&gt; &lt;/span&gt;deploy
usermod&lt;span class="w"&gt; &lt;/span&gt;-aG&lt;span class="w"&gt; &lt;/span&gt;sudo&lt;span class="w"&gt; &lt;/span&gt;deploy
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;Pick a real password for &lt;code&gt;deploy&lt;/code&gt; even though you&amp;rsquo;ll be using SSH keys — you&amp;rsquo;ll need it for &lt;code&gt;sudo&lt;/code&gt; prompts.&lt;/p&gt;
&lt;h3 id="2-set-up-ssh-keys-and-disable-password-login"&gt;2. Set up SSH keys and disable password login&lt;/h3&gt;
&lt;p&gt;On your laptop, if you don&amp;rsquo;t already have a key:&lt;/p&gt;
&lt;div class="codehilite"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;ssh-keygen&lt;span class="w"&gt; &lt;/span&gt;-t&lt;span class="w"&gt; &lt;/span&gt;ed25519&lt;span class="w"&gt; &lt;/span&gt;-C&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;you@laptop&amp;quot;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;Copy it to the server:&lt;/p&gt;
&lt;div class="codehilite"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;ssh-copy-id&lt;span class="w"&gt; &lt;/span&gt;deploy@your.server.ip
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;Now log in as &lt;code&gt;deploy&lt;/code&gt; and confirm &lt;code&gt;sudo&lt;/code&gt; works:&lt;/p&gt;
&lt;div class="codehilite"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;ssh&lt;span class="w"&gt; &lt;/span&gt;deploy@your.server.ip
sudo&lt;span class="w"&gt; &lt;/span&gt;whoami&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="c1"&gt;# should print: root&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;Once you&amp;rsquo;re sure key login works, lock down SSH. Edit &lt;code&gt;/etc/ssh/sshd_config&lt;/code&gt; (or drop a file in &lt;code&gt;/etc/ssh/sshd_config.d/&lt;/code&gt;):&lt;/p&gt;
&lt;div class="codehilite"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;sudo&lt;span class="w"&gt; &lt;/span&gt;tee&lt;span class="w"&gt; &lt;/span&gt;/etc/ssh/sshd_config.d/99-hardening.conf&lt;span class="w"&gt; &lt;/span&gt;&amp;gt;/dev/null&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;lt;&amp;lt;&amp;#39;EOF&amp;#39;&lt;/span&gt;
&lt;span class="s"&gt;PermitRootLogin no&lt;/span&gt;
&lt;span class="s"&gt;PasswordAuthentication no&lt;/span&gt;
&lt;span class="s"&gt;KbdInteractiveAuthentication no&lt;/span&gt;
&lt;span class="s"&gt;EOF&lt;/span&gt;

sudo&lt;span class="w"&gt; &lt;/span&gt;sshd&lt;span class="w"&gt; &lt;/span&gt;-t&lt;span class="w"&gt;          &lt;/span&gt;&lt;span class="c1"&gt;# test config — must print nothing&lt;/span&gt;
sudo&lt;span class="w"&gt; &lt;/span&gt;systemctl&lt;span class="w"&gt; &lt;/span&gt;reload&lt;span class="w"&gt; &lt;/span&gt;ssh
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Do not close your existing SSH session yet.&lt;/strong&gt; Open a second terminal and confirm you can log in fresh. If that works, you&amp;rsquo;re good. If it doesn&amp;rsquo;t, you&amp;rsquo;ve still got the first session to fix things.&lt;/p&gt;
&lt;h3 id="3-turn-on-the-firewall"&gt;3. Turn on the firewall&lt;/h3&gt;
&lt;p&gt;Ubuntu ships with &lt;code&gt;ufw&lt;/code&gt;, which is a friendly wrapper around iptables/nftables. Default-deny inbound, allow only what you need:&lt;/p&gt;
&lt;div class="codehilite"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;sudo&lt;span class="w"&gt; &lt;/span&gt;ufw&lt;span class="w"&gt; &lt;/span&gt;default&lt;span class="w"&gt; &lt;/span&gt;deny&lt;span class="w"&gt; &lt;/span&gt;incoming
sudo&lt;span class="w"&gt; &lt;/span&gt;ufw&lt;span class="w"&gt; &lt;/span&gt;default&lt;span class="w"&gt; &lt;/span&gt;allow&lt;span class="w"&gt; &lt;/span&gt;outgoing
sudo&lt;span class="w"&gt; &lt;/span&gt;ufw&lt;span class="w"&gt; &lt;/span&gt;allow&lt;span class="w"&gt; &lt;/span&gt;OpenSSH
sudo&lt;span class="w"&gt; &lt;/span&gt;ufw&lt;span class="w"&gt; &lt;/span&gt;allow&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;80&lt;/span&gt;/tcp
sudo&lt;span class="w"&gt; &lt;/span&gt;ufw&lt;span class="w"&gt; &lt;/span&gt;allow&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;443&lt;/span&gt;/tcp
sudo&lt;span class="w"&gt; &lt;/span&gt;ufw&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;enable&lt;/span&gt;
sudo&lt;span class="w"&gt; &lt;/span&gt;ufw&lt;span class="w"&gt; &lt;/span&gt;status&lt;span class="w"&gt; &lt;/span&gt;verbose
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;If you don&amp;rsquo;t run a web server on this box, drop the 80/443 lines. The rule is simple: open a port only when something on the box actually needs to listen on it.&lt;/p&gt;
&lt;h3 id="4-enable-automatic-security-updates"&gt;4. Enable automatic security updates&lt;/h3&gt;
&lt;p&gt;Most successful attacks are not clever zero-days — they&amp;rsquo;re known bugs in software you forgot to patch. Let the OS patch itself.&lt;/p&gt;
&lt;div class="codehilite"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;sudo&lt;span class="w"&gt; &lt;/span&gt;apt&lt;span class="w"&gt; &lt;/span&gt;update
sudo&lt;span class="w"&gt; &lt;/span&gt;apt&lt;span class="w"&gt; &lt;/span&gt;install&lt;span class="w"&gt; &lt;/span&gt;-y&lt;span class="w"&gt; &lt;/span&gt;unattended-upgrades
sudo&lt;span class="w"&gt; &lt;/span&gt;dpkg-reconfigure&lt;span class="w"&gt; &lt;/span&gt;-plow&lt;span class="w"&gt; &lt;/span&gt;unattended-upgrades&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="c1"&gt;# answer &amp;quot;Yes&amp;quot;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;Then check &lt;code&gt;/etc/apt/apt.conf.d/50unattended-upgrades&lt;/code&gt; and make sure security updates are uncommented. On Ubuntu the default config already covers &lt;code&gt;${distro_id}:${distro_codename}-security&lt;/code&gt;, which is what you want.&lt;/p&gt;
&lt;p&gt;For peace of mind, make it tell you when reboots are needed and when to install them:&lt;/p&gt;
&lt;div class="codehilite"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;sudo&lt;span class="w"&gt; &lt;/span&gt;tee&lt;span class="w"&gt; &lt;/span&gt;/etc/apt/apt.conf.d/51auto-reboot&lt;span class="w"&gt; &lt;/span&gt;&amp;gt;/dev/null&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;lt;&amp;lt;&amp;#39;EOF&amp;#39;&lt;/span&gt;
&lt;span class="s"&gt;Unattended-Upgrade::Automatic-Reboot &amp;quot;true&amp;quot;;&lt;/span&gt;
&lt;span class="s"&gt;Unattended-Upgrade::Automatic-Reboot-Time &amp;quot;04:00&amp;quot;;&lt;/span&gt;
&lt;span class="s"&gt;EOF&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;Pick a time when nobody&amp;rsquo;s using the app. Yes, this means the box reboots itself sometimes. That&amp;rsquo;s fine. Your app should already survive a reboot — and if it doesn&amp;rsquo;t, that&amp;rsquo;s a bigger problem than security.&lt;/p&gt;
&lt;p&gt;That&amp;rsquo;s first-day setup. Non-root sudo user, keys-only SSH, default-deny firewall, automatic patching. You&amp;rsquo;re now ahead of a lot of production servers.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id="worried-you-missed-something-on-first-day-setup"&gt;Worried you missed something on first-day setup?&lt;/h2&gt;
&lt;p&gt;&lt;a href="https://richgibbs.dev/quickcheck/"&gt;&lt;strong&gt;Run a free QuickCheck on your server →&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;It&amp;rsquo;s a read-only scan that flags the boring stuff: SSH still allows passwords, port 22 open to the world, no automatic updates configured, sketchy listening services, and so on. No agent, no signup wall. Here&amp;rsquo;s a &lt;a href="https://richgibbs.dev/quickcheck/sample-report.html"&gt;sample report&lt;/a&gt; if you&amp;rsquo;d like to see the format first.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id="what-to-actually-monitor"&gt;What to actually monitor&lt;/h2&gt;
&lt;p&gt;You don&amp;rsquo;t need a SIEM. You need a few things you can eyeball once a week (or get a tiny script to email you about). For a single VPS, this short list catches almost everything that matters.&lt;/p&gt;
&lt;h3 id="failed-logins"&gt;Failed logins&lt;/h3&gt;
&lt;p&gt;If somebody is hammering your SSH port, this shows it:&lt;/p&gt;
&lt;div class="codehilite"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;sudo&lt;span class="w"&gt; &lt;/span&gt;journalctl&lt;span class="w"&gt; &lt;/span&gt;-u&lt;span class="w"&gt; &lt;/span&gt;ssh&lt;span class="w"&gt; &lt;/span&gt;--since&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;24 hours ago&amp;quot;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;grep&lt;span class="w"&gt; &lt;/span&gt;-i&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;failed\|invalid&amp;quot;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;A handful of attempts per day is internet background noise. Thousands per hour from one IP is worth blocking with &lt;code&gt;ufw&lt;/code&gt; or installing &lt;code&gt;fail2ban&lt;/code&gt;.&lt;/p&gt;
&lt;h3 id="listening-ports"&gt;Listening ports&lt;/h3&gt;
&lt;p&gt;What&amp;rsquo;s actually accepting connections on this box? Run this every so often and make sure nothing surprising is there:&lt;/p&gt;
&lt;div class="codehilite"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;sudo&lt;span class="w"&gt; &lt;/span&gt;ss&lt;span class="w"&gt; &lt;/span&gt;-tulpn
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;You&amp;rsquo;re looking for things bound to &lt;code&gt;0.0.0.0:&lt;/code&gt; or &lt;code&gt;:::&lt;/code&gt;. Anything bound to &lt;code&gt;127.0.0.1&lt;/code&gt; is fine — only your box can talk to it. The classic mistake: running a dev database with &lt;code&gt;bind = 0.0.0.0&lt;/code&gt; and no password. Don&amp;rsquo;t do that.&lt;/p&gt;
&lt;h3 id="disk-free"&gt;Disk free&lt;/h3&gt;
&lt;p&gt;Servers don&amp;rsquo;t usually die from hackers. They die from full disks at 3 AM.&lt;/p&gt;
&lt;div class="codehilite"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;df&lt;span class="w"&gt; &lt;/span&gt;-h&lt;span class="w"&gt; &lt;/span&gt;/
du&lt;span class="w"&gt; &lt;/span&gt;-sh&lt;span class="w"&gt; &lt;/span&gt;/var/log&lt;span class="w"&gt; &lt;/span&gt;/var/lib/docker&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;2&lt;/span&gt;&amp;gt;/dev/null
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;If &lt;code&gt;/&lt;/code&gt; is over 80% full, plan on cleaning it up before it hits 100% and your database refuses to write.&lt;/p&gt;
&lt;h3 id="package-updates-available"&gt;Package updates available&lt;/h3&gt;
&lt;p&gt;Even with unattended-upgrades, it&amp;rsquo;s worth a manual sanity check now and then:&lt;/p&gt;
&lt;div class="codehilite"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;sudo&lt;span class="w"&gt; &lt;/span&gt;apt&lt;span class="w"&gt; &lt;/span&gt;update
apt&lt;span class="w"&gt; &lt;/span&gt;list&lt;span class="w"&gt; &lt;/span&gt;--upgradable&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;2&lt;/span&gt;&amp;gt;/dev/null
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;And: is a reboot pending after a kernel update?&lt;/p&gt;
&lt;div class="codehilite"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;-f&lt;span class="w"&gt; &lt;/span&gt;/var/run/reboot-required&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;cat&lt;span class="w"&gt; &lt;/span&gt;/var/run/reboot-required
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;If yes, schedule one. A patched kernel that hasn&amp;rsquo;t been booted into is just a download.&lt;/p&gt;
&lt;p&gt;You can wire any of these into a weekly cron that emails you a one-page digest. Five lines of bash. Don&amp;rsquo;t overthink it.&lt;/p&gt;
&lt;h2 id="backups-and-restore-drills"&gt;Backups and restore drills&lt;/h2&gt;
&lt;p&gt;This is the boring section everyone skips. Skip it and you have a hobby project, not a business.&lt;/p&gt;
&lt;p&gt;The minimum viable backup setup for a single VPS:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Database&lt;/strong&gt;: nightly dump (&lt;code&gt;pg_dump&lt;/code&gt;, &lt;code&gt;mysqldump&lt;/code&gt;, or your equivalent), encrypted, sent off-box. To S3, B2, or any object store. Keep at least 7 daily and 4 weekly copies.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;User-uploaded files&lt;/strong&gt;: same deal — sync to object storage on a schedule. &lt;code&gt;restic&lt;/code&gt; and &lt;code&gt;rclone&lt;/code&gt; both work fine.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Config&lt;/strong&gt;: keep it in git. If your &lt;code&gt;nginx.conf&lt;/code&gt; lives only on the server, it&amp;rsquo;s already half-lost.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;That&amp;rsquo;s the easy part. Here&amp;rsquo;s the part people skip:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Actually do a restore. From scratch. On a fresh VPS. Once.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Spin up a new box. Pull last night&amp;rsquo;s backup. Restore the database. Boot the app. Did it work? How long did it take? What did you forget? (Spoiler: an environment variable, an SSL cert, a cron job, or a system package.)&lt;/p&gt;
&lt;p&gt;If you&amp;rsquo;ve never done this drill, you don&amp;rsquo;t have backups. You have files you hope will work. There is a meaningful difference, and you really, really don&amp;rsquo;t want to discover it during an outage.&lt;/p&gt;
&lt;p&gt;Re-do the drill at least once a year, or any time you make a big infrastructure change.&lt;/p&gt;
&lt;h2 id="dont-over-do-it"&gt;Don&amp;rsquo;t over-do it&lt;/h2&gt;
&lt;p&gt;There is a tempting path where, in the name of &amp;ldquo;being thorough,&amp;rdquo; you install:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;An intrusion detection system&lt;/li&gt;
&lt;li&gt;A second intrusion detection system in case the first one misses something&lt;/li&gt;
&lt;li&gt;A file integrity monitor&lt;/li&gt;
&lt;li&gt;A custom auditd ruleset you found on a blog&lt;/li&gt;
&lt;li&gt;An EDR agent&lt;/li&gt;
&lt;li&gt;A SIEM forwarder&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;…on a VPS that hosts one Rails app and gets 200 visitors a day.&lt;/p&gt;
&lt;p&gt;Don&amp;rsquo;t. Each of these has a cost: CPU, memory, alert noise you&amp;rsquo;ll learn to ignore, and your time. For one small box, the basics in this article handle 95% of realistic risk. Adding more tools without tuning them often makes you &lt;em&gt;less&lt;/em&gt; secure, because real signals get buried in junk alerts you stop reading.&lt;/p&gt;
&lt;p&gt;If your business actually grows into the territory where you need that stuff (regulated data, big customer base, real compliance), you&amp;rsquo;ll know — and at that point you&amp;rsquo;ll also have the budget to do it properly. Until then: keep the surface small, keep it patched, and keep watching the four things in the monitoring section.&lt;/p&gt;
&lt;h2 id="common-mistakes"&gt;Common mistakes&lt;/h2&gt;
&lt;p&gt;The same handful of things bite small-team servers over and over:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Port 22 open to the entire internet with password login still enabled.&lt;/strong&gt; This is the #1 thing scanners look for. Even with a strong password, you&amp;rsquo;re contributing to the noise. Keys only.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Logging in as root.&lt;/strong&gt; Either directly, or via a sudoers rule that means a single mistake takes the whole box down. Make a real user.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Skipping reboots after kernel updates.&lt;/strong&gt; A patched-but-not-rebooted kernel still runs the old, vulnerable kernel. &lt;code&gt;unattended-upgrades&lt;/code&gt; with &lt;code&gt;Automatic-Reboot "true"&lt;/code&gt; fixes this for free.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;IMDSv1 left enabled on AWS.&lt;/strong&gt; If you&amp;rsquo;re on EC2/Lightsail, the legacy instance metadata endpoint can be reached by anything that can make an outbound HTTP request from the box — including a bug in your app. Use the &lt;a href="/aws-imdsv2-migration-without-breaking-things/"&gt;IMDSv2 migration playbook&lt;/a&gt; to enforce &lt;code&gt;HttpTokens=required&lt;/code&gt; without breaking older agents or SDKs.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Dev services bound to &lt;code&gt;0.0.0.0&lt;/code&gt;.&lt;/strong&gt; Postgres, Redis, MongoDB, Elasticsearch, a debug UI, that one Jupyter notebook you spun up &amp;ldquo;just for a sec&amp;rdquo; — anything that listens on all interfaces with no auth is a free shell waiting to happen. Bind to &lt;code&gt;127.0.0.1&lt;/code&gt;, or at minimum require a password and put it behind the firewall.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;No backups, or backups that have never been restored.&lt;/strong&gt; See previous section. This is the one that ends businesses.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Storing secrets in committed &lt;code&gt;.env&lt;/code&gt; files.&lt;/strong&gt; You&amp;rsquo;ll forget, push to a public repo, and your API keys are now public. Use a &lt;code&gt;.env.example&lt;/code&gt; checked in, and the real &lt;code&gt;.env&lt;/code&gt; ignored.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;None of these are exotic. All of them are still everywhere.&lt;/p&gt;
&lt;h2 id="worth-a-free-second-opinion"&gt;Worth a free second opinion?&lt;/h2&gt;
&lt;p&gt;Even after a careful first-day setup, things drift. A teammate enables password auth &amp;ldquo;just for a minute.&amp;rdquo; A new service starts listening on &lt;code&gt;0.0.0.0&lt;/code&gt;. Auto-updates silently break and stop running. The point of a periodic external check is to catch that drift before it matters.&lt;/p&gt;
&lt;p&gt;&lt;a href="https://richgibbs.dev/quickcheck/"&gt;&lt;strong&gt;Run a QuickCheck on your VPS →&lt;/strong&gt;&lt;/a&gt; — read-only, no install, takes a few minutes. Or look at a &lt;a href="https://richgibbs.dev/quickcheck/sample-report.html"&gt;sample report&lt;/a&gt; first to see what it covers.&lt;/p&gt;
&lt;h2 id="what-this-is-not"&gt;What this is not&lt;/h2&gt;
&lt;p&gt;This article is a sensible starting checklist for one Linux VPS run by one person or a tiny team. It is &lt;strong&gt;not&lt;/strong&gt;:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;A replacement for security advice from someone who knows your specific stack and threat model.&lt;/li&gt;
&lt;li&gt;A compliance program. If you handle health data, payment data, or anything else regulated, you need more than a blog post.&lt;/li&gt;
&lt;li&gt;A guarantee. Nothing in security is. The goal is to make yourself a much less appealing target than the millions of other servers on the internet that haven&amp;rsquo;t done any of this.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Do the basics, do them well, then go back to building the actual product. That&amp;rsquo;s the job.&lt;/p&gt;
&lt;h2 id="about-tuck-sentinel"&gt;About Tuck Sentinel&lt;/h2&gt;
&lt;p&gt;Tuck Sentinel is a small operation focused on practical security checks for indie founders and small teams running production on a VPS. We build &lt;a href="https://richgibbs.dev/quickcheck/"&gt;QuickCheck&lt;/a&gt;, a free read-only scan that highlights the boring-but-important configuration issues most one-person ops teams miss. No agents, no upsell maze — just the things worth fixing.&lt;/p&gt;
&lt;div class="codehilite"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;@context&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;https://schema.org&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;@type&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;Article&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;headline&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;The Indie Founder&amp;#39;s VPS Security 101&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;description&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;A practical, no-nonsense guide for solo founders running one Linux VPS. Lock the doors, watch the right things, and skip the security theater.&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;author&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;@type&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;Organization&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;name&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;Tuck Sentinel&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;url&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;https://richgibbs.dev/&amp;quot;&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;publisher&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;@type&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;Organization&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;name&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;Tuck Sentinel&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;url&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;https://richgibbs.dev/&amp;quot;&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;mainEntityOfPage&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;@type&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;WebPage&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;@id&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;https://richgibbs.dev/blog/indie-founder-vps-security-101/&amp;quot;&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;image&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;https://richgibbs.dev/og/indie-founder-vps-security-101.png&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;keywords&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;VPS security, indie founder, Linux server hardening, Ubuntu, Debian, SSH, ufw, unattended-upgrades, backups&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;articleSection&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;Security&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;inLanguage&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;en&amp;quot;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;</content:encoded>
      <guid isPermaLink="true">https://blog.richgibbs.dev/indie-founder-vps-security-101/</guid>
      <category>vps</category>
      <category>security</category>
      <category>linux</category>
      <category>indie-founder</category>
      <category>ubuntu</category>
      <category>debian</category>
      <category>sysadmin</category>
      <pubDate>Sun, 10 May 2026 00:22:00 +0000</pubDate>
    </item>
    <item>
      <title>AWS IMDSv2 Migration Without Breaking Things</title>
      <link>https://blog.richgibbs.dev/aws-imdsv2-migration-without-breaking-things/</link>
      <description>A practical, indie-founder guide to migrating EC2 instances from IMDSv1 to IMDSv2 without breaking SDKs, containers, kubelet, or the ECS agent.</description>
      <content:encoded>&lt;p&gt;If you have EC2 instances older than a year or two, some of them probably still allow IMDSv1. The Instance Metadata Service is the HTTP endpoint at &lt;code&gt;169.254.169.254&lt;/code&gt; every EC2 instance can hit to learn about itself: instance ID, region, attached IAM role, and the temporary credentials that come with it. IMDSv1 is the original unauthenticated GET protocol. IMDSv2 is the session-token version that blocks a class of SSRF and confused-deputy attacks from walking off with your IAM Role credentials.&lt;/p&gt;
&lt;p&gt;AWS has been nudging everyone toward IMDSv2 for years, but existing fleets, AMIs baked before the change, and ASGs pinned to old launch templates are full of IMDSv1-allowing instances. Migration is conceptually simple — flip a setting per instance — and operationally annoying, because flipping it on the wrong workload breaks credential lookups for SDKs, kubelet, the ECS agent, or your own scripts.&lt;/p&gt;
&lt;p&gt;This guide walks through the migration the way an operator actually has to do it: detect what is still using v1, change instances in safe waves, validate, and have a rollback path. If you are using the migration window to clean up the rest of the instance, pair this with the broader &lt;a href="/ubuntu-debian-ec2-hardening-checklist-2026/#5-ec2-metadata-service-imdsv2"&gt;Ubuntu/Debian EC2 hardening checklist&lt;/a&gt;.&lt;/p&gt;
&lt;h2 id="why-migrate"&gt;Why Migrate&lt;/h2&gt;
&lt;p&gt;IMDSv1 is a plain HTTP &lt;code&gt;GET&lt;/code&gt; against the link-local address. Anything inside the instance that can make an outbound HTTP request — including a vulnerable web app with SSRF — can read instance metadata, including the &lt;strong&gt;IAM Role Credentials&lt;/strong&gt; path:&lt;/p&gt;
&lt;div class="codehilite"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;GET http://169.254.169.254/latest/meta-data/iam/security-credentials/&amp;lt;role-name&amp;gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;That returns short-lived credentials for whatever role is attached to the instance. With IMDSv1, no proof of locality is required. An SSRF in a public-facing service can pivot directly to your IAM credentials.&lt;/p&gt;
&lt;p&gt;IMDSv2 changes the protocol in two important ways:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Session tokens.&lt;/strong&gt; Callers &lt;code&gt;PUT&lt;/code&gt; to &lt;code&gt;/latest/api/token&lt;/code&gt; for a session token, then send it back as &lt;code&gt;X-aws-ec2-metadata-token&lt;/code&gt;. SSRF primitives that only allow &lt;code&gt;GET&lt;/code&gt; are blocked.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Hop limit.&lt;/strong&gt; The token response honors a TTL hop limit. Default is 1, so a container behind a Docker bridge or a pod behind a CNI cannot reach IMDS unless explicitly allowed.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Set IMDSv2 to &lt;strong&gt;required&lt;/strong&gt; and v1 stops responding. That&amp;rsquo;s the goal state.&lt;/p&gt;
&lt;h2 id="what-breaks"&gt;What Breaks&lt;/h2&gt;
&lt;p&gt;The realistic breakage list is short and well-known. Knowing it upfront is most of the migration.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Old AWS SDKs.&lt;/strong&gt; Anything older than the published cutoffs only knows IMDSv1: AWS CLI v1 &amp;lt; 1.18.x, boto3 &amp;lt; 1.12.x, AWS SDK for Java v1 &amp;lt; 1.11.678, Go SDK v1 &amp;lt; 1.25.38, .NET SDK before late-2019. Modern SDKs auto-negotiate v2 with v1 fallback, but if v2 is &lt;em&gt;required&lt;/em&gt; the fallback never engages.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Containers behind Docker bridge or CNI.&lt;/strong&gt; The default hop limit of 1 denies pods/containers that route through the bridge. Raise the hop limit to 2 — or better, use IRSA on EKS, EC2 Pod Identity, or task roles on ECS so workloads don&amp;rsquo;t depend on instance metadata at all.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;kubelet&lt;/code&gt;&lt;/strong&gt; on self-managed nodes. Older kubelets only spoke v1. Modern EKS-optimized AMIs are fine; legacy kops clusters and old custom AMIs are the usual offenders.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;ECS agent.&lt;/strong&gt; &lt;code&gt;amazon-ecs-init&lt;/code&gt; &amp;gt;= 1.50 supports IMDSv2. Old ECS-optimized AMIs not re-rolled in years can fail credential fetch.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;CloudWatch / SSM agent.&lt;/strong&gt; Recent versions fine; very old pinned versions not.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Custom scripts.&lt;/strong&gt; &lt;code&gt;curl http://169.254.169.254/latest/meta-data/...&lt;/code&gt; without a token will 401 once v1 is off.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Third-party agents in old AMIs.&lt;/strong&gt; Old Datadog, New Relic, Splunk, or backup agents from years-old golden images can be v1-only.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;That&amp;rsquo;s the whole list. Everything else either works on day one or never touched IMDS.&lt;/p&gt;
&lt;h2 id="detect-imdsv1-use"&gt;Detect IMDSv1 Use&lt;/h2&gt;
&lt;p&gt;Don&amp;rsquo;t flip the switch blind. Find the callers first.&lt;/p&gt;
&lt;h3 id="cloudwatch-metric-metadatanotoken"&gt;CloudWatch metric: &lt;code&gt;MetadataNoToken&lt;/code&gt;&lt;/h3&gt;
&lt;p&gt;Every EC2 instance emits a CloudWatch metric called &lt;code&gt;MetadataNoToken&lt;/code&gt; in the &lt;code&gt;AWS/EC2&lt;/code&gt; namespace. It increments every time something on the instance hits IMDSv1. This is the single most useful signal you have.&lt;/p&gt;
&lt;div class="codehilite"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;aws&lt;span class="w"&gt; &lt;/span&gt;cloudwatch&lt;span class="w"&gt; &lt;/span&gt;get-metric-statistics&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;--namespace&lt;span class="w"&gt; &lt;/span&gt;AWS/EC2&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;--metric-name&lt;span class="w"&gt; &lt;/span&gt;MetadataNoToken&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;--dimensions&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;Name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;InstanceId,Value&lt;span class="o"&gt;=&lt;/span&gt;i-0123456789abcdef0&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;--statistics&lt;span class="w"&gt; &lt;/span&gt;Sum&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;--period&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;3600&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;--start-time&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;&lt;/span&gt;&lt;span class="k"&gt;$(&lt;/span&gt;date&lt;span class="w"&gt; &lt;/span&gt;-u&lt;span class="w"&gt; &lt;/span&gt;-d&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;7 days ago&amp;#39;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;+%Y-%m-%dT%H:%M:%SZ&lt;span class="k"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;--end-time&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;&lt;/span&gt;&lt;span class="k"&gt;$(&lt;/span&gt;date&lt;span class="w"&gt; &lt;/span&gt;-u&lt;span class="w"&gt; &lt;/span&gt;+%Y-%m-%dT%H:%M:%SZ&lt;span class="k"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;If &lt;code&gt;Sum&lt;/code&gt; across the last 7 days is &lt;code&gt;0&lt;/code&gt;, that instance is not making any IMDSv1 calls and is safe to switch. Anything non-zero means something is still hitting v1.&lt;/p&gt;
&lt;p&gt;For a fleet view, query across all instance IDs or use CloudWatch Metrics Insights / Metric Math to graph &lt;code&gt;MetadataNoToken&lt;/code&gt; aggregated. Tag the noisy instances and dig in.&lt;/p&gt;
&lt;h3 id="inventory-which-instances-even-allow-v1"&gt;Inventory: which instances even allow v1?&lt;/h3&gt;
&lt;div class="codehilite"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;aws&lt;span class="w"&gt; &lt;/span&gt;ec2&lt;span class="w"&gt; &lt;/span&gt;describe-instances&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;--query&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;Reservations[].Instances[].{&lt;/span&gt;
&lt;span class="s1"&gt;    Id:InstanceId,&lt;/span&gt;
&lt;span class="s1"&gt;    State:State.Name,&lt;/span&gt;
&lt;span class="s1"&gt;    HttpTokens:MetadataOptions.HttpTokens,&lt;/span&gt;
&lt;span class="s1"&gt;    HopLimit:MetadataOptions.HttpPutResponseHopLimit,&lt;/span&gt;
&lt;span class="s1"&gt;    Endpoint:MetadataOptions.HttpEndpoint&lt;/span&gt;
&lt;span class="s1"&gt;  }&amp;#39;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;--output&lt;span class="w"&gt; &lt;/span&gt;table
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;HttpTokens&lt;/code&gt; is what you care about. It will be one of:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;optional&lt;/code&gt; — IMDSv1 still allowed (the thing you&amp;rsquo;re trying to remove)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;required&lt;/code&gt; — IMDSv2 only (the goal state)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;A simple &amp;ldquo;what&amp;rsquo;s left?&amp;rdquo; query:&lt;/p&gt;
&lt;div class="codehilite"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;aws&lt;span class="w"&gt; &lt;/span&gt;ec2&lt;span class="w"&gt; &lt;/span&gt;describe-instances&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;--filters&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;Name=metadata-options.http-tokens,Values=optional&amp;quot;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="w"&gt;            &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;Name=instance-state-name,Values=running&amp;quot;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;--query&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;Reservations[].Instances[].InstanceId&amp;#39;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;--output&lt;span class="w"&gt; &lt;/span&gt;text
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;h3 id="cloudtrail-and-vpc-flow-logs"&gt;CloudTrail and VPC flow logs&lt;/h3&gt;
&lt;p&gt;CloudTrail does &lt;strong&gt;not&lt;/strong&gt; log calls to IMDS itself — those never leave the instance. What it &lt;em&gt;does&lt;/em&gt; show is the AWS API calls made &lt;em&gt;with&lt;/em&gt; the credentials IMDS handed out, via &lt;code&gt;userIdentity.sessionContext&lt;/code&gt; and the &lt;code&gt;accessKeyId&lt;/code&gt; of the temporary credentials. Useful for finding workloads still authenticating via instance role that should have moved to IRSA or task roles.&lt;/p&gt;
&lt;p&gt;VPC flow logs do not see &lt;code&gt;169.254.169.254&lt;/code&gt; traffic either — link-local stays inside the host. Stick to &lt;code&gt;MetadataNoToken&lt;/code&gt; plus the inventory query.&lt;/p&gt;
&lt;h3 id="on-host-detection"&gt;On-host detection&lt;/h3&gt;
&lt;p&gt;If you have shell access to a candidate instance, run something quick before you change settings:&lt;/p&gt;
&lt;div class="codehilite"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="c1"&gt;# Try IMDSv1 — if this returns data, v1 is still on&lt;/span&gt;
curl&lt;span class="w"&gt; &lt;/span&gt;-s&lt;span class="w"&gt; &lt;/span&gt;-o&lt;span class="w"&gt; &lt;/span&gt;/dev/null&lt;span class="w"&gt; &lt;/span&gt;-w&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;%{http_code}\n&amp;quot;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;http://169.254.169.254/latest/meta-data/instance-id

&lt;span class="c1"&gt;# Try IMDSv2 — should always return 200 once v2 is supported&lt;/span&gt;
&lt;span class="nv"&gt;TOKEN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;$(&lt;/span&gt;curl&lt;span class="w"&gt; &lt;/span&gt;-s&lt;span class="w"&gt; &lt;/span&gt;-X&lt;span class="w"&gt; &lt;/span&gt;PUT&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;http://169.254.169.254/latest/api/token&amp;quot;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;-H&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;X-aws-ec2-metadata-token-ttl-seconds: 21600&amp;quot;&lt;/span&gt;&lt;span class="k"&gt;)&lt;/span&gt;
curl&lt;span class="w"&gt; &lt;/span&gt;-s&lt;span class="w"&gt; &lt;/span&gt;-H&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;X-aws-ec2-metadata-token: &lt;/span&gt;&lt;span class="nv"&gt;$TOKEN&lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;http://169.254.169.254/latest/meta-data/instance-id
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;To find callers on a host, &lt;code&gt;auditd&lt;/code&gt; rules on connects to &lt;code&gt;169.254.169.254&lt;/code&gt; plus &lt;code&gt;ss -tnp&lt;/code&gt; snapshots usually identify the offending process. On a Kubernetes node, look at old DaemonSets and sidecars first.&lt;/p&gt;
&lt;h2 id="migration-steps"&gt;Migration Steps&lt;/h2&gt;
&lt;p&gt;The flow that has worked reliably for small and mid-size fleets:&lt;/p&gt;
&lt;h3 id="1-baseline-and-freeze-new-imdsv1"&gt;1. Baseline and freeze new IMDSv1&lt;/h3&gt;
&lt;p&gt;Set account-level defaults so anything launched from now on is IMDSv2-required and any new AMIs are also v2-required:&lt;/p&gt;
&lt;div class="codehilite"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="c1"&gt;# Default IMDS options for new instances in this region&lt;/span&gt;
aws&lt;span class="w"&gt; &lt;/span&gt;ec2&lt;span class="w"&gt; &lt;/span&gt;modify-instance-metadata-defaults&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;--http-tokens&lt;span class="w"&gt; &lt;/span&gt;required&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;--http-put-response-hop-limit&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;2&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;--http-endpoint&lt;span class="w"&gt; &lt;/span&gt;enabled

&lt;span class="c1"&gt;# Default for newly-registered AMIs&lt;/span&gt;
aws&lt;span class="w"&gt; &lt;/span&gt;ec2&lt;span class="w"&gt; &lt;/span&gt;modify-image-attribute&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;--image-id&lt;span class="w"&gt; &lt;/span&gt;ami-xxxxxxxxxxxxxxxxx&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;--imds-support&lt;span class="w"&gt; &lt;/span&gt;v2.0
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;Use &lt;code&gt;modify-image-attribute --imds-support v2.0&lt;/code&gt; on each AMI you control. Once set, instances launched from that AMI get v2-required automatically.&lt;/p&gt;
&lt;p&gt;Also set the launch template / Auto Scaling group launch template versions to require IMDSv2:&lt;/p&gt;
&lt;div class="codehilite"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;aws&lt;span class="w"&gt; &lt;/span&gt;ec2&lt;span class="w"&gt; &lt;/span&gt;create-launch-template-version&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;--launch-template-id&lt;span class="w"&gt; &lt;/span&gt;lt-0123456789abcdef0&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;--source-version&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;--launch-template-data&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;{&lt;/span&gt;
&lt;span class="s1"&gt;    &amp;quot;MetadataOptions&amp;quot;: {&lt;/span&gt;
&lt;span class="s1"&gt;      &amp;quot;HttpTokens&amp;quot;: &amp;quot;required&amp;quot;,&lt;/span&gt;
&lt;span class="s1"&gt;      &amp;quot;HttpPutResponseHopLimit&amp;quot;: 2,&lt;/span&gt;
&lt;span class="s1"&gt;      &amp;quot;HttpEndpoint&amp;quot;: &amp;quot;enabled&amp;quot;&lt;/span&gt;
&lt;span class="s1"&gt;    }&lt;/span&gt;
&lt;span class="s1"&gt;  }&amp;#39;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;This stops the bleeding. Old instances may still be on v1, but no new ones are.&lt;/p&gt;
&lt;h3 id="2-sort-instances-into-waves"&gt;2. Sort instances into waves&lt;/h3&gt;
&lt;p&gt;Pull the list of &lt;code&gt;HttpTokens=optional&lt;/code&gt; instances. Group them by:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Wave 0 — disposable.&lt;/strong&gt; Stateless workers, batch nodes, dev/test. Cheap to break, cheap to recreate. Migrate first.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Wave 1 — replaceable through autoscaling.&lt;/strong&gt; ASG-managed web tiers, ECS/EKS nodes. New launches are already v2-required; old nodes get rotated out by simply triggering an instance refresh.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Wave 2 — stateful or hand-built.&lt;/strong&gt; Bastions, databases on EC2, single-instance services, anything pet-shaped.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;For waves 0 and 1, prefer &lt;strong&gt;rotation over modification&lt;/strong&gt; — relaunch from updated launch templates rather than mutating live instances. Less risky, fewer surprises.&lt;/p&gt;
&lt;h3 id="3-optional-try-optional-required-with-a-hop-bump"&gt;3. Optional: try &lt;code&gt;optional&lt;/code&gt; → &lt;code&gt;required&lt;/code&gt; with a hop bump&lt;/h3&gt;
&lt;p&gt;For a stateful instance you cannot easily relaunch, raise the hop limit first (so containers keep working), then flip tokens to required:&lt;/p&gt;
&lt;div class="codehilite"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="c1"&gt;# Step A: bump hop limit while still allowing v1&lt;/span&gt;
aws&lt;span class="w"&gt; &lt;/span&gt;ec2&lt;span class="w"&gt; &lt;/span&gt;modify-instance-metadata-options&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;--instance-id&lt;span class="w"&gt; &lt;/span&gt;i-0123456789abcdef0&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;--http-put-response-hop-limit&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;2&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;--http-tokens&lt;span class="w"&gt; &lt;/span&gt;optional&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;--http-endpoint&lt;span class="w"&gt; &lt;/span&gt;enabled

&lt;span class="c1"&gt;# Verify everything still works for at least one full agent cycle&lt;/span&gt;
&lt;span class="c1"&gt;# (CloudWatch agent, SSM agent, your app, container credential lookups)&lt;/span&gt;

&lt;span class="c1"&gt;# Step B: require v2&lt;/span&gt;
aws&lt;span class="w"&gt; &lt;/span&gt;ec2&lt;span class="w"&gt; &lt;/span&gt;modify-instance-metadata-options&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;--instance-id&lt;span class="w"&gt; &lt;/span&gt;i-0123456789abcdef0&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;--http-tokens&lt;span class="w"&gt; &lt;/span&gt;required
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;Watch &lt;code&gt;MetadataNoToken&lt;/code&gt; after step A — if any callers are still using v1, they will keep showing up in the metric. Fix or upgrade them before step B.&lt;/p&gt;
&lt;h3 id="4-roll-auto-scaling-groups"&gt;4. Roll Auto Scaling groups&lt;/h3&gt;
&lt;p&gt;After the launch template is updated:&lt;/p&gt;
&lt;div class="codehilite"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;aws&lt;span class="w"&gt; &lt;/span&gt;autoscaling&lt;span class="w"&gt; &lt;/span&gt;start-instance-refresh&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;--auto-scaling-group-name&lt;span class="w"&gt; &lt;/span&gt;my-asg&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;--preferences&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;{&amp;quot;MinHealthyPercentage&amp;quot;: 90, &amp;quot;InstanceWarmup&amp;quot;: 300}&amp;#39;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;For EKS managed node groups, the equivalent is updating the node group to a new launch template version and letting AWS drain and replace nodes. For ECS, update the capacity provider&amp;rsquo;s launch template and either drain instances or wait for natural turnover.&lt;/p&gt;
&lt;h3 id="5-sweep-and-confirm"&gt;5. Sweep and confirm&lt;/h3&gt;
&lt;p&gt;After each wave, re-run the inventory query and the &lt;code&gt;MetadataNoToken&lt;/code&gt; check. Anything still on &lt;code&gt;optional&lt;/code&gt; should have a name attached to it and a reason.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Mid-article CTA:&lt;/strong&gt; Want a one-shot read-only audit that tells you which of your EC2 instances still allow IMDSv1, plus a dozen other quiet AWS posture issues? That&amp;rsquo;s exactly what &lt;a href="https://richgibbs.dev/quickcheck/"&gt;QuickCheck&lt;/a&gt; is built for. Skim a &lt;a href="https://richgibbs.dev/quickcheck/sample-report.html"&gt;sample report&lt;/a&gt; before you decide.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2 id="validation"&gt;Validation&lt;/h2&gt;
&lt;p&gt;After you flip an instance, you want fast confirmation it&amp;rsquo;s actually on v2 and nothing is silently failing.&lt;/p&gt;
&lt;h3 id="confirm-v2-required-at-the-api-level"&gt;Confirm v2-required at the API level&lt;/h3&gt;
&lt;div class="codehilite"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;aws&lt;span class="w"&gt; &lt;/span&gt;ec2&lt;span class="w"&gt; &lt;/span&gt;describe-instances&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;--instance-ids&lt;span class="w"&gt; &lt;/span&gt;i-0123456789abcdef0&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;--query&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;Reservations[0].Instances[0].MetadataOptions&amp;#39;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;Expected:&lt;/p&gt;
&lt;div class="codehilite"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;State&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;applied&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;HttpTokens&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;required&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;HttpPutResponseHopLimit&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;HttpEndpoint&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;enabled&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;HttpProtocolIpv6&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;disabled&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;InstanceMetadataTags&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;disabled&amp;quot;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;State: applied&lt;/code&gt; matters — &lt;code&gt;pending&lt;/code&gt; means the change has not landed yet.&lt;/p&gt;
&lt;h3 id="confirm-v1-is-actually-rejected-on-the-host"&gt;Confirm v1 is actually rejected on the host&lt;/h3&gt;
&lt;div class="codehilite"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="c1"&gt;# Should now return 401 Unauthorized&lt;/span&gt;
curl&lt;span class="w"&gt; &lt;/span&gt;-s&lt;span class="w"&gt; &lt;/span&gt;-o&lt;span class="w"&gt; &lt;/span&gt;/dev/null&lt;span class="w"&gt; &lt;/span&gt;-w&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;v1: %{http_code}\n&amp;quot;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;http://169.254.169.254/latest/meta-data/instance-id

&lt;span class="c1"&gt;# Should return 200 with the instance ID&lt;/span&gt;
&lt;span class="nv"&gt;TOKEN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;$(&lt;/span&gt;curl&lt;span class="w"&gt; &lt;/span&gt;-s&lt;span class="w"&gt; &lt;/span&gt;-X&lt;span class="w"&gt; &lt;/span&gt;PUT&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;http://169.254.169.254/latest/api/token&amp;quot;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;-H&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;X-aws-ec2-metadata-token-ttl-seconds: 21600&amp;quot;&lt;/span&gt;&lt;span class="k"&gt;)&lt;/span&gt;
curl&lt;span class="w"&gt; &lt;/span&gt;-s&lt;span class="w"&gt; &lt;/span&gt;-H&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;X-aws-ec2-metadata-token: &lt;/span&gt;&lt;span class="nv"&gt;$TOKEN&lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;-w&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;\nv2: %{http_code}\n&amp;quot;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;http://169.254.169.254/latest/meta-data/instance-id
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;v1: 401&lt;/code&gt; and &lt;code&gt;v2: 200&lt;/code&gt; is the correct pair.&lt;/p&gt;
&lt;h3 id="confirm-credentials-still-resolve"&gt;Confirm credentials still resolve&lt;/h3&gt;
&lt;div class="codehilite"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="nv"&gt;TOKEN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;$(&lt;/span&gt;curl&lt;span class="w"&gt; &lt;/span&gt;-s&lt;span class="w"&gt; &lt;/span&gt;-X&lt;span class="w"&gt; &lt;/span&gt;PUT&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;http://169.254.169.254/latest/api/token&amp;quot;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;-H&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;X-aws-ec2-metadata-token-ttl-seconds: 21600&amp;quot;&lt;/span&gt;&lt;span class="k"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;ROLE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;$(&lt;/span&gt;curl&lt;span class="w"&gt; &lt;/span&gt;-s&lt;span class="w"&gt; &lt;/span&gt;-H&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;X-aws-ec2-metadata-token: &lt;/span&gt;&lt;span class="nv"&gt;$TOKEN&lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;http://169.254.169.254/latest/meta-data/iam/security-credentials/&lt;span class="k"&gt;)&lt;/span&gt;
curl&lt;span class="w"&gt; &lt;/span&gt;-s&lt;span class="w"&gt; &lt;/span&gt;-H&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;X-aws-ec2-metadata-token: &lt;/span&gt;&lt;span class="nv"&gt;$TOKEN&lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;http://169.254.169.254/latest/meta-data/iam/security-credentials/&lt;span class="nv"&gt;$ROLE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;head&lt;span class="w"&gt; &lt;/span&gt;-c&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;200&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;You should see &lt;code&gt;AccessKeyId&lt;/code&gt;, &lt;code&gt;SecretAccessKey&lt;/code&gt;, &lt;code&gt;Token&lt;/code&gt;, and &lt;code&gt;Expiration&lt;/code&gt;.&lt;/p&gt;
&lt;h3 id="confirm-app-level-health"&gt;Confirm app-level health&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;aws sts get-caller-identity&lt;/code&gt; from the instance using whichever SDK your workloads use.&lt;/li&gt;
&lt;li&gt;Container credential lookups from inside one container per host (especially if you raised the hop limit).&lt;/li&gt;
&lt;li&gt;ECS agent: &lt;code&gt;curl -s http://localhost:51678/v1/metadata&lt;/code&gt; should still respond.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;kubelet&lt;/code&gt; health: nodes still &lt;code&gt;Ready&lt;/code&gt;, image pulls from ECR still work.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id="confirm-metadatanotoken-is-zero"&gt;Confirm &lt;code&gt;MetadataNoToken&lt;/code&gt; is zero&lt;/h3&gt;
&lt;p&gt;After 24–48 hours on v2-required, &lt;code&gt;MetadataNoToken&lt;/code&gt; should be a flat zero line. If not, something is still calling v1 — which now means it is failing. Find it.&lt;/p&gt;
&lt;h2 id="rollback"&gt;Rollback&lt;/h2&gt;
&lt;p&gt;You want this written down before you need it.&lt;/p&gt;
&lt;p&gt;Per-instance rollback is one CLI call:&lt;/p&gt;
&lt;div class="codehilite"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;aws&lt;span class="w"&gt; &lt;/span&gt;ec2&lt;span class="w"&gt; &lt;/span&gt;modify-instance-metadata-options&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;--instance-id&lt;span class="w"&gt; &lt;/span&gt;i-0123456789abcdef0&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;--http-tokens&lt;span class="w"&gt; &lt;/span&gt;optional&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;--http-put-response-hop-limit&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;2&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;--http-endpoint&lt;span class="w"&gt; &lt;/span&gt;enabled
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;That re-enables IMDSv1 immediately, no instance restart required. It is the same call you used to flip forward — just with &lt;code&gt;optional&lt;/code&gt; instead of &lt;code&gt;required&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Launch template rollback: revert to the previous version.&lt;/p&gt;
&lt;div class="codehilite"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;aws&lt;span class="w"&gt; &lt;/span&gt;ec2&lt;span class="w"&gt; &lt;/span&gt;modify-launch-template&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;--launch-template-id&lt;span class="w"&gt; &lt;/span&gt;lt-0123456789abcdef0&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;--default-version&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;Auto Scaling rollback: trigger another instance refresh against the previous LT version, or roll forward with a fixed template once you know what broke. Avoid the temptation to mutate live ASG instances; relaunch is cleaner.&lt;/p&gt;
&lt;p&gt;For account-level defaults, you can re-relax them, but generally do not. Once new instances are v2-required by default, leave that in place even if you have to roll back individual stragglers.&lt;/p&gt;
&lt;h2 id="quickcheck-cta"&gt;QuickCheck CTA&lt;/h2&gt;
&lt;p&gt;If you&amp;rsquo;d rather not hand-roll the inventory queries and CloudWatch checks across every account and region, &lt;strong&gt;&lt;a href="https://richgibbs.dev/quickcheck/"&gt;QuickCheck&lt;/a&gt;&lt;/strong&gt; runs a read-only, one-shot review of your AWS posture and produces a plain-English report. IMDSv1 stragglers are one of the dozen things it surfaces — alongside open security groups, public S3, missing MFA on root, untagged keys, and a few other &amp;ldquo;you&amp;rsquo;d rather know&amp;rdquo; items. See an example in the &lt;a href="https://richgibbs.dev/quickcheck/sample-report.html"&gt;sample report&lt;/a&gt;. It is not magic and not a replacement for proper cloud security tooling, but it is a fast way to know where you stand before you start migrating.&lt;/p&gt;
&lt;h2 id="what-this-is-not"&gt;What This Is Not&lt;/h2&gt;
&lt;p&gt;To set expectations clearly:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;This is &lt;strong&gt;not a penetration test&lt;/strong&gt;. It is a configuration migration, not an adversarial exercise.&lt;/li&gt;
&lt;li&gt;This is &lt;strong&gt;not a certification or compliance attestation&lt;/strong&gt;. Migrating to IMDSv2 is a control improvement; it does not by itself constitute SOC 2, ISO 27001, PCI, or anything else. Your auditor still wants the artifacts they always want.&lt;/li&gt;
&lt;li&gt;This is &lt;strong&gt;not a guarantee&lt;/strong&gt;. Cloud security is a portfolio of controls. IMDSv2 closes one well-known SSRF-to-credentials path; it does not address misconfigured security groups, overly broad IAM policies, leaked long-lived keys, or vulnerable application code. Treat it as one item on the list.&lt;/li&gt;
&lt;li&gt;This is &lt;strong&gt;not a substitute&lt;/strong&gt; for moving workloads to IRSA / EC2 Pod Identity / ECS task roles where those fit. IMDSv2 makes instance metadata safer; per-workload identity is still the better long-term answer for containers.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Migrate to IMDSv2 because it is cheap, well-understood, and removes a real foot-gun. Then keep going.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id="about-tuck-sentinel"&gt;About Tuck Sentinel&lt;/h2&gt;
&lt;p&gt;Tuck Sentinel is the security-focused side of an indie operator workshop by Rich Gibbs. It builds small, sharp tools — like QuickCheck — for founders and small teams who want a competent read of their cloud posture without an enterprise platform. The bias: fast, honest, read-only assessments and migrations you can actually finish.&lt;/p&gt;
&lt;div class="codehilite"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;@context&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;https://schema.org&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;@type&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;Article&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;headline&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;AWS IMDSv2 Migration Without Breaking Things&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;description&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;A practical, indie-founder guide to migrating EC2 instances from IMDSv1 to IMDSv2 without breaking SDKs, containers, kubelet, or the ECS agent.&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;author&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;@type&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;Organization&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;name&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;Tuck Sentinel&amp;quot;&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;publisher&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;@type&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;Organization&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;name&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;Tuck Sentinel&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;url&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;https://richgibbs.dev/&amp;quot;&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;mainEntityOfPage&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;@type&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;WebPage&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;@id&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;https://example.com/blog/aws-imdsv2-migration-without-breaking-things&amp;quot;&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;image&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;https://example.com/og/aws-imdsv2-migration.png&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;articleSection&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;Cloud Security&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;keywords&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;AWS, EC2, IMDSv2, IMDSv1, cloud security, IAM, SSRF, migration&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;about&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;@type&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;Thing&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;name&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;AWS EC2 Instance Metadata Service&amp;quot;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;@type&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;Thing&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;name&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;IMDSv2&amp;quot;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;@type&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;Thing&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;name&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;Cloud Security Posture&amp;quot;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;</content:encoded>
      <guid isPermaLink="true">https://blog.richgibbs.dev/aws-imdsv2-migration-without-breaking-things/</guid>
      <category>aws</category>
      <category>ec2</category>
      <category>imdsv2</category>
      <category>security</category>
      <category>devops</category>
      <category>cloud-security</category>
      <pubDate>Sun, 10 May 2026 00:22:00 +0000</pubDate>
    </item>
    <item>
      <title>SPF, DKIM, DMARC for indie founders: the 20-minute checklist</title>
      <link>https://blog.richgibbs.dev/spf-dkim-dmarc-indie-founder-checklist/</link>
      <description>If your password resets and receipts keep landing in spam, your server is fine and your email DNS probably isn't. Here is the boring, working playbook to fix it in one sitting.</description>
      <content:encoded>&lt;p&gt;You shipped a product. Stripe sends receipts. Postmark sends magic links. Mailchimp blasts your launch list. You replied to a support ticket from your own founder address.&lt;/p&gt;
&lt;p&gt;Then someone said &lt;em&gt;&amp;ldquo;hey, your password reset went to spam.&amp;rdquo;&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;This guide is for that moment.&lt;/p&gt;
&lt;p&gt;It is not a deliverability bible. It is the smallest correct version of the SPF / DKIM / DMARC story for a solo founder or a 2-3 person SaaS team, with one custom domain and two-to-five tools that send email on its behalf. If you can edit DNS and copy a record, you can finish it tonight.&lt;/p&gt;
&lt;p&gt;We are also not selling you a deliverability platform. The point of this post is for you to do it yourself, &lt;em&gt;correctly&lt;/em&gt;, in one sitting.&lt;/p&gt;
&lt;h2 id="what-set-up-email-dns-actually-means-in-2026"&gt;What &amp;ldquo;set up email DNS&amp;rdquo; actually means in 2026&lt;/h2&gt;
&lt;p&gt;Mailbox providers — Gmail, Yahoo, Outlook, Apple, ProtonMail — use three DNS-anchored signals to decide whether a message is plausibly from your domain at all:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;SPF&lt;/strong&gt; says &lt;em&gt;&amp;ldquo;these IP addresses / hostnames are allowed to send mail using my domain in the envelope sender.&amp;rdquo;&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;DKIM&lt;/strong&gt; says &lt;em&gt;&amp;ldquo;messages from my domain will carry a cryptographic signature in the headers, signed by a key whose public half lives in DNS.&amp;rdquo;&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;DMARC&lt;/strong&gt; says &lt;em&gt;&amp;ldquo;if SPF and DKIM both fail to align with my visible From: domain, here is what you should do — nothing, quarantine to spam, or reject — and please send me reports.&amp;rdquo;&lt;/em&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;In 2024–2025 Gmail and Yahoo started requiring all three from any sender shipping more than 5,000 messages a day to their users, and they have been quietly tightening the rules for low-volume senders ever since. In practice, by 2026:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;If your domain has no SPF and no DKIM, password resets and receipts will sometimes silently disappear into spam.&lt;/li&gt;
&lt;li&gt;If your domain has no DMARC at all, anyone can spoof &amp;ldquo;from your domain&amp;rdquo; until enough recipients complain.&lt;/li&gt;
&lt;li&gt;If your DMARC record is malformed, mailbox providers behave the same as if it isn&amp;rsquo;t there — except now your reports vanish too.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;You do not need to be perfect. You need to be &lt;em&gt;not broken&lt;/em&gt;.&lt;/p&gt;
&lt;h2 id="the-20-minute-checklist"&gt;The 20-minute checklist&lt;/h2&gt;
&lt;p&gt;Before you touch DNS, do the boring inventory step. This is the part most founders skip and most spam problems come from.&lt;/p&gt;
&lt;h3 id="1-list-every-tool-that-sends-mail-from-your-domain-3-minutes"&gt;1. List every tool that sends mail &amp;ldquo;from&amp;rdquo; your domain (3 minutes)&lt;/h3&gt;
&lt;p&gt;Open a notes file. Write the domain you want to fix at the top. Then list every place that sends email &lt;em&gt;as&lt;/em&gt; that domain. Real examples for a typical indie SaaS:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Founder mail (you replying from &lt;code&gt;you@yourdomain.com&lt;/code&gt;) — Google Workspace or Fastmail.&lt;/li&gt;
&lt;li&gt;Transactional / product mail — Postmark, Resend, Mailgun, AWS SES, SendGrid, Mailtrap.&lt;/li&gt;
&lt;li&gt;Marketing / newsletter — ConvertKit, Mailchimp, Beehiiv, Buttondown, Substack custom domain.&lt;/li&gt;
&lt;li&gt;Helpdesk — Help Scout, Front, HubSpot, Zendesk, Plain.&lt;/li&gt;
&lt;li&gt;App-platform notifications — Vercel/Render/Heroku notifications using your domain, GitHub on a custom domain.&lt;/li&gt;
&lt;li&gt;Stripe receipts and Tally form notifications, when configured to &amp;ldquo;send from&amp;rdquo; your domain rather than the platform default.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;If you cannot remember, search your inbox for &lt;code&gt;from:@yourdomain.com&lt;/code&gt; and note every &amp;ldquo;tool integration&amp;rdquo; message you find from the last 90 days.&lt;/p&gt;
&lt;p&gt;This list is the single most useful artifact in this entire process. If anyone ever asks you &amp;ldquo;do you know who sends as your domain?&amp;rdquo;, you can answer in one screen.&lt;/p&gt;
&lt;h3 id="2-pick-exactly-one-spf-record-5-minutes"&gt;2. Pick exactly one SPF record (5 minutes)&lt;/h3&gt;
&lt;p&gt;SPF is one TXT record at the apex of your domain (&lt;code&gt;yourdomain.com&lt;/code&gt;, not &lt;code&gt;mail.yourdomain.com&lt;/code&gt;). You are allowed exactly one. If there are two SPF TXT records in DNS, every conforming mailbox server treats the result as &lt;code&gt;permerror&lt;/code&gt; and ignores both.&lt;/p&gt;
&lt;p&gt;A working SPF for the example list above might be:&lt;/p&gt;
&lt;div class="codehilite"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;v=spf1 include:_spf.google.com include:spf.mtasv.net include:_spf.mailgun.org include:_spf.constantcontact.com -all
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;Rules:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Start with &lt;code&gt;v=spf1&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;One &lt;code&gt;include:&lt;/code&gt; per provider, taken from each provider&amp;rsquo;s docs. Do not invent them.&lt;/li&gt;
&lt;li&gt;End with &lt;code&gt;-all&lt;/code&gt; (hard fail) or &lt;code&gt;~all&lt;/code&gt; (soft fail). Use &lt;code&gt;~all&lt;/code&gt; while you are setting up DMARC, then move to &lt;code&gt;-all&lt;/code&gt; once DMARC reports are clean.&lt;/li&gt;
&lt;li&gt;Do not put &lt;code&gt;+all&lt;/code&gt; anywhere. Ever. That tells the world &lt;em&gt;anyone&lt;/em&gt; can send as you.&lt;/li&gt;
&lt;li&gt;Do not exceed 10 DNS lookups across all the &lt;code&gt;include:&lt;/code&gt; and &lt;code&gt;redirect=&lt;/code&gt; directives combined. Tools like Google Workspace + Mailgun + Mailchimp + Constant Contact + Help Scout will quietly exceed 10. If you see &lt;code&gt;permerror&lt;/code&gt; reports, this is usually why.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;If you use &lt;code&gt;mail.yourdomain.com&lt;/code&gt; as a separate sending subdomain (some providers configure it that way), publish a &lt;em&gt;separate&lt;/em&gt; SPF record at that subdomain.&lt;/p&gt;
&lt;h3 id="3-add-dkim-for-each-sending-tool-5-minutes"&gt;3. Add DKIM for each sending tool (5 minutes)&lt;/h3&gt;
&lt;p&gt;DKIM is per-provider. Every provider that sends mail for you should give you one or more &lt;code&gt;selector._domainkey.yourdomain.com&lt;/code&gt; CNAME or TXT records to add.&lt;/p&gt;
&lt;p&gt;Examples of selectors you&amp;rsquo;ll see in a real indie SaaS:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Google Workspace: &lt;code&gt;google._domainkey&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Postmark: &lt;code&gt;&amp;lt;assigned&amp;gt;._domainkey&lt;/code&gt; (Postmark assigns the selector when you verify the domain)&lt;/li&gt;
&lt;li&gt;Mailgun: &lt;code&gt;mailo._domainkey&lt;/code&gt; and &lt;code&gt;pic._domainkey&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;ConvertKit / Mailchimp: their dashboard prints the exact CNAMEs.&lt;/li&gt;
&lt;li&gt;Resend: &lt;code&gt;resend._domainkey&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Two rules that catch people:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;DKIM records &lt;em&gt;do not&lt;/em&gt; show up in plain &lt;code&gt;dig TXT yourdomain.com&lt;/code&gt;. You have to query the selector explicitly: &lt;code&gt;dig TXT selector._domainkey.yourdomain.com&lt;/code&gt;. If you cannot remember selectors, you cannot validate your own DKIM from public DNS — write them down.&lt;/li&gt;
&lt;li&gt;&amp;ldquo;DKIM is set up&amp;rdquo; is not the same as &amp;ldquo;messages are being signed.&amp;rdquo; Each provider has its own toggle for &amp;ldquo;sign outbound mail with this key.&amp;rdquo; If signing is off in the provider dashboard, the selector record alone is useless.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The &lt;code&gt;Authentication-Results&lt;/code&gt; header in any actual sent email is the source of truth. If it says &lt;code&gt;dkim=pass&lt;/code&gt; from your visible domain, signing is real.&lt;/p&gt;
&lt;h3 id="4-publish-a-cautious-dmarc-3-minutes"&gt;4. Publish a &lt;em&gt;cautious&lt;/em&gt; DMARC (3 minutes)&lt;/h3&gt;
&lt;p&gt;DMARC is one TXT record at &lt;code&gt;_dmarc.yourdomain.com&lt;/code&gt;. Start safe:&lt;/p&gt;
&lt;div class="codehilite"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;v=DMARC1; p=none; rua=mailto:dmarc-reports@yourdomain.com; adkim=r; aspf=r; pct=100
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;Translation:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;p=none&lt;/code&gt; — do not block anything yet, just ask for reports.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;rua=mailto:&lt;/code&gt; — a real mailbox you actually read; &lt;em&gt;not&lt;/em&gt; a personal Gmail you ignore. Many founders use a forwarding alias like &lt;code&gt;dmarc-reports@yourdomain.com&lt;/code&gt; that lands in a labeled folder.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;adkim=r; aspf=r&lt;/code&gt; — relaxed alignment. Strict alignment is for later.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;A 14-day &lt;code&gt;p=none&lt;/code&gt; window before you tighten anything is the difference between &amp;ldquo;I learned my newsletter platform sends as &lt;code&gt;mail.mydomain.com&lt;/code&gt;&amp;rdquo; and &amp;ldquo;I broke my newsletter for two days.&amp;rdquo;&lt;/p&gt;
&lt;p&gt;After 14 days of clean reports — meaning every legitimate sender shows up in the reports as passing SPF &lt;em&gt;or&lt;/em&gt; DKIM aligned with &lt;code&gt;yourdomain.com&lt;/code&gt; — move to:&lt;/p&gt;
&lt;div class="codehilite"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;v=DMARC1; p=quarantine; rua=mailto:dmarc-reports@yourdomain.com; pct=25
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;The &lt;code&gt;pct=25&lt;/code&gt; ramp is intentional. It means &amp;ldquo;quarantine 25 % of messages that fail alignment&amp;rdquo; so you can detect any forgotten sender before going full &lt;code&gt;p=quarantine&lt;/code&gt; or &lt;code&gt;p=reject&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;If you are an indie founder, you may stop at &lt;code&gt;p=quarantine&lt;/code&gt; forever. &lt;code&gt;p=reject&lt;/code&gt; is for senders who are confident no legitimate mail anywhere uses their domain incorrectly.&lt;/p&gt;
&lt;h3 id="5-verify-the-result-with-one-real-email-4-minutes"&gt;5. Verify the result with one real email (4 minutes)&lt;/h3&gt;
&lt;p&gt;Send one email to yourself at Gmail, Yahoo, and Outlook from each sending tool you care about most (founder mail, password reset, newsletter). Open the message header.&lt;/p&gt;
&lt;p&gt;You are looking for an &lt;code&gt;Authentication-Results&lt;/code&gt; line that says all three of:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;spf=pass&lt;/code&gt; with &lt;code&gt;smtp.mailfrom=&lt;/code&gt; matching a domain that contains &lt;code&gt;yourdomain.com&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;dkim=pass&lt;/code&gt; with &lt;code&gt;header.d=yourdomain.com&lt;/code&gt; (alignment) — &lt;em&gt;not&lt;/em&gt; &lt;code&gt;header.d=postmarkapp.com&lt;/code&gt; or &lt;code&gt;header.d=mailgun.org&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;dmarc=pass&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;code&gt;dkim=pass header.d=mailgun.org&lt;/code&gt; while your visible From: is &lt;code&gt;support@yourdomain.com&lt;/code&gt; is the most common deliverability bug among indie founders. The message is technically signed, but DMARC-wise it is unsigned by &lt;em&gt;your&lt;/em&gt; domain. Fix it by completing the provider&amp;rsquo;s &amp;ldquo;Use my own domain&amp;rdquo; / &amp;ldquo;Custom domain DKIM&amp;rdquo; configuration. Postmark, Mailgun, Resend, SendGrid, Mailchimp, ConvertKit, and AWS SES all support this; they just don&amp;rsquo;t enable it by default.&lt;/p&gt;
&lt;h2 id="things-to-deliberately-ignore-in-v1"&gt;Things to deliberately ignore in v1&lt;/h2&gt;
&lt;p&gt;You do not need:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;BIMI. Useful only after DMARC is at &lt;code&gt;p=quarantine&lt;/code&gt; or stricter for a long time, and even then it is a logo-display feature, not a deliverability feature.&lt;/li&gt;
&lt;li&gt;ARC. Mailing-list specific.&lt;/li&gt;
&lt;li&gt;DKIM key rotation. Whatever your provider gave you is fine until they tell you to rotate.&lt;/li&gt;
&lt;li&gt;Per-subdomain DMARC strictness (&lt;code&gt;sp=&lt;/code&gt;). Default is fine until you operate dedicated sending subdomains.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;You also do not need:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;A paid &amp;ldquo;deliverability platform&amp;rdquo; subscription.&lt;/li&gt;
&lt;li&gt;A reputation-monitoring agency.&lt;/li&gt;
&lt;li&gt;An IP warmup schedule (you are using shared IPs from your ESP; they handle warmup).&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="common-gotchas-an-indie-founder-will-hit"&gt;Common gotchas an indie founder will hit&lt;/h2&gt;
&lt;p&gt;These are the failure modes I see most often when reviewing single-domain setups:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Two SPF records.&lt;/strong&gt; Often a leftover from when you were trying providers. Merge into one.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;+all&lt;/code&gt; left over from a Google guide that said &amp;ldquo;for testing only.&amp;rdquo;&lt;/strong&gt; Remove.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;DMARC &lt;code&gt;rua&lt;/code&gt; pointing at &lt;code&gt;you@yourdomain.com&lt;/code&gt; itself.&lt;/strong&gt; Your inbox will fill with unreadable XML aggregate reports. Use a sub-alias (&lt;code&gt;dmarc-reports@&lt;/code&gt;) that auto-files.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;DKIM &amp;ldquo;set up&amp;rdquo; but provider has signing disabled.&lt;/strong&gt; Toggle it on in the provider, and confirm with a real test message header.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Marketing tool added later, but DKIM never aligned.&lt;/strong&gt; New newsletter platform turns SPF green, leaves DKIM &lt;code&gt;header.d=&lt;/code&gt; pointing at the platform&amp;rsquo;s domain. DMARC fails alignment for that one tool.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Personal Gmail &amp;ldquo;Send mail as&amp;rdquo; alias used to reply from &lt;code&gt;you@yourdomain.com&lt;/code&gt;.&lt;/strong&gt; Even if Workspace is fine, that alias often sends as &lt;code&gt;gmail.com&lt;/code&gt; underneath. Reply-To is fine; the sending identity matters for alignment.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Subdomain forgotten.&lt;/strong&gt; Stripe receipts sometimes go through &lt;code&gt;mail.yourdomain.com&lt;/code&gt;. If subdomain SPF/DKIM is missing, mailbox providers can still apply the apex DMARC. Check at the &lt;em&gt;exact&lt;/em&gt; subdomain.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;If any of those sound like a problem you cannot debug from your provider&amp;rsquo;s dashboard alone, that is the moment a second pair of eyes is worth more than another deliverability article.&lt;/p&gt;
&lt;h2 id="next-step-a-99-second-pair-of-eyes"&gt;Next step: a $99 second pair of eyes&lt;/h2&gt;
&lt;p&gt;Once you&amp;rsquo;ve done the 20-minute pass above, the question is usually not &lt;em&gt;&amp;ldquo;is the record there?&amp;rdquo;&lt;/em&gt; It&amp;rsquo;s &lt;em&gt;&amp;ldquo;are all these records aligned with the way I actually send mail?&amp;rdquo;&lt;/em&gt; That answer lives partly in DNS and partly in a few real message headers.&lt;/p&gt;
&lt;p&gt;If you&amp;rsquo;d like a written, prioritized fix list for one domain — SPF, DKIM, DMARC, MX, sender-tool inventory, and the obvious mistakes — that is exactly the &lt;a href="https://richgibbs.dev/quickcheck/inbox-dns.html"&gt;Inbox/DNS QuickCheck&lt;/a&gt; we offer. $99, one domain, no DNS login needed, 24-hour turnaround. No managed retainers, no inbox-placement guarantees, no spam help.&lt;/p&gt;
&lt;p&gt;If you&amp;rsquo;d rather DIY but want the printable, fillable Markdown version of the entire process — sender inventory template, SPF builder, DKIM provider reference, DMARC ramp, Authentication-Results decoder — that&amp;rsquo;s the &lt;a href="https://gibbs21.gumroad.com/l/inbox-dns-pack"&gt;Indie Founder Email DNS Pack&lt;/a&gt;, $19 (pay what you want, $9 minimum) on Gumroad.&lt;/p&gt;
&lt;p&gt;That is also the point at which most founders realize there was one tool nobody remembered to align. That tool is almost always a marketing platform.&lt;/p&gt;
&lt;p&gt;You don&amp;rsquo;t have to buy anything to follow the checklist above. The above is the whole working answer for most one-domain indie SaaS. The QuickCheck exists for when you&amp;rsquo;ve done the obvious and still have a quiet 5–10 % of legitimate mail disappearing into spam, and you want a second set of eyes before you tighten DMARC further.&lt;/p&gt;
&lt;p&gt;Either way, the goal is the same: your password resets, your receipts, and your founder replies should reach the inbox. The boring DNS hygiene above is most of the answer.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id="related-downloadable-pack"&gt;Related downloadable pack&lt;/h3&gt;
&lt;p&gt;If you&amp;rsquo;ve already finished the checklist above and tightened DMARC to &lt;code&gt;p=quarantine&lt;/code&gt;, and now a specific sender — newsletter tool, Stripe receipts, a sub-domain — has started being quarantined or hard-bounced (Gmail &lt;code&gt;5.7.26&lt;/code&gt;, Microsoft &lt;code&gt;5.7.509&lt;/code&gt; / &lt;code&gt;5.7.515&lt;/code&gt;), the &lt;strong&gt;DMARC Quarantine Pack&lt;/strong&gt; is the focused diagnostic runbook for that exact moment. It includes a DSN decoder cheat-sheet, three real-world incident walkthroughs (marketing-tool DKIM drift, forgotten sub-domain, forwarding/ARC breakage), and a single-file Python aggregate-XML reader so you can read your own DMARC reports without paying for a SaaS dashboard.&lt;/p&gt;
&lt;p&gt;&lt;a href="https://gibbs21.gumroad.com/l/dmarc-quarantine-pack"&gt;DMARC Quarantine Pack — $29 on Gumroad&lt;/a&gt; · 14-day refund, no questions.&lt;/p&gt;</content:encoded>
      <guid isPermaLink="true">https://blog.richgibbs.dev/spf-dkim-dmarc-indie-founder-checklist/</guid>
      <category>email</category>
      <category>dns</category>
      <category>spf</category>
      <category>dkim</category>
      <category>dmarc</category>
      <category>deliverability</category>
      <category>indie-founder</category>
      <category>saas</category>
      <pubDate>Sun, 10 May 2026 04:30:00 +0000</pubDate>
    </item>
    <item>
      <title>Cloudflare Email Routing for indie founders: the 10-minute support@ setup</title>
      <link>https://blog.richgibbs.dev/cloudflare-email-routing-indie-founders-10-minute-setup/</link>
      <description>Stop paying $6/user/month for a Workspace seat to forward support@yourdomain.com. Cloudflare Email Routing does the same job for free, in ten minutes, with one caveat you need to know about.</description>
      <content:encoded>&lt;p&gt;You launched. Your domain has a website, a payment link, a privacy page that says &amp;ldquo;support@yourdomain.com,&amp;rdquo; and… no actual mailbox at that address. Real customer mail is silently bouncing.&lt;/p&gt;
&lt;p&gt;You don&amp;rsquo;t need a $6-per-user-per-month Workspace seat to fix this. Cloudflare Email Routing forwards &lt;code&gt;support@yourdomain.com&lt;/code&gt; (and any other alias you want) to a Gmail/Fastmail/Proton mailbox you already pay for, for free, in about ten minutes.&lt;/p&gt;
&lt;p&gt;This post is the boring, working playbook to set it up — plus the one thing it can&amp;rsquo;t do that surprises every founder who tries it for the first time.&lt;/p&gt;
&lt;h2 id="what-email-routing-actually-is"&gt;What Email Routing actually is&lt;/h2&gt;
&lt;p&gt;Cloudflare Email Routing is &lt;strong&gt;inbound-only forwarding&lt;/strong&gt; for any domain whose DNS lives at Cloudflare. You publish a few MX and TXT records that Cloudflare manages for you, define some routing rules in the dashboard (&amp;ldquo;send anything to &lt;code&gt;support@yourdomain.com&lt;/code&gt; to my Gmail&amp;rdquo;), and incoming mail gets re-injected into your real mailbox.&lt;/p&gt;
&lt;p&gt;What it is not:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Not a mailbox. You can&amp;rsquo;t log in to a Cloudflare interface to read mail.&lt;/li&gt;
&lt;li&gt;Not an outbound SMTP server. You can&amp;rsquo;t &lt;em&gt;send&lt;/em&gt; from &lt;code&gt;support@yourdomain.com&lt;/code&gt; through Email Routing. Replies will go from whatever mailbox you forwarded &lt;em&gt;into&lt;/em&gt;, unless you also configure your replying client (more on this below).&lt;/li&gt;
&lt;li&gt;Not a deliverability service. It accepts mail from the public internet and re-delivers it. SPF/DKIM/DMARC for your domain are still your job.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="the-10-minute-path"&gt;The 10-minute path&lt;/h2&gt;
&lt;p&gt;Two prerequisites:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Your domain&amp;rsquo;s nameservers are Cloudflare&amp;rsquo;s. (If they aren&amp;rsquo;t, follow Cloudflare&amp;rsquo;s &amp;ldquo;Add a site&amp;rdquo; flow first; it&amp;rsquo;s free, takes about 5 minutes plus DNS propagation.)&lt;/li&gt;
&lt;li&gt;You have a Gmail / Fastmail / Proton / Mailbox.org / etc. mailbox you actually read.&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 id="1-enable-email-routing-1-minute"&gt;1. Enable Email Routing (1 minute)&lt;/h3&gt;
&lt;p&gt;In the Cloudflare dashboard:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Pick your zone → &lt;strong&gt;Email&lt;/strong&gt; → &lt;strong&gt;Email Routing&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Click &lt;strong&gt;Get started&lt;/strong&gt;. Cloudflare will offer to add the required DNS records automatically. Say yes.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Cloudflare will publish:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Three MX records pointing at &lt;code&gt;route1.mx.cloudflare.net&lt;/code&gt;, &lt;code&gt;route2.mx.cloudflare.net&lt;/code&gt;, &lt;code&gt;route3.mx.cloudflare.net&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;A TXT record at the apex with &lt;code&gt;v=spf1 include:_spf.mx.cloudflare.net ~all&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;A DKIM CNAME (&lt;code&gt;cf2024-1._domainkey&lt;/code&gt;) for the routing service.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;If you already have an SPF record at the apex, &lt;strong&gt;stop and merge them by hand&lt;/strong&gt;. You should never have two SPF records. We&amp;rsquo;ll come back to that in the gotchas.&lt;/p&gt;
&lt;h3 id="2-verify-the-destination-address-2-minutes"&gt;2. Verify the destination address (2 minutes)&lt;/h3&gt;
&lt;p&gt;Still in &lt;strong&gt;Email&lt;/strong&gt; → &lt;strong&gt;Email Routing&lt;/strong&gt;:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Destination addresses&lt;/strong&gt; → &lt;strong&gt;Add destination address&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Enter the personal mailbox you want forwarded mail to land in.&lt;/li&gt;
&lt;li&gt;Cloudflare emails a verification link. Click it.&lt;/li&gt;
&lt;li&gt;The destination&amp;rsquo;s status should flip to &lt;strong&gt;Verified&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;You can verify multiple destinations and route different aliases to different mailboxes. Useful if &lt;code&gt;billing@&lt;/code&gt; should go to a finance address and &lt;code&gt;security@&lt;/code&gt; should go to a different one.&lt;/p&gt;
&lt;h3 id="3-add-a-routing-rule-1-minute"&gt;3. Add a routing rule (1 minute)&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Custom addresses&lt;/strong&gt; → &lt;strong&gt;Create address&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Custom address&lt;/code&gt;: &lt;code&gt;support&lt;/code&gt; (so the full address is &lt;code&gt;support@yourdomain.com&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Action&lt;/code&gt;: Send to an email.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Destination&lt;/code&gt;: pick the verified address.&lt;/li&gt;
&lt;li&gt;Save.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Repeat for every alias you advertise: &lt;code&gt;hello@&lt;/code&gt;, &lt;code&gt;billing@&lt;/code&gt;, &lt;code&gt;legal@&lt;/code&gt;, &lt;code&gt;security@&lt;/code&gt;, &lt;code&gt;dmarc-reports@&lt;/code&gt; (very useful, more on this in a minute).&lt;/p&gt;
&lt;h3 id="4-optional-catch-all-30-seconds"&gt;4. (Optional) Catch-all (30 seconds)&lt;/h3&gt;
&lt;p&gt;In &lt;strong&gt;Custom addresses&lt;/strong&gt; → &lt;strong&gt;Catch-all address&lt;/strong&gt;, set it to either:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;em&gt;Drop&lt;/em&gt; — anything not matched is silently dropped. Good for spam hygiene.&lt;/li&gt;
&lt;li&gt;&lt;em&gt;Send to&lt;/em&gt; — any unknown alias is forwarded to your fallback mailbox. Good if you advertise lots of aliases on signup forms and don&amp;rsquo;t want misspellings to bounce.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;There is no third option. Pick one. &amp;ldquo;Drop&amp;rdquo; is what most indie SaaS founders should use.&lt;/p&gt;
&lt;h3 id="5-send-a-real-test-1-minute"&gt;5. Send a real test (1 minute)&lt;/h3&gt;
&lt;p&gt;From a &lt;em&gt;different&lt;/em&gt; mailbox (not the destination — Gmail will helpfully suppress mail you sent to yourself), email &lt;code&gt;support@yourdomain.com&lt;/code&gt;. It should arrive at the destination within a few seconds, with the original sender preserved in the From: header.&lt;/p&gt;
&lt;p&gt;That&amp;rsquo;s the whole setup. The remaining time is what&amp;rsquo;s between you and a working &lt;strong&gt;outbound&lt;/strong&gt; support address, which is the part that catches everyone.&lt;/p&gt;
&lt;h2 id="the-one-thing-email-routing-does-not-do-and-what-to-do-instead"&gt;The one thing Email Routing does &lt;em&gt;not&lt;/em&gt; do — and what to do instead&lt;/h2&gt;
&lt;p&gt;Email Routing is &lt;strong&gt;inbound only&lt;/strong&gt;. If you reply to a customer&amp;rsquo;s email and you do nothing else, your reply will go from &lt;code&gt;your.personal@gmail.com&lt;/code&gt;, not from &lt;code&gt;support@yourdomain.com&lt;/code&gt;. The customer sees a different address than the one they wrote to, the conversation feels off, and you might also leak a personal address you didn&amp;rsquo;t mean to advertise.&lt;/p&gt;
&lt;p&gt;Three options, in order of effort:&lt;/p&gt;
&lt;h3 id="option-a-live-with-replies-coming-from-the-personal-mailbox"&gt;Option A — Live with replies coming from the personal mailbox&lt;/h3&gt;
&lt;p&gt;OK for a v0 SaaS while you have ten customers. Set the Reply-To header in your mail tool to &lt;code&gt;support@yourdomain.com&lt;/code&gt; so further replies route correctly. Most mail clients let you set a default Reply-To per identity. Customers will see your personal address in the visible From, which they will probably tolerate while you&amp;rsquo;re small.&lt;/p&gt;
&lt;h3 id="option-b-use-gmails-send-mail-as-with-a-real-outbound-smtp-server"&gt;Option B — Use Gmail&amp;rsquo;s &amp;ldquo;Send mail as&amp;rdquo; with a real outbound SMTP server&lt;/h3&gt;
&lt;p&gt;In Gmail: &lt;strong&gt;Settings&lt;/strong&gt; → &lt;strong&gt;Accounts and Import&lt;/strong&gt; → &lt;strong&gt;Send mail as&lt;/strong&gt; → &lt;strong&gt;Add another email address&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;You will need:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;A real outbound SMTP host that authorizes you to send as &lt;code&gt;support@yourdomain.com&lt;/code&gt;. &lt;strong&gt;Gmail itself will not let you do this without an SMTP server&lt;/strong&gt;; the &amp;ldquo;treat as alias&amp;rdquo; path that worked years ago is gone.&lt;/li&gt;
&lt;li&gt;An SMTP host can be a paid Workspace seat ($6/mo/user — the thing we just avoided), or a transactional ESP like Postmark / Resend / Mailgun / SES configured with your custom domain and DKIM.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;If you already use Postmark/Resend/Mailgun/SES for product mail, set up an authorized &amp;ldquo;transactional support&amp;rdquo; sender there and feed the SMTP credentials into Gmail&amp;rsquo;s Send-mail-as flow. Postmark and Resend both have specific docs for this. Now your replies go from &lt;code&gt;support@yourdomain.com&lt;/code&gt; over a path that aligns with DKIM.&lt;/p&gt;
&lt;h3 id="option-c-use-a-help-desk-tool-with-custom-domain-support"&gt;Option C — Use a help-desk tool with custom-domain support&lt;/h3&gt;
&lt;p&gt;Help Scout, Plain, Front, Missive, HubSpot Service. These all accept inbound mail forwarded to a tool-specific address (you point Cloudflare Email Routing at it instead of your Gmail) and send outbound replies as &lt;code&gt;support@yourdomain.com&lt;/code&gt; with their own DKIM you authorize. Per-seat pricing varies; some have free tiers up to a few mailboxes.&lt;/p&gt;
&lt;p&gt;For an indie SaaS at 0–500 customers, Option B is usually the sweet spot. For a 2-3 person team that wants conversation handling, Option C earns its keep.&lt;/p&gt;
&lt;h2 id="common-gotchas"&gt;Common gotchas&lt;/h2&gt;
&lt;p&gt;These are the things I see indie founders get wrong with Email Routing.&lt;/p&gt;
&lt;h3 id="gotcha-1-dual-spf-records"&gt;Gotcha 1: dual SPF records&lt;/h3&gt;
&lt;p&gt;If your DNS already had an SPF record (because you set up Postmark or Mailgun before adding Email Routing), Cloudflare will silently publish a &lt;em&gt;second&lt;/em&gt; one. Conforming receivers will treat dual SPF as &lt;code&gt;permerror&lt;/code&gt; and ignore both. Result: legitimate inbound delivery may still work via MX, but your &lt;em&gt;outbound&lt;/em&gt; SPF alignment quietly breaks.&lt;/p&gt;
&lt;p&gt;Fix: keep one record at the apex. If you also send via Postmark and Mailgun and have routing on:&lt;/p&gt;
&lt;div class="codehilite"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;v=spf1 include:_spf.mx.cloudflare.net include:spf.mtasv.net include:_spf.mailgun.org ~all
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;Verify with &lt;code&gt;dig +short TXT yourdomain.com | grep spf1&lt;/code&gt;. You should see exactly one line.&lt;/p&gt;
&lt;h3 id="gotcha-2-forwarded-mail-lands-in-spam"&gt;Gotcha 2: forwarded mail lands in spam&lt;/h3&gt;
&lt;p&gt;Forwarding rewrites the SMTP envelope. The original sender&amp;rsquo;s SPF/DKIM may no longer align by the time Gmail receives the forwarded copy. Symptoms: real customer mail to &lt;code&gt;support@&lt;/code&gt; shows up in Gmail&amp;rsquo;s Spam folder.&lt;/p&gt;
&lt;p&gt;Fix:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;In Gmail, open one such message → &lt;strong&gt;More&lt;/strong&gt; → &lt;strong&gt;Filter messages like this&lt;/strong&gt; → set criteria to &lt;code&gt;to:support@yourdomain.com OR deliveredto:support@yourdomain.com&lt;/code&gt;, then &lt;strong&gt;Never send to spam&lt;/strong&gt; + apply a &lt;code&gt;Support&lt;/code&gt; label + optionally categorize as Primary.&lt;/li&gt;
&lt;li&gt;Test from a non-destination address; do not test from another mailbox owned by the same Google account.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This is a Gmail filter, not a Cloudflare problem. Email Routing sets ARC headers correctly; some receivers still ding forwarded mail.&lt;/p&gt;
&lt;h3 id="gotcha-3-dmarc-reports-vanish"&gt;Gotcha 3: DMARC reports vanish&lt;/h3&gt;
&lt;p&gt;You set up DMARC at &lt;code&gt;_dmarc.yourdomain.com&lt;/code&gt; with &lt;code&gt;rua=mailto:dmarc-reports@yourdomain.com&lt;/code&gt;. That alias must actually route somewhere. If you forgot to add a Cloudflare Email Routing rule for &lt;code&gt;dmarc-reports&lt;/code&gt;, the reports get dropped, and you&amp;rsquo;ll think DMARC is broken when really you just have no inbox to read it from.&lt;/p&gt;
&lt;p&gt;Fix: add &lt;code&gt;dmarc-reports@&lt;/code&gt; as a routed alias. In Gmail set a filter to auto-label and skip the inbox. Aggregate reports are XML and noisy.&lt;/p&gt;
&lt;h3 id="gotcha-4-your-send-mail-as-alias-still-routes-through-gmailcom"&gt;Gotcha 4: your &amp;ldquo;send mail as&amp;rdquo; alias still routes through gmail.com&lt;/h3&gt;
&lt;p&gt;Even with Send-mail-as configured, if you don&amp;rsquo;t enable &amp;ldquo;Treat as alias&amp;rdquo; or you don&amp;rsquo;t use a true outbound SMTP host, Gmail will sometimes send through &lt;code&gt;gmail.com&lt;/code&gt; and tag it as a forwarded sender. The visible From: looks right, but &lt;code&gt;Authentication-Results&lt;/code&gt; will tell on you.&lt;/p&gt;
&lt;p&gt;Fix: read the Authentication-Results header on a real reply (View original in Gmail). You want &lt;code&gt;dkim=pass header.d=yourdomain.com&lt;/code&gt;, not &lt;code&gt;header.d=gmail.com&lt;/code&gt;.&lt;/p&gt;
&lt;h3 id="gotcha-5-paid-workspace-already-exists-for-the-domain"&gt;Gotcha 5: paid Workspace already exists for the domain&lt;/h3&gt;
&lt;p&gt;If your domain previously had Google Workspace MX records, or M365 MX records, the dashboard will warn before overwriting them. &lt;strong&gt;Do not click through that warning&lt;/strong&gt; unless you intend to abandon the existing mailbox. Cloudflare&amp;rsquo;s MX records replace whatever was there — including your live Workspace inbox.&lt;/p&gt;
&lt;p&gt;Fix: pick one. Either keep Workspace and don&amp;rsquo;t enable Email Routing, or migrate everything to Email Routing first.&lt;/p&gt;
&lt;h2 id="authentication-after-email-routing-is-on"&gt;Authentication after Email Routing is on&lt;/h2&gt;
&lt;p&gt;Once routing is live, &lt;code&gt;dig&lt;/code&gt; your domain. You should see exactly:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;3 MX records → &lt;code&gt;route{1,2,3}.mx.cloudflare.net&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;1 SPF TXT (only) at the apex.&lt;/li&gt;
&lt;li&gt;1 DKIM TXT (&lt;code&gt;cf2024-1._domainkey&lt;/code&gt;) for the routing service.&lt;/li&gt;
&lt;li&gt;Your existing DKIMs from product/transactional senders.&lt;/li&gt;
&lt;li&gt;1 DMARC TXT at &lt;code&gt;_dmarc&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;If any of those is duplicated or missing, you have homework. The matching &lt;a href="/spf-dkim-dmarc-indie-founder-checklist/"&gt;SPF/DKIM/DMARC checklist&lt;/a&gt; walks the rest.&lt;/p&gt;
&lt;h2 id="when-to-upgrade-past-email-routing"&gt;When to upgrade past Email Routing&lt;/h2&gt;
&lt;p&gt;Email Routing is the right answer when:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;You need 1–10 aliases on one domain.&lt;/li&gt;
&lt;li&gt;Volume is &amp;ldquo;real customer mail,&amp;rdquo; not &amp;ldquo;we send 50k newsletters a month from this address.&amp;rdquo;&lt;/li&gt;
&lt;li&gt;A 5–60 second delay on inbound is fine.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Outgrow it when:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;You want a shared inbox for two or more people without forwarding to the same Gmail.&lt;/li&gt;
&lt;li&gt;You need calendar/contacts/Drive on the domain (that&amp;rsquo;s Workspace&amp;rsquo;s actual value, not the email forwarding).&lt;/li&gt;
&lt;li&gt;You need server-to-server inbound webhooks (Email Routing supports a &amp;ldquo;Send to a Worker&amp;rdquo; action for this; useful but past the 10-minute mark).&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="want-a-written-second-pair-of-eyes-on-your-setup"&gt;Want a written second-pair-of-eyes on your setup&lt;/h2&gt;
&lt;p&gt;Once routing is live and SPF/DKIM/DMARC are published, the most useful thing you can do is verify &lt;em&gt;every authorized sender&lt;/em&gt; is aligned with your visible From: address. That&amp;rsquo;s exactly the &lt;a href="https://richgibbs.dev/quickcheck/inbox-dns.html"&gt;Inbox/DNS QuickCheck&lt;/a&gt; — a $99 written report on one domain, delivered within 24 hours, no DNS login required.&lt;/p&gt;
&lt;p&gt;If you&amp;rsquo;d rather DIY the whole thing, the same content in printable, fillable Markdown form (sender inventory template, SPF builder, DMARC ramp, Authentication-Results decoder) is in the &lt;a href="https://gibbs21.gumroad.com/l/inbox-dns-pack"&gt;Indie Founder Email DNS Pack&lt;/a&gt; — $19 (pay what you want, $9 minimum) on Gumroad. Either is fine. The point is to do it once, well, then never think about it again.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id="related-downloadable-pack"&gt;Related downloadable pack&lt;/h3&gt;
&lt;p&gt;If you set up Cloudflare Email Routing and &lt;em&gt;also&lt;/em&gt; publish DMARC at &lt;code&gt;p=quarantine&lt;/code&gt; or stricter, a small but real failure mode is forwarded mail that breaks SPF or DKIM alignment at the receiving mailbox. The &lt;strong&gt;DMARC Quarantine Pack&lt;/strong&gt; ($29) is the focused runbook for diagnosing that and related cases — Gmail &lt;code&gt;5.7.26&lt;/code&gt; and Microsoft &lt;code&gt;5.7.509&lt;/code&gt; / &lt;code&gt;5.7.515&lt;/code&gt; decoded with source citations, three incident walkthroughs (one of them is a forwarding/ARC case), and a single-file Python aggregate-XML reader for reading your own reports.&lt;/p&gt;
&lt;p&gt;&lt;a href="https://gibbs21.gumroad.com/l/dmarc-quarantine-pack"&gt;DMARC Quarantine Pack — $29 on Gumroad&lt;/a&gt; · 14-day refund, no questions.&lt;/p&gt;</content:encoded>
      <guid isPermaLink="true">https://blog.richgibbs.dev/cloudflare-email-routing-indie-founders-10-minute-setup/</guid>
      <category>email</category>
      <category>cloudflare</category>
      <category>email-routing</category>
      <category>dns</category>
      <category>indie-founder</category>
      <category>saas</category>
      <category>support</category>
      <pubDate>Sun, 10 May 2026 05:00:00 +0000</pubDate>
    </item>
    <item>
      <title>I had 80,000 unread emails. Here's the cleanup playbook (no apps, no OAuth)</title>
      <link>https://blog.richgibbs.dev/i-had-80000-unread-emails-cleanup-playbook/</link>
      <description>A working, non-SaaS playbook for clearing tens of thousands of old unread emails from a personal Gmail. Survey first, delete second. The 30-day Trash window is your safety net.</description>
      <content:encoded>&lt;p&gt;Last weekend I sat down to clean out my personal Gmail.&lt;/p&gt;
&lt;p&gt;I had 80,675 unread messages older than one year. Most were newsletters from companies I&amp;rsquo;d long since stopped caring about — receipts from a 2019 ride-share account, password-reset emails from accounts that no longer exist, every &amp;ldquo;weekly digest&amp;rdquo; I&amp;rsquo;d ever opted into and forgotten.&lt;/p&gt;
&lt;p&gt;The cleanup itself took about 20 minutes once I had a plan. The &lt;em&gt;having a plan&lt;/em&gt; part took three evenings.&lt;/p&gt;
&lt;p&gt;This post is the playbook I actually used. It isn&amp;rsquo;t a SaaS pitch. It doesn&amp;rsquo;t ask you to log into anything. It&amp;rsquo;s the boring, working sequence for someone who has tens of thousands of old unread emails in a personal Gmail and wants them gone tonight, without nuking something they&amp;rsquo;ll wish they&amp;rsquo;d kept.&lt;/p&gt;
&lt;h2 id="why-most-inbox-zero-advice-fails-on-a-real-mailbox"&gt;Why most &amp;ldquo;inbox zero&amp;rdquo; advice fails on a real mailbox&lt;/h2&gt;
&lt;p&gt;If you Google &amp;ldquo;how to delete old unread emails Gmail bulk,&amp;rdquo; you get three kinds of answers:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;SaaS apps that want full mailbox OAuth.&lt;/strong&gt; Mailstrom, Clean Email, Cleanfox. They work, but the permission cost is large for a job that runs once.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Blog posts from 2014.&lt;/strong&gt; They reference Outlook 2010, IMAP folders, and Gmail&amp;rsquo;s old desktop UI. The screenshots don&amp;rsquo;t match anything you actually see today.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&amp;ldquo;5 tips&amp;rdquo; listicles&lt;/strong&gt; that assume you have 800 unread emails, not 80,000. The &amp;ldquo;select all&amp;rdquo; trick doesn&amp;rsquo;t survive paging through 1,600 pages of 50 messages each.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;None of these are useful when you&amp;rsquo;re staring down a five-figure unread count.&lt;/p&gt;
&lt;p&gt;The reason isn&amp;rsquo;t the operations — Gmail&amp;rsquo;s &lt;code&gt;older_than:&lt;/code&gt; operator does most of the heavy lifting. The reason is &lt;strong&gt;order&lt;/strong&gt;. If you don&amp;rsquo;t survey first, you&amp;rsquo;ll start deleting things you wanted to keep, panic, stop halfway, and end up with a mailbox that&amp;rsquo;s somehow worse than when you started.&lt;/p&gt;
&lt;h2 id="the-order-i-followed-and-you-can-copy"&gt;The order I followed (and you can copy)&lt;/h2&gt;
&lt;p&gt;The whole playbook is five steps. None of them involve installing anything.&lt;/p&gt;
&lt;h3 id="1-survey-before-you-touch-anything"&gt;1. Survey before you touch anything&lt;/h3&gt;
&lt;p&gt;Open Gmail. Open the search bar. Run these queries one at a time and write the result count down on a piece of paper:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;is:unread older_than:1y&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;is:unread older_than:3y&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;from:newsletters older_than:6m&lt;/code&gt; (or substitute a sender domain you know is noise)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;has:attachment older_than:2y larger:10M&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;category:promotions older_than:6m&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Five numbers. That&amp;rsquo;s your map.&lt;/p&gt;
&lt;p&gt;If the first number is over 5,000, congratulations — you have the same shape of problem most indie founders have. Mine was 80,675 against the first query. Yours will be different but the playbook scales.&lt;/p&gt;
&lt;p&gt;If you want a more thorough survey — top 20 senders, oldest cohorts by year, attachment age buckets, label sprawl — that&amp;rsquo;s what the &lt;a href="https://richgibbs.dev/quickcheck/inbox-cleanup.html"&gt;Inbox Cleanup Pack&lt;/a&gt; ships: a small read-only shell script that calls the Gmail API under your own OAuth client and writes a single &lt;code&gt;survey.json&lt;/code&gt; file. No message bodies, no subjects, no message ids. Just counts. You can also do the survey by hand with the queries above; the script just makes it faster on big mailboxes.&lt;/p&gt;
&lt;h3 id="2-filter-the-recurring-noise-first"&gt;2. Filter the recurring noise &lt;em&gt;first&lt;/em&gt;&lt;/h3&gt;
&lt;p&gt;Before you delete anything, kill the inbound flow.&lt;/p&gt;
&lt;p&gt;Open Gmail → Settings → Filters and Blocked Addresses → Create a new filter.&lt;/p&gt;
&lt;p&gt;For each of the top 5 newsletter senders you can name off the top of your head, create a filter:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;from:(news@somecompany.com)&lt;/code&gt; → &amp;ldquo;Skip the Inbox&amp;rdquo; + &amp;ldquo;Mark as read&amp;rdquo; + &amp;ldquo;Apply label: Newsletters&amp;rdquo; + &amp;ldquo;Also apply filter to existing matching conversations.&amp;rdquo;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The &amp;ldquo;also apply&amp;rdquo; checkbox is the part most people miss. It silently archives the existing 4,000 unread newsletters from that sender in one click. No manual select-all required.&lt;/p&gt;
&lt;p&gt;Repeat this for your top 5–10 noisy senders. You&amp;rsquo;ll be surprised how much of the unread count is concentrated in a small number of domains. In my case, six senders accounted for 41% of the 80,675.&lt;/p&gt;
&lt;h3 id="3-bulk-archive-the-old-promotions-cohort"&gt;3. Bulk-archive the old promotions cohort&lt;/h3&gt;
&lt;p&gt;Now that the inbound is filtered, attack the standing cohort.&lt;/p&gt;
&lt;p&gt;In the search bar:&lt;/p&gt;
&lt;div class="codehilite"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;is:unread category:promotions older_than:1y
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;Click the small &lt;strong&gt;&amp;ldquo;Select all conversations that match this search&amp;rdquo;&lt;/strong&gt; link that appears above the message list. (The plain &amp;ldquo;select all&amp;rdquo; checkbox at the top only ticks the 50 visible messages — this is the most common gotcha and the reason people quit halfway.)&lt;/p&gt;
&lt;p&gt;Then &lt;strong&gt;Archive&lt;/strong&gt;, not Delete. Archiving keeps the messages in All Mail; deleting moves them to Trash. For promotions older than a year, archive is enough — they&amp;rsquo;ll never come up in your inbox again unless you specifically search for them.&lt;/p&gt;
&lt;h3 id="4-delete-the-truly-dead-with-older_than-and-a-safety-net"&gt;4. Delete the truly dead — with &lt;code&gt;older_than:&lt;/code&gt; and a safety net&lt;/h3&gt;
&lt;p&gt;For the cohort that genuinely has no future use:&lt;/p&gt;
&lt;div class="codehilite"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;is:unread older_than:2y
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;Same &amp;ldquo;Select all conversations that match this search&amp;rdquo; link, then &lt;strong&gt;Delete&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;Two things to know:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Gmail&amp;rsquo;s Trash auto-purges after 30 days.&lt;/strong&gt; That&amp;rsquo;s your real undo window. If you delete 50,000 messages today, you have 30 days to walk into Trash and pull anything important back.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Deleting from Gmail does not delete from Google Takeout history.&lt;/strong&gt; If you&amp;rsquo;ve ever exported your mail with Takeout, that snapshot is still in Drive. The Trash purge is a Gmail-only concept.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;I deliberately did not delete anything younger than two years. The marginal value of &amp;ldquo;unread receipt from 2024&amp;rdquo; is small but non-zero — there&amp;rsquo;s still a chance you&amp;rsquo;ll need to find one. The marginal value of &amp;ldquo;unread newsletter from 2019&amp;rdquo; is zero.&lt;/p&gt;
&lt;h3 id="5-set-up-the-maintenance-youll-actually-keep"&gt;5. Set up the maintenance you&amp;rsquo;ll actually keep&lt;/h3&gt;
&lt;p&gt;The cleanup is a one-day job. Maintenance is what keeps you from being here again in two years.&lt;/p&gt;
&lt;p&gt;Three filters that survive long-term:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;One filter per platform that sends transactional mail you don&amp;rsquo;t read in real time (Stripe receipts, Vercel deploy notifications, GitHub digest mails) → &amp;ldquo;Skip the Inbox&amp;rdquo; + &amp;ldquo;Apply label: Transactional.&amp;rdquo;&lt;/li&gt;
&lt;li&gt;One filter for &lt;code&gt;unsubscribe&lt;/code&gt; in body → &amp;ldquo;Apply label: Newsletter.&amp;rdquo; This labels every newsletter going forward without skipping the inbox; once a quarter you can sweep the label.&lt;/li&gt;
&lt;li&gt;One filter for &lt;code&gt;from:(*@yourdomain.com)&lt;/code&gt; → &amp;ldquo;Star&amp;rdquo; or &amp;ldquo;Mark as important.&amp;rdquo; Mail from your own domain to yourself is almost always something you actually wanted to act on.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Don&amp;rsquo;t go past three. Filter sprawl is the second cause of inbox bankruptcy after newsletter sprawl.&lt;/p&gt;
&lt;h2 id="what-i-deliberately-did-not-do"&gt;What I deliberately did not do&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;No third-party apps.&lt;/strong&gt; No Mailstrom, no Clean Email. They work for the people they fit; they&amp;rsquo;re the wrong shape for a one-time cleanup.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;No &amp;ldquo;inbox zero&amp;rdquo; rules.&lt;/strong&gt; Inbox zero is a discipline, not a software problem. Either you&amp;rsquo;ll keep up or you won&amp;rsquo;t; no app changes that.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;No deletion of mail younger than two years&lt;/strong&gt; — too much chance of needing one of them.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;No bulk-unsubscribe service.&lt;/strong&gt; Most of them either MITM your unsubscribe (and re-sell the implied opt-in signal) or get blocked by sender reputation systems. Manual unsubscribe from the noisiest five senders, then filter the rest, beats a bulk service every time.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;No DNS or sender-config changes.&lt;/strong&gt; That&amp;rsquo;s a different problem — see the &lt;a href="https://gibbs21.gumroad.com/l/inbox-dns-pack"&gt;Inbox/DNS Pack&lt;/a&gt; and &lt;a href="https://richgibbs.dev/quickcheck/inbox-dns.html"&gt;Inbox/DNS QuickCheck&lt;/a&gt; for the SPF/DKIM/DMARC side.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="the-boring-summary"&gt;The boring summary&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;Survey before you touch anything.&lt;/li&gt;
&lt;li&gt;Filter the recurring noise first, with &amp;ldquo;Also apply filter to existing matching conversations&amp;rdquo; checked.&lt;/li&gt;
&lt;li&gt;Archive (not delete) the old Promotions cohort.&lt;/li&gt;
&lt;li&gt;Delete the cohort older than two years, knowing the 30-day Trash window is your safety net.&lt;/li&gt;
&lt;li&gt;Three maintenance filters, no more.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;That&amp;rsquo;s the whole playbook. It&amp;rsquo;s enough for most personal Gmails carrying tens of thousands of unread.&lt;/p&gt;
&lt;h2 id="want-this-packaged"&gt;Want this packaged?&lt;/h2&gt;
&lt;p&gt;If you&amp;rsquo;d rather have the survey script, the printable Markdown version of the cleanup order, the full filter templates, and the cohort-by-cohort cleanup-order I followed, that&amp;rsquo;s exactly the &lt;a href="https://richgibbs.dev/quickcheck/inbox-cleanup.html"&gt;Inbox Cleanup Pack&lt;/a&gt; — $19 (pay-what-you-want, $9 minimum) on Gumroad.&lt;/p&gt;
&lt;p&gt;If you&amp;rsquo;d rather hand me the counts-only &lt;code&gt;survey.json&lt;/code&gt; from the script and get a written, prioritized cleanup plan tailored to your mailbox in 24 hours, that&amp;rsquo;s the $79 &lt;a href="https://richgibbs.dev/quickcheck/inbox-cleanup.html"&gt;Inbox Cleanup QuickCheck&lt;/a&gt;. I never see message content; just the counts.&lt;/p&gt;
&lt;p&gt;If you&amp;rsquo;re a small Workspace team with up to 10 mailboxes — typical pre-migration scenario — the $499 &lt;a href="https://richgibbs.dev/quickcheck/inbox-cleanup.html"&gt;Enterprise tier&lt;/a&gt; handles it under your own internal-app OAuth path, no third-party permissions added.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;strong&gt;Either way, the playbook above is the working answer for most personal Gmails.&lt;/strong&gt; The product exists for the case where you&amp;rsquo;d rather pay $19 for a pre-written cleanup order than reverse-engineer one yourself, or pay $79 for a custom plan, or have a teammate run the same survey across 10 mailboxes before a migration.&lt;/p&gt;
&lt;p&gt;The 30-day Trash window is your safety net. Use it.&lt;/p&gt;
&lt;p&gt;— Rich&lt;/p&gt;</content:encoded>
      <guid isPermaLink="true">https://blog.richgibbs.dev/i-had-80000-unread-emails-cleanup-playbook/</guid>
      <category>email</category>
      <category>gmail</category>
      <category>inbox-cleanup</category>
      <category>inbox-zero</category>
      <category>indie-founder</category>
      <category>productivity</category>
      <pubDate>Sun, 10 May 2026 17:11:52 +0000</pubDate>
    </item>
    <item>
      <title>I wouldn't give a SaaS my Gmail to clean it. Here's the 30-line read-only alternative.</title>
      <link>https://blog.richgibbs.dev/delete-thousands-emails-gmail-without-oauth-scope-creep/</link>
      <description>The Survey-then-Delete method for cleaning a five-figure unread Gmail backlog using a read-only script that runs under your own Google account. No third-party OAuth scope creep, no mailbox handed to a SaaS, no tokens leaving your laptop.</description>
      <content:encoded>&lt;p&gt;I sat down three weekends in a row to clean out my personal Gmail.&lt;/p&gt;
&lt;p&gt;The first two weekends I did what most people do. I opened the &amp;ldquo;free inbox cleaner&amp;rdquo; tab everyone keeps tweeting about, read the OAuth consent screen — &lt;em&gt;Read, compose, send, and permanently delete all your email from Gmail&lt;/em&gt; — closed the tab, and went back to scrolling. The cost felt wrong for a job I&amp;rsquo;d only run once.&lt;/p&gt;
&lt;p&gt;The third weekend I wrote my own script. Survey-only, read-only, runs under my own Google account, no tokens ever leave my laptop. The actual cleanup, once I had a plan, took less than half an hour: I cleared 80,675 messages older than a year, archived another 14,000-odd, and built three filters that have kept the backlog at zero ever since.&lt;/p&gt;
&lt;p&gt;This is what I learned about doing it without handing a stranger the keys to my mailbox.&lt;/p&gt;
&lt;h2 id="the-oauth-scope-problem-nobody-wants-to-say-out-loud"&gt;The OAuth scope problem nobody wants to say out loud&lt;/h2&gt;
&lt;p&gt;A free email cleanup app is not, in fact, free.&lt;/p&gt;
&lt;p&gt;When you click &amp;ldquo;Sign in with Google&amp;rdquo; and the consent screen asks for &lt;code&gt;https://mail.google.com/&lt;/code&gt; — that&amp;rsquo;s the &lt;strong&gt;all-of-Gmail scope&lt;/strong&gt;. It&amp;rsquo;s not &amp;ldquo;look at counts.&amp;rdquo; It&amp;rsquo;s not &amp;ldquo;look at senders.&amp;rdquo; It&amp;rsquo;s &lt;em&gt;read every message, write every message, delete every message, send mail as you to anyone&lt;/em&gt;. There is no narrower scope that lets a third-party app do bulk cleanup the way most of these tools do it.&lt;/p&gt;
&lt;p&gt;A few honest consequences of granting that scope:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;The app&amp;rsquo;s server can read message bodies, attachments, contacts, calendar invites, and 2FA codes&lt;/strong&gt; at any time it holds a valid token. Most don&amp;rsquo;t &lt;em&gt;advertise&lt;/em&gt; doing that. The capability exists either way.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;OAuth refresh tokens last for months by default.&lt;/strong&gt; Removing the app from your Google account dashboard revokes new tokens, not stored ones. If the vendor&amp;rsquo;s database was already scraped, the bird has flown.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;You are now an upstream dependency&lt;/strong&gt; of every breach that vendor will ever have. The 2014–2024 history of mailbox-OAuth apps is not encouraging on that point — look up any of the big-name &amp;ldquo;smart inbox&amp;rdquo; companies and you&amp;rsquo;ll find at least one incident.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This isn&amp;rsquo;t a hit piece on any specific tool. I won&amp;rsquo;t name any. The economics of &amp;ldquo;free, ad-funded inbox cleaner with full mailbox OAuth&amp;rdquo; are the same regardless of who&amp;rsquo;s running it. The product is the inbox.&lt;/p&gt;
&lt;p&gt;For a recurring assistant you trust — a calendar app, a CRM you live in — that scope is sometimes a fair trade. For a &lt;em&gt;one-time cleanup&lt;/em&gt;, it isn&amp;rsquo;t. The right tool for a one-time job is one that doesn&amp;rsquo;t outlive the job.&lt;/p&gt;
&lt;h2 id="survey-then-delete-the-methodology"&gt;Survey-then-Delete: the methodology&lt;/h2&gt;
&lt;p&gt;Here&amp;rsquo;s the method that actually worked. I call it &lt;strong&gt;Survey-then-Delete&lt;/strong&gt; because reversing those two words is what causes most cleanups to fail halfway.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Survey, counts only.&lt;/strong&gt; Don&amp;rsquo;t look at bodies. Don&amp;rsquo;t look at subjects. Don&amp;rsquo;t even pull message IDs. Just ask Gmail &amp;ldquo;how many messages match this query?&amp;rdquo; for a handful of useful queries — top senders, age cohorts, attachment sizes.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Identify the top 12 senders&lt;/strong&gt; by volume. In every five-figure mailbox I&amp;rsquo;ve audited, fewer than 15 senders account for 40–70% of the noise. This is universal.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Filter the recurring inbound first.&lt;/strong&gt; For each of those senders, build a Gmail filter that skips the inbox, marks as read, and &amp;ldquo;Also apply filter to existing matching conversations.&amp;rdquo; That single checkbox is where most manual cleanups stall.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Bulk delete by sender and age cohort, not by clicking individual messages.&lt;/strong&gt; Use Gmail&amp;rsquo;s &lt;code&gt;from:&lt;/code&gt; and &lt;code&gt;older_than:&lt;/code&gt; operators. The 30-day Trash window is your safety net — anything you delete is recoverable for 30 days.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Run two maintenance filters&lt;/strong&gt; so you never have to do this again.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Notice what&amp;rsquo;s &lt;em&gt;not&lt;/em&gt; on the list: no mailbox migration, no archive-everything panic, no &amp;ldquo;select all 80,000 and pray.&amp;rdquo; You don&amp;rsquo;t even need to know which individual messages you&amp;rsquo;re deleting. You&amp;rsquo;re operating on counts and senders, like a sysadmin culling logs, not on individual emails.&lt;/p&gt;
&lt;p&gt;That&amp;rsquo;s the whole product worldview. Cleanup is a one-time &lt;em&gt;cohort&lt;/em&gt; operation, and a third-party app with permanent mailbox access is overkill for it.&lt;/p&gt;
&lt;h2 id="what-the-survey-actually-looks-like"&gt;What the survey actually looks like&lt;/h2&gt;
&lt;p&gt;The survey step is the part the script does. It calls the Gmail API under your own OAuth — read-only scope, &lt;code&gt;gmail.metadata&lt;/code&gt; plus &lt;code&gt;gmail.readonly&lt;/code&gt; for counts — and writes a single &lt;code&gt;survey.json&lt;/code&gt; to your laptop. No message bodies, no subjects, no message IDs. Just counts.&lt;/p&gt;
&lt;p&gt;Here&amp;rsquo;s a redacted version of what one row looks like, the way the script renders it so you can read it before deciding anything:&lt;/p&gt;
&lt;div class="codehilite"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;sender                          count    oldest                  recommended action
─────────────────────────────────────────────────────────────────────────────────────
news@&amp;lt;redacted-saas&amp;gt;.com       11,842   2017-03-12   filter+delete (&amp;gt;1y)
deals@&amp;lt;redacted-airline&amp;gt;.com    7,901   2014-08-19   filter+delete (&amp;gt;1y)
updates@&amp;lt;redacted-network&amp;gt;.com  5,617   2016-01-08   filter+delete (&amp;gt;1y)
no-reply@&amp;lt;redacted-bank&amp;gt;.com    3,402   2018-04-22   filter only (keep — statements)
receipts@&amp;lt;redacted-cart&amp;gt;.com    2,883   2019-06-30   filter only (keep — receipts)
hello@&amp;lt;redacted-newsletter&amp;gt;     2,114   2020-11-04   filter+delete (&amp;gt;2y)
… 6 more rows …                              
─────────────────────────────────────────────────────────────────────────────────────
top 12 senders                 51,883   covers 64.3% of unread &amp;gt;1y
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;That&amp;rsquo;s the whole output. Four columns, twelve rows, one summary line. With that table you can decide, in 90 seconds, which senders you want to &lt;em&gt;filter and delete&lt;/em&gt; (most of them), which you want to &lt;em&gt;filter only&lt;/em&gt; (anything with statements, receipts, security alerts), and which you want to leave alone (the 30%-ish tail of senders you might still care about).&lt;/p&gt;
&lt;p&gt;The actual deletion is a second pass — different command, explicit confirmation, dry-run by default. You read the count, you say yes, Gmail moves the cohort to Trash, the 30-day undo window protects you.&lt;/p&gt;
&lt;h2 id="safety-properties-in-plain-english"&gt;Safety properties, in plain English&lt;/h2&gt;
&lt;p&gt;This is the bit I want to be very precise about, because &amp;ldquo;we never see your mail&amp;rdquo; is something every cleaner says, and most of them are stretching.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Read-only OAuth at survey time.&lt;/strong&gt; The survey command requests &lt;code&gt;gmail.metadata&lt;/code&gt; + &lt;code&gt;gmail.readonly&lt;/code&gt;. Those scopes &lt;em&gt;cannot&lt;/em&gt; delete, send, or modify mail. Google enforces this at the API edge; it&amp;rsquo;s not a promise, it&amp;rsquo;s a permission.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Deletion runs under a separate, on-demand &lt;code&gt;gmail.modify&lt;/code&gt; scope&lt;/strong&gt; that you grant only when you actually want to delete, and revoke from your Google account afterwards in one click. The script doesn&amp;rsquo;t ask for &lt;code&gt;mail.google.com/&lt;/code&gt; (the all-powerful scope) — ever.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The OAuth client is yours.&lt;/strong&gt; You create the Google Cloud project in your own account, paste the client ID and secret into a config file on your laptop. The tokens are written to a file in your home directory with &lt;code&gt;0600&lt;/code&gt; permissions. &lt;strong&gt;They never touch our infrastructure.&lt;/strong&gt; I literally cannot read your mail; the credentials only exist on your machine.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The Enterprise tier sidesteps the same problem differently:&lt;/strong&gt; your IT admin publishes the script as an &lt;em&gt;Internal&lt;/em&gt; app inside your Google Cloud organization, which means it&amp;rsquo;s exempt from Google&amp;rsquo;s app verification process and the 100-user cap, but also that there&amp;rsquo;s no &amp;ldquo;third-party app&amp;rdquo; to revoke — the script runs as you, on your own org&amp;rsquo;s Cloud project.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;If you&amp;rsquo;re the kind of person who reads OAuth scope strings before clicking through them — same — that&amp;rsquo;s the design.&lt;/p&gt;
&lt;h2 id="the-three-ways-to-do-this"&gt;The three ways to do this&lt;/h2&gt;
&lt;p&gt;Pick the one that matches how much DIY you want to wrangle.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;$19 — Inbox Cleanup Pack (DIY).&lt;/strong&gt; &lt;a href="https://gibbs21.gumroad.com/l/inbox-cleanup-pack"&gt;Get it on Gumroad →&lt;/a&gt; Pay-what-you-want, $9 floor. The same shell script I used (read-only survey + opt-in deletion), the Gmail filter templates, the exact cohort-by-cohort cleanup order, and the printable Markdown playbook. You run everything on your own laptop under your own Google Cloud OAuth client. No third-party permissions added to your account.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;$79 — Inbox Cleanup QuickCheck (we write the plan).&lt;/strong&gt; &lt;a href="https://buy.stripe.com/cNi28tdxa3AC5NLdtJ5ZC03"&gt;Buy on Stripe →&lt;/a&gt; You run the same survey script. You send me the &lt;code&gt;survey.json&lt;/code&gt; file (counts only — no message bodies, no subjects, no IDs). I send back a written, prioritized cleanup plan tailored to your top senders, your age cohorts, and your tolerance for &amp;ldquo;delete vs archive.&amp;rdquo; Delivered within 24 hours, plus one async clarification pass within 14 days — up to 30 minutes&amp;rsquo; worth of follow-up questions over email at &lt;code&gt;support@richgibbs.dev&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;$499 — Inbox Cleanup Enterprise (up to 10 Workspace mailboxes).&lt;/strong&gt; &lt;a href="https://buy.stripe.com/28E14peBe9Z0b85cpF5ZC04"&gt;Buy on Stripe →&lt;/a&gt; For pre-migration or pre-acquisition cleanups across a small team — typically a 2-to-10-person Google Workspace org. Your IT admin publishes our script as an Internal app under your own Cloud project (no third-party verification, no app-store entry, no shared tokens). You run the survey across up to 10 mailboxes, send the merged &lt;code&gt;survey.json&lt;/code&gt;, and we write a per-mailbox plan plus the cross-mailbox patterns (shared newsletters worth bulk-filtering across the org, etc.). 5 business day SLA, one async clarification pass within 14 days — up to 30 minutes&amp;rsquo; worth of follow-up questions, via email to &lt;code&gt;support@richgibbs.dev&lt;/code&gt;. More details on the &lt;a href="https://richgibbs.dev/quickcheck/inbox-cleanup.html"&gt;Inbox Cleanup service page&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;All three deliverables are async-only. Email is the only follow-up channel.&lt;/p&gt;
&lt;h2 id="related-reading"&gt;Related reading&lt;/h2&gt;
&lt;p&gt;If you came in through the deliverability rabbit-hole — receipts going to spam, password resets vanishing — the inbox problem is downstream of the &lt;em&gt;outbox&lt;/em&gt; problem, and both are fixable in one sitting:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href="/spf-dkim-dmarc-indie-founder-checklist/"&gt;SPF, DKIM, DMARC for indie founders: the 20-minute checklist&lt;/a&gt; — the matching DNS-side hygiene pass for your sending domain.&lt;/li&gt;
&lt;li&gt;&lt;a href="/cloudflare-email-routing-indie-founders-10-minute-setup/"&gt;Cloudflare Email Routing for indie founders: the 10-minute support@ setup&lt;/a&gt; — if you don&amp;rsquo;t even have a &lt;code&gt;support@yourdomain.com&lt;/code&gt; yet, start here before you do anything else.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="the-short-version"&gt;The short version&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;The &amp;ldquo;free inbox cleaner&amp;rdquo; model is a scope-creep trap for a job that runs once.&lt;/li&gt;
&lt;li&gt;Survey-then-Delete: count first, identify the top 12 senders, filter the inbound, then bulk-delete by sender and age cohort.&lt;/li&gt;
&lt;li&gt;Read-only OAuth at survey time; on-demand &lt;code&gt;gmail.modify&lt;/code&gt; only when you&amp;rsquo;re actually deleting; tokens live on &lt;em&gt;your&lt;/em&gt; laptop, not ours.&lt;/li&gt;
&lt;li&gt;$19 if you want to run it yourself. $79 if you want me to write the cleanup plan. $499 if you need to do it across a small Workspace team without exposing tokens to a third party.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The 30-day Trash window is your safety net. So is reading the OAuth scope string before you click &amp;ldquo;Allow.&amp;rdquo;&lt;/p&gt;
&lt;p&gt;— Rich&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;Tuck Sentinel — independent. Not affiliated with, endorsed by, or certified by Google, Yahoo, Microsoft, AWS, Cloudflare, Stripe, Tally, or any email or cloud provider.&lt;/em&gt;&lt;/p&gt;</content:encoded>
      <guid isPermaLink="true">https://blog.richgibbs.dev/delete-thousands-emails-gmail-without-oauth-scope-creep/</guid>
      <category>email</category>
      <category>gmail</category>
      <category>inbox-cleanup</category>
      <category>oauth</category>
      <category>privacy</category>
      <category>indie-founder</category>
      <pubDate>Mon, 11 May 2026 16:55:00 +0000</pubDate>
    </item>
    <item>
      <title>DMARC aggregate reports without a SaaS: read your own rua XML in 30 minutes</title>
      <link>https://blog.richgibbs.dev/dmarc-aggregate-reports-without-a-saas/</link>
      <description>You don't need Postmark, Valimail, or dmarcian to read DMARC aggregate reports. 120 lines of stdlib Python on the same VPS that runs your mail will tell you the truth — here is what those XML reports actually contain and how to parse them yourself.</description>
      <content:encoded>&lt;p&gt;You published a DMARC record. The &lt;code&gt;rua=mailto:&lt;/code&gt; part is pointing at a real mailbox you actually read. Reports started arriving 24 hours later. They are zipped XML files with names like &lt;code&gt;google.com!yourdomain.com!1715472000!1715558400.zip&lt;/code&gt;, you cannot read them, and every blog post you find tells you to sign up for Postmark, Valimail, dmarcian, EasyDMARC, or some other $20–$200/month SaaS to &amp;ldquo;decode&amp;rdquo; them.&lt;/p&gt;
&lt;p&gt;You don&amp;rsquo;t need any of that. The DMARC aggregate-report format is a stable, well-defined XML schema published in &lt;a href="https://datatracker.ietf.org/doc/html/rfc7489"&gt;RFC 7489&lt;/a&gt; (&lt;a href="https://datatracker.ietf.org/doc/html/rfc7489#section-7.2"&gt;§7.2&lt;/a&gt;), and a working reader takes about 120 lines of stdlib-only Python — no extra packages, no API keys, runs in cron on the same $20 VPS that already runs your mail.&lt;/p&gt;
&lt;p&gt;This post is the &lt;em&gt;reading&lt;/em&gt; half of that story. What the reports actually contain, what every field means in practice, the 20-line skeleton to walk the XML yourself, what the full reader adds beyond the skeleton, and the cron-friendly workflow that makes the data actionable. If you ever decide you want a SaaS, you will be a much better customer for it.&lt;/p&gt;
&lt;h2 id="why-dmarc-aggregate-rua-reports-exist"&gt;Why DMARC aggregate (&lt;code&gt;rua&lt;/code&gt;) reports exist&lt;/h2&gt;
&lt;p&gt;DMARC, defined in &lt;a href="https://datatracker.ietf.org/doc/html/rfc7489"&gt;RFC 7489&lt;/a&gt;, is a policy layer on top of SPF and DKIM. A receiver (Gmail, Microsoft, Yahoo, Apple, ProtonMail, …) checks each incoming message and decides three things: does SPF pass and align with the &lt;code&gt;RFC5322.From&lt;/code&gt; domain, does DKIM pass and align, and what does the domain owner&amp;rsquo;s published &lt;code&gt;_dmarc&lt;/code&gt; TXT record say to do when neither aligns.&lt;/p&gt;
&lt;p&gt;The receiver acts on every message immediately. But the &lt;em&gt;domain owner&lt;/em&gt; (you) has no idea what happened until somebody complains. Aggregate reports close that loop. From &lt;a href="https://datatracker.ietf.org/doc/html/rfc7489#section-7.2"&gt;RFC 7489 §7.2&lt;/a&gt;:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Aggregate reports are most useful when they all contain the same data; thus this section describes a single report format, generated daily, sent via email, encoded as XML.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;In practice, every major receiver who supports DMARC sends one aggregate report per UTC day per sender domain they saw mail from, to the address (or addresses) listed in the &lt;code&gt;rua=&lt;/code&gt; tag of your &lt;code&gt;_dmarc&lt;/code&gt; record. Each report says: &lt;em&gt;&amp;ldquo;Here is every source IP that claimed to be sending as your domain in the last 24 hours, how many messages each one sent, and what we decided about each one.&amp;rdquo;&lt;/em&gt; It does &lt;strong&gt;not&lt;/strong&gt; contain message bodies, subjects, recipients, or any other PII. It is metadata only.&lt;/p&gt;
&lt;p&gt;That is what makes the format safe to receive, store, and parse on a $20 VPS. Failure reports (&lt;code&gt;ruf=&lt;/code&gt;, separate spec) sometimes carry redacted message content; aggregate reports do not. We are talking about &lt;code&gt;rua&lt;/code&gt; only.&lt;/p&gt;
&lt;h2 id="the-shape-of-a-real-aggregate-xml-report"&gt;The shape of a real aggregate XML report&lt;/h2&gt;
&lt;p&gt;A real Gmail aggregate report, opened in a text editor after &lt;code&gt;gunzip&lt;/code&gt;, looks roughly like this (one record shown; a real report typically has 5–50):&lt;/p&gt;
&lt;div class="codehilite"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;feedback&amp;gt;&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;report_metadata&amp;gt;&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;org_name&amp;gt;&lt;/span&gt;google.com&lt;span class="nt"&gt;&amp;lt;/org_name&amp;gt;&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;email&amp;gt;&lt;/span&gt;noreply-dmarc-support@google.com&lt;span class="nt"&gt;&amp;lt;/email&amp;gt;&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;report_id&amp;gt;&lt;/span&gt;1234567890123456789&lt;span class="nt"&gt;&amp;lt;/report_id&amp;gt;&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;date_range&amp;gt;&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;begin&amp;gt;&lt;/span&gt;1715472000&lt;span class="nt"&gt;&amp;lt;/begin&amp;gt;&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;end&amp;gt;&lt;/span&gt;1715558400&lt;span class="nt"&gt;&amp;lt;/end&amp;gt;&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;/date_range&amp;gt;&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;/report_metadata&amp;gt;&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;policy_published&amp;gt;&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;domain&amp;gt;&lt;/span&gt;yourdomain.com&lt;span class="nt"&gt;&amp;lt;/domain&amp;gt;&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;adkim&amp;gt;&lt;/span&gt;r&lt;span class="nt"&gt;&amp;lt;/adkim&amp;gt;&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;aspf&amp;gt;&lt;/span&gt;r&lt;span class="nt"&gt;&amp;lt;/aspf&amp;gt;&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;p&amp;gt;&lt;/span&gt;quarantine&lt;span class="nt"&gt;&amp;lt;/p&amp;gt;&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;sp&amp;gt;&lt;/span&gt;quarantine&lt;span class="nt"&gt;&amp;lt;/sp&amp;gt;&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;pct&amp;gt;&lt;/span&gt;100&lt;span class="nt"&gt;&amp;lt;/pct&amp;gt;&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;/policy_published&amp;gt;&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;record&amp;gt;&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;row&amp;gt;&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;source_ip&amp;gt;&lt;/span&gt;50.31.156.6&lt;span class="nt"&gt;&amp;lt;/source_ip&amp;gt;&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;count&amp;gt;&lt;/span&gt;42&lt;span class="nt"&gt;&amp;lt;/count&amp;gt;&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;policy_evaluated&amp;gt;&lt;/span&gt;
&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;disposition&amp;gt;&lt;/span&gt;none&lt;span class="nt"&gt;&amp;lt;/disposition&amp;gt;&lt;/span&gt;
&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;dkim&amp;gt;&lt;/span&gt;pass&lt;span class="nt"&gt;&amp;lt;/dkim&amp;gt;&lt;/span&gt;
&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;spf&amp;gt;&lt;/span&gt;pass&lt;span class="nt"&gt;&amp;lt;/spf&amp;gt;&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;/policy_evaluated&amp;gt;&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;/row&amp;gt;&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;identifiers&amp;gt;&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;header_from&amp;gt;&lt;/span&gt;yourdomain.com&lt;span class="nt"&gt;&amp;lt;/header_from&amp;gt;&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;/identifiers&amp;gt;&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;auth_results&amp;gt;&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;dkim&amp;gt;&lt;/span&gt;
&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;domain&amp;gt;&lt;/span&gt;yourdomain.com&lt;span class="nt"&gt;&amp;lt;/domain&amp;gt;&lt;/span&gt;
&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;selector&amp;gt;&lt;/span&gt;pm&lt;span class="nt"&gt;&amp;lt;/selector&amp;gt;&lt;/span&gt;
&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;result&amp;gt;&lt;/span&gt;pass&lt;span class="nt"&gt;&amp;lt;/result&amp;gt;&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;/dkim&amp;gt;&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;spf&amp;gt;&lt;/span&gt;
&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;domain&amp;gt;&lt;/span&gt;pm-bounces.yourdomain.com&lt;span class="nt"&gt;&amp;lt;/domain&amp;gt;&lt;/span&gt;
&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;result&amp;gt;&lt;/span&gt;pass&lt;span class="nt"&gt;&amp;lt;/result&amp;gt;&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;/spf&amp;gt;&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;/auth_results&amp;gt;&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;/record&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/feedback&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;Every aggregate report from any receiver has the same top-level shape: one &lt;code&gt;&amp;lt;feedback&amp;gt;&lt;/code&gt; element, one &lt;code&gt;&amp;lt;report_metadata&amp;gt;&lt;/code&gt; block, one &lt;code&gt;&amp;lt;policy_published&amp;gt;&lt;/code&gt; block, and one &lt;code&gt;&amp;lt;record&amp;gt;&lt;/code&gt; per source IP per disposition outcome. The schema is fixed by &lt;a href="https://datatracker.ietf.org/doc/html/rfc7489#appendix-C"&gt;RFC 7489 Appendix C&lt;/a&gt;; receivers don&amp;rsquo;t get to invent new fields.&lt;/p&gt;
&lt;h2 id="the-report-decoder-ring"&gt;The report decoder ring&lt;/h2&gt;
&lt;p&gt;Once you have the XML in front of you, three fields do most of the work.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;&amp;lt;source_ip&amp;gt;&lt;/code&gt;&lt;/strong&gt; is the IP address the receiver saw the message arrive from. If it is one of your sending platform&amp;rsquo;s IPs (a Postmark, Resend, Mailgun, SES, ConvertKit, Mailchimp range), that is good. If it is an IP you have never heard of &lt;em&gt;and&lt;/em&gt; the count is non-trivial &lt;em&gt;and&lt;/em&gt; alignment failed, that is either a forwarder you forgot about or somebody actively spoofing your domain. Both are worth investigating, but in 2026, the boring forwarder explanation is the answer about 95 % of the time.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;&amp;lt;policy_evaluated&amp;gt;&lt;/code&gt;&lt;/strong&gt; is the receiver&amp;rsquo;s verdict on this batch of messages. Three sub-fields matter:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;&amp;lt;disposition&amp;gt;&lt;/code&gt; — what the receiver did. &lt;code&gt;none&lt;/code&gt; means delivered normally; &lt;code&gt;quarantine&lt;/code&gt; means spam-foldered; &lt;code&gt;reject&lt;/code&gt; means refused at SMTP time. This is the &lt;em&gt;applied&lt;/em&gt; outcome, after any &lt;code&gt;pct=&lt;/code&gt; ramp and local override.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;&amp;lt;dkim&amp;gt;&lt;/code&gt; — whether DKIM passed &lt;em&gt;and aligned&lt;/em&gt; with the &lt;code&gt;RFC5322.From&lt;/code&gt; domain in &lt;code&gt;&amp;lt;identifiers&amp;gt;&amp;lt;header_from&amp;gt;&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;&amp;lt;spf&amp;gt;&lt;/code&gt; — same, for SPF alignment (the &lt;code&gt;MAIL FROM&lt;/code&gt; / Return-Path domain must align with &lt;code&gt;header_from&lt;/code&gt;).&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The single most common indie-founder confusion is the difference between &lt;code&gt;&amp;lt;auth_results&amp;gt;&lt;/code&gt; (the raw SPF/DKIM verification result on whatever domains the message presented) and &lt;code&gt;&amp;lt;policy_evaluated&amp;gt;&lt;/code&gt; (whether those results &lt;em&gt;aligned&lt;/em&gt; with the visible From: domain). A message can have &lt;code&gt;&amp;lt;auth_results&amp;gt;&amp;lt;dkim&amp;gt;&amp;lt;result&amp;gt;pass&amp;lt;/result&amp;gt;&amp;lt;/dkim&amp;gt;&lt;/code&gt; and still show &lt;code&gt;&amp;lt;policy_evaluated&amp;gt;&amp;lt;dkim&amp;gt;fail&amp;lt;/dkim&amp;gt;&lt;/code&gt; — DKIM technically passed, but the signing domain was &lt;code&gt;mailgun.org&lt;/code&gt; instead of your domain, so DMARC alignment failed. That is the most common deliverability bug in this whole article. Fix it by enabling &amp;ldquo;Custom domain DKIM&amp;rdquo; on the offending provider.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;&amp;lt;header_from&amp;gt;&lt;/code&gt;&lt;/strong&gt; under &lt;code&gt;&amp;lt;identifiers&amp;gt;&lt;/code&gt; is the &lt;code&gt;RFC5322.From&lt;/code&gt; domain — what the recipient sees. If this is ever a domain other than yours (a subdomain you forgot, an old sending domain), every alignment decision in the same record is being judged against &lt;em&gt;that&lt;/em&gt; domain, not your apex.&lt;/p&gt;
&lt;p&gt;&lt;a href="https://datatracker.ietf.org/doc/html/rfc7960"&gt;RFC 7960&lt;/a&gt; (&amp;ldquo;Interoperability Issues Between DMARC and Indirect Email Flows&amp;rdquo;) is the official, RFC-blessed description of why honest forwarders break DMARC alignment — mailing lists, forward-to-personal-inbox aliases, and any hop that rewrites headers will show &lt;code&gt;&amp;lt;policy_evaluated&amp;gt;&amp;lt;dkim&amp;gt;fail&amp;lt;/dkim&amp;gt;&lt;/code&gt; on aggregate reports while not being malicious. That is the moment to read the &lt;a href="https://datatracker.ietf.org/doc/html/rfc8617"&gt;ARC spec, RFC 8617&lt;/a&gt;, and decide whether to enable ARC on your forwarder or just stop forwarding mail you publish DMARC for.&lt;/p&gt;
&lt;h2 id="a-20-line-stdlib-only-skeleton"&gt;A 20-line stdlib-only skeleton&lt;/h2&gt;
&lt;p&gt;You can read every report on disk with nothing but the Python standard library. Here is the smallest correct skeleton that walks every record in a single XML file:&lt;/p&gt;
&lt;div class="codehilite"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nn"&gt;xml.etree.ElementTree&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;as&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nn"&gt;ET&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nn"&gt;pathlib&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Path&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;walk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;tree&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ET&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;root&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;tree&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;getroot&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;org&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;root&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;findtext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;report_metadata/org_name&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;default&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;?&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;dom&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;root&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;findtext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;policy_published/domain&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;default&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;?&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;rec&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;root&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;findall&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;record&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;ip&lt;/span&gt;    &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;rec&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;findtext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;row/source_ip&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;default&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;?&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;count&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rec&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;findtext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;row/count&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;default&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;0&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="n"&gt;disp&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;rec&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;findtext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;row/policy_evaluated/disposition&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;default&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;?&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;dkim&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;rec&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;findtext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;row/policy_evaluated/dkim&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;default&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;?&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;spf&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;rec&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;findtext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;row/policy_evaluated/spf&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;default&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;?&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;hfrom&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;rec&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;findtext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;identifiers/header_from&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;default&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;?&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;org&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dom&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ip&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;count&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;disp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dkim&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;spf&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hfrom&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;reports&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;glob&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;*.xml&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;walk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="nb"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;&lt;/span&gt;&lt;span class="se"&gt;\t&lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;That is the whole reading layer. Run it against a directory of un-gzipped XML reports and you have a tab-separated table you can pipe into &lt;code&gt;awk&lt;/code&gt;, &lt;code&gt;sort -k4 -n&lt;/code&gt;, or just &lt;code&gt;grep fail&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;What a &lt;em&gt;full&lt;/em&gt; reader adds on top of this 20-line skeleton — and what the paid pack ships pre-built — is:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Transparent &lt;code&gt;.gz&lt;/code&gt;, &lt;code&gt;.zip&lt;/code&gt;, and raw-&lt;code&gt;.xml&lt;/code&gt; handling (receivers disagree on compression; some send a &lt;code&gt;.zip&lt;/code&gt; containing an &lt;code&gt;.xml&lt;/code&gt;, some send a &lt;code&gt;.xml.gz&lt;/code&gt;, Microsoft used to email both).&lt;/li&gt;
&lt;li&gt;Grouping by source domain and by sending sub-domain, so the report says &lt;em&gt;&amp;ldquo;Postmark sent 1,420 messages on your behalf today, all aligned&amp;rdquo;&lt;/em&gt; instead of one row per IP.&lt;/li&gt;
&lt;li&gt;Disposition rollups: how many &lt;code&gt;none&lt;/code&gt; vs. &lt;code&gt;quarantine&lt;/code&gt; vs. &lt;code&gt;reject&lt;/code&gt; per sender, per day.&lt;/li&gt;
&lt;li&gt;ARC results from &lt;code&gt;&amp;lt;auth_results&amp;gt;&lt;/code&gt; (per &lt;a href="https://datatracker.ietf.org/doc/html/rfc8617"&gt;RFC 8617&lt;/a&gt;), so legit forwarders are flagged as &lt;em&gt;&amp;ldquo;ARC-rescued, ignore&amp;rdquo;&lt;/em&gt; instead of &lt;em&gt;&amp;ldquo;DKIM fail, panic.&amp;rdquo;&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;A multi-day rolling view so a one-bad-day spike does not page you but a seven-day trend does.&lt;/li&gt;
&lt;li&gt;An &amp;ldquo;unknown sender&amp;rdquo; alert for any source IP that has never appeared in your historical reports and is sending more than N messages a day.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The 20-line skeleton is enough to &lt;em&gt;learn the data&lt;/em&gt;. The 120-line full reader is what you keep in cron.&lt;/p&gt;
&lt;h2 id="a-cron-friendly-daily-workflow"&gt;A cron-friendly daily workflow&lt;/h2&gt;
&lt;p&gt;Once you can read the reports, the workflow is short.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Use a dedicated mailbox.&lt;/strong&gt; Point &lt;code&gt;rua=mailto:dmarc-reports@yourdomain.com&lt;/code&gt; at an alias you do not read directly. Cloudflare Email Routing forwarding into a labeled Gmail folder works perfectly for this; so does a Postfix &lt;code&gt;.forward&lt;/code&gt; into a Maildir on the same VPS. Google&amp;rsquo;s own &lt;a href="https://support.google.com/a/answer/2466580"&gt;Workspace Admin Help DMARC guide&lt;/a&gt; recommends the same separation.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Fetch on a schedule.&lt;/strong&gt; Pull the new attachments out of that mailbox once an hour. IMAP, the Gmail API (under your own internal-app OAuth client), or a simple &lt;code&gt;notmuch new&lt;/code&gt; + maildir scan all work. Drop the attachments under &lt;code&gt;~/dmarc/reports/&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Parse and roll up.&lt;/strong&gt; Run the reader nightly. Append a row per &lt;code&gt;(date, sender_domain, source_ip, count, dkim_aligned, spf_aligned, disposition)&lt;/code&gt; to a CSV or SQLite file. This is the historical record you query when something breaks.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Alert only on change.&lt;/strong&gt; Mail yourself when (a) a brand-new source IP appears and sends more than ~50 messages, (b) a previously-aligned sender&amp;rsquo;s DKIM-alignment rate drops below 95 % for two consecutive days, or (c) any &lt;code&gt;disposition=reject&lt;/code&gt; count goes above zero for a sender you care about.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;That is the entire pipeline. There is no dashboard, no per-domain license, no &amp;ldquo;trust score.&amp;rdquo; The data is the data.&lt;/p&gt;
&lt;h2 id="when-you-actually-do-need-a-saas"&gt;When you actually do need a SaaS&lt;/h2&gt;
&lt;p&gt;Be honest: the boring DIY pipeline above is correct for a single domain, one or two sending sub-domains, and 5–50 reports a day. The point at which a SaaS starts pulling its weight is roughly:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;More than ~5 active domains, especially if a deliverability team wants a shared dashboard.&lt;/li&gt;
&lt;li&gt;High-volume marketing senders (&amp;gt;500k messages/month) where you want forensic (&lt;code&gt;ruf=&lt;/code&gt;) reports correlated with bounce categories.&lt;/li&gt;
&lt;li&gt;Anything that needs SPF/DKIM hygiene enforced across an org with 50+ employees and rotating contractors.&lt;/li&gt;
&lt;li&gt;Compliance contexts (&lt;a href="https://learn.microsoft.com/en-us/defender-office-365/anti-spam-protection-about"&gt;Microsoft anti-spam configuration docs&lt;/a&gt; are worth reading here too) where someone external wants an audit trail of the policy itself.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;For one indie founder with one domain and three senders? You are the worst customer dmarcian will ever have. Read your own reports.&lt;/p&gt;
&lt;h2 id="related-downloadable-pack"&gt;Related downloadable pack&lt;/h2&gt;
&lt;p&gt;If you want the full Python reader (gzip- and zip-aware, sub-domain rollups, ARC handling, the &lt;code&gt;unknown_sender&lt;/code&gt; alert function, plus three real incident walkthroughs — marketing-tool DKIM drift, forgotten sub-domain, forwarder/ARC breakage — and a DSN decoder cheat-sheet for Gmail &lt;code&gt;5.7.26&lt;/code&gt; and Microsoft &lt;code&gt;5.7.509&lt;/code&gt; / &lt;code&gt;5.7.515&lt;/code&gt;) in one bundle, the &lt;strong&gt;&lt;a href="https://gibbs21.gumroad.com/l/dmarc-quarantine-pack"&gt;DMARC Quarantine Pack — $29 on Gumroad&lt;/a&gt;&lt;/strong&gt; has it. 14-day refund, no questions.&lt;/p&gt;
&lt;h2 id="related-posts"&gt;Related posts&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;a href="/spf-dkim-dmarc-indie-founder-checklist/"&gt;SPF, DKIM, DMARC for indie founders: the 20-minute checklist&lt;/a&gt;&lt;/strong&gt; — the prerequisite. Publish a sane &lt;code&gt;_dmarc&lt;/code&gt; record and a &lt;code&gt;rua=&lt;/code&gt; target first; then the aggregate reports in this post will actually start arriving.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;a href="/cloudflare-email-routing-indie-founders-10-minute-setup/"&gt;Cloudflare Email Routing for indie founders: the 10-minute support@ setup&lt;/a&gt;&lt;/strong&gt; — the cleanest way to give your &lt;code&gt;dmarc-reports@yourdomain.com&lt;/code&gt; alias a real destination without paying for a Workspace seat, and the post that explains the one forwarder hop ARC (&lt;a href="https://datatracker.ietf.org/doc/html/rfc8617"&gt;RFC 8617&lt;/a&gt;) is designed to rescue.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Related downloadable pack:&lt;/strong&gt; &lt;a href="https://gibbs21.gumroad.com/l/dmarc-quarantine-pack"&gt;DMARC Quarantine Pack — $29 on Gumroad&lt;/a&gt; — the full single-file Python reader, three real-incident walkthroughs, and the DSN decoder cheat-sheet for when DMARC moves from &lt;code&gt;p=none&lt;/code&gt; to &lt;code&gt;p=quarantine&lt;/code&gt; and a specific sender starts getting bounced. 14-day refund, no questions.&lt;/li&gt;
&lt;/ul&gt;</content:encoded>
      <guid isPermaLink="true">https://blog.richgibbs.dev/dmarc-aggregate-reports-without-a-saas/</guid>
      <category>email</category>
      <category>dns</category>
      <category>dmarc</category>
      <category>deliverability</category>
      <category>indie-founder</category>
      <category>python</category>
      <category>rua</category>
      <category>aggregate-reports</category>
      <category>saas</category>
      <pubDate>Tue, 12 May 2026 14:45:00 +0000</pubDate>
    </item>
  </channel>
</rss>
