<?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: Ujwal NK</title>
    <description>The latest articles on DEV Community by Ujwal NK (@ujwalnk).</description>
    <link>https://dev.to/ujwalnk</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%2F4054183%2Fa1407e8f-d6ea-4c92-abbb-54d05cc4c19e.png</url>
      <title>DEV Community: Ujwal NK</title>
      <link>https://dev.to/ujwalnk</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/ujwalnk"/>
    <language>en</language>
    <item>
      <title>Keeping Caddy and Docker in Sync</title>
      <dc:creator>Ujwal NK</dc:creator>
      <pubDate>Tue, 04 Aug 2026 16:51:42 +0000</pubDate>
      <link>https://dev.to/ujwalnk/keeping-caddy-and-docker-in-sync-4b9b</link>
      <guid>https://dev.to/ujwalnk/keeping-caddy-and-docker-in-sync-4b9b</guid>
      <description>&lt;p&gt;If you've been self-hosting for a while, you've probably run into this at least once.&lt;/p&gt;

&lt;p&gt;You spin up a new Docker service, publish a port in &lt;code&gt;docker-compose.yml&lt;/code&gt;, add a route to your reverse proxy, and move on. A few weeks later, you remove the service—but forget to remove the published port. Repeat that enough times, and you end up with a collection of stale port mappings that no longer serve any purpose.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;80:80"&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;443:443"&lt;/span&gt;

  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;50000:50000"&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;30000:30000"&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;50001:50001"&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;30001:30001"&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;50002:50002"&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;50003:50003"&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;30003:30003"&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;50010:50010"&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;30010:30010"&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;50014:50014"&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;50021:50021"&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;30026:30026"&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;50034:50034"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That was exactly the problem I found myself facing. I had no clue, which one of these was the experiments that I had long ago abandoned, which were old services that I had removed, and which ones were actually still in service.&lt;/p&gt;

&lt;p&gt;I run my homelab on a Raspberry Pi 5, and while it's surprisingly capable, I still prefer to keep my stack as lean as possible. I also wanted a workflow that was predictable. Every exposed service should exist because I explicitly declared it, not because a container happened to have the right labels.&lt;/p&gt;

&lt;p&gt;I looked at solutions like Traefik and Caddy Docker Proxy, but they solved a different problem than the one I had. They optimize for automatic discovery. I was looking for control, and multi routing (LAN + Tailscale)&lt;/p&gt;

&lt;p&gt;So I built a small workflow around Caddy that solved four problems for me:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A single source of truth for every exposed service.&lt;/li&gt;
&lt;li&gt;Clean &lt;code&gt;docker-compose.yml&lt;/code&gt; files that know nothing about reverse proxy configuration.&lt;/li&gt;
&lt;li&gt;Multiple routes for the same service (local, Tailscale, and potentially more in the future).&lt;/li&gt;
&lt;li&gt;Automatic synchronization of published Docker ports.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Maybe it'll fit your homelab too.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Design Goals
&lt;/h2&gt;

&lt;p&gt;Before writing a single line of code, I wrote down what I actually wanted from the system.&lt;/p&gt;

&lt;h3&gt;
  
  
  Keep the stack lightweight
&lt;/h3&gt;

&lt;p&gt;This entire setup runs on a Raspberry Pi 5. While the hardware is more than capable of running Docker and Caddy, I didn't want to introduce additional services unless they provided significant value.&lt;/p&gt;

&lt;p&gt;Caddy should remain exactly what it is—a reverse proxy.&lt;/p&gt;

&lt;p&gt;No extra management containers. No background watchers.&lt;/p&gt;

&lt;p&gt;Just configuration files and a couple of lightweight scripts.&lt;/p&gt;




&lt;h3&gt;
  
  
  Separate application deployment from networking
&lt;/h3&gt;

&lt;p&gt;I wanted my application Compose files to describe applications—and nothing else.&lt;/p&gt;

&lt;p&gt;Things like:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;reverse proxy routes,&lt;/li&gt;
&lt;li&gt;authentication,&lt;/li&gt;
&lt;li&gt;published ports,&lt;/li&gt;
&lt;li&gt;and networking policies&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;shouldn't live alongside the application itself.&lt;/p&gt;

&lt;p&gt;That separation makes moving services between hosts much easier, and I don't need to touch ten different Compose files when changing how traffic reaches a service.&lt;/p&gt;




&lt;h3&gt;
  
  
  Support multiple routes per service
&lt;/h3&gt;

