<?xml version="1.0" encoding="UTF-8"?><?xml-stylesheet href="/scripts/pretty-feed-v3.xsl" type="text/xsl"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:h="http://www.w3.org/TR/html4/"><channel><title>srmdn.</title><description>personal project hub</description><link>https://srmdn.com</link><item><title>Before You Add Redis</title><link>https://srmdn.com/blog/before-you-add-redis</link><guid isPermaLink="true">https://srmdn.com/blog/before-you-add-redis</guid><description>Most slow web apps don&apos;t need Redis. They need gzip and a WAL checkpoint. Here is how to tell which problem you have.</description><pubDate>Tue, 31 Mar 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Your app feels slow. The pages take a second too long, and someone mentioned Redis. Before you wire up a new service, check two things: whether nginx is compressing your responses, and whether your SQLite WAL is growing unchecked. Either one will make an app feel slow, and both take minutes to fix.&lt;/p&gt;
&lt;p&gt;This article is written for apps running SQLite and nginx: a common setup for small to mid-size web apps deployed on a single server. The gzip and Redis sections apply regardless of your database. The WAL section is SQLite-specific; if you&apos;re on Postgres or MySQL, skip it.&lt;/p&gt;
&lt;p&gt;Redis is a real solution. But it solves a specific problem: repeated expensive computation. If that&apos;s not your problem, adding Redis adds complexity without fixing anything.&lt;/p&gt;
&lt;h2&gt;Where the Slowness Lives&lt;/h2&gt;
&lt;p&gt;Performance problems in a web app come from three distinct places:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Browser &amp;#x3C;—— network ——&gt; nginx &amp;#x3C;—— proxy ——&gt; app &amp;#x3C;—— query ——&gt; database
               ↑                                       ↑
             gzip                                     WAL
                                        ↑
                                      Redis
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Gzip lives between nginx and the browser. WAL lives inside SQLite. Redis lives between your app and the database. They don&apos;t overlap.&lt;/p&gt;
&lt;p&gt;A 30KB HTML page sent without compression is a network problem. Adding Redis doesn&apos;t help it. A SQLite database with an unchecked WAL file is a read latency problem. Gzip doesn&apos;t help it. A query that takes 200ms and runs 50 times per second is a computation problem. That&apos;s where Redis fits.&lt;/p&gt;
&lt;h2&gt;Gzip: The Free Win&lt;/h2&gt;
&lt;p&gt;Nginx can compress responses before sending them. Enable it in your server block and a 30KB HTML page becomes 7KB. Over a slow mobile connection, that&apos;s the difference between a page that loads and one that spins.&lt;/p&gt;
&lt;p&gt;The config:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-nginx&quot;&gt;gzip on;
gzip_proxied any;
gzip_min_length 1024;
gzip_types text/plain text/css application/javascript application/json image/svg+xml;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;gzip_proxied any&lt;/code&gt; is the directive most people miss. Without it, nginx only compresses files it serves directly from disk. If your app runs behind a reverse proxy (it probably does), nginx won&apos;t compress its responses unless you set this.&lt;/p&gt;
&lt;p&gt;Two things gzip won&apos;t help: responses smaller than 1KB (the compression overhead isn&apos;t worth it) and binary formats like images and video (they&apos;re already compressed; gzip will make them larger).&lt;/p&gt;
&lt;h2&gt;WAL: The One-Line SQLite Fix&lt;/h2&gt;
&lt;p&gt;&lt;em&gt;This section applies to SQLite only. Postgres and MySQL handle concurrency differently and don&apos;t have a WAL checkpoint problem in the same sense.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;SQLite has two main journaling modes. The default (&lt;code&gt;DELETE&lt;/code&gt;) locks the entire database file on every write. Any reader waiting for that lock blocks until the write finishes. For a web app handling concurrent requests, this creates visible latency under load.&lt;/p&gt;
&lt;p&gt;WAL mode (Write-Ahead Log) separates reads from writes. Instead of modifying the database file directly, SQLite appends changes to a &lt;code&gt;.wal&lt;/code&gt; file. Reads see a consistent snapshot of the main database without waiting for writes to finish.&lt;/p&gt;
&lt;p&gt;Enable it once at connection setup:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;PRAGMA journal_mode = WAL;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The catch: SQLite flushes WAL changes back to the main database (a &quot;checkpoint&quot;) only when the WAL reaches 1000 pages by default. Until that threshold is hit, every read has to scan both the main database and the entire WAL to reconstruct the latest state. A WAL that grows for days before checkpointing can reach several megabytes, and reads slow down proportionally.&lt;/p&gt;
&lt;p&gt;Lowering the threshold keeps the WAL small:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;PRAGMA wal_autocheckpoint = 100;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Set this at connection init, alongside &lt;code&gt;journal_mode&lt;/code&gt;. The WAL checkpoints every 100 pages and stays under 400KB. There&apos;s no meaningful downside unless you&apos;re on a write-heavy workload where frequent checkpoints create contention, which is uncommon for typical web apps.&lt;/p&gt;
&lt;h2&gt;Redis: When You Actually Need It&lt;/h2&gt;
&lt;p&gt;Redis is an in-memory key-value store. You run a database query once, store the result in Redis with an expiry, and serve subsequent requests from memory instead of running the query again.&lt;/p&gt;
&lt;p&gt;That&apos;s the core use case. If you&apos;re not running the same expensive query repeatedly, Redis adds a service to deploy, monitor, and keep synchronized with your database without improving response times.&lt;/p&gt;
&lt;p&gt;The cases where it genuinely helps: aggregation or ranking queries that run on every page load, session data that needs to be shared across multiple app instances, or any computation that takes hundreds of milliseconds and produces a result that stays valid for minutes.&lt;/p&gt;
&lt;p&gt;The cases where it doesn&apos;t: simple lookups by primary key (already fast), apps where queries run a few dozen times per minute, or datasets small enough to fit in application memory.&lt;/p&gt;
&lt;p&gt;Cache invalidation is what nobody mentions in the tutorials. Cached data goes stale. You need to decide when to expire it, whether to delete it on writes or wait for the TTL, and what happens when a cache miss occurs under load. None of this is complicated, but it&apos;s all code you write and bugs you debug. The cost is real.&lt;/p&gt;
&lt;h2&gt;Common Gotchas&lt;/h2&gt;
&lt;p&gt;| Layer | Mistake                              | Effect                                                  |
| ----- | ------------------------------------ | ------------------------------------------------------- |
| Gzip  | Missing &lt;code&gt;gzip_proxied any&lt;/code&gt;           | Proxied app responses aren&apos;t compressed                 |
| Gzip  | Compressing images and video         | Response gets larger, not smaller                       |
| WAL   | Default checkpoint threshold of 1000 | WAL grows for days; reads slow down                     |
| WAL   | Multiple concurrent writers          | WAL allows one writer at a time; extra writers queue up |
| Redis | Caching without an expiry            | Stale data served indefinitely                          |
| Redis | Not invalidating on writes           | Cache and database diverge silently                     |&lt;/p&gt;
&lt;h2&gt;Is This Right for You?&lt;/h2&gt;
&lt;p&gt;Add gzip if you&apos;re serving HTML, CSS, or JSON over nginx and haven&apos;t confirmed &lt;code&gt;Content-Encoding: gzip&lt;/code&gt; in your response headers. It&apos;s a config change and a reload.&lt;/p&gt;
&lt;p&gt;Set &lt;code&gt;wal_autocheckpoint = 100&lt;/code&gt; if you&apos;re using SQLite in WAL mode on a web app with concurrent requests. One pragma at connection init, no schema changes, no migration.&lt;/p&gt;
&lt;p&gt;Add Redis if a specific query is measurably slow, runs on every request, and the result stays valid long enough to be worth caching. Profile the query first. If it completes in under 20ms and runs a few hundred times per day, you don&apos;t have a database problem.&lt;/p&gt;
&lt;p&gt;Most apps I&apos;ve seen that feel slow are sending full HTML payloads uncompressed over the wire. The database is fine. Fix the network layer before adding infrastructure.&lt;/p&gt;</content:encoded><h:img src="/_astro/hero.BTzTrMIa.webp"/><enclosure url="/_astro/hero.BTzTrMIa.webp"/></item><item><title>A Network Blip Is Not Just a Blip</title><link>https://srmdn.com/blog/network-blip</link><guid isPermaLink="true">https://srmdn.com/blog/network-blip</guid><description>Network blips are brief and self-healing. They are also the reason a backup job can run cleanly, log no errors, and leave you with nothing to restore from.</description><pubDate>Tue, 31 Mar 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;A network blip is brief, self-healing, and invisible after the fact. It is also the kind of thing that silently wipes out a backup job while your site keeps running normally.&lt;/p&gt;
&lt;h2&gt;What is a network blip&lt;/h2&gt;
&lt;p&gt;A network blip is a temporary interruption in a network connection. Not a full outage. The server stays up, DNS resolves, ping responds. But for a few seconds (sometimes longer), packets get dropped or delayed enough that an active TCP connection times out and closes.&lt;/p&gt;
&lt;p&gt;Common causes:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;A router or switch along the path reboots or flushes its connection table&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;A cloud provider does brief maintenance on a network link&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;BGP route changes between two hosting providers mid-transfer&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;ISP congestion causing packet loss above the TCP retransmission threshold&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The defining characteristic: by the time you notice and go to investigate, everything works fine again. There is no broken host to point at, no down service to restart. The failure window is already closed.&lt;/p&gt;
&lt;h2&gt;Why long-running operations take the hit&lt;/h2&gt;
&lt;p&gt;A web request that hits a blip just fails and retries in milliseconds. The user barely notices, the next request goes through, life continues. The entire exposure window is under a second.&lt;/p&gt;
&lt;p&gt;A long-running transfer is different. An SCP upload that hits a blip mid-transfer loses the entire transfer. No retry, no resume. The connection is gone, the destination file is incomplete or missing, and the script that launched it either crashes or silently moves on.&lt;/p&gt;
&lt;p&gt;This is the asymmetry: short operations tolerate blips, long operations do not. Database backups, large file transfers, remote sync jobs — anything that holds a TCP connection open for more than a few seconds is exposed. The longer the transfer, the higher the probability that a momentary blip lands inside it.&lt;/p&gt;
&lt;h2&gt;Case study: the failed backup&lt;/h2&gt;
&lt;p&gt;My backup timer fired at 3am. The script packaged SQLite databases, config files, and content into a &lt;code&gt;.tar.gz&lt;/code&gt;, then uploaded to a remote VPS over SCP through a jump host. Every local step completed cleanly. Then the upload dropped.&lt;/p&gt;
&lt;p&gt;The setup is a standard bash backup script scheduled via systemd timer. If you want a reference implementation, &lt;a href=&quot;https://gitlab.com/srmdn/sysadmin-scripts/-/blob/main/maintenance/backup.sh&quot;&gt;here is the one I use&lt;/a&gt;.&lt;/p&gt;
&lt;h3&gt;What the logs said&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;journalctl -u your-backup.service --since today
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Output:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Mar 31 03:01:05 backup[12301]: [03:01:05] Creating archive...
Mar 31 03:01:05 backup[12302]: [03:01:05]   archive: backup_20260331.tar.gz (1.1M)
Mar 31 03:01:05 backup[12303]: [03:01:05] Uploading to remote...
Mar 31 03:08:18 backup[12304]: scp: Connection closed
Mar 31 03:08:21 systemd[1]: backup.service: Main process exited, code=exited, status=255/EXCEPTION
Mar 31 03:08:21 systemd[1]: backup.service: Failed with result &apos;exit-code&apos;.
Mar 31 03:08:21 systemd[1]: Failed to start backup.service
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Two things stand out. First, the gap: &lt;code&gt;Uploading to remote...&lt;/code&gt; logged at 03:01, then silence until &lt;code&gt;scp: Connection closed&lt;/code&gt; at 03:08. SCP hung for seven minutes before the connection dropped. Second, the log file itself has no &quot;uploaded&quot; confirmation line:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;[03:01:05] Uploading to remote...
[03:08:18] ← scp: Connection closed (from journalctl, not the log file)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The backup script used &lt;code&gt;set -euo pipefail&lt;/code&gt;. When &lt;code&gt;scp&lt;/code&gt; exited non-zero, the script bailed immediately without writing another log entry. No &quot;upload failed&quot; message, because nothing in the failure path wrote to the log before exiting. The gap in the log file is the signal.&lt;/p&gt;
&lt;h3&gt;What caused it&lt;/h3&gt;
&lt;p&gt;The SCP route went through a jump host. The jump host stayed up (ping responded, port open), but the SSH session dropped mid-transfer. A brief interruption on the relay, a momentary blip on the path between relay and backup destination. The connection was healthy before and after.&lt;/p&gt;
&lt;p&gt;To rule out a key issue or downed destination, test each hop separately:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# Test the relay directly
ssh -p &amp;#x3C;port&gt; user@relay-host &quot;echo OK&quot;

# Test the full chain
ssh user@backup-destination &quot;echo OK&quot;

