<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>infrastructure · wleeaf field notes</title><link>https://articles.wleeaf.dev/tags/infrastructure/</link><description>Hands-on guides and build logs from the wleeaf studio: self-hosting, Linux, networking, Docker, the command line and reverse engineering.</description><language>en-us</language><lastBuildDate>Wed, 01 Apr 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://articles.wleeaf.dev/tags/infrastructure/feed.xml" rel="self" type="application/rss+xml"/><item><title>Writing systemd Services That Actually Work</title><link>https://articles.wleeaf.dev/posts/systemd-services-from-scratch/</link><pubDate>Wed, 01 Apr 2026 00:00:00 +0000</pubDate><guid>https://articles.wleeaf.dev/posts/systemd-services-from-scratch/</guid><description>Unit files, restart policies, environment variables, and journalctl. A practical guide to running your own daemons.</description><category>linux</category><category>systemd</category><category>infrastructure</category><content:encoded><![CDATA[<p>At some point you write a script or a server that needs to run forever. You could use screen, tmux, nohup, or a cron <code>@reboot</code> hack. Or you could write a systemd service and get automatic restarts, logging, resource limits, and dependency management for free.</p>
<h2 id="the-minimal-service">The Minimal Service<a class="anchor" href="#the-minimal-service" aria-label="Link to “The Minimal Service”">#</a></h2>
<p>Create <code>/etc/systemd/system/myapp.service</code>:</p>
<div class="code">
<div class="code-head"><span class="code-lang">ini</span><button type="button" class="code-copy" data-copy-code hidden><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2.5"/><path d="M16 8V6.5A2.5 2.5 0 0 0 13.5 4h-7A2.5 2.5 0 0 0 4 6.5v7A2.5 2.5 0 0 0 6.5 16H8"/></svg><span>Copy</span></button></div>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ini" data-lang="ini"><span class="line"><span class="cl"><span class="k">[Unit]</span>
</span></span><span class="line"><span class="cl"><span class="na">Description</span><span class="o">=</span><span class="s">My Application</span>
</span></span><span class="line"><span class="cl"><span class="na">After</span><span class="o">=</span><span class="s">network.target</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">[Service]</span>
</span></span><span class="line"><span class="cl"><span class="na">Type</span><span class="o">=</span><span class="s">simple</span>
</span></span><span class="line"><span class="cl"><span class="na">ExecStart</span><span class="o">=</span><span class="s">/usr/bin/node /opt/myapp/server.js</span>
</span></span><span class="line"><span class="cl"><span class="na">Restart</span><span class="o">=</span><span class="s">unless-stopped</span>
</span></span><span class="line"><span class="cl"><span class="na">RestartSec</span><span class="o">=</span><span class="s">5</span>
</span></span><span class="line"><span class="cl"><span class="na">WorkingDirectory</span><span class="o">=</span><span class="s">/opt/myapp</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">[Install]</span>
</span></span><span class="line"><span class="cl"><span class="na">WantedBy</span><span class="o">=</span><span class="s">multi-user.target</span></span></span></code></pre></div>
</div>
<p>Then:</p>
<div class="code">
<div class="code-head"><span class="code-lang">bash</span><button type="button" class="code-copy" data-copy-code hidden><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2.5"/><path d="M16 8V6.5A2.5 2.5 0 0 0 13.5 4h-7A2.5 2.5 0 0 0 4 6.5v7A2.5 2.5 0 0 0 6.5 16H8"/></svg><span>Copy</span></button></div>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">sudo systemctl daemon-reload
</span></span><span class="line"><span class="cl">sudo systemctl <span class="nb">enable</span> myapp
</span></span><span class="line"><span class="cl">sudo systemctl start myapp</span></span></code></pre></div>
</div>
<p>That is it. The app starts on boot, restarts on crash, and logs to journald.</p>
<h2 id="service-types">Service Types<a class="anchor" href="#service-types" aria-label="Link to “Service Types”">#</a></h2>
<p><code>Type=simple</code> is correct for most things. The process you start is the service. But there are others:</p>
<div class="code">
<div class="code-head"><span class="code-lang">ini</span><button type="button" class="code-copy" data-copy-code hidden><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2.5"/><path d="M16 8V6.5A2.5 2.5 0 0 0 13.5 4h-7A2.5 2.5 0 0 0 4 6.5v7A2.5 2.5 0 0 0 6.5 16H8"/></svg><span>Copy</span></button></div>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ini" data-lang="ini"><span class="line"><span class="cl"><span class="c1"># App forks into background (like old-school daemons)</span>
</span></span><span class="line"><span class="cl"><span class="na">Type</span><span class="o">=</span><span class="s">forking</span>
</span></span><span class="line"><span class="cl"><span class="na">PIDFile</span><span class="o">=</span><span class="s">/var/run/myapp.pid</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># App signals readiness via sd_notify</span>
</span></span><span class="line"><span class="cl"><span class="na">Type</span><span class="o">=</span><span class="s">notify</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># App is a one-shot script, not a long-running process</span>
</span></span><span class="line"><span class="cl"><span class="na">Type</span><span class="o">=</span><span class="s">oneshot</span>
</span></span><span class="line"><span class="cl"><span class="na">RemainAfterExit</span><span class="o">=</span><span class="s">yes</span></span></span></code></pre></div>
</div>
<p>Use <code>simple</code> unless you have a specific reason not to. <code>oneshot</code> is useful for startup scripts that need to run once.</p>
<h2 id="environment-variables">Environment Variables<a class="anchor" href="#environment-variables" aria-label="Link to “Environment Variables”">#</a></h2>
<p>Three ways to pass them:</p>
<div class="code">
<div class="code-head"><span class="code-lang">ini</span><button type="button" class="code-copy" data-copy-code hidden><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2.5"/><path d="M16 8V6.5A2.5 2.5 0 0 0 13.5 4h-7A2.5 2.5 0 0 0 4 6.5v7A2.5 2.5 0 0 0 6.5 16H8"/></svg><span>Copy</span></button></div>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ini" data-lang="ini"><span class="line"><span class="cl"><span class="c1"># Inline (good for a few vars)</span>
</span></span><span class="line"><span class="cl"><span class="k">[Service]</span>
</span></span><span class="line"><span class="cl"><span class="na">Environment</span><span class="o">=</span><span class="s">PORT=3000</span>
</span></span><span class="line"><span class="cl"><span class="na">Environment</span><span class="o">=</span><span class="s">NODE_ENV=production</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># From a file (good for secrets)</span>
</span></span><span class="line"><span class="cl"><span class="k">[Service]</span>
</span></span><span class="line"><span class="cl"><span class="na">EnvironmentFile</span><span class="o">=</span><span class="s">/opt/myapp/.env</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Multiple files</span>
</span></span><span class="line"><span class="cl"><span class="k">[Service]</span>
</span></span><span class="line"><span class="cl"><span class="na">EnvironmentFile</span><span class="o">=</span><span class="s">/opt/myapp/.env</span>
</span></span><span class="line"><span class="cl"><span class="na">EnvironmentFile</span><span class="o">=</span><span class="s">/opt/myapp/.env.local</span></span></span></code></pre></div>
</div>
<p>The <code>EnvironmentFile</code> approach keeps secrets out of the unit file. The unit file itself might be in version control or readable by any user. The <code>.env</code> file can be locked down with permissions.</p>
<h2 id="running-as-a-non-root-user">Running As a Non-Root User<a class="anchor" href="#running-as-a-non-root-user" aria-label="Link to “Running As a Non-Root User”">#</a></h2>
<div class="code">
<div class="code-head"><span class="code-lang">ini</span><button type="button" class="code-copy" data-copy-code hidden><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2.5"/><path d="M16 8V6.5A2.5 2.5 0 0 0 13.5 4h-7A2.5 2.5 0 0 0 4 6.5v7A2.5 2.5 0 0 0 6.5 16H8"/></svg><span>Copy</span></button></div>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ini" data-lang="ini"><span class="line"><span class="cl"><span class="k">[Service]</span>
</span></span><span class="line"><span class="cl"><span class="na">User</span><span class="o">=</span><span class="s">myapp</span>
</span></span><span class="line"><span class="cl"><span class="na">Group</span><span class="o">=</span><span class="s">myapp</span>
</span></span><span class="line"><span class="cl"><span class="na">ExecStart</span><span class="o">=</span><span class="s">/usr/bin/node /opt/myapp/server.js</span></span></span></code></pre></div>
</div>
<p>Create the user first:</p>
<div class="code">
<div class="code-head"><span class="code-lang">bash</span><button type="button" class="code-copy" data-copy-code hidden><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2.5"/><path d="M16 8V6.5A2.5 2.5 0 0 0 13.5 4h-7A2.5 2.5 0 0 0 4 6.5v7A2.5 2.5 0 0 0 6.5 16H8"/></svg><span>Copy</span></button></div>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">sudo useradd -r -s /usr/sbin/nologin -d /opt/myapp myapp
</span></span><span class="line"><span class="cl">sudo chown -R myapp:myapp /opt/myapp</span></span></code></pre></div>
</div>
<p>The <code>-r</code> flag creates a system user. <code>-s /usr/sbin/nologin</code> prevents interactive login.</p>
<h2 id="resource-limits">Resource Limits<a class="anchor" href="#resource-limits" aria-label="Link to “Resource Limits”">#</a></h2>
<p>Prevent a runaway process from eating all your RAM or CPU:</p>
<div class="code">
<div class="code-head"><span class="code-lang">ini</span><button type="button" class="code-copy" data-copy-code hidden><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2.5"/><path d="M16 8V6.5A2.5 2.5 0 0 0 13.5 4h-7A2.5 2.5 0 0 0 4 6.5v7A2.5 2.5 0 0 0 6.5 16H8"/></svg><span>Copy</span></button></div>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ini" data-lang="ini"><span class="line"><span class="cl"><span class="k">[Service]</span>
</span></span><span class="line"><span class="cl"><span class="na">MemoryMax</span><span class="o">=</span><span class="s">512M</span>
</span></span><span class="line"><span class="cl"><span class="na">CPUQuota</span><span class="o">=</span><span class="s">50%</span>
</span></span><span class="line"><span class="cl"><span class="na">TasksMax</span><span class="o">=</span><span class="s">100</span>
</span></span><span class="line"><span class="cl"><span class="na">LimitNOFILE</span><span class="o">=</span><span class="s">65535</span></span></span></code></pre></div>
</div>
<p><code>MemoryMax</code> kills the process if it exceeds the limit. <code>CPUQuota</code> throttles but does not kill. <code>TasksMax</code> limits the number of threads and child processes. <code>LimitNOFILE</code> sets the open file descriptor limit.</p>
<h2 id="restart-policies">Restart Policies<a class="anchor" href="#restart-policies" aria-label="Link to “Restart Policies”">#</a></h2>
<div class="code">
<div class="code-head"><span class="code-lang">ini</span><button type="button" class="code-copy" data-copy-code hidden><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2.5"/><path d="M16 8V6.5A2.5 2.5 0 0 0 13.5 4h-7A2.5 2.5 0 0 0 4 6.5v7A2.5 2.5 0 0 0 6.5 16H8"/></svg><span>Copy</span></button></div>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ini" data-lang="ini"><span class="line"><span class="cl"><span class="c1"># Restart on any exit (crash, signal, clean exit)</span>
</span></span><span class="line"><span class="cl"><span class="na">Restart</span><span class="o">=</span><span class="s">always</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Restart on failure only (non-zero exit, signal)</span>
</span></span><span class="line"><span class="cl"><span class="na">Restart</span><span class="o">=</span><span class="s">on-failure</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Restart unless you explicitly stopped it</span>
</span></span><span class="line"><span class="cl"><span class="na">Restart</span><span class="o">=</span><span class="s">unless-stopped</span></span></span></code></pre></div>
</div>
<p>I use <code>unless-stopped</code> for everything. It restarts on crashes but stays down when I manually stop the service for debugging.</p>
<p>Add rate limiting to prevent restart loops:</p>
<div class="code">
<div class="code-head"><span class="code-lang">ini</span><button type="button" class="code-copy" data-copy-code hidden><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2.5"/><path d="M16 8V6.5A2.5 2.5 0 0 0 13.5 4h-7A2.5 2.5 0 0 0 4 6.5v7A2.5 2.5 0 0 0 6.5 16H8"/></svg><span>Copy</span></button></div>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ini" data-lang="ini"><span class="line"><span class="cl"><span class="na">RestartSec</span><span class="o">=</span><span class="s">5</span>
</span></span><span class="line"><span class="cl"><span class="na">StartLimitIntervalSec</span><span class="o">=</span><span class="s">60</span>
</span></span><span class="line"><span class="cl"><span class="na">StartLimitBurst</span><span class="o">=</span><span class="s">5</span></span></span></code></pre></div>
</div>
<p>This allows 5 restarts within 60 seconds. After that, the service enters a failed state and stops trying. Check what happened with <code>journalctl -u myapp</code>.</p>
<h2 id="logging">Logging<a class="anchor" href="#logging" aria-label="Link to “Logging”">#</a></h2>
<p>systemd captures stdout and stderr automatically:</p>
<div class="code">
<div class="code-head"><span class="code-lang">bash</span><button type="button" class="code-copy" data-copy-code hidden><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2.5"/><path d="M16 8V6.5A2.5 2.5 0 0 0 13.5 4h-7A2.5 2.5 0 0 0 4 6.5v7A2.5 2.5 0 0 0 6.5 16H8"/></svg><span>Copy</span></button></div>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="c1"># View logs</span>
</span></span><span class="line"><span class="cl">journalctl -u myapp
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Follow in real time</span>
</span></span><span class="line"><span class="cl">journalctl -u myapp -f
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Last 50 lines</span>
</span></span><span class="line"><span class="cl">journalctl -u myapp -n <span class="m">50</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Since last boot</span>
</span></span><span class="line"><span class="cl">journalctl -u myapp -b
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># JSON output for parsing</span>
</span></span><span class="line"><span class="cl">journalctl -u myapp -o json-pretty</span></span></code></pre></div>
</div>
<p>No log rotation config needed. journald handles it automatically based on disk space.</p>
<h2 id="dependencies-and-ordering">Dependencies and Ordering<a class="anchor" href="#dependencies-and-ordering" aria-label="Link to “Dependencies and Ordering”">#</a></h2>
<div class="code">
<div class="code-head"><span class="code-lang">ini</span><button type="button" class="code-copy" data-copy-code hidden><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2.5"/><path d="M16 8V6.5A2.5 2.5 0 0 0 13.5 4h-7A2.5 2.5 0 0 0 4 6.5v7A2.5 2.5 0 0 0 6.5 16H8"/></svg><span>Copy</span></button></div>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ini" data-lang="ini"><span class="line"><span class="cl"><span class="k">[Unit]</span>
</span></span><span class="line"><span class="cl"><span class="na">Description</span><span class="o">=</span><span class="s">My App</span>
</span></span><span class="line"><span class="cl"><span class="na">After</span><span class="o">=</span><span class="s">network.target postgresql.service</span>
</span></span><span class="line"><span class="cl"><span class="na">Requires</span><span class="o">=</span><span class="s">postgresql.service</span></span></span></code></pre></div>
</div>
<p><code>After</code> controls startup order. <code>Requires</code> means if PostgreSQL stops, myapp stops too. Use <code>Wants</code> instead of <code>Requires</code> for soft dependencies where your app can handle the other service being down.</p>
<h2 id="pre-and-post-commands">Pre and Post Commands<a class="anchor" href="#pre-and-post-commands" aria-label="Link to “Pre and Post Commands”">#</a></h2>
<div class="code">
<div class="code-head"><span class="code-lang">ini</span><button type="button" class="code-copy" data-copy-code hidden><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2.5"/><path d="M16 8V6.5A2.5 2.5 0 0 0 13.5 4h-7A2.5 2.5 0 0 0 4 6.5v7A2.5 2.5 0 0 0 6.5 16H8"/></svg><span>Copy</span></button></div>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ini" data-lang="ini"><span class="line"><span class="cl"><span class="k">[Service]</span>
</span></span><span class="line"><span class="cl"><span class="na">ExecStartPre</span><span class="o">=</span><span class="s">/opt/myapp/migrate.sh</span>
</span></span><span class="line"><span class="cl"><span class="na">ExecStart</span><span class="o">=</span><span class="s">/usr/bin/node /opt/myapp/server.js</span>
</span></span><span class="line"><span class="cl"><span class="na">ExecStartPost</span><span class="o">=</span><span class="s">/usr/bin/curl -s http://localhost:3000/health</span>
</span></span><span class="line"><span class="cl"><span class="na">ExecStopPost</span><span class="o">=</span><span class="s">/opt/myapp/cleanup.sh</span></span></span></code></pre></div>
</div>
<p><code>ExecStartPre</code> runs before the main process. Good for database migrations or config validation. If it fails, the service does not start.</p>
<h2 id="debugging">Debugging<a class="anchor" href="#debugging" aria-label="Link to “Debugging”">#</a></h2>
<div class="code">
<div class="code-head"><span class="code-lang">bash</span><button type="button" class="code-copy" data-copy-code hidden><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2.5"/><path d="M16 8V6.5A2.5 2.5 0 0 0 13.5 4h-7A2.5 2.5 0 0 0 4 6.5v7A2.5 2.5 0 0 0 6.5 16H8"/></svg><span>Copy</span></button></div>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="c1"># Check service status</span>
</span></span><span class="line"><span class="cl">systemctl status myapp
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># View full logs on failure</span>
</span></span><span class="line"><span class="cl">journalctl -u myapp -e --no-pager
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Check the actual unit file being used</span>
</span></span><span class="line"><span class="cl">systemctl cat myapp
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Verify unit file syntax</span>
</span></span><span class="line"><span class="cl">systemd-analyze verify myapp.service</span></span></code></pre></div>
</div>
<p>The <code>systemd-analyze verify</code> command catches syntax errors without starting anything. Use it before reloading.</p>
<p>systemd is verbose and opinionated, but once you get past the initial learning curve, it handles all the edge cases that screen and nohup do not. Crashes, reboots, logging, resource limits, dependencies. Write the unit file once and forget about it.</p>
]]></content:encoded></item><item><title>Caddy Patterns That Replaced My Nginx Config</title><link>https://articles.wleeaf.dev/posts/caddy-reverse-proxy-patterns/</link><pubDate>Sat, 28 Mar 2026 00:00:00 +0000</pubDate><guid>https://articles.wleeaf.dev/posts/caddy-reverse-proxy-patterns/</guid><description>Wildcard certs, host matching, VPN-only routes, and SPA fallbacks in a single Caddyfile.</description><category>caddy</category><category>webserver</category><category>infrastructure</category><content:encoded><![CDATA[<p>I switched from Nginx to Caddy about a year ago and I am never going back. Not because Nginx is bad, but because Caddy eliminates an entire category of problems I used to spend time on.</p>
<h2 id="automatic-tls">Automatic TLS<a class="anchor" href="#automatic-tls" aria-label="Link to “Automatic TLS”">#</a></h2>
<p>This is the obvious one. Caddy gets certificates from Let&rsquo;s Encrypt automatically. But the real power is the DNS challenge with wildcard certs:</p>
<div class="code">
<div class="code-head"><span class="code-lang">caddy</span><button type="button" class="code-copy" data-copy-code hidden><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2.5"/><path d="M16 8V6.5A2.5 2.5 0 0 0 13.5 4h-7A2.5 2.5 0 0 0 4 6.5v7A2.5 2.5 0 0 0 6.5 16H8"/></svg><span>Copy</span></button></div>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-caddy" data-lang="caddy"><span class="line"><span class="cl"><span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="k">acme_dns</span> <span class="s">cloudflare</span> <span class="se">{env.CLOUDFLARE_API_TOKEN}</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="gh">*.example.dev</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="k">tls</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="k">dns</span> <span class="s">cloudflare</span> <span class="se">{env.CLOUDFLARE_API_TOKEN}</span>
</span></span><span class="line"><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="nd">@app</span> <span class="k">host</span> <span class="s">app.example.dev</span>
</span></span><span class="line"><span class="cl">  <span class="k">handle</span> <span class="nd">@app</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="k">reverse_proxy</span> <span class="n">app</span><span class="p">:</span><span class="mi">3000</span>
</span></span><span class="line"><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div>
</div>
<p>One wildcard cert covers every subdomain. No per-service cert management. No Certificate Transparency log leaking your internal subdomain names. And the DNS challenge means you do not even need port 80 open.</p>
<h2 id="host-matching">Host Matching<a class="anchor" href="#host-matching" aria-label="Link to “Host Matching”">#</a></h2>
<p>Caddy lets you route by hostname inside a single server block:</p>
<div class="code">
<div class="code-head"><span class="code-lang">caddy</span><button type="button" class="code-copy" data-copy-code hidden><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2.5"/><path d="M16 8V6.5A2.5 2.5 0 0 0 13.5 4h-7A2.5 2.5 0 0 0 4 6.5v7A2.5 2.5 0 0 0 6.5 16H8"/></svg><span>Copy</span></button></div>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-caddy" data-lang="caddy"><span class="line"><span class="cl"><span class="gh">*.example.dev</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="nd">@git</span> <span class="k">host</span> <span class="s">git.example.dev</span>
</span></span><span class="line"><span class="cl">  <span class="k">handle</span> <span class="nd">@git</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="k">reverse_proxy</span> <span class="n">gitea</span><span class="p">:</span><span class="mi">3000</span>
</span></span><span class="line"><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="nd">@chat</span> <span class="k">host</span> <span class="s">chat.example.dev</span>
</span></span><span class="line"><span class="cl">  <span class="k">handle</span> <span class="nd">@chat</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="k">reverse_proxy</span> <span class="n">element</span><span class="p">:</span><span class="mi">80</span>
</span></span><span class="line"><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="k">handle</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="k">respond</span> <span class="s2">&#34;Not found&#34;</span> <span class="mi">404</span>
</span></span><span class="line"><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div>
</div>
<p>In Nginx, each of these would be a separate <code>server</code> block with its own SSL configuration. In Caddy, it is one block with named matchers.</p>
<h2 id="vpn-only-routes">VPN-Only Routes<a class="anchor" href="#vpn-only-routes" aria-label="Link to “VPN-Only Routes”">#</a></h2>
<p>I restrict admin services to my VPN subnet:</p>
<div class="code">
<div class="code-head"><span class="code-lang">caddy</span><button type="button" class="code-copy" data-copy-code hidden><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2.5"/><path d="M16 8V6.5A2.5 2.5 0 0 0 13.5 4h-7A2.5 2.5 0 0 0 4 6.5v7A2.5 2.5 0 0 0 6.5 16H8"/></svg><span>Copy</span></button></div>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-caddy" data-lang="caddy"><span class="line"><span class="cl"><span class="gh">@admin</span> <span class="gh">host</span> <span class="gh">admin.example.dev</span><span class="p">
</span></span></span><span class="line"><span class="cl"><span class="p"></span><span class="k">handle</span> <span class="nd">@admin</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="nd">@blocked</span> <span class="k">not</span> <span class="k">remote_ip</span> <span class="mi">10</span><span class="s">.0.0.0/24</span>
</span></span><span class="line"><span class="cl">  <span class="k">respond</span> <span class="nd">@blocked</span> <span class="s2">&#34;Access Denied&#34;</span> <span class="mi">403</span>
</span></span><span class="line"><span class="cl">  <span class="k">reverse_proxy</span> <span class="n">admin</span><span class="p">:</span><span class="mi">3001</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div>
</div>
<p>If the request does not come from the VPN, it gets a 403. Combined with DNS that only resolves through the VPN, this is two layers of protection without any auth middleware.</p>
<h2 id="spa-fallback-with-sub-paths">SPA Fallback With Sub-Paths<a class="anchor" href="#spa-fallback-with-sub-paths" aria-label="Link to “SPA Fallback With Sub-Paths”">#</a></h2>
<p>Serving a single-page app under a sub-path is surprisingly tricky. The app lives at <code>/app/</code> but the client-side router handles routes like <code>/app/settings</code>:</p>
<div class="code">
<div class="code-head"><span class="code-lang">caddy</span><button type="button" class="code-copy" data-copy-code hidden><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2.5"/><path d="M16 8V6.5A2.5 2.5 0 0 0 13.5 4h-7A2.5 2.5 0 0 0 4 6.5v7A2.5 2.5 0 0 0 6.5 16H8"/></svg><span>Copy</span></button></div>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-caddy" data-lang="caddy"><span class="line"><span class="cl"><span class="gh">handle_path</span> <span class="gh">/app*</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="k">reverse_proxy</span> <span class="n">spa</span><span class="p">:</span><span class="mi">3000</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nd">@notfound</span> <span class="k">status</span> <span class="mi">404</span>
</span></span><span class="line"><span class="cl">    <span class="k">handle_response</span> <span class="nd">@notfound</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">      <span class="k">rewrite</span> <span class="nd">*</span> <span class="s">/index.html</span>
</span></span><span class="line"><span class="cl">      <span class="k">reverse_proxy</span> <span class="n">spa</span><span class="p">:</span><span class="mi">3000</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div>
</div>
<p><code>handle_path</code> strips the <code>/app</code> prefix before forwarding. If the upstream returns 404, we rewrite to <code>index.html</code> and try again. The SPA router takes over from there.</p>
<h2 id="gzip-and-security-headers">Gzip and Security Headers<a class="anchor" href="#gzip-and-security-headers" aria-label="Link to “Gzip and Security Headers”">#</a></h2>
<div class="code">
<div class="code-head"><span class="code-lang">caddy</span><button type="button" class="code-copy" data-copy-code hidden><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2.5"/><path d="M16 8V6.5A2.5 2.5 0 0 0 13.5 4h-7A2.5 2.5 0 0 0 4 6.5v7A2.5 2.5 0 0 0 6.5 16H8"/></svg><span>Copy</span></button></div>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-caddy" data-lang="caddy"><span class="line"><span class="cl"><span class="gh">*.example.dev</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="k">encode</span> <span class="s">gzip</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="k">header</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="k">Strict-Transport-Security</span> <span class="s2">&#34;max-age=31536000; includeSubDomains&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="k">X-Content-Type-Options</span> <span class="s2">&#34;nosniff&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="k">X-Frame-Options</span> <span class="s2">&#34;DENY&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="k">Referrer-Policy</span> <span class="s2">&#34;strict-origin-when-cross-origin&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div>
</div>
<p>These apply to every subdomain automatically. No copying the same headers into fifteen different location blocks.</p>
<h2 id="static-files-with-custom-404">Static Files With Custom 404<a class="anchor" href="#static-files-with-custom-404" aria-label="Link to “Static Files With Custom 404”">#</a></h2>
<div class="code">
<div class="code-head"><span class="code-lang">caddy</span><button type="button" class="code-copy" data-copy-code hidden><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2.5"/><path d="M16 8V6.5A2.5 2.5 0 0 0 13.5 4h-7A2.5 2.5 0 0 0 4 6.5v7A2.5 2.5 0 0 0 6.5 16H8"/></svg><span>Copy</span></button></div>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-caddy" data-lang="caddy"><span class="line"><span class="cl"><span class="gh">@landing</span> <span class="gh">host</span> <span class="gh">example.dev</span><span class="p">
</span></span></span><span class="line"><span class="cl"><span class="p"></span><span class="k">handle</span> <span class="nd">@landing</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="k">root</span> <span class="nd">*</span> <span class="s">/srv/example.dev</span>
</span></span><span class="line"><span class="cl">  <span class="k">file_server</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">handle_errors</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="nd">@404</span> <span class="k">expression</span> <span class="se">{err.status_code}</span> <span class="s">==</span> <span class="mi">404</span>
</span></span><span class="line"><span class="cl">  <span class="k">handle</span> <span class="nd">@404</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="k">root</span> <span class="nd">*</span> <span class="s">/srv</span>
</span></span><span class="line"><span class="cl">    <span class="k">rewrite</span> <span class="nd">*</span> <span class="s">/404.html</span>
</span></span><span class="line"><span class="cl">    <span class="k">file_server</span>
</span></span><span class="line"><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div>
</div>
<p>The <code>handle_errors</code> block catches 404s across all routes and serves a custom page. One error handler for the entire server.</p>
<h2 id="websocket-proxying">WebSocket Proxying<a class="anchor" href="#websocket-proxying" aria-label="Link to “WebSocket Proxying”">#</a></h2>
<p>Caddy handles WebSocket upgrades automatically:</p>
<div class="code">
<div class="code-head"><span class="code-lang">caddy</span><button type="button" class="code-copy" data-copy-code hidden><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2.5"/><path d="M16 8V6.5A2.5 2.5 0 0 0 13.5 4h-7A2.5 2.5 0 0 0 4 6.5v7A2.5 2.5 0 0 0 6.5 16H8"/></svg><span>Copy</span></button></div>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-caddy" data-lang="caddy"><span class="line"><span class="cl"><span class="gh">reverse_proxy</span> <span class="gh">game:3000</span></span></span></code></pre></div>
</div>
<p>That is it. No <code>proxy_http_version 1.1</code>, no <code>proxy_set_header Upgrade</code>, no <code>proxy_set_header Connection</code>. Caddy detects the upgrade request and does the right thing.</p>
<h2 id="streaming-responses">Streaming Responses<a class="anchor" href="#streaming-responses" aria-label="Link to “Streaming Responses”">#</a></h2>
<p>For Server-Sent Events or long-running responses:</p>
<div class="code">
<div class="code-head"><span class="code-lang">caddy</span><button type="button" class="code-copy" data-copy-code hidden><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2.5"/><path d="M16 8V6.5A2.5 2.5 0 0 0 13.5 4h-7A2.5 2.5 0 0 0 4 6.5v7A2.5 2.5 0 0 0 6.5 16H8"/></svg><span>Copy</span></button></div>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-caddy" data-lang="caddy"><span class="line"><span class="cl"><span class="gh">reverse_proxy</span> <span class="gh">backend:3000</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="k">flush_interval</span> <span class="s">-1</span>
</span></span><span class="line"><span class="cl">  <span class="k">transport</span> <span class="s">http</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="k">read_timeout</span> <span class="mi">0</span>
</span></span><span class="line"><span class="cl">    <span class="k">write_timeout</span> <span class="mi">0</span>
</span></span><span class="line"><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div>
</div>
<p><code>flush_interval -1</code> disables response buffering. The timeout overrides prevent Caddy from killing long-lived connections.</p>
<p>The Caddyfile syntax is not perfect. The matcher system takes some getting used to, and the documentation could be better organized. But the amount of boilerplate it eliminates compared to Nginx is staggering. My entire server config is under 120 lines, and it handles TLS, routing, static files, reverse proxying, WebSockets, and error pages for a dozen services.</p>
]]></content:encoded></item><item><title>Running 15 Services on One VPS With Docker Compose</title><link>https://articles.wleeaf.dev/posts/docker-compose-single-server/</link><pubDate>Sun, 22 Mar 2026 00:00:00 +0000</pubDate><guid>https://articles.wleeaf.dev/posts/docker-compose-single-server/</guid><description>How a single compose file, two networks, and some naming discipline keeps everything manageable.</description><category>docker</category><category>infrastructure</category><category>self-hosting</category><content:encoded><![CDATA[<p>At some point I looked at my server and counted fifteen containers. A reverse proxy, a git server, a CI runner, a game server, a mail server, a chat platform, a webmail client, a database, a VPN, an auth portal, an admin panel, a music bot, a TURN server, and a couple of static site builders. All on one four-core VPS with eight gigs of RAM.</p>
<p>It sounds like a mess, but it is actually pretty clean. The trick is structure: one compose file, two networks and some naming discipline.</p>
<h2 id="one-compose-file-two-networks">One Compose File, Two Networks<a class="anchor" href="#one-compose-file-two-networks" aria-label="Link to “One Compose File, Two Networks”">#</a></h2>
<p>Every service lives in a single docker-compose.yml. I tried splitting it into multiple compose files early on and it was a nightmare. Services that need to talk to each other end up on different networks, you forget which file defines what, and debugging becomes archaeology.</p>
<p>One file means one place to look. The trade-off is that the file is about 200 lines long, but that is way better than hunting through five separate files.</p>
<p>The networking is simple: two Docker bridge networks. The first one connects Caddy to the internet. The second one connects Caddy to every backend service. Services that need to be reached by Caddy join the backend network. Services that need direct port access to the internet get explicit port mappings.</p>
<p>The WireGuard container is the exception. It runs with host networking because it needs raw access to the network stack for the VPN tunnel. Everything else uses the bridge networks.</p>
<h2 id="container-naming">Container Naming<a class="anchor" href="#container-naming" aria-label="Link to “Container Naming”">#</a></h2>
<p>I name every container explicitly with <code>container_name</code>. Docker Compose generates names like <code>wleeaf-caddy-1</code> by default, which is ugly in logs and hard to type in commands. Explicit names mean I can do <code>docker logs caddy</code> instead of <code>docker logs wleeaf-caddy-1</code>.</p>
<p>This also matters for DNS resolution inside Docker networks. When you give a container a name, other containers on the same network can reach it by that name. So my Caddy config just says <code>reverse_proxy gitea:3000</code> and Docker handles the resolution.</p>
<h2 id="resource-reality">Resource Reality<a class="anchor" href="#resource-reality" aria-label="Link to “Resource Reality”">#</a></h2>
<p>People overestimate how much RAM you need. Here is what my containers actually use:</p>
<p>The reverse proxy sits at 15 megabytes. The git server uses about 110. The mail server is the hungriest at 160. The Matrix homeserver varies between 100 and 500 depending on federation activity. The database backing it uses 70. Everything else is under 50 each.</p>
<p>Total memory usage hovers around 2.5 gigabytes. That leaves five gigs free for spikes, builds, and the occasional agent session eating a gig for a few minutes.</p>
<p>CPU is basically idle. The load average rarely goes above 0.1 unless someone is building a Docker image or running a CI job. Four cores is overkill for this workload.</p>
<h2 id="secrets-management">Secrets Management<a class="anchor" href="#secrets-management" aria-label="Link to “Secrets Management”">#</a></h2>
<p>I keep secrets in a .env file next to the compose file. Docker Compose reads it automatically. Database passwords, API tokens, and hashed passwords all live there. The compose file references them with the dollar-sign-curly-brace syntax.</p>
<p>The .env file is not in any git repo. I back it up manually. Some people use Docker secrets or Vault for this, but for a single-server personal setup, a .env file is fine.</p>
<h2 id="restart-policies">Restart Policies<a class="anchor" href="#restart-policies" aria-label="Link to “Restart Policies”">#</a></h2>
<p>Every service has <code>restart: unless-stopped</code>. This means containers come back after a reboot or a crash, but stay down if I explicitly stop them. I used to use <code>always</code> but that fights you when you are debugging, because the container keeps restarting while you are trying to read the logs.</p>
<p>The one exception is the CI runner. It has <code>restart: unless-stopped</code> too, but I sometimes need to stop it to free resources during heavy builds. With <code>unless-stopped</code>, it stays down until I bring it back up.</p>
<h2 id="volume-strategy">Volume Strategy<a class="anchor" href="#volume-strategy" aria-label="Link to “Volume Strategy”">#</a></h2>
<p>I mount named directories from the host filesystem, not Docker volumes. Every service gets a directory under the main project folder. Caddy gets <code>./caddy</code>, Gitea gets <code>./gitea</code>, the mail server gets <code>./stalwart</code>, and so on.</p>
<p>This makes backups trivial. I can tar the entire project directory and I have everything: configs, data, and the compose file itself. Docker volumes are fine, but they hide the data in a location you have to look up every time.</p>
<h2 id="the-upgrade-dance">The Upgrade Dance<a class="anchor" href="#the-upgrade-dance" aria-label="Link to “The Upgrade Dance”">#</a></h2>
<p>Upgrading a service usually means changing the image tag and running <code>docker compose up -d</code>. For services using <code>latest</code>, I pull first with <code>docker compose pull</code> and then bring them up.</p>
<p>I do not use Watchtower or any automatic update tool. I want to know exactly when things change. An automatic update at 3 AM that breaks the mail server is not a problem I want to have.</p>
<p>The whole setup has been running for months with essentially zero unplanned downtime. The only time services go down is when I am actively working on them. For a personal infrastructure project, that is more than good enough.</p>
]]></content:encoded></item><item><title>What I Learned Setting Up My Own Mail Server in 2026</title><link>https://articles.wleeaf.dev/posts/self-hosting-mail-server/</link><pubDate>Sun, 15 Mar 2026 00:00:00 +0000</pubDate><guid>https://articles.wleeaf.dev/posts/self-hosting-mail-server/</guid><description>Running your own SMTP/IMAP server is supposedly a nightmare. Here is what actually happened when I tried it.</description><category>self-hosting</category><category>email</category><category>infrastructure</category><content:encoded><![CDATA[<p>Everyone tells you not to run your own mail server. I did it anyway.</p>
<p>The usual advice goes something like this: spam filters will eat your messages, IP reputation is impossible to build, and you will spend your weekends debugging DKIM signatures instead of doing anything useful. After a couple weeks of running Stalwart on a cheap Hetzner VPS, I can say that some of those warnings are legitimate but most of them are outdated.</p>
<h2 id="the-setup">The Setup<a class="anchor" href="#the-setup" aria-label="Link to “The Setup”">#</a></h2>
<p>I went with Stalwart, which is a relatively new mail server written in Rust. It handles SMTP, IMAP, and JMAP out of the box, and the admin UI is surprisingly decent for a self-hosted project. The whole thing runs in a single Docker container with a RocksDB backend, which means no separate database to manage.</p>
<p>The stack looks like this:</p>
<ul>
<li>Stalwart for mail handling on ports 25, 465, 587, and 993</li>
<li>Caddy as the reverse proxy with a wildcard TLS cert</li>
<li>SnappyMail for webmail access</li>
<li>Cloudflare for DNS on the free plan</li>
</ul>
<p>The VPS itself is nothing special. 4 cores, 8 gigs of RAM, running Ubuntu 24.04. Stalwart barely touches the resources and sits at around 150MB of RAM even after a few thousand messages.</p>
<h2 id="dns-was-the-easy-part">DNS Was the Easy Part<a class="anchor" href="#dns-was-the-easy-part" aria-label="Link to “DNS Was the Easy Part”">#</a></h2>
<p>Setting up DNS records is the part that scares most people, but it is genuinely straightforward if you take it step by step. You need an A record for your mail subdomain, an MX record pointing to it, SPF to declare which IPs can send for your domain, DKIM for cryptographic signature verification, and DMARC to set a policy for failed checks.</p>
<p>The Cloudflare API makes all of this scriptable. I wrote a shell script that creates all five records in about 30 seconds. The DKIM key was the only tricky part because Stalwart generates it internally and you have to copy it out of the admin panel and paste it into DNS.</p>
<h2 id="the-reputation-problem">The Reputation Problem<a class="anchor" href="#the-reputation-problem" aria-label="Link to “The Reputation Problem”">#</a></h2>
<p>This is the real challenge. When you spin up a brand new IP and start sending mail, every major provider treats you with suspicion. Gmail sent my first few messages straight to spam even though SPF, DKIM, and DMARC all passed perfectly.</p>
<p>The fix was not technical, it was behavioral. I marked those messages as not spam in my Gmail account, added the addresses to my contacts, and started having normal conversations. Within a few days, Gmail stopped flagging my messages. Google Postmaster Tools confirmed that the domain reputation was climbing.</p>
<p>One thing that helped: my VPS provider has decent IP reputation to begin with. If you are on a provider known for abuse, you will have a much harder time. Also make sure your reverse DNS is set correctly because Gmail checks that the PTR record for your IP matches your mail hostname.</p>
<h2 id="tls-certificate-sharing">TLS Certificate Sharing<a class="anchor" href="#tls-certificate-sharing" aria-label="Link to “TLS Certificate Sharing”">#</a></h2>
<p>I did not want to manage separate certificates for the mail server. Caddy already handles TLS for all my subdomains using a wildcard cert from Lets Encrypt via DNS challenge. I just mounted the certificate directory read-only into the Stalwart container.</p>
<p>The only catch is that Caddy stores the cert in a specific directory structure and you need to figure out the exact path. Once you find that, a volume mount in Docker Compose is all you need.</p>
<h2 id="was-it-worth-it">Was It Worth It<a class="anchor" href="#was-it-worth-it" aria-label="Link to “Was It Worth It”">#</a></h2>
<p>Honestly, yes. The total ongoing cost is zero beyond the VPS I was already paying for. I have full control over my email, the data never touches a third party, and the whole thing took an afternoon to set up.</p>
<p>The one thing I would do differently: I would have registered my domain and set up the mail server on the same day. Domain age matters for reputation, and the sooner you start sending from a domain, the sooner it builds trust with the big providers.</p>
<p>If you are already running a VPS for other things, adding a mail server is a no-brainer. If you are starting from scratch just for email, you might want to think about whether the learning experience is worth the first week of dealing with spam folders.</p>
]]></content:encoded></item></channel></rss>