&lt;p&gt;Some of my services are only accessible over Tailscale.&lt;/p&gt;

&lt;p&gt;Others should also be available locally.&lt;/p&gt;

&lt;p&gt;Some available over both.&lt;/p&gt;

&lt;p&gt;Having a local endpoint isn't just about convenience—it also means that if my ISP or Tailscale is unavailable, I can still access my services from inside my home network (at least). External connectivity shouldn't become a single point of failure for services that never leave my LAN.&lt;/p&gt;

&lt;p&gt;Tomorrow I might decide to expose services another way (CloudFlare, NetBird, etc...). I wanted a workflow that could grow without needing to redesign everything.&lt;/p&gt;




&lt;h3&gt;
  
  
  Keep everything explicit
&lt;/h3&gt;

&lt;p&gt;I wanted one file that answered a simple question:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;What services are exposed? How &amp;amp; Where?&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;If a service exists there, it's available with details. If it doesn't, it isn't.&lt;/p&gt;

&lt;p&gt;Simple.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Workflow
&lt;/h2&gt;

&lt;p&gt;The entire workflow revolves around three small pieces.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://mermaid.live/edit#pako:eNplkF1rgzAUhv9KOBdjA3XaqlUZhVYLuxmMsavNXWTmVKWaSBK3daX_fYmlpbBc5ZDn_cg5QCUYQgbbTnxXDZWavBYlLzkxZ_VeghSjRlfTtlMV7dCrKGP7h095v7x9okqjJLng27YeJdWt4HclfFzkxHWXZG1MerpDd3JSnmomxALrCcgvKZ0wEaeEfzaFoQYhdU-Ha4v89Hhmi2ncGJaJaofSrUQ_CIXevu9OpVejFm6NHE1fZMRaKqKwmsqTGyJRabuFSnDzaYNdNdlM7o_GPbcdyQt-oVRInqX4sY3BgVq2DDItR3SgR9lTO8LB6kvQDfZYQmaujMpdCSU_Gs1A-ZsQ_VlmdlE352EcmOlZtLSW1BBb2imLIGcoczFyDVkQThaQHeAHslkUe1GQhPE8mIeLOIod2EMW-17oz9PEn0VRMIvT6OjA75Tpe0kcRuki9MM0Wfj-4vgHPoylqg" rel="noopener noreferrer"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2Fpako%3AeNplkFFLwzAUhf9KuA-isNZua7u2yGDrBr4IIj5pfYjNXVfWJOUmVefYfzft2BiYp1xyzndO7gFKLRAy2DT6u9xysux1VahCMXcW7wWQ7ix6lteNKXmDfsmF2D980v389okbi8RyrTZ11RG3tVZ3BXxc7Mzz5mzpIJLv0BtIxjfbQdILloMgv6Q02kWcEv5hVk7VarKSt9eI_PR41q6Gce20Qpc7JK_UstUG_b1sTqUXndVehQpdXxSsRxpmsBzKsxtGaGy_hVIr92knu2qyHuiPjp73HdkLfiEZZM-kf_rGMIKKagHZhjcGRyCRJO9nOPSAAuwWJRaQuavgtCugUEdnarl601pCZqlzNreManseula4oquaV8TlhUyoBFKuO2UhG0cDArID_EA2iWI_GidhPB1Pw1kcxSPYQxYHfhhM0ySYRNF4EqfRcQS_Q2bgJ3EYpbMwCNNkFgSz4x_kS6X2%3Ftype%3Dpng" alt="Workflow diagram" width="300" height="662"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Everything starts with a single master Caddyfile.&lt;/p&gt;

&lt;p&gt;From that file, everything else is generated automatically.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Master Caddyfile
&lt;/h2&gt;

&lt;p&gt;Instead of treating my Caddyfile as just another configuration file, I made it the source of truth for the rest of the workflow.&lt;/p&gt;

