<?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>Sun, 10 May 2026 00:22: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.&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. Enforce IMDSv2 (&lt;code&gt;HttpTokens=required&lt;/code&gt;) so a token is required to read instance credentials.&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.&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>
  </channel>
</rss>
