<?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>One Shortcut, Not One Hundred for Tiling your Windows</title>
      <dc:creator>Ujwal NK</dc:creator>
      <pubDate>Thu, 10 Sep 2026 03:25:50 +0000</pubDate>
      <link>https://dev.to/ujwalnk/one-shortcut-not-one-hundred-for-tiling-your-windows-4oao</link>
      <guid>https://dev.to/ujwalnk/one-shortcut-not-one-hundred-for-tiling-your-windows-4oao</guid>
      <description>&lt;h1&gt;
  
  
  TL;DR
&lt;/h1&gt;

&lt;ul&gt;
&lt;li&gt;Rectangle &amp;amp; Magnet gives you 100 different window tiling options, but you need to remember 100 different shortcuts.&lt;/li&gt;
&lt;li&gt;Raycast gives you a 100 different window tiling options, but it's no more a shortcut but a phrase away.&lt;/li&gt;
&lt;li&gt;GridTile gives you a 100 different window tiling options, and you need to remember one shortcut.
Using Claude and your (not-main) project itself as a tutorial, is an amazing way to get started with learning a new language&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://ujwalnk.github.io/GridTile" rel="noopener noreferrer"&gt;Download GridTile here for FREE&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;Window management is an essential requirement. Moving to macOS made me realise just how much I relied on it. On Linux, I used the amazing Tactile extension for grid-based tiling.&lt;/p&gt;

&lt;p&gt;Coming to MacOS, I tried a few options - Raycast, Magnet, Rectangle. That's when I ran into a problem.&lt;/p&gt;

&lt;p&gt;Keyboard shortcuts - They're fast, they keep my hands on the keyboard, and once something becomes muscle memory, they are hard to beat.&lt;/p&gt;

&lt;p&gt;So the first window-management tools I tried made perfect sense to me. Give every action a shortcut.&lt;/p&gt;

&lt;p&gt;Left half. Right half. Top-left. Top-right. Bottom-left. Bottom-right. Maximize. Center. Move to another display. One-third. Two-thirds. And so on.&lt;/p&gt;

&lt;p&gt;The problem is that window layouts don't stop at the layouts someone thought of when building the application.&lt;/p&gt;

&lt;p&gt;I might want a window to take up the left 40% of my screen and another 60%. Or I might want a particular arrangement across two monitors. The moment I want something slightly different, I either need another shortcut or I need to start interacting with the application in some other way.&lt;br&gt;
The shortcut problem&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Keyboard shortcuts aren't free. (cue conflicts)&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Utilities like Magnet, Rectange give you keyboard shortcuts to tile your windows and my keyboard has a finite number of combinations that I'm comfortable using. Other applications have their own shortcuts. The operating system has its own shortcuts. Other utilities are competing for the same combinations.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fu49mziq68vqfy7ektu29.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fu49mziq68vqfy7ektu29.png" alt=" " width="800" height="737"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Everything is competing for the same pool of keyboard shortcuts. Imagine being a developer and discovering that the shortcut you use every day conflicts with your window manager.&lt;/p&gt;

&lt;p&gt;Raycast solves this problem from another direction, if you want. Tiling is no more a few keys away, it's an entire phrase away.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnqzad22np189186uccmc.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnqzad22np189186uccmc.png" alt=" " width="800" height="544"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Tiling is no more a few keys away, it's an entire phrase away&lt;/p&gt;




&lt;h1&gt;
  
  
  Bring tactile to MacOS 
&lt;/h1&gt;

&lt;p&gt;Having gotten used to grid-based tiling, I thought: why not bring the same interaction to macOS? I could implement the whole thing as a simple script.&lt;/p&gt;

&lt;p&gt;I would have one shortcut.&lt;/p&gt;

&lt;p&gt;Not one shortcut for the left half, another for the right half, another for the top-left, another for the second monitor, and another for some obscure 37% layout I might use once a month.&lt;/p&gt;