&lt;p&gt;A few simple comment markers tell my scripts whether a service should: (to be placed right after the row of # characters, placed on the next line for readability)&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;generate a local route - &lt;code&gt;!&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;generate a local route protected with Basic Authentication - &lt;code&gt;:&lt;/code&gt; (last 4 digits of the port is the password used in this example)&lt;/li&gt;
&lt;li&gt;or remain Tailscale-only - &lt;code&gt;;&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That means I only ever edit one file.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Example&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="c"&gt;############################################################################################# 
&lt;/span&gt;&lt;span class="n"&gt;AudiobookShelf&lt;/span&gt;: &lt;span class="m"&gt;50030&lt;/span&gt;
&lt;span class="n"&gt;https&lt;/span&gt;://&lt;span class="n"&gt;my&lt;/span&gt;.&lt;span class="n"&gt;tailscale&lt;/span&gt;.&lt;span class="n"&gt;ts&lt;/span&gt;.&lt;span class="n"&gt;net&lt;/span&gt;:&lt;span class="m"&gt;50030&lt;/span&gt; {...} &lt;span class="c"&gt;# Generates local route with authentication
&lt;/span&gt;
&lt;span class="c"&gt;############################################################################################# 
&lt;/span&gt;&lt;span class="n"&gt;PgAdmin&lt;/span&gt;; &lt;span class="m"&gt;50031&lt;/span&gt;
&lt;span class="n"&gt;https&lt;/span&gt;://&lt;span class="n"&gt;my&lt;/span&gt;.&lt;span class="n"&gt;tailscale&lt;/span&gt;.&lt;span class="n"&gt;ts&lt;/span&gt;.&lt;span class="n"&gt;net&lt;/span&gt;:&lt;span class="m"&gt;50031&lt;/span&gt; {...} &lt;span class="c"&gt;# Tailscale only route
&lt;/span&gt;
&lt;span class="c"&gt;############################################################################################# 
&lt;/span&gt;&lt;span class="n"&gt;CalibreWeb&lt;/span&gt;! &lt;span class="m"&gt;50032&lt;/span&gt;
&lt;span class="n"&gt;https&lt;/span&gt;://&lt;span class="n"&gt;my&lt;/span&gt;.&lt;span class="n"&gt;tailscale&lt;/span&gt;.&lt;span class="n"&gt;ts&lt;/span&gt;.&lt;span class="n"&gt;net&lt;/span&gt;:&lt;span class="m"&gt;50032&lt;/span&gt; {...} &lt;span class="c"&gt;# Generates local route without authentication
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Generating Local Routes
&lt;/h2&gt;

&lt;p&gt;The first script, &lt;code&gt;make-routes.sh&lt;/code&gt;, reads the master Caddyfile and generates a second one dedicated to local access.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://gist.github.com/ujwalnk/5a9724ac80036452a2178db4f41c9933" rel="noopener noreferrer"&gt;Github Gist &lt;/a&gt;&lt;/p&gt;

&lt;p&gt;During that process it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;converts external ports into local ports&lt;/li&gt;
&lt;li&gt;removes TLS directives where required&lt;/li&gt;
&lt;li&gt;injects Basic Authentication when required&lt;/li&gt;
&lt;li&gt;and skips services that should remain Tailscale-only&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The result is a second Caddyfile that never needs to be edited manually.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Generated Output&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="c"&gt;#############################################################################################
&lt;/span&gt;&lt;span class="n"&gt;AudiobookShelf&lt;/span&gt; : &lt;span class="m"&gt;30030&lt;/span&gt;
&lt;span class="n"&gt;http&lt;/span&gt;://:&lt;span class="m"&gt;30030&lt;/span&gt; {
    &lt;span class="n"&gt;basicauth&lt;/span&gt; {
        &lt;span class="n"&gt;admin&lt;/span&gt; $&lt;span class="m"&gt;2&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;$...
    } 
    ...
}

&lt;span class="c"&gt;#############################################################################################
&lt;/span&gt;&lt;span class="n"&gt;CalibreWeb&lt;/span&gt; : &lt;span class="m"&gt;30032&lt;/span&gt;
&lt;span class="n"&gt;http&lt;/span&gt;://:&lt;span class="m"&gt;30032&lt;/span&gt; {...}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;AudiobookShelf has &lt;strong&gt;basic auth&lt;/strong&gt; in the second file (Will be exposed on the local network with HTTP Authentication)&lt;/li&gt;
&lt;li&gt;PgAdmin is &lt;strong&gt;not&lt;/strong&gt; present in the second file (Will not be exposed on the local network)&lt;/li&gt;
&lt;li&gt;CalibreWeb is present in the second file, but &lt;strong&gt;lacks auth&lt;/strong&gt; (Will be exposed on the local network)&lt;/li&gt;
&lt;li&gt;The ports have been changed from 50000 series 30000 series, to avoid port conflicts&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Keeping Docker Ports in Sync
&lt;/h2&gt;

