<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Paul Olguin</title>
    <description>The latest articles on DEV Community by Paul Olguin (@vegan-morpheus).</description>
    <link>https://dev.to/vegan-morpheus</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4010133%2F91c2e4b8-2f1e-4150-abce-7b072c2be388.jpg</url>
      <title>DEV Community: Paul Olguin</title>
      <link>https://dev.to/vegan-morpheus</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/vegan-morpheus"/>
    <language>en</language>
    <item>
      <title>How I Built and Secured a Self-Hosted Stack</title>
      <dc:creator>Paul Olguin</dc:creator>
      <pubDate>Tue, 30 Jun 2026 23:15:54 +0000</pubDate>
      <link>https://dev.to/vegan-morpheus/how-i-built-and-secured-a-self-hosted-stack-32bm</link>
      <guid>https://dev.to/vegan-morpheus/how-i-built-and-secured-a-self-hosted-stack-32bm</guid>
      <description>&lt;p&gt;I built and operate a 13-service self-hosted platform on a single Linux VPS: a personal AI chat interface, budgeting, RSS, notes, bookmarks, uptime monitoring, a dashboard, dev utilities — and a self-hosted autonomous AI agent. Everything sits behind one reverse proxy with automatic HTTPS, most of it behind single sign-on, and the whole thing is captured as Docker Compose config that survives reboots and rebuilds.&lt;/p&gt;

&lt;p&gt;Up front, honestly: this is a personal, self-directed project, and I'd put my level at junior / early-career. I designed and run it, but it isn't an audited, production-grade environment. The value I'd point a reviewer to isn't enterprise completeness — it's the &lt;em&gt;reasoning&lt;/em&gt;. So I'm going to lead with the part I cared most about: containing the AI agent.&lt;/p&gt;

&lt;h2&gt;
  
  
  The decision I'm proudest of (and the mistake behind it)
&lt;/h2&gt;

&lt;p&gt;The interesting security question in this stack is: how do you contain a thing that's actively trying to get around your controls?&lt;/p&gt;

&lt;p&gt;The agent — Hermes, by Nous Research — has persistent memory and tool use: it can execute code, browse, and run web searches. It legitimately needs exactly two things from the rest of the stack: the chat front-end (to talk to) and the private metasearch service (to search). It does &lt;strong&gt;not&lt;/strong&gt; need the database, the notes app, the budget data, or the host.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The mistake I caught.&lt;/strong&gt; In the first iteration, the agent was sitting on the shared application network alongside everything else — which meant it had a network path to the database &lt;em&gt;port&lt;/em&gt;. I didn't intend that; it was just the default outcome of dropping it on the same network as the apps.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What I did instead of panicking.&lt;/strong&gt; I stopped and reasoned about the actual blast radius. The database &lt;em&gt;password&lt;/em&gt; was never readable by the agent — it lives in the database/Compose environment, not on the agent's filesystem or in anything its tools could read. What &lt;em&gt;was&lt;/em&gt; exposed was an open port: a reachable network path to a data store from a code-executing agent.&lt;/p&gt;

&lt;p&gt;So the real exposure was narrower than "the agent can read my database." But that distinction doesn't earn the path a pass. Least privilege says an autonomous agent shouldn't have a route to a data store it has no reason to touch — full stop — regardless of whether I currently believe the credentials are safe.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The re-architecture.&lt;/strong&gt; I moved the agent onto a dedicated, isolated Docker network (&lt;code&gt;hermes-net&lt;/code&gt;):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The chat front-end and metasearch service join &lt;strong&gt;both&lt;/strong&gt; networks, so they bridge between the app stack and the agent's network.&lt;/li&gt;
&lt;li&gt;The agent sits on its isolated network &lt;strong&gt;only&lt;/strong&gt; — it can reach exactly those two services and nothing else.&lt;/li&gt;
&lt;li&gt;The posture is &lt;strong&gt;default-closed&lt;/strong&gt;: granting the agent a new service is a deliberate, one-at-a-time action (&lt;code&gt;docker network connect hermes-net &amp;lt;service&amp;gt;&lt;/code&gt;), and the database is intentionally never on that list.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why a network boundary beats a "soft" gate
&lt;/h2&gt;