&lt;p&gt;Just one. Press it, and a grid appears. (I mapped it to the Stage Manager key, since I don't use Stage Manager anyway.)&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpf62ano6go12bgy61hcj.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpf62ano6go12bgy61hcj.png" alt=" " width="800" height="520"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Then use the keyboard to select two tiles on that grid.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fr2elsa126xfclyes91es.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fr2elsa126xfclyes91es.gif" alt=" " width="600" height="390"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Those two points describe the rectangle where the current window should go. That's it.&lt;/p&gt;

&lt;h1&gt;
  
  
  It started as a Hammerspoon experiment
&lt;/h1&gt;

&lt;p&gt;The first version wasn't a Mac application. It was a Hammerspoon Spoon that I built for myself.&lt;/p&gt;

&lt;p&gt;I was mainly trying to answer one question: Could I bring Tactile's interaction model to macOS, and would it actually solve my problem?&lt;/p&gt;

&lt;p&gt;The prototype put a keyboard-addressable grid over the screen. One key selected a cell, and a second key defined the opposite corner. The focused window would then be moved to the resulting region.&lt;/p&gt;

&lt;p&gt;The important part wasn't the implementation. It was the interaction.&lt;/p&gt;

&lt;p&gt;Two keypresses could describe a huge number of possible window positions without requiring a huge number of shortcuts.&lt;/p&gt;

&lt;p&gt;The original Hammerspoon project was deliberately small and configurable. The grid itself was defined in Lua, with configurable rows, columns, weights, and key assignments.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/ujwalnk/GridTile.Archive" rel="noopener noreferrer"&gt;You can download the spoon for your hammerspoon configuration here&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;And, to my surprise, I wasn't the only person who liked it. &lt;a href="https://github.com/ujwalnk/GridTile.Archive" rel="noopener noreferrer"&gt;I put it on GitHub&lt;/a&gt;. People starred it. People forked it. People started using it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.reddit.com/r/hammerspoon/comments/1pwxm8v/comment/p6hib7u/?utm_source=share&amp;amp;utm_medium=web3x&amp;amp;utm_name=web3xcss&amp;amp;utm_term=1&amp;amp;utm_content=share_button" rel="noopener noreferrer"&gt;Someone&lt;/a&gt; even specifically requested for the spoon after I built the native version. even specifically requested for the spoon after I built the native version.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbn7zpzx6i6mgj3n6nq0e.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbn7zpzx6i6mgj3n6nq0e.png" alt=" " width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Then there were responses on Reddit from people who understood exactly why I had built it. That was the point where the project stopped feeling like a weird little script that solved a problem on my laptop.&lt;/p&gt;

&lt;p&gt;Maybe it was worth turning into an actual application.&lt;/p&gt;

&lt;h1&gt;
  
  
  The reason for a native version
&lt;/h1&gt;

&lt;p&gt;I wasn't a Swift developer, so Hammerspoon was the natural choice for the first version. It became my primary — and eventually my only — tool for managing windows.&lt;/p&gt;

&lt;p&gt;But over time, I started noticing the limitations. Occasionally, it would lag or feel sluggish. I tried fixing it several times, but whenever Hammerspoon got into that state, restarting hammerspoon was often the easiest solution.&lt;/p&gt;

&lt;p&gt;At the same time, I wanted to add more layouts and make the experience feel faster and more responsive. Those two things pushed me towards building a native version.&lt;/p&gt;

&lt;p&gt;I wasn't particularly interested in spending six months first learning Swift and then eventually getting around to rebuilding GridTile.&lt;/p&gt;

&lt;p&gt;And this was 2026. So I did something that would have felt almost absurd a few years ago.&lt;/p&gt;

&lt;p&gt;I asked Claude to help me build it. Actually, "help me build it" is understating it.&lt;/p&gt;

&lt;p&gt;Claude wrote a lot of the code.&lt;/p&gt;

&lt;p&gt;The current application is a native Swift/SwiftUI/AppKit implementation, with macOS Accessibility APIs handling the actual window movement. It is a proper menu-bar application rather than a Hammerspoon script.&lt;/p&gt;

&lt;p&gt;But there is an important distinction in how I approached this. I wasn't trying to get Claude to produce an application that I didn't understand. I was trying to learn Swift while Claude wrote the code.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://ujwalnk.github.io/GridTile" rel="noopener noreferrer"&gt;You can download the native version here&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Let Claude write the code. Make myself understand it.&lt;/p&gt;

&lt;p&gt;So whenever Claude introduced something I didn't understand, I stopped and asked. When something broke, I tried to understand why before asking it to fix it. The goal wasn't to write every line myself, but to make sure I could follow the code I was shipping.&lt;/p&gt;

&lt;p&gt;That turned out to be a surprisingly effective way to learn. I was learning Swift, AppKit, SwiftUI, and macOS APIs because I had a real problem in front of me—not because I was working my way through a textbook.&lt;/p&gt;

&lt;p&gt;And that became the part of the project I found most interesting.&lt;/p&gt;

&lt;h1&gt;
  
  
  My Insights
&lt;/h1&gt;

&lt;p&gt;Looking back, this changed how I think about learning to program.&lt;/p&gt;

&lt;p&gt;Whenever I've thought about teaching someone programming, I've always felt that it would be more interesting to start with a small project they actually want to build, rather than making them learn the basics first, then the language, then the framework, and only eventually letting them build what they wanted in the first place.&lt;/p&gt;

&lt;p&gt;I've been taught both ways, and more often than not, it is the latter. You spend your time learning syntax, concepts, and frameworks without always knowing why you need them. For me, that can make learning feel unnecessarily boring.&lt;/p&gt;

&lt;p&gt;Building GridTile was the opposite. I was learning Swift because I needed Swift. I was learning macOS APIs because I needed them. Every new concept had an immediate purpose.&lt;/p&gt;

&lt;p&gt;I probably learned less breadth than I would have through a traditional, textbook approach. But I had much more fun learning, and what I did learn felt much more relevant because I had a reason to remember it.&lt;/p&gt;

&lt;p&gt;That, for me, was the most interesting part of building GridTile with AI.&lt;/p&gt;

&lt;p&gt;Claude helped me write the application. But more importantly, it gave me a way to learn a language by building something I actually wanted to use.&lt;/p&gt;

</description>
      <category>swift</category>
      <category>showdev</category>
      <category>learning</category>
    </item>
    <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>