&lt;p&gt;The second script, &lt;code&gt;portmap.sh&lt;/code&gt;, solves the problem that started this entire project.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://gist.github.com/ujwalnk/f04d34417bc8b77f587fe6a5ebacaa95" rel="noopener noreferrer"&gt;Github Gist&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Instead of manually publishing ports inside &lt;code&gt;docker-compose.yml&lt;/code&gt;, it scans every generated Caddy route, collects the ports that are actually in use, and updates a dedicated section inside my Compose file.&lt;/p&gt;

&lt;p&gt;If I remove a route, the published port disappears automatically.&lt;br&gt;
No stale configuration.&lt;br&gt;
No forgotten ports.&lt;br&gt;
No manual cleanup.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;caddy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;caddy:latest&lt;/span&gt;

    &lt;span class="c1"&gt;# ...&lt;/span&gt;

    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;80:80&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;443:443&lt;/span&gt;

    &lt;span class="c1"&gt;# ===== AUTO-GENERATED PORTS (DO NOT EDIT MANUALLY) =====&lt;/span&gt;
    &lt;span class="c1"&gt;# ===== END AUTO-GENERATED PORTS =====&lt;/span&gt;

    &lt;span class="na"&gt;stop_grace_period&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;10s&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;caddy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;caddy:latest&lt;/span&gt;

    &lt;span class="c1"&gt;# ...&lt;/span&gt;

    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;80:80&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;443:443&lt;/span&gt;

    &lt;span class="c1"&gt;# ===== AUTO-GENERATED PORTS (DO NOT EDIT MANUALLY) =====&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;50030:50030&lt;/span&gt; &lt;span class="c1"&gt;# AudiobookShelf&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;30030:30030&lt;/span&gt; &lt;span class="c1"&gt;# AudiobookShelf&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;30032:30032&lt;/span&gt; &lt;span class="c1"&gt;# CalibreWeb&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;50032:50032&lt;/span&gt; &lt;span class="c1"&gt;# CalibreWeb&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;50031:50031&lt;/span&gt; &lt;span class="c1"&gt;# PgAdmin&lt;/span&gt;
    &lt;span class="c1"&gt;# ===== END AUTO-GENERATED PORTS =====&lt;/span&gt;

    &lt;span class="na"&gt;stop_grace_period&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;10s&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h3&gt;
  
  
  A Couple of Handy Flags
&lt;/h3&gt;

&lt;p&gt;Now here's the thing — once you've got a dozen-odd services running, you'll eventually forget which port you gave to what. Was AdventureLog on 50021 or 50012? Happens to me all the time, honestly.&lt;/p&gt;

&lt;p&gt;So &lt;code&gt;portmap.sh&lt;/code&gt; isn't just a sync-and-forget script. I threw in a query mode for exactly this situation.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;./portmap.sh &lt;span class="nt"&gt;-q&lt;/span&gt; calibre
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This does a case-insensitive, partial match search across all your &lt;code&gt;route-*.caddy&lt;/code&gt; files and just tells you straight up:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;CalibreWeb → 50032 (route-tailscale.caddy)
CalibreWeb → 30032 (route-local.caddy)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No need to grep through files or scroll up and down the Caddyfile trying to remember your own naming. You just ask, and it answers. Pretty neat for something so small.&lt;/p&gt;

&lt;p&gt;There's also a &lt;code&gt;-r&lt;/code&gt; flag, which is basically the "I trust this, just do it" option:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;./portmap.sh &lt;span class="nt"&gt;-r&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Instead of syncing the ports and leaving you to restart Caddy yourself, this one goes ahead and recreates the container right after — &lt;code&gt;docker compose up -d --force-recreate&lt;/code&gt; under the hood. Good for when you're actively adding a service and don't want the extra step of remembering to reload things after.&lt;/p&gt;

&lt;p&gt;One small catch — you can't use &lt;code&gt;-q&lt;/code&gt; and &lt;code&gt;-r&lt;/code&gt; together, and that's on purpose. One's for looking things up, the other's for actually changing your running setup. Mixing the two felt like asking for trouble, so the script just refuses and tells you to pick one.&lt;/p&gt;




&lt;h2&gt;
  
  
  Adding a New Service
&lt;/h2&gt;