# Manual SCP test
scp /tmp/testfile user@backup-destination:/tmp/
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;All three succeeded immediately. The relay was healthy. The failure was a moment-in-time blip: gone before the investigation started.&lt;/p&gt;
&lt;h3&gt;Why it is easy to miss&lt;/h3&gt;
&lt;p&gt;The systemd unit did report &lt;code&gt;Failed with result &apos;exit-code&apos;&lt;/code&gt;. So &lt;code&gt;systemctl status your-backup.service&lt;/code&gt; would show a failed state. But you only check that when something is obviously broken. A 3am backup failure does not break anything visible. The site stays up, requests keep coming in, nothing pages you.&lt;/p&gt;
&lt;p&gt;The only reason I caught it was an alert email. Without that, I would have gone days believing the remote copy existed.&lt;/p&gt;
&lt;h3&gt;The real consequence&lt;/h3&gt;
&lt;p&gt;If a disk fails or a deploy goes wrong the next morning and you reach for the latest backup, you are restoring from data that is older than you think. In a low-traffic personal project that might be acceptable. In anything with active writes, that gap is data loss.&lt;/p&gt;
&lt;h2&gt;What to do after a failure&lt;/h2&gt;
&lt;p&gt;Check whether the local archive is still there. A backup script that packages locally before uploading will still have the archive even when the upload fails:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;ls -lh ~/backups/
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If the file is there, the data is not lost. Upload it manually:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;scp ~/backups/backup_20260331.tar.gz user@backup-destination:/path/to/backups/
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then run the full backup script again to get a fresh timestamped copy and let it handle remote rotation normally.&lt;/p&gt;
&lt;h2&gt;What to add to prevent silent failures&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Trap on exit and log the outcome.&lt;/strong&gt; With &lt;code&gt;set -euo pipefail&lt;/code&gt;, the failure path exits without writing to the log by default. Add a trap:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;on_exit() {
    local code=$1
    if [[ $code -ne 0 ]]; then
        log &quot;Backup FAILED (exit code: $code)&quot;
        send_failure_alert &quot;$code&quot;
    fi
}
trap &apos;on_exit $?&apos; EXIT
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Send a failure notification.&lt;/strong&gt; The systemd unit failure state is not enough because it requires you to look for it. Email via &lt;code&gt;curl --ssl-reqd&lt;/code&gt; to an SMTP relay, a webhook, anything. The goal is a push notification, not a pull check.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Verify the remote after upload.&lt;/strong&gt; After &lt;code&gt;scp&lt;/code&gt; returns, SSH into the destination and confirm the file exists and is non-empty:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;ssh backup-destination &quot;ls -lh /backups/backup_${TIMESTAMP}.tar.gz&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A zero-byte file after a dropped connection is a real failure mode. SCP can create the destination file before the transfer completes.&lt;/p&gt;
&lt;h2&gt;The check that matters&lt;/h2&gt;
&lt;p&gt;After any incident, read the log file directly alongside &lt;code&gt;journalctl&lt;/code&gt;. The log file shows what the script wrote. &lt;code&gt;journalctl&lt;/code&gt; shows what systemd saw, including stderr. Together they give you the full picture. A gap between &quot;Uploading...&quot; and the next entry is the signature of a blip-killed transfer.&lt;/p&gt;
&lt;p&gt;Network blips are outside your control. What is inside your control: whether your script logs the outcome, whether a failure sends you a notification, and whether you verify the remote copy after every upload. The blip cannot be prevented. The silent failure can.&lt;/p&gt;</content:encoded><h:img src="/_astro/hero.CoCE0NEX.webp"/><enclosure url="/_astro/hero.CoCE0NEX.webp"/></item><item><title>Claude Code&apos;s Usage Bug and the Fight Behind It</title><link>https://srmdn.com/blog/claude-code-pentagon</link><guid isPermaLink="true">https://srmdn.com/blog/claude-code-pentagon</guid><description>My session limit drained in two hours. The bug is real, but the timing points to something bigger: Anthropic&apos;s standoff with the Pentagon.</description><pubDate>Thu, 26 Mar 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;My Claude Code session limit hit 100% in under two hours last week. I was doing normal work. Nothing unusual in my workflow, no massive context dumps, no runaway loops. I checked Reddit and found dozens of people reporting the same thing: limits draining in minutes, sessions dying mid-task, &lt;a href=&quot;https://www.anthropic.com/pricing&quot;&gt;plans&lt;/a&gt; costing $100–$200/month behaving like free tiers.&lt;/p&gt;
&lt;p&gt;The first instinct is to blame a bug. And there is a bug. GitHub issues are piling up on Anthropic&apos;s own repo (&lt;a href=&quot;https://github.com/anthropics/claude-code/issues/38335&quot;&gt;#38335&lt;/a&gt;, &lt;a href=&quot;https://github.com/anthropics/claude-code/issues/9424&quot;&gt;#9424&lt;/a&gt;), and Anthropic responded by doubling usage limits through &lt;a href=&quot;https://believemy.com/en/r/claude-code-is-temporarily-doubles-its-usage-limits&quot;&gt;March 27&lt;/a&gt; as a temporary fix. The frustration is measurable: &lt;a href=&quot;https://metr.org/blog/2025-07-10-early-2025-ai-experienced-os-dev-study/&quot;&gt;a METR study&lt;/a&gt; found that Claude Code increased task completion time by 19% compared to working without it, largely because users kept hitting limits mid-task and losing flow. But the timing of when this latest wave of issues started deserves more attention than it&apos;s getting.&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://www.techradar.com/ai-platforms-assistants/claude/claude-has-temporarily-doubled-usage-limits-for-everyone-but-there-is-a-catch&quot;&gt;March 23, 2026&lt;/a&gt;. The same week Anthropic&apos;s standoff with the Pentagon became public.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;What Anthropic Refused&lt;/h2&gt;
&lt;p&gt;This is not a company that quietly declined a government contract. Anthropic published a &lt;a href=&quot;https://www.anthropic.com/news/statement-department-of-war&quot;&gt;formal statement&lt;/a&gt; explaining exactly what the Department of War demanded and what they refused to allow.&lt;/p&gt;
&lt;p&gt;Two things. Mass domestic surveillance of Americans using their AI models. And fully autonomous weapons systems without human oversight in the decision chain.&lt;/p&gt;
&lt;p&gt;On the weapons point: the distinction matters. Partially autonomous weapons, where a human confirms the final decision to engage, already exist and Anthropic has no objection to those. Fully autonomous means the AI selects and strikes a target with no human in the loop. Think a drone that identifies, decides, and fires without a soldier ever pressing a button. Anthropic&apos;s argument is that current AI models hallucinate and make errors at rates that are simply not acceptable for that kind of irreversible action.&lt;/p&gt;
&lt;p&gt;The government&apos;s position: accept &quot;any lawful use&quot; and remove the safety restrictions. Anthropic&apos;s position: no.&lt;/p&gt;
&lt;p&gt;The Pentagon&apos;s response was to threaten designating Anthropic a &lt;a href=&quot;https://www.aljazeera.com/economy/2026/3/25/anthropics-case-against-the-pentagon-could-open-space-for-ai-regulation&quot;&gt;&quot;supply chain risk.&quot;&lt;/a&gt; That label has previously been reserved for foreign adversaries like Huawei and ZTE. Applying it to a U.S. company is unprecedented. A California judge noted the government may be &quot;attempting to cripple Anthropic.&quot;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The government&apos;s counterargument is worth stating clearly.&lt;/strong&gt; The Pentagon&apos;s position is not &quot;we want rogue AI.&quot; Their argument is that AI deployment decisions in national security contexts should be governed by existing law and military oversight, not unilaterally by a private company&apos;s internal safety team. From that angle, Anthropic is being asked to trust the legal and institutional framework the U.S. already has, not to override it. Whether you find that convincing depends on how much you trust those institutions right now.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;The OpenAI Contrast&lt;/h2&gt;
&lt;p&gt;OpenAI joined &lt;a href=&quot;https://openai.com/index/announcing-the-stargate-project/&quot;&gt;Stargate&lt;/a&gt;, a &lt;a href=&quot;https://www.theverge.com/2025/1/21/24348816/trump-openai-softbank-oracle-ai-stargate-investment&quot;&gt;U.S. government AI initiative&lt;/a&gt; worth $500B with direct Pentagon involvement. They accepted the terms. They are not facing this pressure.&lt;/p&gt;
&lt;p&gt;I&apos;m not saying one decision is obviously correct. But the contrast explains a lot about why Claude Code feels like it&apos;s running on strained infrastructure right now while other tools operate without incident.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;The Migration Wave&lt;/h2&gt;
&lt;p&gt;Claude.ai now lets you import your ChatGPT memory directly from settings. Anthropic built that feature with purpose, and the timing tells you something about how they see the market.&lt;/p&gt;
&lt;p&gt;A number of developers left OpenAI&apos;s products after the Stargate announcement. Some didn&apos;t want their daily tools tied to autonomous weapons contracting. Others just followed the momentum. Claude Code&apos;s user numbers climbed, which put more load on infrastructure already under political pressure.&lt;/p&gt;
&lt;p&gt;Whether any of the usage drain traces back to deliberate attacks on Anthropic&apos;s servers, I can&apos;t confirm from the outside. It&apos;s speculation, and I want to be clear about that. But the context (a public standoff with a powerful government institution, combined with a user surge) makes infrastructure pressure of all kinds more plausible than it would have been six months ago.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;Does Anthropic Know?&lt;/h2&gt;
&lt;p&gt;Yes. And the evidence is in their own actions.&lt;/p&gt;
&lt;p&gt;The GitHub issues flagging the usage drain are filed directly on Anthropic&apos;s repo. Their engineering team sees every one. The decision to double limits through March 27 was not a coincidence. It was a direct response to the volume of complaints hitting Reddit, X, and their own issue tracker within days. Companies at this scale have people whose job is to monitor exactly that.&lt;/p&gt;
&lt;p&gt;The harder question is bandwidth. A team simultaneously managing an existential legal fight with the Pentagon, an infrastructure surge from new users, and a billing bug that&apos;s hard to reproduce cleanly is a team with limited capacity even when fully informed. Knowing about a problem and having the space to fix it properly are two different things. The doubled limits are the fastest lever they could pull without a proper fix in place.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;The Stakes Are Existential&lt;/h2&gt;
&lt;p&gt;Anthropic is not yet profitable. &lt;a href=&quot;https://www.cnbc.com/2023/10/27/google-commits-to-invest-2-billion-in-openai-competitor-anthropic.html&quot;&gt;Google&lt;/a&gt; has invested $2B and &lt;a href=&quot;https://www.aboutamazon.com/news/company-news/amazon-anthropic-ai-investment&quot;&gt;Amazon&lt;/a&gt; $4B. A &quot;supply chain risk&quot; designation would cut off government contracts and create pressure on those investors. Both Google Cloud and AWS have their own federal contracts that a high-profile association with a designated &quot;supply chain risk&quot; could complicate. This is not a PR dispute. The company&apos;s ability to keep operating is on the line.&lt;/p&gt;
&lt;p&gt;The irony: Anthropic was &lt;a href=&quot;https://en.wikipedia.org/wiki/Anthropic&quot;&gt;founded by people who left OpenAI&lt;/a&gt; specifically over AI safety disagreements. They are now being threatened by their own government for maintaining those same safety standards.&lt;/p&gt;
&lt;p&gt;If you&apos;re on &lt;a href=&quot;https://www.anthropic.com/pricing&quot;&gt;Claude&apos;s paid plans&lt;/a&gt; ($100 or $200 a month) and your limits drain in two hours, you deserve an explanation. &quot;We&apos;re doubling limits temporarily&quot; is not one. It&apos;s a patch.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;What Happens Next&lt;/h2&gt;
&lt;p&gt;The case is being heard in a California federal court. No ruling date has been confirmed publicly, but the judge&apos;s early comments suggest the court is taking the &quot;supply chain risk&quot; designation seriously as an overreach.&lt;/p&gt;
&lt;p&gt;Two outcomes matter beyond Anthropic itself. If the Pentagon wins, every AI company operating in the U.S. faces the same demand: remove your safety restrictions or lose government access. The pressure would cascade quickly given how much of the AI industry depends on federal contracts and cloud revenue. If Anthropic wins, it creates legal precedent that private companies can hold the line on specific use cases even under government pressure, and it opens space for actual legislation on autonomous weapons and AI surveillance, something &lt;a href=&quot;https://www.aljazeera.com/economy/2026/3/25/anthropics-case-against-the-pentagon-could-open-space-for-ai-regulation&quot;&gt;69% of Americans say they want&lt;/a&gt; according to polling cited by Al Jazeera.&lt;/p&gt;
&lt;p&gt;Follow the case through &lt;a href=&quot;https://www.courtlistener.com/&quot;&gt;court filings on CourtListener&lt;/a&gt; and coverage from &lt;a href=&quot;https://www.theregister.com/2026/01/05/claude_devs_usage_limits/&quot;&gt;The Register&lt;/a&gt; if you want to track it directly.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;My Take&lt;/h2&gt;
&lt;p&gt;I&apos;m going to keep using Claude Code. The usage issues are frustrating, but the company&apos;s position on autonomous weapons and domestic surveillance is defensible. Plenty of AI tools exist with no restrictions on either use case. I&apos;d rather wait out a broken session limit than use one of them.&lt;/p&gt;
&lt;p&gt;I understand the Pentagon&apos;s argument too. Institutional oversight exists for a reason, and private companies unilaterally deciding what the military can and can&apos;t do with a product sets a complicated precedent in the other direction. This is a genuinely hard problem, not a clean villain story.&lt;/p&gt;
&lt;p&gt;But when a government labels its own citizen company using the same designation reserved for adversary nations, that&apos;s worth paying attention to regardless of where you land on the underlying policy question.&lt;/p&gt;
&lt;p&gt;The bug you noticed in your session usage is real. The context around it is bigger than the bug.&lt;/p&gt;</content:encoded><h:img src="/_astro/hero.BdzZVRBX.webp"/><enclosure url="/_astro/hero.BdzZVRBX.webp"/></item><item><title>nginx 1.28.3: Six CVEs, One Upgrade</title><link>https://srmdn.com/blog/nginx-1283-security-release</link><guid isPermaLink="true">https://srmdn.com/blog/nginx-1283-security-release</guid><description>nginx 1.28.3 dropped March 24 with six CVEs patched. What each one means and whether you need to care.</description><pubDate>Thu, 26 Mar 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;nginx has been running on your server for months, probably years. You haven&apos;t touched its config. It just works. Three days ago it got six security fixes in one release, and every one of them is worth at least a glance.&lt;/p&gt;
&lt;h2&gt;The release&lt;/h2&gt;
&lt;p&gt;nginx 1.28.x is the stable branch. When it ships security fixes, they&apos;re deliberate backports from mainline, not experimental changes. Version 1.28.3 came out March 24, 2026. If you&apos;re on Ubuntu and haven&apos;t upgraded yet, &lt;code&gt;apt upgrade nginx&lt;/code&gt; is all it takes.&lt;/p&gt;
&lt;h2&gt;Six CVEs at a glance&lt;/h2&gt;
&lt;p&gt;| CVE                                                               | Severity | CVSS 4.0 | What&apos;s vulnerable                        | Impact                               |
| ----------------------------------------------------------------- | -------- | -------- | ---------------------------------------- | ------------------------------------ |
| &lt;a href=&quot;https://nvd.nist.gov/vuln/detail/CVE-2026-27654&quot;&gt;CVE-2026-27654&lt;/a&gt; | High     | 8.8      | &lt;code&gt;alias&lt;/code&gt; + WebDAV COPY/MOVE               | Path escape outside document root    |
| &lt;a href=&quot;https://nvd.nist.gov/vuln/detail/CVE-2026-27651&quot;&gt;CVE-2026-27651&lt;/a&gt; | High     | 8.7      | CRAM-MD5/APOP mail auth with retry       | Worker process segfault              |
| &lt;a href=&quot;https://nvd.nist.gov/vuln/detail/CVE-2026-27784&quot;&gt;CVE-2026-27784&lt;/a&gt; | High     | 8.5      | MP4 module, 32-bit platforms             | Worker process crash                 |
| &lt;a href=&quot;https://nvd.nist.gov/vuln/detail/CVE-2026-32647&quot;&gt;CVE-2026-32647&lt;/a&gt; | High     | 8.5      | MP4 module, all platforms                | Worker process crash                 |
| &lt;a href=&quot;https://nvd.nist.gov/vuln/detail/CVE-2026-28755&quot;&gt;CVE-2026-28755&lt;/a&gt; | Medium   | 5.3      | OCSP in stream module                    | Revoked client cert accepted         |
| &lt;a href=&quot;https://nvd.nist.gov/vuln/detail/CVE-2026-28753&quot;&gt;CVE-2026-28753&lt;/a&gt; | Medium   | 6.3      | PTR DNS records in auth_http/SMTP proxy | Data injection into backend requests |&lt;/p&gt;
&lt;h2&gt;Breaking each one down&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href=&quot;https://nvd.nist.gov/vuln/detail/CVE-2026-27654&quot;&gt;CVE-2026-27654&lt;/a&gt;&lt;/strong&gt; &lt;a href=&quot;https://nvd.nist.gov/vuln/detail/CVE-2026-27654&quot;&gt;l&lt;/a&gt;ives in how nginx handles the &lt;code&gt;alias&lt;/code&gt; directive when WebDAV methods (COPY, MOVE) are enabled. An attacker can craft a request that shifts the source or destination path outside the document root. The &lt;code&gt;alias&lt;/code&gt; directive has a long history of path-handling edge cases in nginx. No config change is needed to get the fix, just the upgrade. But if you have WebDAV enabled on a public server, audit what methods you&apos;re actually allowing.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href=&quot;https://nvd.nist.gov/vuln/detail/CVE-2026-27651&quot;&gt;CVE-2026-27651&lt;/a&gt;&lt;/strong&gt; affects nginx&apos;s mail proxy module when CRAM-MD5 or APOP authentication is used with retry enabled. A segmentation fault in the worker process means the request dies and the worker restarts. That&apos;s availability impact, not data exposure. If you&apos;re not running nginx as a mail proxy, this doesn&apos;t touch you.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href=&quot;https://nvd.nist.gov/vuln/detail/CVE-2026-27784&quot;&gt;CVE-2026-27784&lt;/a&gt;&lt;/strong&gt; &lt;strong&gt;and&lt;/strong&gt; &lt;strong&gt;&lt;a href=&quot;https://nvd.nist.gov/vuln/detail/CVE-2026-32647&quot;&gt;CVE-2026-32647&lt;/a&gt;&lt;/strong&gt; both live in the MP4 module. The first affects 32-bit platforms specifically; the second affects all platforms. Both result in worker process crashes when processing a specially crafted MP4 file. If you&apos;re not using &lt;code&gt;ngx_http_mp4_module&lt;/code&gt;, nginx doesn&apos;t compile or load it by default on most distributions anyway. Check with &lt;code&gt;nginx -V 2&gt;&amp;#x26;1 | grep mp4&lt;/code&gt; to confirm.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href=&quot;https://nvd.nist.gov/vuln/detail/CVE-2026-28755&quot;&gt;CVE-2026-28755&lt;/a&gt;&lt;/strong&gt; is the one that surprised me. In the stream module, an OCSP check could reject a client certificate and the TLS handshake would succeed anyway. The entire point of OCSP is to revoke certificates that should no longer be trusted. A bypass here means a revoked cert gets through silently. Most deployments don&apos;t use stream with mutual TLS, so the practical impact is limited. But the failure mode is worth knowing: the check ran, it said no, and nginx said yes anyway.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href=&quot;https://nvd.nist.gov/vuln/detail/CVE-2026-28753&quot;&gt;CVE-2026-28753&lt;/a&gt;&lt;/strong&gt; is the DNS injection one. When nginx does reverse DNS lookups for auth_http or the XCLIENT command in SMTP proxy flows, it uses the PTR record response as input. An attacker who controls the DNS server answering those PTR queries can inject data into the headers sent to your backend. Exploiting this requires controlling upstream DNS, which raises the bar considerably. Still, it belongs to a class of bugs that keeps appearing across different software: DNS responses are attacker-controlled data, and treating them as trusted input is the root cause every time.&lt;/p&gt;
&lt;h2&gt;QUIC improvements&lt;/h2&gt;
&lt;p&gt;This release also ships two non-CVE changes to QUIC handling worth noting.&lt;/p&gt;
&lt;p&gt;nginx now limits the size and rate of QUIC stateless reset packets. Without this, a misbehaving or malicious peer could trigger an unbounded volume of stateless resets. The second fix addresses a bug where a QUIC packet received by the wrong worker process caused the connection to terminate instead of being handed off correctly.&lt;/p&gt;
&lt;p&gt;Neither of these is a security vulnerability with a CVE, but both affect connection stability for any deployment using QUIC.&lt;/p&gt;
&lt;h2&gt;Updating&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;apt upgrade nginx
nginx -v
systemctl status nginx
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The package post-install script handles the service restart. No config changes, no downtime beyond the few seconds nginx takes to reload. Worker processes drain and restart cleanly.&lt;/p&gt;
&lt;p&gt;After the upgrade, &lt;code&gt;nginx -v&lt;/code&gt; should show &lt;code&gt;nginx/1.28.3&lt;/code&gt;. If the status shows the service running with a timestamp matching your upgrade, you&apos;re done.&lt;/p&gt;</content:encoded><h:img src="/_astro/hero.C0oXSjrI.webp"/><enclosure url="/_astro/hero.C0oXSjrI.webp"/></item><item><title>ProtectHome=yes in Systemd Breaks Subprocesses Too</title><link>https://srmdn.com/blog/systemd-protecthome-subprocess</link><guid isPermaLink="true">https://srmdn.com/blog/systemd-protecthome-subprocess</guid><description>You hardened your systemd service with ProtectHome=yes. Your Go service spawns a subprocess. It crashes with EACCES on a path you never touched.</description><pubDate>Wed, 25 Mar 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Your service runs fine. You click a button that triggers a subprocess: &lt;code&gt;npm run build&lt;/code&gt;, a shell script, anything. It crashes immediately with &lt;code&gt;EACCES: permission denied&lt;/code&gt; on some path under &lt;code&gt;/home&lt;/code&gt;. You didn&apos;t touch &lt;code&gt;/home&lt;/code&gt;. Your service doesn&apos;t use &lt;code&gt;/home&lt;/code&gt;. Nothing makes sense.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;ProtectHome=yes&lt;/code&gt; is why.&lt;/p&gt;
&lt;h2&gt;What ProtectHome Actually Does&lt;/h2&gt;
&lt;p&gt;When you add &lt;code&gt;ProtectHome=yes&lt;/code&gt; to a systemd unit, systemd makes &lt;code&gt;/home&lt;/code&gt;, &lt;code&gt;/root&lt;/code&gt;, and &lt;code&gt;/run/user&lt;/code&gt; completely inaccessible to that service. Not just hidden. Inaccessible. Any read or write attempt returns permission denied.&lt;/p&gt;
&lt;p&gt;The restriction applies to every subprocess the service spawns, not just the service itself. The sandbox is inherited. Your Go binary running as &lt;code&gt;deploy&lt;/code&gt; can&apos;t access &lt;code&gt;/home/deploy&lt;/code&gt;. Neither can the &lt;code&gt;npm&lt;/code&gt; process your Go binary spawns.&lt;/p&gt;
&lt;h2&gt;Why It&apos;s Hard to Catch&lt;/h2&gt;
&lt;p&gt;The service itself rarely touches home directories. You run it, it works, you move on. The problem only surfaces when your service runs a tool that assumes it can write to &lt;code&gt;~/.config/&lt;/code&gt; or &lt;code&gt;~/.cache/&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Build tools are the usual culprit. Many of them write telemetry or cache data to the home directory on first run. Astro writes to &lt;code&gt;~/.config/astro&lt;/code&gt;. Some npm tools write to &lt;code&gt;~/.npm&lt;/code&gt;. These writes happen before the actual task starts, so the error appears immediately and looks like a misconfiguration, not a sandbox issue.&lt;/p&gt;
&lt;h2&gt;The Fix&lt;/h2&gt;
&lt;p&gt;You have two options.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Option 1: Disable the home-dir write on the subprocess side.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Most tools that write to home directories do it for telemetry or caching, and they provide an environment variable to disable it:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;cmd := exec.Command(&quot;npm&quot;, &quot;run&quot;, &quot;build&quot;)
cmd.Env = append(os.Environ(), &quot;ASTRO_TELEMETRY_DISABLED=1&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;os.Environ()&lt;/code&gt; carries the parent&apos;s full environment through. The extra variable disables the telemetry write. No other behavior changes.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Option 2: Remove ProtectHome.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Don&apos;t. &lt;code&gt;ProtectHome=yes&lt;/code&gt; prevents a compromised service from reading your SSH keys, dotfiles, and anything else stored under home directories. Removing it to fix a telemetry write is the wrong trade-off.&lt;/p&gt;
&lt;h2&gt;Finding the Right Disable Flag&lt;/h2&gt;
&lt;p&gt;The pattern is consistent across build tools:&lt;/p&gt;
&lt;p&gt;| Tool | Environment variable |
|------|----------------------|
| Astro | &lt;code&gt;ASTRO_TELEMETRY_DISABLED=1&lt;/code&gt; |
| Next.js | &lt;code&gt;NEXT_TELEMETRY_DISABLED=1&lt;/code&gt; |
| Nuxt | &lt;code&gt;NUXT_TELEMETRY_DISABLED=1&lt;/code&gt; |
| Gatsby | &lt;code&gt;GATSBY_TELEMETRY_DISABLED=1&lt;/code&gt; |
| Angular CLI | &lt;code&gt;NG_CLI_ANALYTICS=false&lt;/code&gt; |
| .NET CLI | &lt;code&gt;DOTNET_CLI_TELEMETRY_OPTOUT=1&lt;/code&gt; |&lt;/p&gt;
&lt;p&gt;If the tool doesn&apos;t have a telemetry flag, check whether it respects &lt;code&gt;XDG_CONFIG_HOME&lt;/code&gt; or &lt;code&gt;XDG_CACHE_HOME&lt;/code&gt;. You can redirect those to a path your service can actually write to:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;cmd.Env = append(os.Environ(),
    &quot;XDG_CONFIG_HOME=/var/www/myapp/.config&quot;,
    &quot;XDG_CACHE_HOME=/var/www/myapp/.cache&quot;,
)
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;How to Confirm This Is Your Problem&lt;/h2&gt;
&lt;p&gt;Check your service unit:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;systemctl cat your-service-name
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Look for &lt;code&gt;ProtectHome=yes&lt;/code&gt; or &lt;code&gt;ProtectHome=read-only&lt;/code&gt;. Then check what the subprocess is trying to access in the error output. If the path is under &lt;code&gt;/home&lt;/code&gt;, &lt;code&gt;/root&lt;/code&gt;, or &lt;code&gt;/run/user&lt;/code&gt;, this is your problem.&lt;/p&gt;
&lt;p&gt;Run the subprocess manually as the service user outside of systemd to verify:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo -u deploy npm run build
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If it works manually but fails under systemd, the sandbox is the issue.&lt;/p&gt;
&lt;h2&gt;Is This Right for You?&lt;/h2&gt;
&lt;p&gt;Keep &lt;code&gt;ProtectHome=yes&lt;/code&gt;. Fix the subprocess by passing the right environment variable.&lt;/p&gt;
&lt;p&gt;If your subprocess legitimately needs home directory access, consider whether that data belongs there at all. Writing user-specific state or reading credentials from &lt;code&gt;~/.config&lt;/code&gt; works fine outside a sandbox, but it&apos;s a dependency you shouldn&apos;t need. A service running under a dedicated system user with &lt;code&gt;/var/www/...&lt;/code&gt; as its working directory rarely needs home directory access.&lt;/p&gt;
&lt;p&gt;Tighten the sandbox. Reduce what the subprocess expects to find there.&lt;/p&gt;</content:encoded><h:img src="/_astro/hero.7K83ULAg.webp"/><enclosure url="/_astro/hero.7K83ULAg.webp"/></item><item><title>Self-Hosted Referral Links Without the /s/</title><link>https://srmdn.com/blog/self-hosted-referral-links</link><guid isPermaLink="true">https://srmdn.com/blog/self-hosted-referral-links</guid><description>I tried Slash for my referral links. The /s/ prefix gave them away. So I built plink: a single-binary shortener with clean slugs on your own domain.</description><pubDate>Sun, 22 Mar 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Raw referral links are self-defeating. When someone sees a URL with &lt;code&gt;ref=&lt;/code&gt; or a &lt;code&gt;/s/&lt;/code&gt; prefix, they know two things: it&apos;s a shortener, and you earn something if they click. Some people skip these on principle. Others open them but strip the referral parameter before buying. Either way, you lose the commission.&lt;/p&gt;
&lt;p&gt;The fix is to hide the destination. Give people &lt;code&gt;yourdomain.com/shopee&lt;/code&gt; with no indication of where it goes.&lt;/p&gt;
&lt;h2&gt;I looked at Slash first&lt;/h2&gt;
&lt;p&gt;Slash is a self-hosted link shortener with a few thousand GitHub stars, active development, Docker deployment, analytics, and multi-user workspace support. For team bookmarks and internal shortcuts, it works well.&lt;/p&gt;
&lt;p&gt;The problem: Slash uses a &lt;code&gt;/s/&lt;/code&gt; prefix. Your link becomes &lt;code&gt;yourdomain.com/s/shopee&lt;/code&gt;. For internal shortcuts shared with a team, that format is fine. For referral links meant to look natural, the &lt;code&gt;/s/&lt;/code&gt; announces what you&apos;re doing.&lt;/p&gt;
&lt;p&gt;Slash targets teams sharing internal shortcuts. The &lt;code&gt;/s/&lt;/code&gt; prefix keeps the namespace clean and predictable for that use case. Referral link hiding is a different problem.&lt;/p&gt;
&lt;h2&gt;What I built instead&lt;/h2&gt;
&lt;p&gt;plink is a single Go binary. SQLite database, templates embedded directly in the binary via Go&apos;s &lt;code&gt;embed&lt;/code&gt; package. No Docker, no npm, no build step. Deploy it by copying a binary and an env file to your server. Clean slugs by default: &lt;code&gt;yourdomain.com/shopee&lt;/code&gt;, destination invisible from the URL.&lt;/p&gt;
&lt;p&gt;The admin panel sits behind a configurable path you set in your env file. The path doesn&apos;t appear in the source code. Visitors browsing your public link list have no way to find the admin URL from the page source.&lt;/p&gt;
&lt;p&gt;Click analytics are built in: total counts, a 30-day chart, and referrer breakdown. That last one tells you where your traffic comes from, which is more useful than the total number alone.&lt;/p&gt;
&lt;p&gt;The code is on GitHub: &lt;a href=&quot;https://github.com/srmdn/plink&quot;&gt;github.com/srmdn/plink&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;Slash vs plink&lt;/h2&gt;
&lt;p&gt;|                  | Slash                   | plink                   |
|------------------|-------------------------|-------------------------|
| URL format       | &lt;code&gt;domain.com/s/link&lt;/code&gt;     | &lt;code&gt;domain.com/link&lt;/code&gt;       |
| Deployment       | Docker                  | Single binary           |
| Users            | Multi-user, teams       | Single user             |
| Frontend         | React + TypeScript      | Vanilla HTML, embedded  |
| Database         | SQLite or PostgreSQL    | SQLite                  |
| Browser extension| Yes                     | No                      |
| License          | AGPL-3.0                | MIT                     |&lt;/p&gt;
&lt;h2&gt;Is This Right for You?&lt;/h2&gt;
&lt;p&gt;Use plink if you run referral links on your own domain and want the destination hidden from the URL. The single-binary deployment is a practical advantage on a VPS you&apos;re already managing.&lt;/p&gt;
&lt;p&gt;Use Slash if you want team collaboration, a browser extension, or you&apos;d rather run a maintained Docker image. The community is larger and the feature set is broader.&lt;/p&gt;
&lt;p&gt;The clean slug vs &lt;code&gt;/s/&lt;/code&gt; distinction sounds minor. For referral links, it changes whether visitors click or not.&lt;/p&gt;</content:encoded><h:img src="/_astro/hero.DEkfkzFe.webp"/><enclosure url="/_astro/hero.DEkfkzFe.webp"/></item><item><title>A Private Client Portal for Freelancers</title><link>https://srmdn.com/blog/client-portal-for-freelancers</link><guid isPermaLink="true">https://srmdn.com/blog/client-portal-for-freelancers</guid><description>Managing clients through Notion and email gets messy. I built a private portal to fix that, and you can use it too.</description><pubDate>Thu, 19 Mar 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;If you&apos;re doing client work, someone already solved this problem for you. Dubsado, HoneyBook, Notion, even a well-organized Gmail folder. These exist, they work, and they&apos;re cheaper than the time it takes to build something from scratch.&lt;/p&gt;
&lt;p&gt;Why did I build my own client portal anyway?&lt;/p&gt;
&lt;p&gt;The short answer: I wanted my client data on my server, not someone else&apos;s.&lt;/p&gt;
&lt;h2&gt;The problem with existing tools&lt;/h2&gt;
&lt;p&gt;Most tools in this space are either too general-purpose or too expensive. Notion gives you infinite flexibility but zero client access control. Share a Notion page with a client and they can see everything in that workspace if you&apos;re not careful. Project management tools like Trello or Linear are built for internal teams, not external clients. And the professional CRM options are full suites with pricing to match, more than you need if you&apos;re running a small freelance operation.&lt;/p&gt;
&lt;p&gt;What I actually needed was straightforward: a place where I can invite a client, assign them to their project, share documents, and see a clean record of what happened and when.&lt;/p&gt;
&lt;h2&gt;What I built&lt;/h2&gt;
&lt;p&gt;The system runs at &lt;code&gt;sys.srmdn.com&lt;/code&gt;. Each client gets their own space and only sees their own projects, with no visibility into anyone else&apos;s work.&lt;/p&gt;
&lt;p&gt;Clients get an invite link by email when I add them. They set a password, land on their dashboard, and see their projects and any documents I&apos;ve shared.&lt;/p&gt;
&lt;p&gt;The document editor is Markdown-based. I write notes, deliverables, or reports inside the system, and when something is client-facing, I send it directly to their email with one click. That last part replaced a workflow I used to hate: draft in one app, copy to email, reformat, send, and then immediately lose track of whether I actually sent it.&lt;/p&gt;
&lt;p&gt;There&apos;s also an audit log. Every significant action gets recorded with a timestamp. The value of this only became clear after using it: when a client says &quot;I never received that,&quot; you can pull up exactly what happened.&lt;/p&gt;
&lt;h2&gt;How it&apos;s built&lt;/h2&gt;
&lt;p&gt;Backend is Go, using the Fiber framework. Frontend is React with Vite, served as a static SPA. Database is SQLite. Everything runs on the same VPS that serves this blog: one more nginx vhost, one more systemd service.&lt;/p&gt;
&lt;p&gt;No Docker, no Kubernetes. Deployment is building a binary and restarting a service. The whole thing runs comfortably under 50MB of RAM.&lt;/p&gt;
&lt;p&gt;I chose SQLite deliberately. This is a single-user system with a small number of clients. SQLite&apos;s concurrency limits aren&apos;t a concern at this scale, and I can back up the entire database by copying one file. For a system like this, SQLite is the right call.&lt;/p&gt;
&lt;h2&gt;What surprised me&lt;/h2&gt;
&lt;p&gt;Building it was faster than I expected. Getting it secure took much longer.&lt;/p&gt;
&lt;p&gt;After the initial build, I ran a self-audit and found 17 issues. Most were minor (missing security headers, too-long JWT expiry, overly verbose error messages on auth failures), but a few would have been real problems in production. None were catastrophic, but it was a useful reminder that &quot;it works&quot; and &quot;it&apos;s safe&quot; are different checkboxes.&lt;/p&gt;
&lt;p&gt;The invite flow was also more complex than it looks. Expired links, duplicate accepts, re-invites for existing accounts: each one has to be handled explicitly. The happy path is five lines of code. The edge cases are fifty.&lt;/p&gt;
&lt;p&gt;The other surprise was the editor. I started with a popular rich text editor and eventually replaced it with Milkdown, a Markdown-first WYSIWYG. The reason: the original editor stored content as HTML blobs, which means documents only render correctly inside that same editor. Markdown is plain text, readable anywhere without a special tool, and it doesn&apos;t become unreadable as software changes.&lt;/p&gt;
&lt;p&gt;Most freelancers should use an existing tool. Building your own is only worth it if control matters more than time cost, and you&apos;re clear-eyed about what that trade looks like over two or three years of maintenance.&lt;/p&gt;
&lt;p&gt;For me, it was worth it. I know exactly what the system does, I own the data, and it fits my workflow precisely. But I&apos;ve also spent more hours on it than any SaaS subscription would have cost me. That&apos;s not a regret, just a fact worth naming.&lt;/p&gt;
&lt;p&gt;The system is open to other freelancers at &lt;a href=&quot;https://sys.srmdn.com&quot;&gt;sys.srmdn.com&lt;/a&gt;. You get the same setup I use: project workspaces, a Markdown doc editor, and invite-by-email for clients. No seat pricing or company tiers.&lt;/p&gt;</content:encoded><h:img src="/_astro/hero.BQNLjt3-.webp"/><enclosure url="/_astro/hero.BQNLjt3-.webp"/></item><item><title>AppArmor Had a Privilege Escalation Bug. Since 2017.</title><link>https://srmdn.com/blog/crackarmor-apparmor-vulnerability-2026</link><guid isPermaLink="true">https://srmdn.com/blog/crackarmor-apparmor-vulnerability-2026</guid><description>Nine flaws in AppArmor let an unprivileged local user reach root. Active on every Ubuntu server by default. Sitting there since 2017.</description><pubDate>Sat, 14 Mar 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;AppArmor is supposed to be one of the deeper layers of Linux security. It sits inside the kernel, enforces access control policies, and restricts what any given process can do even after it is already running. The pitch is: even if something breaks through, AppArmor contains the damage.&lt;/p&gt;
&lt;p&gt;On March 12, 2026, Qualys published nine vulnerabilities in AppArmor itself. They named them CrackArmor. The flaws had been sitting there since 2017.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;What AppArmor Actually Does&lt;/h2&gt;
&lt;p&gt;Before getting into the bugs, it is worth being clear on what AppArmor is and why it matters.&lt;/p&gt;
&lt;p&gt;AppArmor is a Linux Security Module that enforces mandatory access control. Unlike filesystem permissions, which are set by the file owner, AppArmor policies are defined by the administrator and enforced by the kernel regardless of what the process wants to do. A web server process confined by an AppArmor profile cannot read &lt;code&gt;/etc/shadow&lt;/code&gt; even if it is running as root, cannot open a network socket it was not explicitly allowed, and cannot exec arbitrary binaries.&lt;/p&gt;
&lt;p&gt;On Ubuntu, AppArmor is enabled by default. You did not have to opt in. It is running on your server right now, with profiles active for a number of system services.&lt;/p&gt;
&lt;p&gt;The idea is that AppArmor is a last line. Even if an attacker exploits your app, they land inside the AppArmor box and cannot get further.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;The CrackArmor Flaws&lt;/h2&gt;
&lt;p&gt;Qualys found nine vulnerabilities in the AppArmor kernel code, all requiring only an unprivileged local user account. No root. No special group membership. Just a shell.&lt;/p&gt;
&lt;p&gt;The impacts break into three categories:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Local privilege escalation to root.&lt;/strong&gt; The most serious outcome. By chaining AppArmor bugs with interactions through standard system tools like &lt;code&gt;sudo&lt;/code&gt; and &lt;code&gt;postfix&lt;/code&gt;, an unprivileged user could reach root on the machine. This is the kind of bug that turns a limited foothold into full control.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Denial of service via stack exhaustion.&lt;/strong&gt; AppArmor handles nested policy namespaces recursively. An attacker could craft a deeply nested policy structure to blow the stack and crash the kernel. No special privilege required — anyone with a local account could take down the machine.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;KASLR bypass via out-of-bounds reads.&lt;/strong&gt; KASLR hides where kernel code and data live in memory, making exploitation harder. An out-of-bounds read in AppArmor&apos;s pattern matching engine could leak kernel addresses and make other attacks more reliable.&lt;/p&gt;
&lt;p&gt;The specifics: missing bounds checks in the DFA verifier, a double-free in namespace cleanup, race conditions in policy data lifecycle, and an unprivileged user being able to trigger privileged policy management operations. Nine separate issues, all in the AppArmor policy loading and parsing code.&lt;/p&gt;
&lt;p&gt;None of these are exotic. Out-of-bounds reads and double-frees are the kind of bugs that turn up in security audits of C code that handles untrusted input. AppArmor parses policy files from userspace. That is the attack surface, and it had not been audited thoroughly for nine years.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;The Scale&lt;/h2&gt;
&lt;p&gt;AppArmor ships enabled by default on Ubuntu. Qualys estimated over 12.6 million enterprise Linux instances actively running it at the time of disclosure.&lt;/p&gt;
&lt;p&gt;That is not 12.6 million servers any attacker on the internet can reach. The attack requires a local user account. But local access is not as rare as it sounds. A compromised web app that achieves code execution, a misconfigured multi-tenant system, a service account a former employee still has access to — all of these count as local access.&lt;/p&gt;
&lt;p&gt;The exploitability depends on context. On a single-user VPS where you are the only person with a shell, the practical risk is lower. On a shared system, it is much more serious. But the point of CrackArmor is that the very thing meant to contain a breach after local access was achieved was itself the path to escalate from that access.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;The Fix&lt;/h2&gt;
&lt;p&gt;The kernel fix shipped on March 6, six days before Qualys published the details publicly. If you updated your kernel before March 12, you were patched before the vulnerability was public knowledge.&lt;/p&gt;
&lt;p&gt;On Ubuntu 24.04, that means kernel version &lt;code&gt;6.8.0-106-generic&lt;/code&gt; or later.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;uname -r
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If it shows something older:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;apt update &amp;#x26;&amp;#x26; apt upgrade
reboot
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The reboot is not optional. A kernel update does not take effect until you boot into the new kernel. Running &lt;code&gt;apt upgrade&lt;/code&gt; and skipping the reboot leaves you on the old kernel regardless of what &lt;code&gt;apt&lt;/code&gt; reports.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;What This Changes About the Maintenance Routine&lt;/h2&gt;
&lt;p&gt;Most people treat kernel updates as optional. The kernel rarely breaks anything, updates are infrequent, and a reboot means downtime. So it slides. Weeks, sometimes months.&lt;/p&gt;
&lt;p&gt;CrackArmor is a good example of why that is the wrong call for security updates specifically.&lt;/p&gt;
&lt;p&gt;The kernel is not just the thing that boots. It is the security boundary between processes, between users, between the OS and the hardware. Vulnerabilities in it cannot be mitigated with a config change or a WAF rule. The only fix is the patched kernel, and the only way to run it is to reboot.&lt;/p&gt;
&lt;p&gt;A practical approach: check for kernel updates weekly, apply them, schedule the reboot. On a low-traffic personal site, a 60-second reboot at off-peak hours is not a meaningful event. Treating it as one leads to running a known-vulnerable kernel for months.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;Is This Right for You?&lt;/h2&gt;
&lt;p&gt;If you run a single-tenant VPS where you are the only one with shell access, apply the kernel update and you are done. The practical risk from a local privilege escalation on a server only you can log into is real but bounded.&lt;/p&gt;
&lt;p&gt;If you run anything with multiple users, shared hosting, or services that execute code on behalf of untrusted input, this is higher priority. Local privilege escalation in that context means any foothold becomes full root.&lt;/p&gt;
&lt;p&gt;Either way, the fix is the same: update the kernel, reboot, verify you are on the patched version. The only bad outcome is knowing about it and not applying it.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;References&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://blog.qualys.com/vulnerabilities-threat-research/2026/03/12/crackarmor-critical-apparmor-flaws-enable-local-privilege-escalation-to-root&quot;&gt;CrackArmor: Critical AppArmor Flaws Enable Local Privilege Escalation to Root&lt;/a&gt; — Qualys, March 12, 2026&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://ubuntu.com/blog/apparmor-vulnerability-fixes-available&quot;&gt;AppArmor vulnerability fixes available&lt;/a&gt; — Ubuntu Blog&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://thehackernews.com/2026/03/nine-crackarmor-flaws-in-linux-apparmor.html&quot;&gt;Nine CrackArmor Flaws in Linux AppArmor Enable Root Escalation, Bypass Container Isolation&lt;/a&gt; — The Hacker News&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://www.phoronix.com/news/Ubuntu-AppArmor-Security-Issues&quot;&gt;Ubuntu&apos;s AppArmor Hit By Several Security Issues&lt;/a&gt; — Phoronix&lt;/li&gt;
&lt;/ul&gt;</content:encoded><h:img src="/_astro/hero.19BQ_Lr-.webp"/><enclosure url="/_astro/hero.19BQ_Lr-.webp"/></item><item><title>Attacked Every 23 Seconds. Why I&apos;m Not Worried.</title><link>https://srmdn.com/blog/vps-security-layers</link><guid isPermaLink="true">https://srmdn.com/blog/vps-security-layers</guid><description>Running a self-managed VPS means bots will find you within hours. Here&apos;s the layered setup I use to sleep well anyway.</description><pubDate>Mon, 09 Mar 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;When I checked my server logs last week, I found over 23,000 failed SSH login attempts in seven days. That works out to roughly one attempt every 26 seconds, around the clock.&lt;/p&gt;
&lt;p&gt;My first reaction was panic. My second was: this is completely normal.&lt;/p&gt;
&lt;p&gt;Any server with a public IP gets this. It is not a targeted attack. It is automated bots sweeping the entire internet, trying default credentials on every IP they find. They are looking for the one server where someone left the root password as &lt;code&gt;admin123&lt;/code&gt;, or where SSH still accepts passwords at all. They do not know whose server this is. They do not care.&lt;/p&gt;
&lt;p&gt;What stops them is not magic. It is several layers of boring configuration.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;Layer 1: SSH Hardening&lt;/h2&gt;
&lt;p&gt;SSH is the most common attack vector on a public VPS. The bots know this. So the first job is making your SSH as uninteresting as possible.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Disable password authentication.&lt;/strong&gt; This is the single most important change. With &lt;code&gt;PasswordAuthentication no&lt;/code&gt;, a bot can guess the right username and it still does not matter. They cannot get in without your private key. Password brute-force stops being a threat entirely.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# /etc/ssh/sshd_config
PasswordAuthentication no
PubkeyAuthentication yes
PermitRootLogin prohibit-password
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Move off port 22.&lt;/strong&gt; Port 22 is the first port every SSH scanner checks. Moving to a non-standard port will not stop a determined attacker, but it eliminates most background noise and reduces log spam significantly.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Tighten the other knobs.&lt;/strong&gt; A few more settings worth setting explicitly:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;MaxAuthTries 3        # disconnect after 3 failed attempts per connection
LoginGraceTime 30     # 30 seconds to authenticate, not the default 2 minutes
X11Forwarding no      # no reason to have this on a headless server
MaxStartups 10:30:60  # start dropping new connections above 10 pending, reject all above 60
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Add fail2ban.&lt;/strong&gt; Even with key-only auth, bots can waste server resources by hammering connections. fail2ban watches your SSH logs and bans IPs after repeated failures. A 24-hour ban after 3 failed attempts is a reasonable starting point, matching &lt;code&gt;MaxAuthTries&lt;/code&gt; so the ban triggers the moment they exhaust their attempts.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ini&quot;&gt;# /etc/fail2ban/jail.local
[sshd]
enabled  = true
port     = your-ssh-port
maxretry = 3
bantime  = 86400
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Whitelist users explicitly.&lt;/strong&gt; List only the accounts that actually need SSH access using &lt;code&gt;AllowUsers&lt;/code&gt;. Any account not on that list cannot log in over SSH, even with a valid key. This matters if a compromised service account somehow gets a key added.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;AllowUsers appuser
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If you want to audit your current state rather than configure from scratch, a few scripts from &lt;a href=&quot;https://gitlab.com/srmdn/sysadmin-scripts&quot;&gt;sysadmin-scripts&lt;/a&gt; are useful here. &lt;code&gt;ssh-audit.sh&lt;/code&gt; checks your sshd configuration for common weaknesses and gives a CLEAN, WARNING, or CRITICAL verdict with the exact command to fix each finding. &lt;code&gt;user-audit.sh&lt;/code&gt; scans for UID 0 duplicates, accounts with empty passwords, unexpected sudo access, and SSH authorized keys across all home directories. &lt;code&gt;fail2ban-report.sh&lt;/code&gt; gives you a per-jail summary, top offending IPs, and recent ban events. Useful for a quick picture of what is actually hitting your server.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;Layer 2: Network, Lock Down What Is Reachable&lt;/h2&gt;
&lt;p&gt;A typical self-hosted web app runs multiple processes: a backend API on one port, maybe a frontend server on another. None of these should be directly reachable from the internet. Only nginx should face the outside world.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Bind to localhost.&lt;/strong&gt; When your app starts, configure it to listen on &lt;code&gt;127.0.0.1&lt;/code&gt;, not &lt;code&gt;0.0.0.0&lt;/code&gt;. The difference is significant. &lt;code&gt;0.0.0.0&lt;/code&gt; listens on all interfaces including your public IP. &lt;code&gt;127.0.0.1&lt;/code&gt; only listens on the loopback interface, unreachable from outside the machine.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;// Good: only reachable locally
server.ListenAndServe(&quot;127.0.0.1:8080&quot;, handler)

// Exposes the port to the public internet
server.ListenAndServe(&quot;:8080&quot;, handler)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Most frameworks read the bind address from an environment variable. Set &lt;code&gt;HOST=127.0.0.1&lt;/code&gt; alongside your &lt;code&gt;PORT&lt;/code&gt; and make sure your app actually reads it. It is easy to set &lt;code&gt;HOST&lt;/code&gt; in an env file and then have the code ignore it entirely.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Back it up with iptables.&lt;/strong&gt; Even if the app binds correctly, an explicit firewall rule adds a second line of defence:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# Allow localhost, drop everything else
iptables -I INPUT -p tcp --dport 8080 ! -s 127.0.0.1 -j DROP

# IPv6: blanket drop
ip6tables -I INPUT -p tcp --dport 8080 -j DROP

# Persist across reboots
netfilter-persistent save
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;One thing to watch: use &lt;code&gt;! -s 127.0.0.1 -j DROP&lt;/code&gt; rather than a plain &lt;code&gt;-j DROP&lt;/code&gt;. If you need to debug via an SSH tunnel, the tunnel traffic comes from 127.0.0.1. A blanket DROP silently breaks it.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;open-ports-audit.sh&lt;/code&gt; from the same &lt;a href=&quot;https://gitlab.com/srmdn/sysadmin-scripts&quot;&gt;sysadmin-scripts&lt;/a&gt; collection lists every listening port with its process name and owner, compares it against a whitelist you define, and flags anything unexpected. Worth running after any deployment or infrastructure change.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;Layer 3: Process Isolation via systemd&lt;/h2&gt;
&lt;p&gt;Running your app as root is a bad idea. If the process gets compromised, the attacker gets root. Run it as an unprivileged user instead, one with write access to the app directory and nowhere else.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ini&quot;&gt;[Service]
User=appuser
Group=appuser
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;systemd has built-in sandboxing directives that add meaningful isolation at no extra cost:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ini&quot;&gt;NoNewPrivileges=yes     # process cannot gain privileges via SUID binaries
PrivateTmp=yes          # isolated /tmp, cannot see other processes&apos; temp files
ProtectHome=yes         # system home directories are invisible to this process
ProtectSystem=strict    # filesystem is read-only except for explicitly allowed paths
ReadWritePaths=/var/www/myapp  # the one directory the app actually needs
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;These are defense-in-depth. If your app gets exploited, the attacker ends up stuck inside a box. They cannot read sensitive system directories, cannot modify system files, and cannot escalate privileges. The damage radius shrinks dramatically.&lt;/p&gt;
&lt;p&gt;One gotcha: &lt;code&gt;ProtectHome=yes&lt;/code&gt; breaks any runtime that writes to home directories for caching or telemetry. Check your runtime&apos;s docs for an env var to redirect or disable it rather than removing the protection entirely.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;Layer 4: HTTP Security Headers&lt;/h2&gt;
&lt;p&gt;Once traffic reaches nginx, a few response headers tell browsers how to handle your content. Put these in a shared snippet included by every vhost:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-nginx&quot;&gt;add_header Strict-Transport-Security &quot;max-age=63072000; includeSubDomains&quot; always;
add_header X-Content-Type-Options &quot;nosniff&quot; always;
add_header X-Frame-Options &quot;SAMEORIGIN&quot; always;
add_header Referrer-Policy &quot;strict-origin-when-cross-origin&quot; always;
add_header Permissions-Policy &quot;camera=(), microphone=(), geolocation=()&quot; always;
server_tokens off;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;HSTS is the most important one. Once a browser sees it, it will refuse to connect to your domain over plain HTTP for the duration of &lt;code&gt;max-age&lt;/code&gt;. Two years is the standard recommendation.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;server_tokens off&lt;/code&gt; hides your nginx version from response headers. There is no reason to advertise which version you are running to anyone scanning for known vulnerabilities.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Content Security Policy needs to be per-vhost, not global.&lt;/strong&gt; A CSP defines which origins a page is allowed to load scripts, styles, fonts, and make API calls to. Different apps have different requirements. A shared CSP either ends up so permissive it is useless, or it silently breaks something. Define it individually for each vhost based on what that app actually loads.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;Layer 5: Consider a WAF on a Separate VPS&lt;/h2&gt;
&lt;p&gt;The four layers above protect the server itself. A Web Application Firewall (WAF) works one level higher, inspecting HTTP traffic before it even reaches nginx, and blocking common attack patterns like SQL injection, XSS, and malicious bots.&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://safepoint.cloud/landing/safeline&quot;&gt;SafeLine&lt;/a&gt; is a self-hostable WAF worth looking at. The recommended setup is to run it on a separate VPS under the same cloud provider, connected via a private network to your main server. The reason for a separate VPS is practical: WAF software can be resource-intensive depending on traffic volume, it has its own port requirements that can conflict with existing services, and keeping it isolated means you can scale it independently without touching your app server.&lt;/p&gt;
&lt;p&gt;The traffic flow looks like this:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Internet -&gt; WAF VPS (inspects &amp;#x26; filters) -&gt; App VPS (your nginx + apps)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Your app server never receives traffic directly from the internet. Everything passes through the WAF first.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;Layer 6: TLS and Certificate Auto-Renewal&lt;/h2&gt;
&lt;p&gt;Every site should be HTTPS only. Not just the login page. Everything. Plain HTTP leaks session cookies, exposes content to network-level tampering, and modern browsers are actively warning users away from it.&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://letsencrypt.org&quot;&gt;Let&apos;s Encrypt&lt;/a&gt; makes this free. Certbot handles issuance and renewal:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# Issue a cert for your domain
certbot certonly --nginx -d yourdomain.com

# Test auto-renewal
certbot renew --dry-run
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The renewal part matters as much as the issuance. Let&apos;s Encrypt certificates expire after 90 days. Set up a systemd timer or cron job to run &lt;code&gt;certbot renew&lt;/code&gt; twice a day. That way you are never caught with an expired cert.&lt;/p&gt;
&lt;p&gt;Redirect HTTP to HTTPS at the nginx level so there is no way to accidentally serve content over plain HTTP:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-nginx&quot;&gt;server {
    listen 80;
    server_name yourdomain.com;
    return 301 https://$host$request_uri;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;HSTS from Layer 4 completes the picture. Once the browser has seen the HSTS header over HTTPS, it will refuse to even attempt an HTTP connection to that domain in the future.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;Layer 7: Sit Behind a CDN&lt;/h2&gt;
&lt;p&gt;A CDN does more than cache static files. When your server is behind Cloudflare or a similar provider, your real server IP is hidden from the public internet. Attackers scanning for your origin have a harder time finding where to actually send traffic.&lt;/p&gt;
&lt;p&gt;More importantly, volumetric DDoS attacks hit the CDN edge, not your server. A flood of traffic that would knock over a small VPS gets absorbed across hundreds of edge nodes. This is one of the most cost-effective security layers available, and the free tier of most CDN providers covers everything a personal site or small project needs.&lt;/p&gt;
&lt;p&gt;A few things to verify when using a CDN:&lt;/p&gt;
&lt;p&gt;Configure your origin server to only accept connections from the CDN&apos;s IP ranges, not the open internet. Otherwise the protection is cosmetic. An attacker who discovers your real IP can bypass the CDN entirely and hit your server directly.&lt;/p&gt;
&lt;p&gt;Make sure your SSL configuration is set to &quot;Full (strict)&quot; mode if you use Cloudflare. The &quot;Flexible&quot; mode means traffic between Cloudflare and your server travels unencrypted, which defeats the point of having a certificate.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;Layer 8: Automatic Security Updates&lt;/h2&gt;
&lt;p&gt;A server that is never updated is a server that will eventually be compromised. Known CVEs get published, exploit code follows, and bots start scanning for vulnerable versions within days.&lt;/p&gt;
&lt;p&gt;On Ubuntu and Debian-based systems, &lt;code&gt;unattended-upgrades&lt;/code&gt; handles this:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;apt install unattended-upgrades
dpkg-reconfigure -plow unattended-upgrades
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The default configuration applies security updates only, not every available update. That is the right call. You want patches for known vulnerabilities applied automatically. You do not want an unattended dist-upgrade breaking something on a production server at 3am.&lt;/p&gt;
&lt;p&gt;Verify it is actually running:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;systemctl status unattended-upgrades
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Check the logs periodically at &lt;code&gt;/var/log/unattended-upgrades/&lt;/code&gt; to confirm packages are being updated. It is easy to install and forget, then discover months later that it was silently failing.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;Layer 9: Backups Are a Security Layer&lt;/h2&gt;
&lt;p&gt;Most people think of backups as an ops concern. They are also a security concern. Ransomware encrypts your data and demands payment. A disgruntled ex-contributor deletes your database. A breach happens and you need to restore to a known-clean state. In all of these cases, backups are the only thing that lets you recover without starting over.&lt;/p&gt;
&lt;p&gt;A backup strategy needs three things to be useful:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Offsite storage.&lt;/strong&gt; A backup on the same server it is protecting is not a backup. If the server is compromised or destroyed, you lose both. Store backups on a separate machine, ideally under a different provider.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Automation.&lt;/strong&gt; A backup you have to remember to run is a backup that will not exist when you need it. Use a systemd timer or cron job to run backups daily. Log the output somewhere you can check.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Tested restoration.&lt;/strong&gt; A backup you have never restored from is a backup you cannot trust. Periodically restore a backup to a test environment and verify the data is intact and the app runs. Do this before you need it, not during an incident.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# Example: back up a SQLite database daily via systemd timer
# /etc/systemd/system/myapp-backup.service
[Service]
Type=oneshot
ExecStart=/bin/sh -c &apos;cp /var/www/myapp/data/app.db /backups/app-$(date +%%Y%%m%%d).db&apos;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Two retained copies is a minimum. If you can afford more, keep more.&lt;/p&gt;
&lt;p&gt;If you want a ready-made starting point, &lt;code&gt;backup.sh&lt;/code&gt; from &lt;a href=&quot;https://gitlab.com/srmdn/sysadmin-scripts&quot;&gt;sysadmin-scripts&lt;/a&gt; handles SQLite hot backups, directory archives, and individual files like &lt;code&gt;.env&lt;/code&gt;. It packages everything into a timestamped &lt;code&gt;.tar.gz&lt;/code&gt;, uploads to a remote server via SCP, and rotates old copies both locally and remotely. Configure the paths at the top of the file, add a cron entry, and it runs itself.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;What This Does Not Cover&lt;/h2&gt;
&lt;p&gt;These nine layers cover the common ground for a self-managed server. They are not exhaustive.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Application-level vulnerabilities&lt;/strong&gt; like SQL injection, XSS, and broken access control live in your code. No firewall rule protects you from a login endpoint that concatenates user input directly into a query. Use prepared statements, validate input on the server, and hash passwords with bcrypt or argon2.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Dependency vulnerabilities&lt;/strong&gt; need active monitoring. Go has &lt;code&gt;govulncheck&lt;/code&gt;, npm has &lt;code&gt;npm audit&lt;/code&gt;. Run them before deploying, not after something breaks. Tools like Dependabot can automate this in CI.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Secrets hygiene&lt;/strong&gt; is on you. Environment files should be &lt;code&gt;chmod 600&lt;/code&gt;, never committed to git, and not world-readable. A world-readable secrets file on a server is a plaintext credential dump waiting to be found.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Mandatory access control&lt;/strong&gt; goes deeper than systemd sandboxing. AppArmor (enabled by default on Ubuntu) and SELinux define system-wide policies restricting what files and syscalls any process can access. Worth learning if you run services that handle sensitive data.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Kernel hardening&lt;/strong&gt; via sysctl parameters tightens the network stack itself, things like SYN flood protection and source address verification. Reasonable defaults exist but they are not always on out of the box.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Two-factor authentication for SSH&lt;/strong&gt; adds a TOTP layer on top of key-based auth. If a private key is ever stolen, 2FA is the last line. Look into &lt;code&gt;libpam-google-authenticator&lt;/code&gt; or similar.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Log monitoring and alerting&lt;/strong&gt; means being notified when something unusual happens, not just reacting after the fact. fail2ban reacts to patterns but does not alert you. A spike in 403s, repeated probing of sensitive paths, or a login at 3am from an unknown country are all worth knowing about in real time.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;Going Deeper: WordPress&lt;/h2&gt;
&lt;p&gt;If you run WordPress, the attack surface is wider. WordPress powers a large share of the web, which makes it a high-value target. Automated scanners specifically probe for outdated plugins, exposed &lt;code&gt;wp-admin&lt;/code&gt;, and known vulnerabilities in popular themes.&lt;/p&gt;
&lt;p&gt;The same principles apply: SSH hardening, firewall, unprivileged process user, security headers. But the WordPress-specific layer on top of that is a different topic. This course covers it in depth, including WAF configuration: &lt;a href=&quot;https://klikgan.com/s/Wordpress-Security&quot;&gt;WordPress Security&lt;/a&gt;.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;Is This Right for You?&lt;/h2&gt;
&lt;p&gt;This setup makes sense if you are self-hosting on a VPS and want to understand what you are actually running. It requires no paid tools and no external services. Just sshd, iptables, systemd, and nginx doing their jobs.&lt;/p&gt;
&lt;p&gt;It is not a complete answer for an app handling sensitive user data at scale. For that you would want a proper secrets manager, structured audit logging, network-level intrusion detection, and a security review before launch.&lt;/p&gt;
&lt;p&gt;For a personal site or small project on a VPS you own: this is the setup I run. 23,000 failed login attempts later, nothing has gotten through. The bots are still out there. They just keep hitting a wall.&lt;/p&gt;</content:encoded><h:img src="/_astro/hero.DIeDK3vj.webp"/><enclosure url="/_astro/hero.DIeDK3vj.webp"/></item><item><title>Systemd Unit Files for Web Apps</title><link>https://srmdn.com/blog/systemd-unit-files-for-web-apps</link><guid isPermaLink="true">https://srmdn.com/blog/systemd-unit-files-for-web-apps</guid><description>The systemd options that actually matter for a Go backend and Node.js frontend: unit file anatomy, hardening directives, and the gotchas that burn you.</description><pubDate>Sun, 08 Mar 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;The systemd documentation is thorough. It covers every directive, every option, every edge case. What it doesn&apos;t show you is which 10% of that actually matters when you&apos;re running a Go API and a Node.js frontend on a VPS.&lt;/p&gt;
&lt;p&gt;This post covers that 10%: the unit file options you&apos;ll use, the gotchas that will cost you an afternoon, and why some directives that look optional will quietly break your app if you skip them.&lt;/p&gt;
&lt;h2&gt;The Setup&lt;/h2&gt;
&lt;p&gt;If you&apos;re not familiar with the overall deployment model, &lt;a href=&quot;/blog/deploying-to-vps-without-docker-and-cicd&quot;&gt;Deploying to a VPS Without Docker or CI/CD&lt;/a&gt; covers the full picture: two environments running side by side, nginx as the front door, and a &lt;code&gt;git pull&lt;/code&gt; to deploy. That post treats systemd as a supporting character. This one puts it center stage.&lt;/p&gt;
&lt;p&gt;The assumption here: you have a non-root &lt;code&gt;deploy&lt;/code&gt; user that runs your services, and your apps live somewhere under &lt;code&gt;/var/www/&lt;/code&gt;.&lt;/p&gt;
&lt;h2&gt;What systemd Is Actually Doing&lt;/h2&gt;
&lt;p&gt;systemd is your process manager. When you run &lt;code&gt;systemctl start myapp&lt;/code&gt;, systemd reads the unit file, sets up the environment, starts the process, and watches it. That&apos;s the whole job.&lt;/p&gt;
&lt;p&gt;The restart loop is what makes it worth using over just running your binary directly. If your app crashes at 3am, systemd restarts it. If the VPS reboots, systemd starts it. You don&apos;t have to be there.&lt;/p&gt;
&lt;h2&gt;A Unit File, Line by Line&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-ini&quot;&gt;[Unit]
Description=My App Backend (Staging)
After=network.target

[Service]
Type=simple
User=deploy
WorkingDirectory=/var/www/myapp-staging/myapp/backend
EnvironmentFile=/var/www/myapp-staging/myapp/backend/.env.staging
ExecStart=/var/www/myapp-staging/myapp/backend/myapp-backend
Restart=always
RestartSec=3

NoNewPrivileges=yes
PrivateTmp=yes
ProtectHome=yes
ProtectSystem=strict
ReadWritePaths=/var/www/myapp-staging/myapp

[Install]
WantedBy=multi-user.target
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The non-obvious ones:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;After=network.target&lt;/code&gt;&lt;/strong&gt; tells systemd to start your service after the network is up. Without it, your app might try to bind a port before the network stack is ready. It&apos;s an ordering hint, not a hard dependency, but you always want it.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;Type=simple&lt;/code&gt;&lt;/strong&gt; tells systemd your process doesn&apos;t fork. The process you start in &lt;code&gt;ExecStart&lt;/code&gt; &lt;em&gt;is&lt;/em&gt; the service. This is correct for almost every Go and Node.js app. &lt;code&gt;Type=forking&lt;/code&gt; is for old-style daemons that fork into the background on startup. You probably don&apos;t have one of those. &lt;code&gt;Type=notify&lt;/code&gt; is for apps that actively signal systemd when they&apos;re ready; most apps don&apos;t do this.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;WorkingDirectory&lt;/code&gt;&lt;/strong&gt; sets the current directory before starting your app. Your app resolves relative file paths from here. Leave this out and a path like &lt;code&gt;./data/app.db&lt;/code&gt; gets resolved relative to &lt;code&gt;/&lt;/code&gt;, where it obviously doesn&apos;t exist.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;EnvironmentFile&lt;/code&gt;&lt;/strong&gt; loads environment variables from a file. One &lt;code&gt;KEY=VALUE&lt;/code&gt; per line, same as a &lt;code&gt;.env&lt;/code&gt; file. systemd reads this as root before dropping privileges to the &lt;code&gt;deploy&lt;/code&gt; user, so you can own it as root with &lt;code&gt;chmod 600&lt;/code&gt; and the service still gets the variables. The running process itself can&apos;t read the file directly.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;Restart=always&lt;/code&gt;&lt;/strong&gt; restarts the service on any exit: crash, OOM kill, or a clean exit with code 0. &lt;code&gt;on-failure&lt;/code&gt; would only restart on non-zero exits. For a long-running server that should never exit cleanly on its own, &lt;code&gt;always&lt;/code&gt; is the right choice. A deliberate &lt;code&gt;systemctl stop&lt;/code&gt; still stops it.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;RestartSec=3&lt;/code&gt;&lt;/strong&gt; waits 3 seconds before restarting. Without this, a crashing app will restart in a tight loop and fill your journal with noise before you can investigate. Three seconds is enough breathing room.&lt;/p&gt;
&lt;h2&gt;The Hardening Directives&lt;/h2&gt;
&lt;p&gt;The lower half of the &lt;code&gt;[Service]&lt;/code&gt; section (&lt;code&gt;NoNewPrivileges&lt;/code&gt;, &lt;code&gt;ProtectHome&lt;/code&gt;, &lt;code&gt;ProtectSystem&lt;/code&gt;, &lt;code&gt;ReadWritePaths&lt;/code&gt;) restricts what the service process can access on the filesystem. These are kernel namespace features, not virtualization. They have no runtime overhead.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;NoNewPrivileges=yes&lt;/code&gt;&lt;/strong&gt; prevents the process from gaining elevated privileges through setuid binaries. Turn this on for every web app.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;PrivateTmp=yes&lt;/code&gt;&lt;/strong&gt; gives the service its own isolated &lt;code&gt;/tmp&lt;/code&gt; instead of the shared system &lt;code&gt;/tmp&lt;/code&gt;. Another process can&apos;t snoop on your app&apos;s temp files, and your temp files don&apos;t accumulate in the system &lt;code&gt;/tmp&lt;/code&gt; on crash.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;ProtectHome=yes&lt;/code&gt;&lt;/strong&gt; makes &lt;code&gt;/home&lt;/code&gt;, &lt;code&gt;/root&lt;/code&gt;, and &lt;code&gt;/run/user&lt;/code&gt; invisible to the service. Your app binary cannot reach user home directories.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;ProtectSystem=strict&lt;/code&gt;&lt;/strong&gt; makes the entire filesystem read-only for the service, except for &lt;code&gt;/dev&lt;/code&gt;, &lt;code&gt;/proc&lt;/code&gt;, and &lt;code&gt;/sys&lt;/code&gt;. Used together with &lt;code&gt;ReadWritePaths&lt;/code&gt;, this gives your service exactly the write access it needs and nothing else.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;ReadWritePaths&lt;/code&gt;&lt;/strong&gt; carves out an exception to &lt;code&gt;ProtectSystem=strict&lt;/code&gt;. List every directory your app needs to write to. If you have a data directory and a separate log directory, add both.&lt;/p&gt;
&lt;h2&gt;The ProtectHome Gotcha&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;ProtectHome=yes&lt;/code&gt; will silently break any subprocess your app spawns if that subprocess tries to write to a home directory.&lt;/p&gt;
&lt;p&gt;The common case: your backend triggers a build step as a subprocess. The build tool tries to write to &lt;code&gt;~/.config&lt;/code&gt; or &lt;code&gt;~/.cache&lt;/code&gt;. With &lt;code&gt;ProtectHome=yes&lt;/code&gt;, that path is invisible to the process. The subprocess fails with &lt;code&gt;EACCES&lt;/code&gt; or a confusing missing-directory error that doesn&apos;t obviously point to systemd.&lt;/p&gt;
&lt;p&gt;The fix is not to remove &lt;code&gt;ProtectHome=yes&lt;/code&gt;. The fix is to redirect those cache paths to somewhere your service can write:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ini&quot;&gt;Environment=HOME=/var/www/myapp-staging
Environment=npm_config_cache=/var/www/myapp-staging/.npm-cache
Environment=XDG_CONFIG_HOME=/var/www/myapp-staging/.config
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The better fix is to not run build tools from inside a running service at all. Build steps belong in your deploy script. The service should start a pre-built artifact, not build one.&lt;/p&gt;
&lt;h2&gt;The Deploy Cycle: Stop, Build, Start&lt;/h2&gt;
&lt;p&gt;You can&apos;t overwrite a running executable on Linux. If you try to &lt;code&gt;go build&lt;/code&gt; while the binary is running, you get &lt;code&gt;text file busy&lt;/code&gt;. The sequence is:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;systemctl stop myapp-backend-staging
/usr/local/go/bin/go build -o myapp-backend ./cmd/server/
systemctl start myapp-backend-staging
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The downtime is under a second. For a personal project, that&apos;s fine. If you need zero-downtime deploys, you&apos;d pre-build the binary to a temp path and swap it atomically. That&apos;s a different problem.&lt;/p&gt;
&lt;p&gt;For the Node.js frontend, you don&apos;t stop the service before building. The build writes to &lt;code&gt;dist/&lt;/code&gt;, not to the running process. Build first, then restart to pick up the new files:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo -u deploy npm run build
systemctl restart myapp-astro-staging
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Reading Logs&lt;/h2&gt;
&lt;p&gt;systemd captures stdout and stderr from your service automatically. Write to stdout in your app and it shows up in the journal.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# Follow in real time
journalctl -u myapp-backend-staging -f

# Last 100 lines, no pager
journalctl -u myapp-backend-staging -n 100 --no-pager

# Since last boot
journalctl -u myapp-backend-staging -b

# With full timestamps
journalctl -u myapp-backend-staging --output=short-iso
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If your app is failing to start, &lt;code&gt;journalctl -u myapp -n 50 --no-pager&lt;/code&gt; immediately after &lt;code&gt;systemctl start&lt;/code&gt; will show you why. Don&apos;t reach for &lt;code&gt;systemctl status&lt;/code&gt; first. The status output truncates the error message.&lt;/p&gt;
&lt;h2&gt;Common Gotchas&lt;/h2&gt;
&lt;p&gt;| Problem | Cause | Fix |
|---|---|---|
| Subprocess fails with EACCES | &lt;code&gt;ProtectHome=yes&lt;/code&gt; blocks &lt;code&gt;~/.config&lt;/code&gt; access | Redirect cache dirs via &lt;code&gt;Environment=&lt;/code&gt;, or don&apos;t spawn build tools from the service |
| App can&apos;t write to its data directory | &lt;code&gt;ProtectSystem=strict&lt;/code&gt; without a matching &lt;code&gt;ReadWritePaths&lt;/code&gt; | Add the directory to &lt;code&gt;ReadWritePaths&lt;/code&gt; |
| App can&apos;t find a relative file path | &lt;code&gt;WorkingDirectory&lt;/code&gt; not set or wrong | Set &lt;code&gt;WorkingDirectory&lt;/code&gt; to the directory your app expects |
| &lt;code&gt;EnvironmentFile&lt;/code&gt; not loaded | File doesn&apos;t exist at that path | Check the path and that the file exists before starting the service |
| &lt;code&gt;text file busy&lt;/code&gt; on deploy | Binary still running when you try to overwrite it | &lt;code&gt;systemctl stop&lt;/code&gt; before rebuilding |
| Service restarts immediately after stopping | &lt;code&gt;Restart=always&lt;/code&gt; with no start-limit configured | Use &lt;code&gt;Restart=on-failure&lt;/code&gt; or adjust &lt;code&gt;StartLimitBurst&lt;/code&gt; |&lt;/p&gt;
&lt;h2&gt;Is This Right for You?&lt;/h2&gt;
&lt;p&gt;This approach works well if:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;You&apos;re running a small number of long-running processes on a VPS&lt;/li&gt;
&lt;li&gt;You want automatic restarts and structured logs without adding a process manager tool&lt;/li&gt;
&lt;li&gt;You can tolerate a second of downtime during Go binary deploys&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;It&apos;s worth knowing the limits:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Zero-downtime deploys&lt;/strong&gt; require pre-building to a temp path and swapping atomically. The stop-build-start pattern has a gap. For a personal project that&apos;s acceptable; for something that needs continuous availability it isn&apos;t.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Many services&lt;/strong&gt; means many unit files. systemd&apos;s tooling is all CLI. If you have more than a dozen services, a tool like Coolify might be worth the tradeoff.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Complex startup ordering&lt;/strong&gt;: if your app needs a database to be healthy before it starts, &lt;code&gt;After=network.target&lt;/code&gt; isn&apos;t enough. systemd has a full dependency system for this, but it&apos;s more involved than what&apos;s covered here.&lt;/p&gt;
&lt;p&gt;For the common case of two or three processes on a single VPS, this is all you need. The unit files above run exactly as written in production. No surprises.&lt;/p&gt;</content:encoded><h:img src="/_astro/hero.B1PVWQNo.webp"/><enclosure url="/_astro/hero.B1PVWQNo.webp"/></item><item><title>Cloudflare Turnstile Console Errors Are Not Your Fault</title><link>https://srmdn.com/blog/cloudflare-turnstile-console-errors</link><guid isPermaLink="true">https://srmdn.com/blog/cloudflare-turnstile-console-errors</guid><description>You integrated Turnstile, your forms work fine, but the browser console is full of errors and warnings. Here&apos;s exactly what each one means.</description><pubDate>Fri, 06 Mar 2026 17:00:00 GMT</pubDate><content:encoded>&lt;p&gt;You add Cloudflare Turnstile to your site. Login works. Form submissions go through. Everything functions correctly. Then you open devtools and see a wall of red errors and yellow warnings coming from &lt;code&gt;challenges.cloudflare.com&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Your first instinct is that you misconfigured something. You didn&apos;t.&lt;/p&gt;
&lt;p&gt;Every single one of these errors comes from inside Cloudflare&apos;s own code, running inside iframes that Cloudflare creates. None of them are yours to fix.&lt;/p&gt;
&lt;h2&gt;The three errors you&apos;re seeing&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;1. The sandboxed iframe error&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Blocked script execution in &apos;about:blank&apos; because the document&apos;s frame
is sandboxed and the &apos;allow-scripts&apos; permission is not set.

Note that &apos;script-src&apos; was not explicitly set, so &apos;default-src&apos; is
used as a fallback.
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This one looks the most alarming. It mentions script blocking, which sounds like a CSP misconfiguration on your end.&lt;/p&gt;
&lt;p&gt;It isn&apos;t. Turnstile creates an intermediate &lt;code&gt;about:blank&lt;/code&gt; iframe with the &lt;code&gt;sandbox&lt;/code&gt; attribute set — intentionally, as a security isolation mechanism. Then Turnstile&apos;s own code tries to run scripts inside that sandboxed frame. The browser blocks it, logs the error, and Turnstile handles the fallback internally. Your CSP has no control over the &lt;code&gt;sandbox&lt;/code&gt; attribute that Cloudflare&apos;s JavaScript sets on its own iframes.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;2. The 401 Unauthorized in the network tab&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;GET https://challenges.cloudflare.com/cdn-cgi/challenge-platform/h/g/pat/...
Status Code: 401 Unauthorized
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Turnstile attempts a Private Access Token (PAT) challenge — a protocol where the browser asks Apple or Google&apos;s attestation servers to vouch for it. Most browsers either don&apos;t support PAT or don&apos;t have a valid token at that moment. The 401 just means &quot;no token available.&quot; Turnstile registers that, falls back to its standard challenge flow, and continues normally.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;3. The preload warning&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;The resource https://challenges.cloudflare.com/cdn-cgi/challenge-platform/h/g/cmg/1
was preloaded using link preload but not used within a few seconds from
the window&apos;s load event.
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Turnstile speculatively preloads some resources it might need. In many cases it ends up not needing them within the browser&apos;s expected timeframe. The browser warns you, Turnstile doesn&apos;t care.&lt;/p&gt;
&lt;h2&gt;How Turnstile actually works internally&lt;/h2&gt;
&lt;p&gt;Turnstile doesn&apos;t run directly in your page&apos;s context. It creates a chain of iframes:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Your page
  └── Turnstile outer iframe (challenges.cloudflare.com)
        └── about:blank sandboxed iframe  ← errors originate here
              └── Turnstile challenge logic
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The sandboxing is intentional — it isolates the challenge from your page and prevents your JavaScript from inspecting or tampering with it. The errors are a side effect of that isolation leaking into your browser&apos;s console.&lt;/p&gt;
&lt;h2&gt;How to verify it&apos;s not you&lt;/h2&gt;
&lt;p&gt;Open devtools on any other site using Turnstile. You&apos;ll see the exact same errors, regardless of how that site configured its CSP. The errors aren&apos;t tied to your configuration — they&apos;re tied to Turnstile&apos;s internal implementation.&lt;/p&gt;
&lt;p&gt;If your own CSP was wrong, you&apos;d see different symptoms: the Turnstile widget wouldn&apos;t render at all, or form submissions would fail silently.&lt;/p&gt;
&lt;h2&gt;When you should actually worry&lt;/h2&gt;
&lt;p&gt;The only signal that matters is whether Turnstile is working. If users can submit your forms and the widget renders, Turnstile is doing its job. The console noise is irrelevant.&lt;/p&gt;
&lt;p&gt;You have a real problem if:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;The Turnstile widget renders but form submissions always fail validation&lt;/li&gt;
&lt;li&gt;The widget doesn&apos;t render at all (usually a missing or wrong site key)&lt;/li&gt;
&lt;li&gt;Your backend reports all tokens as invalid (site key / secret key mismatch)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Console errors from &lt;code&gt;challenges.cloudflare.com&lt;/code&gt; are not on that list.&lt;/p&gt;
&lt;h2&gt;Is this right for you?&lt;/h2&gt;
&lt;p&gt;If you&apos;re building a public form and want bot protection without rolling your own CAPTCHA, Turnstile is a solid choice. The integration is straightforward and the UX is far less annoying than reCAPTCHA.&lt;/p&gt;
&lt;p&gt;Accept that the console will always have Cloudflare&apos;s noise in it. It doesn&apos;t reflect on your code quality or your CSP configuration. Some third-party tools are just loud.&lt;/p&gt;</content:encoded><h:img src="/_astro/hero.8wlb6_jA.webp"/><enclosure url="/_astro/hero.8wlb6_jA.webp"/></item><item><title>What 2 GB of Logs on a Fresh VPS Actually Means</title><link>https://srmdn.com/blog/vps-ssh-log-bloat</link><guid isPermaLink="true">https://srmdn.com/blog/vps-ssh-log-bloat</guid><description>I traced a growing journal to SSH brute-force bots, found a cloud-init misconfiguration hiding in plain sight, and cleaned it all up. Here&apos;s the investigation.</description><pubDate>Tue, 03 Mar 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;A few weeks after moving to a self-managed VPS, I noticed the system journal had grown to over 2 GB. The server had only been running about a month. Nothing about the apps was unusual: traffic was normal, no crashes, no deployments that week.&lt;/p&gt;
&lt;p&gt;So I started digging.&lt;/p&gt;
&lt;h2&gt;Finding the source&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;journalctl --disk-usage&lt;/code&gt; confirmed the size. To find what was writing so aggressively, I pulled the last seven days of logs and counted entries per service:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;journalctl --no-pager -q --since &quot;7 days ago&quot; -o json \
  | python3 -c &quot;
import sys, json, collections
counts = collections.Counter()
for line in sys.stdin:
    try:
        d = json.loads(line)
        svc = d.get(&apos;_SYSTEMD_UNIT&apos;) or d.get(&apos;SYSLOG_IDENTIFIER&apos;) or &apos;unknown&apos;
        counts[svc] += 1
    except: pass
for svc, n in counts.most_common(10):
    print(f&apos;{n:&gt;8}  {svc}&apos;)
&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The result:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;   73978  ssh.service
    9308  cron.service
    4752  app-backend.service
    1740  app-production.service
     606  kernel
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;SSH had nearly 74,000 log entries in seven days. Everything else combined didn&apos;t come close.&lt;/p&gt;
&lt;h2&gt;What&apos;s actually in there&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;journalctl -u ssh.service --no-pager -n 10
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;Mar 03 04:07:02 vps sshd[63622]: Invalid user xiedr from 45.148.10.118 port 43446
Mar 03 04:07:02 vps sshd[63622]: pam_unix(sshd:auth): authentication failure; logname= uid=0 euid=0 tty=ssh ruser= rhost=45.148.10.118
Mar 03 04:07:05 vps sshd[63622]: Failed password for invalid user xiedr from 45.148.10.118 port 43446 ssh2
Mar 03 04:09:15 vps sshd[63704]: Failed password for root from 181.23.107.93 port 38173 ssh2
Mar 03 04:11:41 vps sshd[63950]: Invalid user a from 134.122.46.171 port 53312
Mar 03 04:11:42 vps sshd[63951]: Invalid user a from 134.122.46.171 port 53318
Mar 03 04:11:42 vps sshd[63952]: Invalid user a from 134.122.46.171 port 53322
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;SSH brute-force attempts. About 10,000 a day, every day, since the server went live.&lt;/p&gt;
&lt;p&gt;My first reaction was concern. But before doing anything, I wanted to understand what I was actually looking at.&lt;/p&gt;
&lt;h2&gt;Why this actually matters&lt;/h2&gt;
&lt;p&gt;10,000 attempts a day sounds containable until you do the math. At that rate, with no journal size limit set, you&apos;re looking at several gigabytes a month and it compounds as long as the server is up. I&apos;ve had this happen before on a different server: a log file that started as noise quietly grew to 60 GB over a few months. There was no warning. The disk just filled up.&lt;/p&gt;
&lt;p&gt;When a Linux disk hits 100%, it doesn&apos;t degrade gracefully. nginx stops writing access logs and starts returning errors. Databases that need to write to disk, whether that&apos;s a WAL file, a lock, or a temp file, start failing. Applications throw write errors that look like bugs until you realize the real cause. Depending on what&apos;s running, recovery can mean emergency cleanup under pressure while services are down.&lt;/p&gt;
&lt;p&gt;The second problem is signal loss. Your journal is also where real security events show up: failed sudo attempts, service crashes, actual intrusion attempts with valid usernames. When it&apos;s buried under 70,000 SSH noise entries a week, you lose the ability to notice anything real. The logs become a liability instead of a tool.&lt;/p&gt;
&lt;p&gt;None of this is the fault of the bots. They&apos;re doing what bots do. The failure mode is leaving the journal unconfigured and assuming it self-manages.&lt;/p&gt;
&lt;h2&gt;Bots, not people&lt;/h2&gt;
&lt;p&gt;There are a few ways to tell automated scanning from a targeted attack.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The usernames give it away.&lt;/strong&gt; Pull the top targets:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;journalctl -u ssh.service --no-pager --since &quot;7 days ago&quot; -q \
  | grep -oP &apos;(Invalid user|Failed password for) \K\S+&apos; \
  | sort | uniq -c | sort -rn | head -20
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;9114  invalid
7534  root
 160  admin
  98  hik
  55  oracle
  53  test
  51  ubuntu
  42  git
  40  postgres
  36  dell
  28  deploy
  27  ansible
  21  tomcat
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This is a wordlist. &lt;code&gt;hik&lt;/code&gt; is the default user on Hikvision cameras. &lt;code&gt;dell&lt;/code&gt; is on some Dell iDRAC systems. &lt;code&gt;oracle&lt;/code&gt;, &lt;code&gt;postgres&lt;/code&gt;, &lt;code&gt;tomcat&lt;/code&gt; are server software default accounts. Nobody targeting my server specifically would try &lt;code&gt;hik&lt;/code&gt; or &lt;code&gt;orangepi&lt;/code&gt;. They&apos;re running the same list against every IP they can reach.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The timing is mechanical.&lt;/strong&gt; When I looked at one of the most persistent IPs:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;journalctl -u ssh.service --no-pager --since &quot;7 days ago&quot; -q \
  | grep &quot;80.94.92.65&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;Feb 24 04:29:44 vps sshd[31690]: Invalid user equipment from 80.94.92.65 port 59946
Feb 24 04:43:04 vps sshd[31933]: Failed password for sshd from 80.94.92.65 port 41176 ssh2
Feb 24 04:57:25 vps sshd[32182]: Invalid user zhangdong from 80.94.92.65 port 59176
Feb 24 05:10:45 vps sshd[32441]: Invalid user thum from 80.94.92.65 port 36168
Feb 24 05:24:59 vps sshd[32619]: Invalid user huan from 80.94.92.65 port 37594
Feb 24 05:38:35 vps sshd[32743]: Invalid user shengziqi from 80.94.92.65 port 51948
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Every 13 to 14 minutes. Exactly. That&apos;s deliberate throttling to stay under rate-limit windows. Not a human typing.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Coordinated subnets.&lt;/strong&gt; The top attackers included &lt;code&gt;80.94.92.65&lt;/code&gt;, &lt;code&gt;.69&lt;/code&gt;, &lt;code&gt;.70&lt;/code&gt;, &lt;code&gt;.64&lt;/code&gt;: four IPs from the same /24, hitting simultaneously. That&apos;s a botnet or a rented VPS farm running a distributed scan across the entire IPv4 space.&lt;/p&gt;
&lt;p&gt;Your server is not the target. It&apos;s just an address that exists.&lt;/p&gt;
&lt;h2&gt;Why anyone bothers&lt;/h2&gt;
&lt;p&gt;IPv4 has about 4 billion addresses. Tools like Masscan can sweep the entire space in under an hour. Running these scans costs almost nothing, and the economics work even at a very low hit rate.&lt;/p&gt;
&lt;p&gt;A server with default credentials gets compromised in seconds and immediately put to work: spam relays, crypto mining, DDoS botnet nodes, proxy services. Operators sell access to these networks or run them directly. The bots don&apos;t care what your server does or who you are. They&apos;re looking for the small percentage of newly spun-up machines where someone left &lt;code&gt;root&lt;/code&gt;/&lt;code&gt;password&lt;/code&gt; as the login, or where a cloud provider silently re-enabled password auth on provisioning.&lt;/p&gt;
&lt;p&gt;This is why the noise starts within minutes of a server going live. Scanners watch for new IPs appearing in BGP routes and routing tables. By the time you finish setting up nginx, you&apos;re already being scanned.&lt;/p&gt;
&lt;h2&gt;Am I already compromised?&lt;/h2&gt;
&lt;p&gt;This is the right question to ask before anything else. Check successful logins:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;journalctl -u ssh.service --no-pager --since &quot;30 days ago&quot; -q \
  | grep &quot;Accepted&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;What you want to see is every successful login using the same key fingerprint, from IPs you recognise. What I saw was exactly that: my ED25519 key, from my ISP&apos;s dynamic IP range and a cloud provider I use. Nothing else.&lt;/p&gt;
&lt;p&gt;If you see an &lt;code&gt;Accepted publickey&lt;/code&gt; entry from an IP you don&apos;t recognise, that&apos;s worth investigating immediately. SSH brute-force noise by itself is not evidence of a breach. It&apos;s background radiation on any public IP.&lt;/p&gt;
&lt;h2&gt;The part that was actually worrying&lt;/h2&gt;
&lt;p&gt;While investigating the SSH config, I found this in &lt;code&gt;/etc/ssh/sshd_config.d/&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;50-cloud-init.conf       →  PasswordAuthentication yes
60-cloudimg-settings.conf  →  PasswordAuthentication no
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Cloud-init, the provisioning tool most VPS providers use, had dropped a config file setting password authentication to &lt;code&gt;yes&lt;/code&gt;. A later file was overriding it to &lt;code&gt;no&lt;/code&gt;, so the effective setting was correct. But &lt;code&gt;50-cloud-init.conf&lt;/code&gt; was a loaded gun. If the second file ever got removed by a package update, password auth would silently re-enable. The brute-force bots hammering the server every few minutes would immediately start getting password prompts instead of rejections.&lt;/p&gt;
&lt;p&gt;This is the actual risk. Not the scanning, which is just noise, but the possibility that a routine system update quietly removes one file and undoes your security config without any indication that anything changed.&lt;/p&gt;
&lt;p&gt;The fix: edit &lt;code&gt;50-cloud-init.conf&lt;/code&gt; and change the value to &lt;code&gt;no&lt;/code&gt;. Don&apos;t delete it because cloud-init may recreate it. Just make it say the right thing so both files agree.&lt;/p&gt;
&lt;p&gt;If you&apos;re on a VPS with cloud-init, check yours:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;grep -r &quot;PasswordAuthentication&quot; /etc/ssh/sshd_config.d/
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then confirm the effective config:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sshd -T | grep passwordauthentication
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That last command is what matters: it shows what sshd actually resolved after processing all the drop-in files, not what any single file says.&lt;/p&gt;
&lt;p&gt;If you want to audit your SSH config more systematically, I wrote &lt;a href=&quot;https://gitlab.com/srmdn/sysadmin-scripts/-/blob/main/security/ssh-audit.sh&quot;&gt;ssh-audit.sh&lt;/a&gt; which checks for common weaknesses, flags misconfigurations, and runs a reputation check on your server&apos;s public IP.&lt;/p&gt;
&lt;h2&gt;SSH hardening worth adding&lt;/h2&gt;
&lt;p&gt;With password auth confirmed off, two more settings help.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;MaxStartups 10:30:60&lt;/code&gt;&lt;/strong&gt;: by default, sshd accepts up to 100 simultaneous unauthenticated connections before starting to drop them. Bots can hold open dozens of connections doing nothing, just occupying sshd threads. Setting this to &lt;code&gt;10:30:60&lt;/code&gt; means sshd starts probabilistically dropping new connections once 10 are pending, and hard-drops all above 60.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;ClientAliveInterval 300&lt;/code&gt;&lt;/strong&gt; &lt;strong&gt;with&lt;/strong&gt; &lt;strong&gt;&lt;code&gt;ClientAliveCountMax 2&lt;/code&gt;&lt;/strong&gt;: sends a keepalive every 5 minutes and disconnects if the client doesn&apos;t respond after two attempts. This cleans up ghost sessions from dropped connections.&lt;/p&gt;
&lt;p&gt;Put these in a new drop-in file to keep the change auditable:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# /etc/ssh/sshd_config.d/70-hardening.conf
MaxStartups 10:30:60
ClientAliveInterval 300
ClientAliveCountMax 2
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Validate and reload:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sshd -t &amp;#x26;&amp;#x26; systemctl reload ssh
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Fixing the log bloat&lt;/h2&gt;
&lt;p&gt;fail2ban was already running and banning aggressively: 3 failures in 10 minutes gets an IP banned for 24 hours. After a month it had banned 1,541 IPs. That&apos;s working correctly. If you want a cleaner view of what it&apos;s actually doing, &lt;a href=&quot;https://gitlab.com/srmdn/sysadmin-scripts/-/blob/main/security/fail2ban-report.sh&quot;&gt;fail2ban-report.sh&lt;/a&gt; shows per-jail stats, top offending IPs, and recent bans in one output.&lt;/p&gt;
&lt;p&gt;The logs themselves were still growing because the journal had no size limit configured. Two changes fix this permanently.&lt;/p&gt;
&lt;p&gt;First, vacuum what&apos;s already there:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;journalctl --vacuum-size=500M
journalctl --vacuum-time=30d
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then set a permanent cap in &lt;code&gt;/etc/systemd/journald.conf&lt;/code&gt; so it never grows back:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ini&quot;&gt;[Journal]
SystemMaxUse=500M
MaxRetentionSec=30day
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Restart journald to apply:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;systemctl restart systemd-journald
journalctl --disk-usage
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;On my server that brought 2 GB down to 499 MB in a few seconds. With the cap set, the journal self-manages: oldest entries are dropped automatically when it hits the limit.&lt;/p&gt;
&lt;p&gt;500M is generous for most personal servers. If you need long retention for debugging or compliance, raise it. Just set something so it doesn&apos;t grow unbounded.&lt;/p&gt;
&lt;h2&gt;Is this right for you?&lt;/h2&gt;
&lt;p&gt;If you run a public-facing server on any major cloud or VPS provider, you are getting scanned. There&apos;s no configuration that stops it. It&apos;s just the internet. The right response is to make sure your actual defenses are solid, not to try to make the noise stop.&lt;/p&gt;
&lt;p&gt;Password auth off, key-only login, fail2ban active, journal capped: these are the floor, not a complete hardening guide. If your threat model goes beyond random bots, look at &lt;code&gt;AllowUsers&lt;/code&gt;, IP allowlisting, and port knocking on top of this. For a personal server or small project, these steps get you to a state where the noise is contained and you can actually notice if something real happens.&lt;/p&gt;
&lt;p&gt;The cloud-init config issue is worth checking regardless of anything else. It&apos;s easy to miss, it won&apos;t show up in any obvious error, and the consequence of missing it is that the wall you think is solid has a door in it.&lt;/p&gt;</content:encoded><h:img src="/_astro/hero.CGwXPVfZ.webp"/><enclosure url="/_astro/hero.CGwXPVfZ.webp"/></item><item><title>You Don&apos;t Need a Message Queue</title><link>https://srmdn.com/blog/you-dont-need-a-message-queue</link><guid isPermaLink="true">https://srmdn.com/blog/you-dont-need-a-message-queue</guid><description>Before you reach for Redis or RabbitMQ, there&apos;s a simpler pattern. A database table and a background worker handle most use cases.</description><pubDate>Sun, 01 Mar 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Every backend tutorial that mentions background jobs ends the same way: &quot;and then you add a message queue.&quot; Redis. RabbitMQ. SQS. Choose one, wire it up, and now you have another service to deploy, monitor, and debug.&lt;/p&gt;
&lt;p&gt;I built a newsletter system without any of that. The emails go out. Failed deliveries retry automatically. It&apos;s been running in production quietly, without me thinking about it. The entire thing is a database table and a background worker.&lt;/p&gt;
&lt;h2&gt;What a Message Queue Actually Does&lt;/h2&gt;
&lt;p&gt;Strip away the marketing and a message queue does three things: stores a task somewhere durable, delivers it to a worker, and handles retries when the worker fails. That&apos;s the whole job.&lt;/p&gt;
&lt;p&gt;The complexity in RabbitMQ and Kafka comes from doing this across many services, at high throughput, with multiple consumers competing for work. That&apos;s a real problem for distributed systems processing millions of events per minute.&lt;/p&gt;
&lt;p&gt;Most backends are not that. Most backends need to send a welcome email, process an uploaded file, or retry a failed webhook. Your database can do all three. You already have one.&lt;/p&gt;
&lt;h2&gt;The Pattern&lt;/h2&gt;
&lt;p&gt;Instead of pushing a task to a queue, write a row to a database table. A background worker periodically reads that table, processes what&apos;s pending, and updates the row.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;User action
    ↓
Write row to jobs table  →  HTTP response (immediate)
    ↓
Background worker wakes up on a timer
    ↓
Reads pending rows → processes → updates status
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;No broker. No separate worker process. No infrastructure to configure.&lt;/p&gt;
&lt;h2&gt;How It Looks in Practice&lt;/h2&gt;
&lt;p&gt;When a newsletter goes out, each recipient gets a row in a jobs table with a status of &lt;code&gt;pending&lt;/code&gt;. A worker started at server boot processes those rows and retries any that fail.&lt;/p&gt;
&lt;p&gt;Here&apos;s the entire worker setup in Go:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;func StartWorker(interval time.Duration) {
    ticker := time.NewTicker(interval)
    go func() {
        for range ticker.C {
            if err := processJobs(); err != nil {
                log.Printf(&quot;worker error: %v&quot;, err)
            }
        }
    }()
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A goroutine, a ticker, one function call. It starts when the server starts and runs forever.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;processJobs&lt;/code&gt; reads the jobs table, finds rows with &lt;code&gt;status = failed&lt;/code&gt; and &lt;code&gt;attempts &amp;#x3C; 3&lt;/code&gt;, checks if enough time has passed since the last attempt, and retries them. The backoff is a plain switch:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;func retryDelay(attempts int) time.Duration {
    switch attempts {
    case 0:
        return 1 * time.Minute
    case 1:
        return 5 * time.Minute
    case 2:
        return 15 * time.Minute
    default:
        return 0
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Exponential backoff. Max 3 retries. The logic fits in one screen. No dependency to install.&lt;/p&gt;
&lt;p&gt;The same pattern works for one-off tasks. When a user triggers something slow, the handler fires a goroutine and returns immediately:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;go func() {
    result, err := doSlowThing()
    // update status in DB when done
}()

w.WriteHeader(http.StatusAccepted)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The HTTP response is instant. The slow work happens in the background. The client polls a status endpoint. No queue needed.&lt;/p&gt;
&lt;h2&gt;What You Get for Free&lt;/h2&gt;
&lt;p&gt;Using the database as the job store gives you things that message queues charge extra for.&lt;/p&gt;
&lt;p&gt;Visibility is the obvious one. Want to see what&apos;s queued? Run a SELECT. No separate management UI, no extra CLI tool. The jobs are where the rest of your data is, queryable the same way.&lt;/p&gt;
&lt;p&gt;Transactional writes are the less obvious but more important one. You can insert the job row in the same transaction as the record that triggered it. If the transaction rolls back, the job disappears too. With an external queue there&apos;s always a window where the DB commit succeeded but the enqueue failed, or the other way around. Both are bugs that only show up under load.&lt;/p&gt;
&lt;p&gt;Durability comes for free too. Your database is already backed up. Your jobs are backed up with it. Any pending work, retry count, error message — it all comes back if the server dies.&lt;/p&gt;
&lt;p&gt;Debugging gets simpler. When something fails, you check the row. The error is right there in the table. No hunting through queue consumer logs across services.&lt;/p&gt;
&lt;h2&gt;When This Breaks Down&lt;/h2&gt;
&lt;p&gt;High throughput is the first limit. If you&apos;re processing thousands of jobs per second, polling a table becomes a bottleneck. Queues use push delivery and are built for this. SQLite in particular has write concurrency limits that will hit you before Postgres does.&lt;/p&gt;
&lt;p&gt;Multiple consumers is the second. This pattern assumes one worker pulling from the table. If you need to scale horizontally, multiple instances will race on the same rows. Postgres has &lt;code&gt;SELECT ... FOR UPDATE SKIP LOCKED&lt;/code&gt; for this, but now you&apos;re managing that complexity yourself.&lt;/p&gt;
&lt;p&gt;Cross-service is the third. If the producer and consumer are different services, a shared database is tight coupling. A message queue is the right abstraction there — that&apos;s what it was built for.&lt;/p&gt;
&lt;p&gt;Near-instant pickup is the last one. Polling every N seconds means jobs wait up to N seconds to start. If you need sub-second job pickup, look at Postgres LISTEN/NOTIFY or just use a proper queue.&lt;/p&gt;
&lt;h2&gt;Is This Right for You?&lt;/h2&gt;
&lt;p&gt;This pattern works if you have one backend process, your job volume is in the hundreds to low thousands per day, and you want fewer moving parts to deploy and debug.&lt;/p&gt;
&lt;p&gt;It doesn&apos;t work if you&apos;re building something that needs to scale horizontally, process jobs in real time, or communicate across service boundaries.&lt;/p&gt;
&lt;p&gt;In my case, the worker wakes up on a regular interval, finds nothing to do most of the time, and goes back to sleep. The one time a batch of emails failed mid-send, it caught them on the next cycle without me doing anything.&lt;/p&gt;
&lt;p&gt;That&apos;s the bar I was aiming for. Something that works quietly, without infrastructure I have to manage.&lt;/p&gt;</content:encoded><h:img src="/_astro/hero.42uFk3d7.webp"/><enclosure url="/_astro/hero.42uFk3d7.webp"/></item><item><title>Ever wondered why Linux has two commands for the same task?</title><link>https://srmdn.com/blog/useradd-and-adduser</link><guid isPermaLink="true">https://srmdn.com/blog/useradd-and-adduser</guid><description>Let’s break down the useradd vs adduser mystery.</description><pubDate>Tue, 06 Jan 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Creating a user seems simple, but choosing the wrong tool can leave you with a “broken” account (no home directory, no shell!).&lt;/p&gt;
&lt;h2&gt;&lt;strong&gt;The Core Difference&lt;/strong&gt;&lt;/h2&gt;
&lt;h3&gt;&lt;strong&gt;Think of it this way:&lt;/strong&gt;&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;useradd: The raw, low-level tool. It’s a binary that does exactly what it’s told, nothing more.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;adduser: The smart, high-level wrapper (Perl script). It uses useradd in the background but adds “common sense” automation.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;&lt;strong&gt;The “useradd” Way (The Hard Way)&lt;/strong&gt;&lt;/h2&gt;
&lt;p&gt;If you run sudo useradd john: No home directory created. No password set (account is locked). Default shell is often /bin/sh (very basic).&lt;/p&gt;
&lt;p&gt;You have to manually add flags like -m for home or -s for shell. It’s built for scripts, not humans.&lt;/p&gt;
&lt;h2&gt;&lt;strong&gt;The “adduser” Way (The Easy Way)&lt;/strong&gt;&lt;/h2&gt;
&lt;p&gt;If you run sudo adduser john: Automatically creates /home/john. Copies skeleton files (.bashrc, etc.). Prompts you for a password immediately. Asks for user details (Full name, room number).&lt;/p&gt;
&lt;p&gt;It’s interactive and “just works.”&lt;/p&gt;
&lt;h2&gt;&lt;strong&gt;Practice&lt;/strong&gt;&lt;/h2&gt;
&lt;p&gt;If you’re managing an ubuntu server, creating the user is just step 1. You’ll likely want them to have admin rights:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo adduser username
sudo usermod -aG sudo username
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Now your new user can perform administrative tasks!&lt;/p&gt;
&lt;h2&gt;&lt;strong&gt;When to use which?&lt;/strong&gt;&lt;/h2&gt;
&lt;h3&gt;&lt;strong&gt;Use adduser if:&lt;/strong&gt;&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;You are a beginner.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;You are working on Debian/Ubuntu-based systems.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;You want a ready-to-use account in 10 seconds.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;&lt;strong&gt;Use useradd if:&lt;/strong&gt;&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;You are writing bash scripts.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;You are on a minimal distro (Arch, Alpine) where adduser might not be installed.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;&lt;strong&gt;Pro-Tip: The “Skel” Directory&lt;/strong&gt;&lt;/h2&gt;
&lt;p&gt;Both commands rely on /etc/skel. Anything you put in this folder will automatically appear in a new user’s home directory. Perfect for pre-configuring .vimrc or alias settings for your team!&lt;/p&gt;
&lt;h3&gt;&lt;strong&gt;Wait, why do some tutorials say they are the same?&lt;/strong&gt;&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;In Debian/Ubuntu, they are different: adduser is a friendly script, useradd is the raw tool.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;In RHEL/CentOS/Fedora, adduser is often just a symbolic link to useradd.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Know your distro before you type!&lt;/p&gt;
&lt;h2&gt;&lt;strong&gt;To summarize for my ubuntu server friends:&lt;/strong&gt;&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;Use adduser for a fast, interactive, and “complete” setup.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Use useradd only if you’re writing automated scripts. Mastering these small nuances is what makes a great sysadmin!&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;</content:encoded><h:img src="/_astro/hero.BnPcnZ8_.webp"/><enclosure url="/_astro/hero.BnPcnZ8_.webp"/></item><item><title>Using Claude Code on a Self-Managed VPS: My Workflow</title><link>https://srmdn.com/blog/claude-code-on-a-self-managed-vps</link><guid isPermaLink="true">https://srmdn.com/blog/claude-code-on-a-self-managed-vps</guid><description>How I run Claude Code directly on my VPS, why CLAUDE.md is the most important file in the repo, and the memory setup that makes it work across sessions.</description><pubDate>Sat, 28 Feb 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Most people run AI coding assistants on their laptop, pointed at a local project. That works fine when your app runs locally. But when your project lives on a VPS (a &lt;code&gt;deploy&lt;/code&gt; user running services, nginx routing between staging and production, systemd managing processes), the AI has no idea what it&apos;s working with. It suggests Docker. It tries to run &lt;code&gt;go&lt;/code&gt; without the full path. It creates files as root and wonders why the service crashes.&lt;/p&gt;
&lt;p&gt;I run Claude Code directly on the server. Here&apos;s the setup that makes it actually useful.&lt;/p&gt;
&lt;h2&gt;Why Not Run It Locally?&lt;/h2&gt;
&lt;p&gt;The obvious alternative is to run Claude locally, write code, push to git, pull on the server, and rebuild. That works, and for frontend-heavy projects it&apos;s probably the right call.&lt;/p&gt;
&lt;p&gt;But for backend work: API changes, database migrations, systemd service tweaks, nginx config updates. You&apos;re constantly switching context between your laptop and the server. Claude suggests a fix, you paste it, push it, pull it, rebuild, check the logs, paste the error back. It&apos;s friction. When Claude is running on the server itself, it can read the actual log output, check running services, and build the binary right there. The feedback loop is tighter.&lt;/p&gt;
&lt;p&gt;There&apos;s also a context problem. Your laptop doesn&apos;t know that Go lives at &lt;code&gt;/usr/local/go/bin/go&lt;/code&gt; instead of just &lt;code&gt;go&lt;/code&gt;. It doesn&apos;t know that services run as a &lt;code&gt;deploy&lt;/code&gt; user, not root. It doesn&apos;t know which ports are in use or how nginx is configured. Without that context, every session starts with Claude making wrong assumptions that you have to correct.&lt;/p&gt;
&lt;p&gt;The fix is &lt;code&gt;CLAUDE.md&lt;/code&gt;.&lt;/p&gt;
&lt;h2&gt;CLAUDE.md: The File That Changes Everything&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;CLAUDE.md&lt;/code&gt; is a file you put in the root of your project. Claude Code reads it automatically at the start of every session, before you type anything. It&apos;s not documentation for humans. It&apos;s instructions for Claude.&lt;/p&gt;
&lt;p&gt;Mine looks like this (simplified):&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-markdown&quot;&gt;## Stack
- Backend: Go, port 8081 (staging) / 8082 (production)
- Frontend: Astro SSR, port 4321 (staging) / 4322 (production)
- Database: SQLite at backend/data/cms.db

## This Server
- Go binary: /usr/local/go/bin/go — NOT in PATH, always use full path
- Services run as the deploy user. Claude Code runs as root.
- Files created as root must be chowned to deploy where services write to them.
- ProtectHome=yes is set in systemd — subprocesses cannot access /home

## Environments
- Staging: /var/www/myproject-staging/ → staging.myproject.com
- Production: /var/www/myproject-production/ → myproject.com
- Always work on staging first. Never edit production directly.

## Deploy Pattern
1. Edit on staging
2. Build: /usr/local/go/bin/go build -o bin/server ./cmd/server/
3. Restart: systemctl restart myproject-backend-staging
4. Test on staging domain
5. Promote: merge staging → main → rebuild production
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That&apos;s it. Claude now knows the exact binary path, the user model, the port layout, and the deployment pattern. It stops suggesting &lt;code&gt;go build&lt;/code&gt; and starts suggesting &lt;code&gt;/usr/local/go/bin/go build&lt;/code&gt;. It stops creating files owned by root in directories the service writes to. It knows to test on staging before touching production.&lt;/p&gt;
&lt;p&gt;The first session with a good &lt;code&gt;CLAUDE.md&lt;/code&gt; feels noticeably different from one without it. You stop spending the first ten minutes correcting wrong assumptions.&lt;/p&gt;
&lt;h2&gt;Memory Across Sessions&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;CLAUDE.md&lt;/code&gt; captures stable facts: the stack, the ports, the conventions. But Claude also learns things during a session that aren&apos;t in &lt;code&gt;CLAUDE.md&lt;/code&gt;: a bug it fixed and why, a pattern that&apos;s specific to this codebase, a decision you made and the reasoning behind it.&lt;/p&gt;
&lt;p&gt;By default, that knowledge is gone when the session ends.&lt;/p&gt;
&lt;p&gt;Claude Code has a memory system: a &lt;code&gt;MEMORY.md&lt;/code&gt; file it reads at the start of every session and updates as it learns. Out of the box, this file lives in a hidden directory on the server. If the server dies, it&apos;s gone.&lt;/p&gt;
&lt;p&gt;My fix: store the memory files in a separate git repository (I use one for VPS infrastructure and shared scripts) and symlink Claude&apos;s memory directory to a folder inside it. The memory is now version-controlled and backed up daily alongside the databases and env files.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# Move memory into your ops repo
mv ~/.claude/projects/-var-www-myproject-staging/memory \
   /var/www/ops-repo/claude-memory

# Symlink back so Claude still finds it
ln -s /var/www/ops-repo/claude-memory \
      ~/.claude/projects/-var-www-myproject-staging/memory
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The project name in that path (&lt;code&gt;-var-www-myproject-staging&lt;/code&gt;) is just the working directory with slashes replaced by dashes. Claude Code creates it automatically based on where you run it.&lt;/p&gt;
&lt;p&gt;When I start a session now, Claude already knows things like: the &lt;code&gt;deploy&lt;/code&gt; user issue that causes silent 500 errors when root creates files in directories the service writes to, the SQLite migration pattern we use, which blog posts are published and what they&apos;re about. It picks up where the last session left off.&lt;/p&gt;
&lt;p&gt;After any session where something notable was figured out, I commit the memory files:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;cd /var/www/ops-repo
git add claude-memory/
git commit -m &quot;chore: update Claude memory&quot;
git push
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;The Actual Workflow&lt;/h2&gt;
&lt;p&gt;A normal development session looks like this:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# SSH into the server
ssh root@myserver.com

# Start Claude in the project directory
cd /var/www/myproject-staging
claude
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Claude reads &lt;code&gt;CLAUDE.md&lt;/code&gt; and &lt;code&gt;MEMORY.md&lt;/code&gt;. No re-explaining the project.&lt;/p&gt;
&lt;p&gt;I describe what I want to build or fix. Claude reads the relevant files, proposes a change, and writes it. Then:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# Rebuild backend (if Go files changed)
systemctl stop myproject-backend-staging
/usr/local/go/bin/go build -o bin/server ./cmd/server/
systemctl start myproject-backend-staging

# Or rebuild frontend (if Astro files changed)
sudo -u deploy npm run build
systemctl restart myproject-astro-staging
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;I open the staging domain in the browser, check that it works, and either iterate or commit.&lt;/p&gt;
&lt;p&gt;The staging to production promotion is explicit and manual, same as without AI:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# Sync staging → production
cd /var/www/myproject-production
git fetch origin
git merge origin/staging
/usr/local/go/bin/go build -o bin/server ./cmd/server/
systemctl restart myproject-backend-production
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Claude doesn&apos;t touch production. I do that step myself, deliberately.&lt;/p&gt;
&lt;h2&gt;The Gotcha That Will Get You&lt;/h2&gt;
&lt;p&gt;The most common issue when running Claude on a VPS as root: Claude creates a file, the service crashes, logs say &lt;code&gt;permission denied&lt;/code&gt;, and it&apos;s not obvious why.&lt;/p&gt;
&lt;p&gt;The cause is the root/deploy user split. Claude runs as root. Your services run as a &lt;code&gt;deploy&lt;/code&gt; user. When Claude creates or edits a file, that file is owned by root. The &lt;code&gt;deploy&lt;/code&gt; service can&apos;t write to it.&lt;/p&gt;
&lt;p&gt;This matters for:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Directories the service writes to (database files, uploaded content, cache)&lt;/li&gt;
&lt;li&gt;Files the service reads at runtime that it might also need to write&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The fix is consistent:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;chown -R deploy:deploy /path/to/dir
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;And to prevent it from recurring every time root touches those directories, set a default ACL:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;setfacl -d -m u:deploy:rwX /path/to/dir
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Now any file created in that directory, by root, by Claude, by anyone, automatically gets deploy write access.&lt;/p&gt;
&lt;p&gt;I document this in &lt;code&gt;CLAUDE.md&lt;/code&gt; so Claude knows about it. When it creates a file in a service-writable directory, it adds the &lt;code&gt;chown&lt;/code&gt; step. Most of the time. When it forgets, the error is quick to diagnose.&lt;/p&gt;
&lt;h2&gt;What I Tell Claude Before It Writes Any Code&lt;/h2&gt;
&lt;p&gt;For a new feature, I don&apos;t say &quot;build me a comment system&quot;. I say:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;I want to add a comment system. Before writing any code:
1. Propose the database schema
2. Propose the API endpoints
3. List any questions or assumptions

Do NOT write any code yet.
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Reviewing a plan before code exists is much faster than reviewing code that made wrong assumptions. Once I&apos;m happy with the plan, I say &quot;looks good, proceed with the database migration first.&quot;&lt;/p&gt;
&lt;p&gt;One feature at a time. Review between each one. This produces better code and catches wrong directions early.&lt;/p&gt;
&lt;h2&gt;Is This Right for You?&lt;/h2&gt;
&lt;p&gt;This setup makes sense if:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Your project runs on a VPS you control directly&lt;/li&gt;
&lt;li&gt;Most of your work is backend: API changes, database schema, server configuration&lt;/li&gt;
&lt;li&gt;You&apos;re working solo or with a small team where one person manages the server&lt;/li&gt;
&lt;li&gt;You want a tight feedback loop without pushing and pulling for every test&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;It probably doesn&apos;t make sense if:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Your project is frontend-heavy and runs fine locally&lt;/li&gt;
&lt;li&gt;You have multiple people making server changes simultaneously&lt;/li&gt;
&lt;li&gt;You&apos;re not comfortable with an AI assistant that has root access to your server&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;On the root access point: Claude Code asks for confirmation before destructive operations. You see every command before it runs. But it is root access, and the risk is real. The practical risk on a personal project is low. Claude Code is conservative by default. On a production server handling real users, I&apos;d think more carefully before running it there directly.&lt;/p&gt;
&lt;p&gt;For my personal site, the workflow is working well. A CLAUDE.md that actually reflects the server setup, memory that persists across sessions, and the discipline to always test on staging first. That combination makes the AI genuinely useful instead of a context-reset every session.&lt;/p&gt;</content:encoded><h:img src="/_astro/hero.4SXelKZ5.webp"/><enclosure url="/_astro/hero.4SXelKZ5.webp"/></item><item><title>The &quot;Magic Numbers&quot; of Software: SemVer Explained</title><link>https://srmdn.com/blog/semver-explained</link><guid isPermaLink="true">https://srmdn.com/blog/semver-explained</guid><description>Ever wonder why your favorite app goes from version 2.1.5 to 3.0.0? It’s not random. It’s a language called Semantic Versioning (SemVer).</description><pubDate>Mon, 02 Feb 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Here is the breakdown of what those three numbers actually mean. 👇&lt;/p&gt;
&lt;h2&gt;The Breakdown: X . Y . Z&lt;/h2&gt;
&lt;p&gt;Think of a version number like a scale of &quot;How much will this change my life?&quot;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;MAJOR (X): The &quot;Breaking&quot; Change.&lt;/li&gt;
&lt;li&gt;MINOR (Y): The &quot;New Feature&quot; Change.&lt;/li&gt;
&lt;li&gt;PATCH (Z): The &quot;Oops, Fixed It&quot; Change.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;## PATCH (0.0.1)&lt;/h2&gt;
&lt;p&gt;The &quot;Under the Hood&quot; fix.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;What it is: Bug fixes that don’t change how the software works.&lt;/li&gt;
&lt;li&gt;The Vibe: Everything stays the same, it just works better now.&lt;/li&gt;
&lt;li&gt;Action: Safe to update immediately.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;MINOR (0.1.0)&lt;/h2&gt;
&lt;p&gt;The &quot;Bonus Content&quot; update.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;What it is: New features added, but the old stuff still works exactly the same way (Backward Compatible).&lt;/li&gt;
&lt;li&gt;The Vibe: &quot;Oh cool, a new dark mode button!&quot;&lt;/li&gt;
&lt;li&gt;Action: Update when you want the new toys.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;MAJOR (1.0.0) ⚠️&lt;/h2&gt;
&lt;p&gt;The &quot;Clean Slate&quot; update.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;What it is: Big architectural shifts. Old code might break if you try to use it with this version.&lt;/li&gt;
&lt;li&gt;The Vibe: &quot;We moved the furniture and changed the locks.&quot;&lt;/li&gt;
&lt;li&gt;Action: Read the manual before hitting &apos;Update.&apos;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Why does this matter?&lt;/h2&gt;
&lt;p&gt;Without SemVer, updating software is like Russian Roulette. With it, developers know exactly what to expect before they click &quot;install.&quot;&lt;/p&gt;
&lt;p&gt;Consistency = Trust. 🤝&lt;/p&gt;</content:encoded><h:img src="/_astro/hero.ac0C63n_.webp"/><enclosure url="/_astro/hero.ac0C63n_.webp"/></item><item><title>Why Your og:image Doesn&apos;t Show in Social Shares</title><link>https://srmdn.com/blog/fixing-og-image-in-social-shares</link><guid isPermaLink="true">https://srmdn.com/blog/fixing-og-image-in-social-shares</guid><description>Two bugs that silently break social share previews in Astro SSR, a localhost URL and a base64 image in frontmatter. How to find and fix both.</description><pubDate>Thu, 26 Feb 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;When I shared one of my posts on socials, the link preview was blank. No image, just the title and a gray box. I&apos;d set a featured image in my CMS dashboard — it was clearly there — but social crawlers were ignoring it completely.&lt;/p&gt;
&lt;p&gt;Turns out there were two separate bugs. They&apos;re easy to miss because the site looks perfectly fine in a browser.&lt;/p&gt;
&lt;h2&gt;How Social Share Previews Work&lt;/h2&gt;
&lt;p&gt;When you paste a URL into Twitter/X, iMessage, LinkedIn, or Slack, the platform&apos;s crawler fetches that URL and reads the Open Graph meta tags in the &lt;code&gt;&amp;#x3C;head&gt;&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-html&quot;&gt;&amp;#x3C;meta property=&quot;og:image&quot; content=&quot;https://example.com/image.webp&quot; /&gt;
&amp;#x3C;meta property=&quot;twitter:image&quot; content=&quot;https://example.com/image.webp&quot; /&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The crawler then fetches the image at that URL and renders the preview card. Two things can silently break this:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;The URL isn&apos;t a real HTTP URL — it&apos;s a &lt;code&gt;data:&lt;/code&gt; URI (base64-encoded image embedded directly in the HTML)&lt;/li&gt;
&lt;li&gt;The URL is technically a URL, but it points to &lt;code&gt;localhost&lt;/code&gt; — unreachable from the outside&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Both give you the same result: no image in the preview. The crawler quietly fails and moves on.&lt;/p&gt;
&lt;h2&gt;How to Diagnose&lt;/h2&gt;
&lt;p&gt;Before guessing, check what your page is actually serving. View source on the live page (&lt;code&gt;Ctrl+U&lt;/code&gt;) and search for &lt;code&gt;og:image&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-html&quot;&gt;&amp;#x3C;!-- Bug 1: base64 data URI — crawlers can&apos;t fetch this --&gt;
&amp;#x3C;meta property=&quot;og:image&quot; content=&quot;data:image/webp;base64,UklGRvpD...&quot; /&gt;

&amp;#x3C;!-- Bug 2: localhost URL — unreachable from the internet --&gt;
&amp;#x3C;meta property=&quot;og:image&quot; content=&quot;http://localhost:4321/_astro/hero.C4SheoqF.webp&quot; /&gt;

&amp;#x3C;!-- Correct --&gt;
&amp;#x3C;meta property=&quot;og:image&quot; content=&quot;https://yoursite.com/_astro/hero.C4SheoqF.webp&quot; /&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If you&apos;re seeing either of the first two, read on.&lt;/p&gt;
&lt;h2&gt;Bug 1: The Base64 Image&lt;/h2&gt;
&lt;p&gt;This shows up when your CMS stores the hero image as a base64 data URI directly in the markdown frontmatter:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;---
title: My Post
heroImage: data:image/webp;base64,UklGRvpDAABXRUJQVlA4...
---
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This works fine in the browser — the image renders — but when Astro processes it into an &lt;code&gt;og:image&lt;/code&gt; tag, the full base64 string ends up as the &lt;code&gt;content&lt;/code&gt; attribute. Social crawlers treat &lt;code&gt;og:image&lt;/code&gt; as a URL to fetch. They won&apos;t decode an embedded binary blob.&lt;/p&gt;
&lt;h3&gt;The Fix: Save Images as Real Files&lt;/h3&gt;
&lt;p&gt;Extract the base64 data URI and write it to a real file on disk. In a Go backend, &lt;code&gt;SavePost()&lt;/code&gt; is the right place to intercept it:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;func SavePost(dir string, post models.Post) error {
    // ... setup ...

    // If heroImage is a base64 data URI, save it as a file instead
    heroImage := post.HeroImage
    if strings.HasPrefix(post.HeroImage, &quot;data:&quot;) {
        if path, err := saveHeroImageToDisk(postDir, post.HeroImage); err != nil {
            fmt.Printf(&quot;Warning: could not save hero image for %s: %v\n&quot;, post.Slug, err)
        } else {
            heroImage = path
        }
    }

    fm := frontmatterData{
        // ...
        HeroImage: heroImage, // now &quot;./hero.webp&quot; instead of &quot;data:...&quot;
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;saveHeroImageToDisk&lt;/code&gt; function parses the MIME type from the data URI, decodes the base64, and writes &lt;code&gt;hero.webp&lt;/code&gt; (or &lt;code&gt;.jpg&lt;/code&gt;, &lt;code&gt;.png&lt;/code&gt;, etc.) into the post directory. The frontmatter ends up with:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;heroImage: ./hero.webp
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Astro&apos;s content collection schema with &lt;code&gt;image()&lt;/code&gt; picks up that relative path at build time, optimizes it, and outputs a proper &lt;code&gt;/_astro/hero.{hash}.webp&lt;/code&gt; URL. That&apos;s a real, crawlable HTTPS URL.&lt;/p&gt;
&lt;p&gt;One more thing: your admin editor was probably uploading the image as base64 and expecting base64 back from the API. Now that the backend stores a file path, the &lt;code&gt;GET /api/admin/posts/{slug}&lt;/code&gt; endpoint needs to read the file and return it as a data URI for the editor to display:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;func (s *Server) GetPostAdminHandler(w http.ResponseWriter, r *http.Request) {
    // ...
    post, _ := fs.GetPost(s.ContentDir, slug)

    // Convert file path back to base64 for the editor
    if post.HeroImage != &quot;&quot; {
        post.HeroImage, _ = fs.ReadHeroImageAsDataURI(s.ContentDir, slug, post.HeroImage)
    }

    respondJSON(w, http.StatusOK, post)
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The storage format (file on disk) is now separate from the API contract (base64 for the editor). Public readers get an optimized &lt;code&gt;/_astro/&lt;/code&gt; URL; the dashboard editor still sees the image it uploaded.&lt;/p&gt;
&lt;h2&gt;Bug 2: The Localhost URL&lt;/h2&gt;
&lt;p&gt;This one is specific to Astro in SSR mode (&lt;code&gt;output: &apos;server&apos;&lt;/code&gt;). In SSR, &lt;code&gt;Astro.url&lt;/code&gt; returns the &lt;em&gt;internal&lt;/em&gt; server URL — the one Node.js sees, not the public domain:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Astro.url → http://localhost:4321/blog/my-post
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If you build your &lt;code&gt;og:image&lt;/code&gt; URL from &lt;code&gt;Astro.url&lt;/code&gt;, you&apos;re embedding localhost in every social share tag on every page:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-astro&quot;&gt;---
// ❌ Wrong — Astro.url is localhost in SSR
const socialImageURL = new URL(ogImage, Astro.url).href
// result: http://localhost:4321/_astro/hero.C4SheoqF.webp
---
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;The Fix: Use Astro.site&lt;/h3&gt;
&lt;p&gt;Astro gives you &lt;code&gt;Astro.site&lt;/code&gt;, which is the canonical public URL you configured in &lt;code&gt;astro.config.mjs&lt;/code&gt;. Build your canonical URL from that instead:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-astro&quot;&gt;---
// ✅ Correct — canonicalURL built from Astro.site
const canonicalURL = new URL(Astro.url.pathname, Astro.site)
const socialImageURL = new URL(ogImage, canonicalURL).href
// result: https://yoursite.com/_astro/hero.C4SheoqF.webp
---
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The same issue applies to any other URL you construct in &lt;code&gt;BaseHead.astro&lt;/code&gt; — canonical links, &lt;code&gt;og:url&lt;/code&gt;, &lt;code&gt;twitter:url&lt;/code&gt;. All of them should come from &lt;code&gt;canonicalURL&lt;/code&gt;, not &lt;code&gt;Astro.url&lt;/code&gt; directly:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-astro&quot;&gt;---
const canonicalURL = new URL(Astro.url.pathname, Astro.site)
const socialImageURL = new URL(ogImage ?? config.socialCard, canonicalURL).href
---

&amp;#x3C;link rel=&apos;canonical&apos; href={canonicalURL} /&gt;
&amp;#x3C;meta content={canonicalURL} property=&apos;og:url&apos; /&gt;
&amp;#x3C;meta content={socialImageURL} property=&apos;og:image&apos; /&gt;
&amp;#x3C;meta content={socialImageURL} property=&apos;twitter:image&apos; /&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Verifying the Fix&lt;/h2&gt;
&lt;p&gt;After rebuilding, view source on the live page and look for &lt;code&gt;og:image&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-html&quot;&gt;&amp;#x3C;meta content=&quot;https://yoursite.com/_astro/hero.C4SheoqF.webp&quot; property=&quot;og:image&quot; /&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If it&apos;s a real &lt;code&gt;https://&lt;/code&gt; URL pointing to your domain, you&apos;re done. You can also run it through social debugger tools — Twitter Card Validator, LinkedIn Post Inspector, or OpenGraph.xyz — though these cache aggressively and may show stale results for a while. Most have a &quot;Scrape Again&quot; button that forces a fresh fetch.&lt;/p&gt;
&lt;h2&gt;Gotchas&lt;/h2&gt;
&lt;p&gt;| Problem | Cause | Fix |
|---|---|---|
| Image shows in browser, not in social preview | base64 data URI in &lt;code&gt;og:image&lt;/code&gt; | Save image as real file, store relative path |
| &lt;code&gt;og:image&lt;/code&gt; URL contains &lt;code&gt;localhost&lt;/code&gt; | &lt;code&gt;Astro.url&lt;/code&gt; used in SSR mode | Use &lt;code&gt;new URL(Astro.url.pathname, Astro.site)&lt;/code&gt; |
| Social debugger shows old/wrong image | Platform cache | Wait ~30 min, use &quot;Scrape Again&quot; |
| Editor shows broken image after backend change | Admin endpoint returning file path, not data URI | Convert back to base64 in admin handler |&lt;/p&gt;
&lt;h2&gt;Both Bugs at Once&lt;/h2&gt;
&lt;p&gt;Worth noting: both bugs can exist simultaneously and compound each other. A &lt;code&gt;data:&lt;/code&gt; URI embedded in a tag that&apos;s also been constructed from &lt;code&gt;localhost&lt;/code&gt; is doubly broken. Fix the localhost URL first, then check whether the image content itself is valid — the failure mode for the second bug is harder to see until the URL is actually well-formed.&lt;/p&gt;
&lt;p&gt;In my case I had both at the same time. The page looked completely fine in the browser on production. The only symptom was a blank card when sharing a link — easy to ignore if you&apos;re not actively testing it.&lt;/p&gt;</content:encoded><h:img src="/_astro/hero.Dk_mTfRs.webp"/><enclosure url="/_astro/hero.Dk_mTfRs.webp"/></item><item><title>Deploying to a VPS Without Docker or CI/CD</title><link>https://srmdn.com/blog/deploying-to-vps-without-docker-and-cicd</link><guid isPermaLink="true">https://srmdn.com/blog/deploying-to-vps-without-docker-and-cicd</guid><description>How I moved my Go + Astro site off a managed static host onto a bare VPS using git, systemd, and nginx. No Docker, no pipeline.</description><pubDate>Sun, 22 Feb 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;When I moved this site from a managed static host to a bare VPS, the first thing people asked was: &quot;Why not Docker?&quot; or &quot;Why not just use Coolify?&quot; Fair questions. Let me answer them before getting into the actual workflow.&lt;/p&gt;
&lt;h2&gt;Why Not X?&lt;/h2&gt;
&lt;h3&gt;Why not Docker?&lt;/h3&gt;
&lt;p&gt;Docker shines when you have complex multi-service apps, a team that needs consistent environments across many machines, or you&apos;re shipping to Kubernetes. For a personal site running a Go API and a Node.js Astro frontend, it&apos;s overkill.&lt;/p&gt;
&lt;p&gt;Docker adds:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Build time overhead (image layers, registry pushes)&lt;/li&gt;
&lt;li&gt;Runtime overhead (container daemon, networking abstraction)&lt;/li&gt;
&lt;li&gt;Operational complexity (container orchestration, volume management)&lt;/li&gt;
&lt;li&gt;A new failure domain to debug when something goes wrong&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;When your app is two processes and a static site, just run the processes. &lt;code&gt;systemd&lt;/code&gt; is your process manager. It starts services on boot, restarts on crash, and gives you &lt;code&gt;journalctl&lt;/code&gt; for logs. That&apos;s everything you need.&lt;/p&gt;
&lt;h3&gt;Why not a managed platform (Railway, Render, Fly.io)?&lt;/h3&gt;
&lt;p&gt;These platforms are genuinely good, and I&apos;d recommend them for most projects. But I wanted to understand what&apos;s happening underneath — how nginx sits in front of your app, how systemd manages processes, how firewall rules protect internal ports from the internet. Managed platforms abstract all of that away. Also, at the hobby tier, they get expensive once you add a database and background workers.&lt;/p&gt;
&lt;h3&gt;Why not Coolify or Dokku?&lt;/h3&gt;
&lt;p&gt;Coolify and Dokku are excellent self-hosted tools that give you a Heroku-like experience on your own VPS. But they run Docker under the hood, so you&apos;re still adding that complexity. And they introduce an abstraction layer you have to learn and trust. For a site I&apos;ll maintain solo indefinitely, I&apos;d rather know exactly what&apos;s running than have a tool I don&apos;t fully understand managing it.&lt;/p&gt;
&lt;h3&gt;Why not CI/CD?&lt;/h3&gt;
&lt;p&gt;I use GitLab for source control, but I don&apos;t use GitLab CI/CD for deployment. A few reasons:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Free compute minutes are limited.&lt;/strong&gt; GitLab&apos;s free tier gives you 400 CI/CD compute minutes per month. For a personal project where you might deploy dozens of times during active development, that evaporates fast. Paying for compute just to run &lt;code&gt;git pull &amp;#x26;&amp;#x26; go build&lt;/code&gt; on a server you already pay for doesn&apos;t make sense.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;A self-hosted GitLab Runner&lt;/strong&gt; is the right long-term solution — the runner runs on your own VPS, uses your own compute, and has no minute limits. I plan to set that up eventually. But the manual workflow I&apos;m using now works fine, and I actually prefer having an explicit promotion gate.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Manual deploys are fine for solo projects.&lt;/strong&gt; CI/CD adds real value when multiple people are merging code and you need automated testing before every deploy. For a personal site with one contributor, a deliberate &quot;I&apos;m pushing this to production now&quot; moment has its own value. You know exactly what you&apos;re deploying and when.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;The Mental Model&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;[Local machine]  →  git push  →  [GitLab]  →  git pull  →  [VPS]
   write code          transport              runs code
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Your local machine is where you write. GitLab is the transport layer. The VPS is where things run. You never SCP files directly — git is always in the middle.&lt;/p&gt;
&lt;p&gt;The VPS runs two environments side by side:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;/var/www/
├── myproject-staging/      ← test changes here first
│   ├── backend/            ← Go API (localhost-only port)
│   └── frontend/           ← Astro SSR (localhost-only port)
└── myproject-production/   ← promote when staging looks good
    ├── backend/
    └── frontend/
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;nginx routes by domain name, so both environments run simultaneously without interfering with each other.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;Prerequisites&lt;/h2&gt;
&lt;h3&gt;On the VPS&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;A non-root &lt;code&gt;deploy&lt;/code&gt; user that runs your services&lt;/li&gt;
&lt;li&gt;nginx&lt;/li&gt;
&lt;li&gt;Go (installed system-wide — often not in PATH, use the full binary path)&lt;/li&gt;
&lt;li&gt;Node.js + npm&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;GitLab deploy key&lt;/h3&gt;
&lt;p&gt;The VPS needs to &lt;code&gt;git pull&lt;/code&gt; from your private repo. Create a key specifically for this:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# On the VPS as root
ssh-keygen -t ed25519 -f /root/.ssh/id_ed25519_gitlab -N &quot;&quot; -C &quot;vps-deploy&quot;
cat /root/.ssh/id_ed25519_gitlab.pub  # copy this
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Add the public key to GitLab: &lt;strong&gt;Project → Settings → Repository → Deploy Keys → Add key&lt;/strong&gt; (read-only is fine).&lt;/p&gt;
&lt;p&gt;Then add to &lt;code&gt;/root/.ssh/config&lt;/code&gt; on the VPS:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Host gitlab.com
    IdentityFile /root/.ssh/id_ed25519_gitlab
    IdentitiesOnly yes
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Test it:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;ssh -T git@gitlab.com
# Welcome to GitLab, @yourusername!
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h2&gt;First-Time Server Setup&lt;/h2&gt;
&lt;h3&gt;Clone and set ownership&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;mkdir -p /var/www/myproject-staging
git clone git@gitlab.com:yourusername/myproject.git /var/www/myproject-staging/myproject
chown -R deploy:deploy /var/www/myproject-staging/
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If you later get &lt;code&gt;fatal: detected dubious ownership&lt;/code&gt; when running git as root:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;git config --global --add safe.directory /var/www/myproject-staging/myproject
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Environment files&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;.env&lt;/code&gt; files are never committed to git. Create them manually on the server:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# Backend — owned by root (systemd reads it before dropping to deploy user)
nano /var/www/myproject-staging/myproject/backend/.env.staging
chmod 600 /var/www/myproject-staging/myproject/backend/.env.staging
chown root:root /var/www/myproject-staging/myproject/backend/.env.staging

# Frontend — owned by deploy (npm build runs as deploy)
nano /var/www/myproject-staging/myproject/frontend/.env.staging
chmod 600 /var/www/myproject-staging/myproject/frontend/.env.staging
chown deploy:deploy /var/www/myproject-staging/myproject/frontend/.env.staging
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Content directory ownership&lt;/h3&gt;
&lt;p&gt;If your app writes files to disk (for example, blog posts as markdown files), that directory must be owned by &lt;code&gt;deploy&lt;/code&gt; — the user the service runs as:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;chown -R deploy:deploy /var/www/myproject-staging/myproject/frontend/src/content/
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Missing this step causes confusing 500 errors when the API tries to create files. The error in the logs is &lt;code&gt;permission denied&lt;/code&gt; on &lt;code&gt;mkdir&lt;/code&gt;, not something that obviously points to ownership.&lt;/p&gt;
&lt;p&gt;This same issue appears whenever you touch files as root. If you SSH in as root and edit a file, copy a file, or use any tool that runs as root (an AI coding assistant, a script, anything) — the resulting file is &lt;code&gt;root:root&lt;/code&gt;. The &lt;code&gt;deploy&lt;/code&gt; service silently fails to write it. The fix is always &lt;code&gt;chown deploy:deploy &amp;#x3C;file&gt;&lt;/code&gt;.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;Building the App&lt;/h2&gt;
&lt;h3&gt;Go backend&lt;/h3&gt;
&lt;p&gt;Build directly on the VPS. This avoids cross-compilation issues if your VPS is Linux x86_64 and your dev machine is Apple Silicon:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;cd /var/www/myproject-staging/myproject/backend

# Stop the service first — you can&apos;t overwrite a running binary (&quot;text file busy&quot;)
systemctl stop myproject-backend-staging

/usr/local/go/bin/go build -o myproject-backend ./cmd/server/main.go
chown deploy:deploy myproject-backend

systemctl start myproject-backend-staging
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Node.js / Astro frontend&lt;/h3&gt;
&lt;p&gt;Always run npm as the &lt;code&gt;deploy&lt;/code&gt; user, not root. Running as root creates files owned by root inside &lt;code&gt;node_modules/&lt;/code&gt; and &lt;code&gt;dist/&lt;/code&gt;, which the service (running as &lt;code&gt;deploy&lt;/code&gt;) can&apos;t later write or overwrite:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;cd /var/www/myproject-staging/myproject/frontend
sudo -u deploy npm install
sudo -u deploy npm run build
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h2&gt;systemd Services&lt;/h2&gt;
&lt;p&gt;systemd is your process manager. Two service files, one per process.&lt;/p&gt;
&lt;h3&gt;Backend service&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;/etc/systemd/system/myproject-backend-staging.service&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ini&quot;&gt;[Unit]
Description=Myproject Go Backend (Staging)
After=network.target

[Service]
Type=simple
User=deploy
WorkingDirectory=/var/www/myproject-staging/myproject/backend
EnvironmentFile=/var/www/myproject-staging/myproject/backend/.env.staging
ExecStart=/var/www/myproject-staging/myproject/backend/myproject-backend
Restart=always
RestartSec=3

# Hardening
NoNewPrivileges=yes
PrivateTmp=yes
ProtectHome=yes
ProtectSystem=strict
ReadWritePaths=/var/www/myproject-staging/myproject

[Install]
WantedBy=multi-user.target
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Frontend service (SSR only)&lt;/h3&gt;
&lt;p&gt;Only needed if your frontend uses server-side rendering. For a static site, nginx serves the &lt;code&gt;dist/&lt;/code&gt; folder directly — no service needed at all.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;/etc/systemd/system/myproject-astro-staging.service&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ini&quot;&gt;[Unit]
Description=Myproject Astro SSR (Staging)
After=network.target

[Service]
Type=simple
User=deploy
WorkingDirectory=/var/www/myproject-staging/myproject/frontend
ExecStart=/usr/bin/node dist/server/entry.mjs
Restart=always
RestartSec=3
Environment=HOST=127.0.0.1
Environment=PORT=3000
Environment=NODE_ENV=production

# Hardening
NoNewPrivileges=yes
PrivateTmp=yes
ProtectHome=yes
ProtectSystem=strict
ReadWritePaths=/var/www/myproject-staging/myproject/frontend

[Install]
WantedBy=multi-user.target
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Enable and start:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;systemctl daemon-reload
systemctl enable myproject-backend-staging myproject-astro-staging
systemctl start  myproject-backend-staging myproject-astro-staging

# Verify
systemctl status myproject-backend-staging
journalctl -u myproject-backend-staging -f
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h2&gt;nginx Reverse Proxy&lt;/h2&gt;
&lt;p&gt;nginx listens on port 80/443 and proxies traffic to your internal app ports. The apps bind to &lt;code&gt;127.0.0.1&lt;/code&gt; — they&apos;re never directly reachable from the internet.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-nginx&quot;&gt;server {
    listen 80;
    server_name staging.myproject.com;

    location / {
        proxy_pass http://127.0.0.1:3000;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection &apos;upgrade&apos;;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_cache_bypass $http_upgrade;
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Repeat for your API subdomain, pointing to the backend&apos;s port.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;nginx -t &amp;#x26;&amp;#x26; systemctl reload nginx
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h2&gt;Firewall: Lock Down App Ports&lt;/h2&gt;
&lt;p&gt;Your app ports should only be reachable from localhost (via nginx). Block everything else:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# IPv4 — allow localhost, drop everything else
iptables -I INPUT -p tcp --dport 3000 ! -s 127.0.0.1 -j DROP
iptables -I INPUT -p tcp --dport 8080 ! -s 127.0.0.1 -j DROP

# IPv6 — blanket drop
ip6tables -I INPUT -p tcp --dport 3000 -j DROP
ip6tables -I INPUT -p tcp --dport 8080 -j DROP

# Persist across reboots
netfilter-persistent save
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;One subtlety: use &lt;code&gt;! -s 127.0.0.1 -j DROP&lt;/code&gt; rather than a plain &lt;code&gt;-j DROP&lt;/code&gt;. The localhost exemption means SSH tunnels (&lt;code&gt;ssh -L 8888:127.0.0.1:8888&lt;/code&gt;) still work for local debugging. A blanket DROP silently breaks them.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;The Daily Deploy Loop&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# 1. Push from your local machine
git add . &amp;#x26;&amp;#x26; git commit -m &quot;fix: something&quot; &amp;#x26;&amp;#x26; git push

# 2. On the VPS — pull latest
cd /var/www/myproject-staging/myproject &amp;#x26;&amp;#x26; git pull

# 3a. If backend changed
systemctl stop myproject-backend-staging
/usr/local/go/bin/go build -o myproject-backend ./cmd/server/main.go
chown deploy:deploy myproject-backend
systemctl start myproject-backend-staging

# 3b. If frontend changed
cd frontend &amp;#x26;&amp;#x26; sudo -u deploy npm run build
systemctl restart myproject-astro-staging

# 4. Open browser → check staging → done
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If only the frontend changed, you don&apos;t touch the backend. If only the backend changed, you don&apos;t rebuild the frontend. Do both if both changed.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;Staging → Production: The Manual Promotion Gate&lt;/h2&gt;
&lt;p&gt;Once staging looks good, promote to production:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# Pull the same code into the production directory
cd /var/www/myproject-production/myproject &amp;#x26;&amp;#x26; git pull

# Rebuild frontend
cd frontend &amp;#x26;&amp;#x26; sudo -u deploy npm run build
systemctl restart myproject-astro-production

# Rebuild backend if it changed
cd ../backend
systemctl stop myproject-backend-production
/usr/local/go/bin/go build -o myproject-backend ./cmd/server/main.go
chown deploy:deploy myproject-backend
systemctl start myproject-backend-production
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This is the manual promotion gate — you explicitly decide when production gets updated. For a solo project, this is a feature, not a limitation. You&apos;re never wondering why production is broken because something got auto-deployed while you were away.&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;Common Gotchas&lt;/h2&gt;
&lt;p&gt;| Problem | Cause | Fix |
|---|---|---|
| &lt;code&gt;permission denied&lt;/code&gt; when app writes files | Service runs as &lt;code&gt;deploy&lt;/code&gt;, directory owned by &lt;code&gt;root&lt;/code&gt; | &lt;code&gt;chown -R deploy:deploy &amp;#x3C;dir&gt;&lt;/code&gt; |
| API returns 500 after you manually created/edited a file as root | Root-owned files are unwritable by the &lt;code&gt;deploy&lt;/code&gt; service | &lt;code&gt;chown deploy:deploy &amp;#x3C;file&gt;&lt;/code&gt; |
| &lt;code&gt;text file busy&lt;/code&gt; on Go rebuild | Can&apos;t overwrite a running binary | &lt;code&gt;systemctl stop&lt;/code&gt; first, then build |
| &lt;code&gt;npm run build&lt;/code&gt; fails with EACCES | Ran npm as root | Always &lt;code&gt;sudo -u deploy npm ...&lt;/code&gt; |
| &lt;code&gt;detected dubious ownership&lt;/code&gt; | git clone ran as root | &lt;code&gt;git config --global --add safe.directory &amp;#x3C;path&gt;&lt;/code&gt; |
| nginx 502 | App not listening on expected port | Check &lt;code&gt;journalctl -u &amp;#x3C;service&gt;&lt;/code&gt;, verify PORT env var |
| Changes don&apos;t appear after deploy | Forgot to rebuild or restart | Rebuild frontend, restart service |
| SSH tunnel broken after adding DROP rule | Blanket &lt;code&gt;-j DROP&lt;/code&gt; blocks localhost | Use &lt;code&gt;! -s 127.0.0.1 -j DROP&lt;/code&gt; |&lt;/p&gt;
&lt;hr&gt;
&lt;h2&gt;Is This Right for You?&lt;/h2&gt;
&lt;p&gt;This workflow makes sense if:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;You&apos;re running a personal project or small site on a VPS you already pay for&lt;/li&gt;
&lt;li&gt;You want to understand deployment fundamentals rather than abstract them away&lt;/li&gt;
&lt;li&gt;You have one or two contributors — the manual step doesn&apos;t scale to a team&lt;/li&gt;
&lt;li&gt;You don&apos;t want to burn CI/CD compute minutes on simple deploys&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;It probably doesn&apos;t make sense if:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Multiple people are merging code and you need automated testing on every commit&lt;/li&gt;
&lt;li&gt;You need zero-downtime blue/green deploys (use a proper pipeline)&lt;/li&gt;
&lt;li&gt;You&apos;re managing many services and want a unified dashboard (Coolify is genuinely great for that)&lt;/li&gt;
&lt;li&gt;You need horizontal scaling across multiple VPS nodes&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;For this site, the boring approach is working fine. Two systemd services, one nginx config, and a &lt;code&gt;git pull&lt;/code&gt; to deploy. No containers, no orchestration, no surprise bills.&lt;/p&gt;</content:encoded><h:img src="/_astro/hero.C4SheoqF.webp"/><enclosure url="/_astro/hero.C4SheoqF.webp"/></item><item><title>AI CLI Panic Wasn&apos;t Spying. It Was Permissions</title><link>https://srmdn.com/blog/ai-cli-permissions</link><guid isPermaLink="true">https://srmdn.com/blog/ai-cli-permissions</guid><description>Real AI CLI risk isn&apos;t omniscience, it&apos;s access. The true story and practical safeguards to stay safe.</description><pubDate>Sun, 08 Feb 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h2&gt;The Myth vs. The Real Risk edited&lt;/h2&gt;
&lt;p&gt;There’s a story that keeps circulating in developer circles: “AI CLIs can see your entire machine.”&lt;/p&gt;
&lt;p&gt;It’s the kind of claim that sticks because it feels plausible, after all, these tools can run commands, read files, and automate workflows.&lt;/p&gt;
&lt;p&gt;The image people form is a black box roaming their filesystem, peeking into secrets, and reporting back.&lt;/p&gt;
&lt;p&gt;But the truth is more grounded, and more useful. The real problem wasn’t secret surveillance. It was permissions.&lt;/p&gt;
&lt;p&gt;The early panic around AI CLIs mostly came from people giving tools too much access, sometimes without realizing it, and then accidentally exposing sensitive data during normal usage.&lt;/p&gt;
&lt;p&gt;This article explains the actual story behind the fear, then gives a clean, practical, battle‑tested playbook for using AI CLIs safely.&lt;/p&gt;
&lt;p&gt;You’ll also get a checklist you can apply to any AI agent, regardless of vendor or tool.&lt;/p&gt;
&lt;h2&gt;The Origin Story: What Actually Happened&lt;/h2&gt;
&lt;h3&gt;Phase 1: Hype and experimentation&lt;/h3&gt;
&lt;p&gt;When AI CLIs emerged, people rushed to test them. They ran them in their home directories, fed them logs, or asked them to “scan the repo.” The novelty was intoxicating: “Watch this tool refactor my codebase in seconds!”&lt;/p&gt;
&lt;h3&gt;Phase 2: Accidental oversharing&lt;/h3&gt;
&lt;p&gt;Soon after, a few stories appeared: someone pasted tokens into a chat, another ran a command that dumped a .env file, and a third granted the AI direct access to a directory containing SSH keys.&lt;/p&gt;
&lt;p&gt;None of this required malicious behavior. It was just normal developer habits combined with powerful new tools. But the outcome was real: secrets ended up in logs or prompts.&lt;/p&gt;
&lt;h3&gt;Phase 3: The myth spreads&lt;/h3&gt;
&lt;p&gt;Those incidents quickly morphed into a simplified narrative: “AI CLIs can see your whole machine.” It’s emotionally compelling, but inaccurate. The AI doesn’t magically scan your system. It only sees what you explicitly share or what it is given permission to read or execute.&lt;/p&gt;
&lt;h3&gt;The real takeaway&lt;/h3&gt;
&lt;p&gt;The risk isn’t the AI. The risk is access, and how easy it is to accidentally widen access without noticing. That’s why the most important theme in safe AI CLI usage is AI CLI permissions: what the tool can read, execute, and exfiltrate.&lt;/p&gt;
&lt;h2&gt;What an AI CLI Actually Sees&lt;/h2&gt;
&lt;p&gt;An AI CLI is just an interface to:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;What you ask it to read (files, output, logs)&lt;/li&gt;
&lt;li&gt;What you ask it to run (commands, scripts, tests)&lt;/li&gt;
&lt;li&gt;What you show it (copied text, pasted configs)&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;It isn’t omniscient. It doesn’t crawl your machine unless you allow it to. However, it can easily access more than you intended if you run it in the wrong directory or feed it with the wrong command output.&lt;/p&gt;
&lt;h2&gt;The Core Concept: AI CLI Permissions&lt;/h2&gt;
&lt;p&gt;Think of an AI CLI like a new teammate who wants to help. By default, it should only see what you decide to show them. The more rights you give it, the more damage it could do, usually accidentally.&lt;/p&gt;
&lt;p&gt;This is why AI CLI permissions are the right mental model. It’s not about whether the AI is “trusted” or “safe.” It’s about what it can access, and whether that access is proportionate to the task.&lt;/p&gt;
&lt;h2&gt;The Practical Safeguards (The Real Best Practices)&lt;/h2&gt;
&lt;p&gt;Below are the safeguards that experienced teams now use. These are pragmatic, not theoretical. If you apply these, you can use AI CLIs with confidence.&lt;/p&gt;
&lt;h3&gt;1. Use the Principle of Least Access&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;Rule: Never run AI CLIs at a directory scope that is larger than necessary.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Why it works: This prevents accidental reads of unrelated files.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Good:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;- ~/projects/my-app/
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Bad:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;- ~/
- /Users/yourname/
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This single habit eliminates most accidental exposures.&lt;/p&gt;
&lt;h3&gt;2. Use a Dedicated Workspace for AI Tasks&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;Rule: Keep “AI‑assisted work” in a repo‑specific folder.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Why it works: If an AI agent scans or modifies files, it only touches what it should.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;If your machine contains secrets or personal data, this separation reduces risk drastically.&lt;/p&gt;
&lt;h3&gt;3. Don’t Paste Secrets (Ever)&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;Rule: Never paste API keys, tokens, or private keys into any AI prompt.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Why it works: Even if the tool is trustworthy, you reduce the chance of accidental logging or exposure.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Use placeholders like:&lt;/p&gt;
&lt;p&gt;&lt;code&gt;OPENAI_API_KEY=REDACTED&lt;/code&gt;&lt;/p&gt;
&lt;h3&gt;4. Avoid Reading .env by Default&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;Rule: Keep .env files out of AI prompts unless absolutely necessary.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Why it works: These files typically contain the very secrets that should never leave your machine.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;If a task requires environment variables, paste only the variable names (not values).&lt;/p&gt;
&lt;h3&gt;5. Use Scoped Tokens&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;Rule: Use least‑privilege tokens.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Why it works: If a token leaks, its damage is limited.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Example: A token limited to read‑only GitHub repos is safer than a token that can write, delete, or create.&lt;/p&gt;
&lt;h3&gt;6. Treat “Command Output” as Sensitive&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;Rule: Always skim output before pasting it into AI.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Why it works: Logs often contain secrets, file paths, or debug traces.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Even harmless commands like env or printenv can leak credentials.&lt;/p&gt;
&lt;h3&gt;7. Separate “Automation” from “Reasoning”&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;Rule: Use AI for planning and code review, but keep it away from secret‑bearing automation.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Why it works: It reduces the risk of exposing credentials while still benefiting from AI assistance.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;8. Use a VM or Isolated Dev Environment (Optional but Powerful)&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;Rule: If you handle sensitive data, use a dedicated VM or container for AI‑assisted work.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Why it works: Even if a command is run, the blast radius is limited.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This is why some teams use isolated dev machines or VPN‑protected environments. It’s not because the AI “sees everything,” but because they want extra boundaries.&lt;/p&gt;
&lt;h3&gt;9. Rotate Credentials After Mistakes&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;Rule: If you ever accidentally paste a token, rotate it immediately.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Why it works: Reduces the time window of exposure.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This is the safest habit you can build, even if it’s a little inconvenient.&lt;/p&gt;
&lt;h3&gt;10. Ask for Command Explanations&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;Rule: If the AI suggests a command, ask it what it does before running.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Why it works: AI is helpful, but it can make mistakes. You should understand the command’s impact.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;The Real Risk Model (Simplified)&lt;/h2&gt;
&lt;p&gt;When people panic about AI CLIs, they’re usually imagining a malicious tool. In reality, the risk is almost always accidental:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;You run the tool in the wrong directory&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;You paste a config file without realizing it contains secrets&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;You run a command that dumps too much context&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;That’s why AI CLI permissions are the single most important concept. It’s not about whether the AI is safe. It’s about whether you gave it too much access.&lt;/p&gt;
&lt;h2&gt;A Simple Checklist You Can Keep&lt;/h2&gt;
&lt;p&gt;If you only remember one thing, remember this list:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Work in a dedicated repo folder&lt;/li&gt;
&lt;li&gt;Don’t paste secrets&lt;/li&gt;
&lt;li&gt;Avoid .env files&lt;/li&gt;
&lt;li&gt;Use scoped tokens&lt;/li&gt;
&lt;li&gt;Review output before sharing&lt;/li&gt;
&lt;li&gt;Rotate keys if you slip&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;That’s the 80/20. Everything else is optional.&lt;/p&gt;
&lt;h2&gt;Why This Works: The Principle of Bounded Access&lt;/h2&gt;
&lt;p&gt;Most issues disappear if you bound the AI’s access. That’s the real solution. The tool doesn’t need full access to be useful. It only needs the files relevant to your task.&lt;/p&gt;
&lt;p&gt;This is the same principle used in security engineering: the fewer privileges a system has, the fewer ways it can fail.&lt;/p&gt;
&lt;h2&gt;The Myth Finally Dies&lt;/h2&gt;
&lt;p&gt;The “AI sees everything” myth is a shortcut explanation. It feels true because the tools are powerful. But it’s not the right mental model.&lt;/p&gt;
&lt;p&gt;The correct model is:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;AI is a tool&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Tools need permissions&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Permissions should be minimal&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Once you internalize that, you can enjoy the productivity benefits of AI CLIs without the fear.&lt;/p&gt;
&lt;h2&gt;Final Takeaway&lt;/h2&gt;
&lt;p&gt;The story behind AI CLI fear isn’t about spying. It’s about misunderstanding access. When you use these tools with intention: proper scope, no secrets, least privilege, they are safe, powerful, and genuinely worth it.&lt;/p&gt;
&lt;p&gt;If you treat AI CLIs like a superuser, they’ll behave like one. If you treat them like a scoped assistant, they’ll be safe and useful.&lt;/p&gt;
&lt;p&gt;That’s the real lesson. That’s the end of the story.&lt;/p&gt;</content:encoded><h:img src="/_astro/hero.Blsr6mma.webp"/><enclosure url="/_astro/hero.Blsr6mma.webp"/></item></channel></rss>