<?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>webserver · wleeaf field notes</title><link>https://articles.wleeaf.dev/tags/webserver/</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>Sat, 28 Mar 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://articles.wleeaf.dev/tags/webserver/feed.xml" rel="self" type="application/rss+xml"/><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></channel></rss>