&lt;p&gt;Once everything is in place, adding a service becomes surprisingly simple.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://mermaid.live/edit#pako:eNplkFFLwzAUhf9KuA-isNZua7u2yGDrBr4IIj5pfYjNXVfWJOUmVefYfzft2BiYp1xyzndO7gFKLRAy2DT6u9xysux1VahCMXcW7wWQ7ix6lteNKXmDfsmF2D980v389okbi8RyrTZ11RG3tVZ3BXxc7Mzz5mzpIJLv0BtIxjfbQdILloMgv6Q02kWcEv5hVk7VarKSt9eI_PR41q6Gce20Qpc7JK_UstUG_b1sTqUXndVehQpdXxSsRxpmsBzKsxtGaGy_hVIr92knu2qyHuiPjp73HdkLfiEZZM-kf_rGMIKKagHZhjcGRyCRJO9nOPSAAuwWJRaQuavgtCugUEdnarl601pCZqlzNreManseula4oquaV8TlhUyoBFKuO2UhG0cDArID_EA2iWI_GidhPB1Pw1kcxSPYQxYHfhhM0ySYRNF4EqfRcQS_Q2bgJ3EYpbMwCNNkFgSz4x_kS6X2" rel="noopener noreferrer"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2Fpako%3AeNpVjk1PwzAMhv9K5HNb-pV0jdCksR45jRuEQ2i8tVqTVGkKjGn_nWyDSfPJr_w8to_QWoXAYTvYr7aTzpPnjTDCkFCrNwENjoM9kMa2e3TkBd1n36KA9ysRx0vyFKiVUmRjZ4_E28cP97B05xB72Q9TKwdMWqnU4U5bB20zG6LlHuMLPiVTd4c0f8honddyvI4hgp3rFfCtHCaMQKPT8pzheDYF-A51-JCHVkm3FyDMKUijNK_WauDezUELF3fdf5hHJT02vdw5qW-bHRqFbm1n44Fn9WUF8CN8A88pS2i2KFmRFWXFKIvgAJylSZkW9SLNKc1yVtNTBD-Xm2myYCWtq6IqiyqlrCpPv567dDA%3Ftype%3Dpng" alt="Flowchart of workflow" width="973" height="94"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Every new service follows exactly the same process.&lt;/p&gt;

&lt;p&gt;Likewise, removing one is just the reverse: delete the route, rerun the scripts, and the configuration cleans itself up.&lt;/p&gt;




&lt;h2&gt;
  
  
  Is This Better Than Traefik or Caddy Docker Proxy?
&lt;/h2&gt;

&lt;p&gt;Probably not.&lt;/p&gt;

&lt;p&gt;It's simply solving a different problem.&lt;/p&gt;

&lt;p&gt;If you like automatic service discovery, Docker labels, and infrastructure that configures itself, those tools are excellent choices. I just wanted something different.&lt;/p&gt;

&lt;p&gt;My priority wasn't maximum automation—it was having a workflow that's easy to understand six months later. Every exposed service is explicitly declared.&lt;/p&gt;

&lt;p&gt;My application Compose files stay focused on applications. The reverse proxy stays focused on routing. And I only ever edit one file when exposing something new.&lt;/p&gt;

&lt;p&gt;For me, that's a trade-off worth making.&lt;/p&gt;




&lt;h2&gt;
  
  
  Source Code
&lt;/h2&gt;

&lt;p&gt;The complete implementation, including both helper scripts, is available on GitHub.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;GitHub Gists:&lt;/strong&gt; &lt;a href="https://gist.github.com/ujwalnk/5a9724ac80036452a2178db4f41c9933" rel="noopener noreferrer"&gt;https://gist.github.com/ujwalnk/5a9724ac80036452a2178db4f41c9933&lt;/a&gt;, &lt;a href="https://gist.github.com/ujwalnk/f04d34417bc8b77f587fe6a5ebacaa95" rel="noopener noreferrer"&gt;https://gist.github.com/ujwalnk/f04d34417bc8b77f587fe6a5ebacaa95&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Closing Thoughts
&lt;/h2&gt;

&lt;p&gt;One of my favorite things about self-hosting is that there isn't a single "right" way to build a homelab. The best solution is often the one that fits the way &lt;em&gt;you&lt;/em&gt; think.&lt;/p&gt;

&lt;p&gt;This workflow probably isn't for everyone. If you're managing dozens of servers where automatic service discovery is a necessity, tools like Traefik or Caddy Docker Proxy make perfect sense.&lt;/p&gt;

&lt;p&gt;But if you enjoy explicit configuration, prefer keeping your Compose files clean, and like having a single place that defines everything your reverse proxy exposes, this approach might give you a few ideas.&lt;/p&gt;

&lt;p&gt;Even if you don't adopt it exactly, I hope it encourages you to build workflows that match your own priorities instead of defaulting to the most automated option available.&lt;/p&gt;

</description>
      <category>homelab</category>
      <category>automation</category>
    </item>
  </channel>
</rss>