&lt;p&gt;Here's the principle the whole design rests on:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;An autonomous agent with tool use will try multiple routes around a soft "are you sure?" block. The boundary that actually holds is the one it can't reason its way past.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The agent ships with an in-app approve/deny prompt — but that gate is part of its &lt;em&gt;native&lt;/em&gt; UI, and it isn't even reachable when the agent is driven through its API, which is how the chat interface talks to it. So the soft gate is doubly weak: it can be routed around, and on the path I actually use it isn't in the loop at all.&lt;/p&gt;

&lt;p&gt;A network boundary has neither weakness. If there's no route, there's nothing to negotiate and no alternate path to find. That's the lesson I'd most want a reviewer to take from this: I didn't just flip a safety toggle and trust it — I reasoned about whether it could be bypassed, decided it could, and moved the boundary somewhere it couldn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  Defense in depth around the agent
&lt;/h2&gt;

&lt;p&gt;Network isolation is the main wall; these reduce what a problem could do even inside it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Filesystem isolation&lt;/strong&gt; — the agent's terminal runs inside its own container, scoped to its own data directory. It can't see the host or other containers' files.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;In-container terminal, not the Docker socket&lt;/strong&gt; — wiring it to a Docker backend would mean handing it host control. Deliberately not done.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Internal-only API&lt;/strong&gt; — its OpenAI-compatible endpoint is on an unpublished port with no reverse-proxy route, reachable only by containers on its own network, and it still requires an API key.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Outbound-only, allow-listed remote access&lt;/strong&gt; — phone access is a Telegram bot using outbound polling (no inbound ports), locked to a single allow-listed user ID. The "allow any user" flag is never set.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Image pinned by digest&lt;/strong&gt; — updates are deliberate, reviewed pulls, not whatever &lt;code&gt;:latest&lt;/code&gt; happens to be that day.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A hard cost ceiling&lt;/strong&gt; — a credit cap on the model provider bounds runaway token spend if a scheduled job loops.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Host hardening (the foundation under everything)
&lt;/h2&gt;

&lt;p&gt;A few decisions worth calling out, each here for a reason rather than because a guide said so:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;SSH: key-only, with a tested escape hatch.&lt;/strong&gt; &lt;code&gt;ed25519&lt;/code&gt; keys only; password auth and root-password login disabled. The part that mattered more than the config: I set up and &lt;em&gt;tested&lt;/em&gt; the provider's out-of-band recovery console &lt;strong&gt;before&lt;/strong&gt; disabling password login. Test the escape hatch first, then flip the switch.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;ufw: default-deny, and verifying what's actually open.&lt;/strong&gt; The firewall is default-deny with explicit allows — rate-limited SSH and 80/443. The lesson came when I found Docker's API ports (2375/2376) showing as open — stray rules, nothing listening — and removed them. Docker manipulates the host firewall directly and can punch holes you didn't author, so I verified what was &lt;em&gt;actually&lt;/em&gt; exposed rather than trusting my config described reality.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;fail2ban.&lt;/strong&gt; Honestly, with passwords already disabled this is more about cutting log noise than a hard security gain — but it's cheap, correct, and the right default.&lt;/p&gt;

&lt;h2&gt;
  
  
  One reverse proxy, one login, one deliberate exception
&lt;/h2&gt;

&lt;p&gt;The internet only ever talks to &lt;strong&gt;Caddy&lt;/strong&gt;, on ports 80/443. Caddy terminates TLS (certs auto-issued via Let's Encrypt) and reverse-proxies each subdomain to the right internal container. Most services sit behind &lt;strong&gt;Tinyauth&lt;/strong&gt; single sign-on, implemented as reverse-proxy forward-auth — one login sets a cookie scoped to the parent domain that covers every gated subdomain:&lt;/p&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
caddyfile
# reusable snippet, imported into each gated app's block
(tinyauth) {
    forward_auth tinyauth:3000 {
        uri /api/auth/caddy
    }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

</description>
      <category>selfhosted</category>
      <category>docker</category>
      <category>security</category>
      <category>devops</category>
    </item>
  </channel>
</rss>
