[{"content":"Dedicated space for tech tinkering and digital experiments.\n","date":"February 28, 2026","externalUrl":null,"permalink":"/","section":"","summary":"Dedicated space for tech tinkering and digital experiments.\n","title":"","type":"page"},{"content":" The blog started as a Hugo site running on my home server. Markdown files in, static HTML out, served by Caddy through a Cloudflare Tunnel. It worked well. But the more I thought about it, the less sense it made to keep it there.\nWhy move at all # The blog was the only thing on my server that needed to be publicly accessible. Everything else, Immich, Kopia, AdGuard, WireGuard, is internal. Private services behind a VPN, the way I want them.\nHaving the blog on the same machine meant running a Cloudflare Tunnel to expose it. The tunnel itself isn\u0026rsquo;t a big deal, it\u0026rsquo;s outbound-only and doesn\u0026rsquo;t open any ports, but it\u0026rsquo;s still a public path into my home network. Every public-facing service is one more thing to think about when something goes wrong.\nBut the real reason was simpler than security. The blog content lived on the server, and editing markdown through SSH in nano is not a great experience. It works, but it\u0026rsquo;s not how you want to write blog posts. I wanted to edit my posts in a proper editor on my laptop, or even from my phone, and push them when they\u0026rsquo;re ready. That\u0026rsquo;s it.\nThe new setup # The blog now lives in a GitHub repository. When I push to the main branch, a GitHub Actions workflow builds the Hugo site and deploys it to Cloudflare Pages. The whole pipeline:\nPush markdown to GitHub GitHub Actions checks out the repo, runs hugo --minify The built site gets deployed to Cloudflare Pages via wrangler Cloudflare serves it from their edge network worldwide The workflow file is about 30 lines:\nname: Deploy to Cloudflare Pages on: push: branches: [main] jobs: deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: submodules: true - name: Setup Hugo uses: peaceiris/actions-hugo@v3 with: hugo-version: \u0026#34;0.157.0\u0026#34; extended: true - name: Build run: hugo --minify - name: Deploy to Cloudflare Pages uses: cloudflare/wrangler-action@v3 with: apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} command: pages deploy public --project-name=rudysam Two secrets in the repo, a Cloudflare API token and account ID. That\u0026rsquo;s the entire infrastructure.\nWhat I removed from the server # Once the blog was running on Cloudflare Pages, I could clean up the server:\nDeleted /opt/docker/hugo/ — the Hugo source files, themes, build output, rebuild script. All gone. Removed the Caddy site block — no more blog.rudysam.com pointing to static files on disk. Removed Ghost and Umami — Ghost was the original blog platform I replaced with Hugo. Umami was self-hosted analytics. Both were still running, using resources for nothing. Stopped the containers, deleted the directories. Updated the Cloudflare Tunnel — the blog no longer routes through the tunnel. That\u0026rsquo;s one less public service exposed through my home network. The tunnel now only handles Immich share links, which is all it ever needed to do.\nDNS and local network # One thing I had to sort out was DNS. On my home network, AdGuard rewrites *.rudysam.com to the server\u0026rsquo;s local IP. That was fine when Caddy served the blog locally, but now the blog lives on Cloudflare\u0026rsquo;s network.\nThe fix was simple: add a specific DNS rewrite for blog.rudysam.com pointing to rudysam.pages.dev. In AdGuard, specific entries override wildcards. So on my home network, the blog now resolves to Cloudflare just like it does everywhere else.\nWhat\u0026rsquo;s better # The workflow. This was the whole point. Write a post, commit, push. That\u0026rsquo;s it. No SSH, no copying files to a server, no rebuild scripts. Fix a typo from my phone if I want to. The content lives in a Git repo, and pushing to main is publishing. It\u0026rsquo;s the simplest workflow I could ask for.\nReliability. My server could go down, lose power, need a reboot for updates. None of that affects the blog anymore. It\u0026rsquo;s on Cloudflare\u0026rsquo;s infrastructure.\nSpeed. Cloudflare Pages serves from edge nodes worldwide. My home server is a single Dell laptop in one location. A nice bonus, but not why I moved.\nLess attack surface. No Cloudflare Tunnel path to the blog, no static files to serve, no Caddy block to maintain. The server does less, which means less can go wrong.\nAnalytics. I replaced self-hosted Umami with Cloudflare Web Analytics. One line of JavaScript, no database, no container. It just works.\nWhat I learned # The instinct with a home server is to self-host everything. That\u0026rsquo;s the whole point, right? Own your data, run your services, keep control.\nBut \u0026ldquo;self-host everything\u0026rdquo; isn\u0026rsquo;t actually the goal. The goal is to have things work well, be reliable, and stay under your control. A static blog on Cloudflare Pages still uses my domain, my content, my workflow. I can move it anywhere. The markdown files are in a Git repo I own. Nothing is locked in.\nSometimes the best thing you can do for your home server is take things off it. Every service you remove is one less thing to maintain, monitor, back up, and secure. The server should run what benefits from being local: photos, backups, DNS, VPN. A public blog that serves static files to the internet is better suited to infrastructure that was built for exactly that.\nThe server is lighter now. Four fewer containers, less disk usage, a simpler Caddy config, a narrower tunnel. And the blog is faster and more reliable than it ever was running from my living room.\n","date":"February 28, 2026","externalUrl":null,"permalink":"/posts/moving-to-cloudflare-pages/","section":"Posts","summary":" The blog started as a Hugo site running on my home server. Markdown files in, static HTML out, served by Caddy through a Cloudflare Tunnel. It worked well. But the more I thought about it, the less sense it made to keep it there.\nWhy move at all # The blog was the only thing on my server that needed to be publicly accessible. Everything else, Immich, Kopia, AdGuard, WireGuard, is internal. Private services behind a VPN, the way I want them.\n","title":"I Moved My Blog Off My Home Server and It's Better for It","type":"posts"},{"content":"","date":"February 28, 2026","externalUrl":null,"permalink":"/posts/","section":"Posts","summary":"","title":"Posts","type":"posts"},{"content":" This one\u0026rsquo;s a bit meta. The blog you\u0026rsquo;re reading was set up entirely by an AI tool running in my terminal. I didn\u0026rsquo;t write a single config file. I described what I wanted, and it happened. Twice, actually, because the first attempt was overkill and I scrapped it.\nWhy a blog at all # I\u0026rsquo;d been documenting the home server build in markdown files on my laptop. Notes for myself, mostly. But the more I wrote, the more it started reading like something other people might find useful. The information I wished I\u0026rsquo;d had when I started.\nI didn\u0026rsquo;t want to use Medium or Substack. The whole point of this project is owning my own stuff. So a self-hosted blog felt right.\nClaude Code # I\u0026rsquo;ve mentioned using AI tools throughout this series. Early on it was mostly Claude and Gemini in a browser window, copying and pasting commands back and forth. It works, but there\u0026rsquo;s friction. You\u0026rsquo;re constantly switching between the chat, your terminal, and whatever file you\u0026rsquo;re editing.\nRecently I\u0026rsquo;ve been using Claude Code, which is Anthropic\u0026rsquo;s CLI tool. It runs directly in the terminal and can execute commands, read and edit files, and SSH into remote machines. Instead of copying a command from a chat window and pasting it into your terminal, you just tell it what you want and it does it. It asks for permission before running anything, so you\u0026rsquo;re still in control, but the loop is much tighter.\nFor this particular setup, it made a noticeable difference.\nFirst attempt: Ghost # My first instinct was Ghost, a full-featured blogging platform. Open source, clean editor, runs in Docker. I told Claude Code to set it up and it did the whole thing in one go: created a Docker Compose file with Ghost and MySQL, added the Caddy reverse proxy block, put it on the Homepage dashboard. A couple of minutes, most of which was waiting for images to pull.\nGhost worked. It looked good. I switched it to dark mode, removed the subscribe banner, set up a Content API key so Homepage could show a post count widget. It was a proper blogging platform running on my server.\nBut the more I looked at it, the more it felt like too much. Ghost runs a Node.js application and a MySQL database, permanently. It has user accounts, an admin panel, a members system, email newsletters, a full REST API. I have a handful of markdown files and no readers. It was like buying a commercial kitchen to make toast.\nSecond attempt: Hugo # What I actually needed was something that takes markdown files and turns them into a website. That\u0026rsquo;s it. No database, no running application, no admin panel.\nHugo does exactly that. It\u0026rsquo;s a static site generator: you give it markdown files, it outputs plain HTML. No JavaScript, no backend, nothing running. Just files on disk.\nThe setup was even simpler than Ghost. Hugo doesn\u0026rsquo;t run as a server, so there\u0026rsquo;s no persistent Docker container. Instead, you run the Hugo Docker image once to build the site, and it exits. Caddy serves the output as static files:\nblog.yourdomain.com { import tls_common root * /opt/docker/hugo/public file_server } The markdown files I\u0026rsquo;d already been writing just needed a small front matter block at the top of each:\n--- title: \u0026#34;Your Post Title\u0026#34; date: 2026-02-01 draft: false --- Hugo builds the entire site in about 130 milliseconds. There\u0026rsquo;s a rebuild script on the server, so publishing a new post is: drop the markdown file in the content folder, run the script, done.\nMaking it public # Everything else on my server is internal only, accessible from home WiFi or VPN. But a blog that nobody can read isn\u0026rsquo;t much of a blog.\nI already had a Cloudflare Tunnel set up for Immich share links. Adding the blog was just another entry in the tunnel config pointing blog.yourdomain.com to Caddy, plus a CNAME record in Cloudflare DNS.\nThe security picture here is as good as it gets. There\u0026rsquo;s no application to exploit, no database to breach, no login page to brute force. It\u0026rsquo;s static HTML behind Cloudflare\u0026rsquo;s network. Someone visiting the blog hits Cloudflare\u0026rsquo;s edge, which sends the request through the tunnel to Caddy, which serves a file. That\u0026rsquo;s the entire attack surface: files on disk.\nWhat surprised me # The whole journey, from \u0026ldquo;I want a blog\u0026rdquo; to \u0026ldquo;there\u0026rsquo;s a public blog with six posts\u0026rdquo;, happened in a single conversation with Claude Code. It set up Ghost, I used it for a bit, decided it was too much, asked about alternatives, and it tore the approach down and rebuilt with Hugo. It read my existing posts, added the right front matter, picked a dark theme, configured the tunnel, updated the dashboard.\nThere was one small hiccup early on. SSH password authentication doesn\u0026rsquo;t work non-interactively by default, so it needed sshpass installed on my Mac to send commands to the server. A minor detour, but it figured that out on its own after the first connection failed.\nThe thing that would have taken me the longest manually wasn\u0026rsquo;t any single config, it was the back-and-forth of researching options, reading docs, checking compatibility, and iterating. Having something that can do all of that in context, while also executing the commands, collapses the whole process.\nWhere things stand # The blog is live at blog.yourdomain.com, publicly accessible through Cloudflare Tunnel. It\u0026rsquo;s a static Hugo site using the Blowfish theme in dark mode. Ghost is still running on the server too, because why not. Maybe I\u0026rsquo;ll use it for something else later, or maybe I\u0026rsquo;ll clean it up eventually.\nPublishing a new post means writing a markdown file and running a one-line rebuild. No editor, no CMS, no deploy pipeline. Just markdown in, HTML out.\nThere\u0026rsquo;s something satisfying about the blog running on the same server it\u0026rsquo;s documenting. The infrastructure is the content, and the content runs on the infrastructure. It\u0026rsquo;s home servers all the way down.\n","date":"February 26, 2026","externalUrl":null,"permalink":"/posts/setting-up-this-blog/","section":"Posts","summary":" This one’s a bit meta. The blog you’re reading was set up entirely by an AI tool running in my terminal. I didn’t write a single config file. I described what I wanted, and it happened. Twice, actually, because the first attempt was overkill and I scrapped it.\nWhy a blog at all # I’d been documenting the home server build in markdown files on my laptop. Notes for myself, mostly. But the more I wrote, the more it started reading like something other people might find useful. The information I wished I’d had when I started.\n","title":"Setting Up the Blog You're Reading Right Now, Using an AI in My Terminal","type":"posts"},{"content":" I keep seeing this question come up when people are thinking about starting a home server: what should I buy?\nMy honest answer is: probably nothing, at least not yet. Start with whatever you have.\nThe machine I\u0026rsquo;m running # It\u0026rsquo;s a Dell Latitude E6430, a 2013 business laptop I picked up cheap when my previous company was clearing out old hardware. It had already been upgraded over the years: 8GB of dual channel RAM, a 240GB SSD for the OS, and the CD drive swapped out for a caddy mounting the original 320GB HDD as a data drive. So by the time I repurposed it as a server, the hardware was already in decent shape.\nSpecs:\nCPU: Intel Core i5-3320M RAM: 8GB DDR3 dual channel Storage: 240GB SSD (OS) + 320GB HDD (data, via caddy) The screen is ugly, but it runs headless so it doesn\u0026rsquo;t matter. It\u0026rsquo;s still there if I ever need to plug in and do something directly, which is occasionally useful.\nI wiped it, installed Ubuntu, and started. Setting up Ubuntu and getting Docker running was actually fun, more than I expected.\nStarting with a desktop, then going headless # I actually started with Ubuntu Desktop, not the server edition. Early on that was genuinely useful. Unzipping the Google Takeout files, uploading photos to Koofr through a browser, getting familiar with the setup while having a GUI to fall back on. Having a desktop environment made those first few days easier.\nOnce things were stable and I understood what I was doing, I removed GNOME and snapd entirely and set the machine to boot into text mode only. No display manager, no desktop. Everything managed over SSH or through a browser pointed at whatever service I needed.\nThat freed up about 1GB of RAM, which matters when you\u0026rsquo;re running multiple containers on 8GB.\nIf you\u0026rsquo;re starting out, Ubuntu Desktop is a perfectly reasonable choice. You can always strip it back later once you don\u0026rsquo;t need it anymore.\nWhere do you actually put it # I\u0026rsquo;d been overthinking this before I started. I had this vague image of a rack or at least a dedicated shelf somewhere. Turns out it\u0026rsquo;s a laptop. Lid closed, it\u0026rsquo;s basically just a thick book. You can tuck it anywhere with a power outlet. In my case it also needed to be near the router for the ethernet cable, so it just sits in a corner near the network cabinet. It\u0026rsquo;s not glamorous. It looks like I forgot a laptop in the corner. But it\u0026rsquo;s been running 24/7 for days, so.\nWhat specs actually matter (it depends) # It really comes down to what you want to run.\nStorage matters most if you\u0026rsquo;re dealing with photos, videos, or backups. More capacity, more redundancy. Speed matters less, you\u0026rsquo;re not serving a data centre.\nRAM adds up with Docker containers, but not as fast as you\u0026rsquo;d think. Most lightweight services barely use anything. AdGuard Home for example will run on practically anything, including a Raspberry Pi. Immich needs a bit more headroom for its AI processing, but any reasonably modern laptop handles it fine. 8GB is comfortable for a moderate number of services.\nCPU is mostly irrelevant at idle. The exception is transcoding — if you\u0026rsquo;re planning to run Plex or Jellyfin and want it to transcode video on the fly, you\u0026rsquo;ll want something with more grunt. That\u0026rsquo;s probably the one workload where an old laptop might genuinely struggle.\nFor what I\u0026rsquo;m running (Immich, WireGuard, AdGuard, backups, a few utilities), the 2013 laptop with an i5 is way more than enough. With all services running, it idles at around 1.9GB of RAM used out of 8GB and 1-2% CPU. I didn\u0026rsquo;t expect it to be this light.\nShould you buy something new? # Only if you\u0026rsquo;ve already proved the project is worth it to you.\nRun it on whatever you have first. An old laptop, an old desktop, a spare machine, all viable. There\u0026rsquo;s something genuinely satisfying about taking a device that\u0026rsquo;s been sitting in a drawer for years and giving it a purpose. You\u0026rsquo;ll quickly learn what the actual constraints are for your usage, and experimenting on hardware you already own means there\u0026rsquo;s nothing to lose. Then, if you want to upgrade, you\u0026rsquo;ll know exactly what you\u0026rsquo;re buying and why.\nIf you don\u0026rsquo;t have anything spare, it\u0026rsquo;s worth renting a cheap VPS for a month or two just to tinker. You get a real Linux server, you learn what you actually want to run, and you spend a few dollars instead of buying hardware for a hobby you\u0026rsquo;re not sure about yet.\nThe answer to \u0026ldquo;what hardware do I need?\u0026rdquo; is almost always: less than you think, and probably something you already own.\n","date":"February 24, 2026","externalUrl":null,"permalink":"/posts/hardware/","section":"Posts","summary":" I keep seeing this question come up when people are thinking about starting a home server: what should I buy?\nMy honest answer is: probably nothing, at least not yet. Start with whatever you have.\nThe machine I’m running # It’s a Dell Latitude E6430, a 2013 business laptop I picked up cheap when my previous company was clearing out old hardware. It had already been upgraded over the years: 8GB of dual channel RAM, a 240GB SSD for the OS, and the CD drive swapped out for a caddy mounting the original 320GB HDD as a data drive. So by the time I repurposed it as a server, the hardware was already in decent shape.\n","title":"What Hardware Do You Actually Need for a Home Server?","type":"posts"},{"content":" Having a home server that works at home is one thing. Having it work exactly the same when you\u0026rsquo;re out, on mobile data, at a café, anywhere, is what makes it actually useful day to day.\nGetting this right was worth the effort.\nKeeping exposure minimal # The obvious shortcut for remote access is port forwarding: expose your services on port 443, put a login screen in front of them, done. The problem is that everything becomes internet-facing. Every service needs to be hardened. Every misconfiguration is a risk. And some things, like local DNS filtering, don\u0026rsquo;t make sense to expose publicly at all.\nI ran into this before even setting up WireGuard. I\u0026rsquo;d tried to get always-on ad blocking outside the home by exposing AdGuard\u0026rsquo;s DNS-over-HTTPS endpoint publicly, so I could set it as Android\u0026rsquo;s Private DNS on mobile data. I set it up, then pulled it back. A publicly accessible DNS server and another open port on the router felt like more exposure than I wanted for something optional.\nWireGuard is a cleaner approach. Your server stays completely private. The only port open on my router is 51820 UDP for WireGuard. Nothing else. Everything else goes through the tunnel — and the AdGuard DNS problem solved itself, since all VPN traffic routes through the home network anyway.\nWireGuard # WireGuard is a modern VPN protocol, faster and lighter than older options like OpenVPN. The battery impact on Android is noticeably lower, which matters if the VPN is running all the time.\nI run it using a Docker container called wg-easy, which wraps WireGuard with a web UI for adding and managing clients. Without something like wg-easy, WireGuard configuration involves generating key pairs by hand and editing config files carefully. wg-easy turns it into a few clicks.\nThe core of the setup:\nservices: wg-easy: image: ghcr.io/wg-easy/wg-easy environment: - WG_HOST=yourdomain.com - WG_DEFAULT_DNS=\u0026lt;your server local IP\u0026gt; ports: - \u0026#34;51820:51820/udp\u0026#34; - \u0026#34;51821:51821/tcp\u0026#34; cap_add: - NET_ADMIN - SYS_MODULE WG_HOST is your public domain, what clients will connect to. WG_DEFAULT_DNS pushes your server\u0026rsquo;s AdGuard as the DNS resolver for all connected clients. This is the key detail that makes everything work seamlessly away from home: your phone uses AdGuard as DNS over the VPN, so photos.yourdomain.com still resolves to your local server, and the ad blocking still applies.\nThe web UI for managing clients runs on port 51821, accessible via a subdomain, internal only.\nSet up DDNS at the same time # One thing that bit me early: home internet connections have dynamic IPs that change occasionally. When mine changed, the VPN endpoint stopped working because the domain was still pointing to the old IP.\nThe fix is DDNS Updater, a small Docker container that watches your public IP and updates your Cloudflare DNS record whenever it changes. Set it up at the same time as WireGuard, not after you\u0026rsquo;ve been caught out by a stale IP.\nOn the phone: automatic tunneling # On Android I use an app called WG Tunnel rather than the official WireGuard client. The reason is one specific feature: automatic tunneling based on WiFi network.\nI set my home WiFi as trusted. When I\u0026rsquo;m home, the VPN is off. I\u0026rsquo;m already on the local network. The moment I join any other network, mobile data, a café, anywhere else, the VPN connects automatically without me touching anything.\nThe first time I walked out of the house and photos.yourdomain.com just loaded on mobile data without me doing anything, that was the setup paying off. It just runs in the background. I don\u0026rsquo;t think about it.\nFallback: Tailscale # I also have Tailscale installed, mostly as insurance. Tailscale is particularly useful if your home internet is behind CGNAT, where you can\u0026rsquo;t open ports at all — WireGuard wouldn\u0026rsquo;t work in that scenario but Tailscale would. That\u0026rsquo;s not my situation, but it\u0026rsquo;s good to have a fallback while I\u0026rsquo;m still building confidence in the WireGuard setup. I\u0026rsquo;ll probably drop it once I\u0026rsquo;m satisfied everything holds up.\nWhat went wrong: the speed problem # WireGuard connected fine from day one. But speeds outside the house were noticeably slower than they should have been, around 40 Mbps when my connection was capable of 80+. It worked, but it felt off.\nTurns out it was several things stacked on top of each other.\nwg-easy defaulting to the wrong network interface. The container hardcodes eth0 for its firewall rules, but Ubuntu had named the ethernet adapter something different (eno1 in my case). The MASQUERADE rule that actually routes VPN traffic to the internet was being applied to an interface that didn\u0026rsquo;t exist. WireGuard was connecting, but traffic wasn\u0026rsquo;t routing properly. The fix was switching wg-easy to network_mode: host and setting the iptables rules manually with the correct interface name via WG_POST_UP and WG_POST_DOWN.\niptables legacy vs nft mismatch. Ubuntu 24.04 uses iptables-nft by default, but wg-easy writes its rules to iptables-legacy. The kernel was ignoring them entirely. Writing the rules explicitly in the compose file worked around this.\nThe server was on WiFi. After all that, speeds were still not where they should be. The laptop was running wirelessly, which means all VPN traffic in and out was competing on the same radio. WiFi is half-duplex. I plugged in an ethernet cable and speeds jumped to 130 Mbps.\nNone of these were obvious from the outside. WireGuard appeared to be working the whole time. The only sign something was wrong was the speed. If your VPN feels sluggier than expected, check the interface name and iptables backend first — and make sure your server is actually on ethernet.\nHow it actually feels # I leave the house, my phone connects to WireGuard automatically, and photos.yourdomain.com works exactly the same as when I\u0026rsquo;m home. Ad blocking applies. Everything is HTTPS. Nothing feels different.\nThat\u0026rsquo;s the goal. When the infrastructure is working well, you stop noticing it.\n","date":"February 20, 2026","externalUrl":null,"permalink":"/posts/wireguard/","section":"Posts","summary":" Having a home server that works at home is one thing. Having it work exactly the same when you’re out, on mobile data, at a café, anywhere, is what makes it actually useful day to day.\nGetting this right was worth the effort.\nKeeping exposure minimal # The obvious shortcut for remote access is port forwarding: expose your services on port 443, put a login screen in front of them, done. The problem is that everything becomes internet-facing. Every service needs to be hardened. Every misconfiguration is a risk. And some things, like local DNS filtering, don’t make sense to expose publicly at all.\n","title":"Remote Access That Actually Works: Running Your Own VPN with WireGuard","type":"posts"},{"content":" Once Immich was running, I had a working photo server. But accessing it meant typing an IP address and a port number into my browser every time. Not the end of the world, but not great either, especially when you start adding more services.\nThere\u0026rsquo;s a better way, and setting it up opened a door I hadn\u0026rsquo;t expected.\nThe problem with IP addresses and ports # When a service runs on your home server, it listens on a port. Your photo app might be on port 2283, your backup tool on 51515, your DNS dashboard on 3000. To access them you type something like:\nhttp://192.168.x.x:2283 A few problems with this:\nNo HTTPS, so browsers show warnings and some features don\u0026rsquo;t work You have to remember which port belongs to which service Bookmarks break if you change a port It just looks and feels rough The solution is a reverse proxy: one piece of software that sits in front of all your services and routes traffic based on a domain name instead of a port. Visit photos.yourdomain.com and the proxy sends you to the photo app. Visit backup.yourdomain.com and it sends you to the backup tool. Clean, consistent, no port numbers to remember.\nGetting a domain # A domain is the foundation. I registered mine through Cloudflare, around $11 a year. That\u0026rsquo;s the only recurring cost in this part of the setup.\nWith a domain you can create subdomains for each service. You can also get a wildcard SSL certificate, a single certificate that covers every subdomain automatically, so everything gets HTTPS without any extra effort per service.\nFor the wildcard cert I used Let\u0026rsquo;s Encrypt with a DNS challenge via the Cloudflare API:\ncertbot certonly \\ --dns-cloudflare \\ --dns-cloudflare-credentials /path/to/cloudflare.ini \\ -d \u0026#34;*.yourdomain.com\u0026#34; The certificate renews automatically every 90 days. A small deploy hook restarts the relevant services after renewal so nothing breaks silently.\nCaddy: the reverse proxy # I use Caddy as the reverse proxy, installed natively on the server rather than in Docker. The config syntax is genuinely simple. Each service is about three lines:\nphotos.yourdomain.com { tls /path/to/cert.pem /path/to/key.pem reverse_proxy localhost:2283 } backup.yourdomain.com { tls /path/to/cert.pem /path/to/key.pem reverse_proxy localhost:51515 } Adding a new service takes thirty seconds. Caddy handles HTTPS termination and passes traffic through to wherever the service is actually listening.\nThe first time I typed photos.yourdomain.com into the browser and got a proper HTTPS page instead of an IP address and port number, it felt like a proper upgrade. Small thing, but it matters.\nCaddy only runs on port 443. That port is not exposed to the internet, only accessible from inside the home network or through VPN.\nThe DNS piece (and the accidental bonus) # Here\u0026rsquo;s the part that wasn\u0026rsquo;t obvious to me at first.\nYour domain is registered publicly on Cloudflare. But most of your services are internal. You don\u0026rsquo;t want backup.yourdomain.com resolving publicly on the internet. And even when you\u0026rsquo;re home, you want the domain to resolve to your local server IP directly, not go out to the internet and come back.\nThe solution is local DNS. I needed something running on the network that could intercept those lookups and return the local IP instead.\nI ended up with AdGuard Home, not because I was specifically looking for it, but because it was the cleanest tool for the job. It acts as a DNS server for the whole network, and I configured my router to use it. Then I added a single wildcard rewrite:\n*.yourdomain.com → your.server.local.ip One entry, covers every subdomain automatically. Add a new service, point Caddy at it, done. No extra DNS step.\nNow when any device on the network asks \u0026ldquo;where is photos.yourdomain.com?\u0026rdquo;, AdGuard returns the server\u0026rsquo;s local IP. Fast, private, never leaves the house.\nThe bonus I didn\u0026rsquo;t plan for: AdGuard also blocks ads and trackers across every device on the network, with no apps to install. Every phone, every laptop, just covered. It\u0026rsquo;s become one of my favourite parts of the whole setup, and I almost didn\u0026rsquo;t set it up at all.\nWhat goes wrong: the mistakes I made # Forgetting to point the router at AdGuard. I set everything up, tested in the browser, got nothing. Spent a while confused before realising my phone was still using the ISP\u0026rsquo;s DNS. The DNS rewrites only work if devices are actually using AdGuard as their resolver. Router config first.\nTrying to avoid buying a domain. I asked an AI and got told Tailscale subdomains could work as an alternative. Tried it, and the problem is you don\u0026rsquo;t control the subdomain. It\u0026rsquo;s on Tailscale\u0026rsquo;s domain, not yours. You can\u0026rsquo;t point photos.tailscale-domain.com somewhere and have it behave like your own. I spent longer than I\u0026rsquo;d like to admit figuring out that AI had confidently sent me down a dead end. $11 a year, not worth the rabbit hole.\nOne subdomain that\u0026rsquo;s public: sharing photos # Most subdomains have no public DNS records, they\u0026rsquo;re internal only. But one is different.\nImmich has a photo sharing feature: generate a link, send it to someone, they open it in their browser without needing an account. For that to work, the link needs to be reachable from the internet.\nI handle this with a Cloudflare Tunnel, an outbound connection from my server to Cloudflare\u0026rsquo;s edge with no inbound ports required. The tunnel is configured to only forward specific URL paths (the share link paths), and return 404 for everything else. So someone with a share link can view the photos, but someone trying to reach the login page gets nothing.\nThis keeps the service private while making sharing work.\nWhat the whole thing looks like # When I open photos.yourdomain.com on my phone at home:\nPhone asks AdGuard: \u0026ldquo;where is photos.yourdomain.com?\u0026rdquo; AdGuard returns the server\u0026rsquo;s local IP Phone connects to Caddy on port 443 Caddy sees the domain, proxies to the photo app Photo app responds, all over HTTPS, all on the local network The request never leaves the house. No round-trip to the internet.\n","date":"February 16, 2026","externalUrl":null,"permalink":"/posts/networking/","section":"Posts","summary":" Once Immich was running, I had a working photo server. But accessing it meant typing an IP address and a port number into my browser every time. Not the end of the world, but not great either, especially when you start adding more services.\nThere’s a better way, and setting it up opened a door I hadn’t expected.\nThe problem with IP addresses and ports # When a service runs on your home server, it listens on a port. Your photo app might be on port 2283, your backup tool on 51515, your DNS dashboard on 3000. To access them you type something like:\n","title":"Clean URLs and HTTPS for Your Home Server (Without Touching a Port Number Again)","type":"posts"},{"content":" The first thing I installed on my new home server was a photo management app. Not because I had a perfectly thought-out plan. I just wanted to see if this was even possible, and photos were the whole point of starting.\nI ended up testing two: Immich and PhotoPrism. Both are open source Google Photos alternatives. Both run in Docker. I figured I\u0026rsquo;d try them side by side and see which one I preferred.\nFirst impressions: running both at once # Getting both up was straightforward enough. Docker Compose files, a few environment variables, done. I installed the Immich mobile app on my phone, pointed it at the server, and started backing up. Watching the photos appear on the server in real time was a good moment.\nThen the server slowed to a crawl.\nImmich does AI processing in the background: face recognition, object detection, generating embeddings for smart search. PhotoPrism does similar things. Running both at the same time on a 2013 laptop CPU was too much. CPU hit 100% and stayed there. I just left it running, though I was impatient the whole time because I had a list of other things I wanted to set up and couldn\u0026rsquo;t do anything until this settled.\nIt also became clear pretty quickly that the two apps overlapped too much to bother running both. I went with Immich — it\u0026rsquo;s more actively developed and has a proper Android app. The processing took longer than I expected too, partly because the library kept growing as I added more photos through the cleanup process I\u0026rsquo;ll get to shortly. But more on that later.\nThe storage situation I hadn\u0026rsquo;t thought through # At this point, Immich had my phone photos, about two years\u0026rsquo; worth that I\u0026rsquo;d just backed up directly. But I had a bigger problem I\u0026rsquo;d been ignoring.\nMy actual photo library was split across two places:\nKoofr: a 1TB lifetime account with about two years of photos backed up there Google Photos: everything else, including older photos and, awkwardly, the same last two years that were also in Koofr So I had duplicates, I had photos in Google that weren\u0026rsquo;t anywhere else, and I\u0026rsquo;d just added a third copy of recent photos in Immich\u0026rsquo;s local storage. It was a mess.\nThe right solution was to get everything out of Google, merge it all, clean it up, and put it in one place. Which sounds simple. It is not simple.\nGoogle Takeout: the first circle of hell # Google lets you export all your Photos data through a service called Google Takeout. You request an export, they prepare it, you download it. In theory. In practice, Google treats your own data like a hostage negotiation.\nMy library was large enough that it split into multiple zip files. The export took time to prepare. Downloading everything was slow. And when I extracted it all, I had a folder full of photos where roughly half of them had their metadata (dates, locations, descriptions) stored in separate .json sidecar files instead of embedded in the image itself.\nThis is a known Google Photos quirk and it is genuinely annoying. The photos and their metadata are separate files, named in a way that\u0026rsquo;s sometimes obvious and sometimes not. If you just copy the photos and ignore the sidecar files, you end up with images where the date is wrong or missing entirely, which messes up your timeline.\nFixing the metadata: the second circle of hell # There\u0026rsquo;s a tool called exiftool that can read the sidecar .json files and write the metadata back into the images. There\u0026rsquo;s also a Python script that automates this across an entire Takeout folder. I used one I found after some searching.\nThe process works, but it takes time and you need to be careful about running it on the right folders in the right order. I ran it, spot-checked a bunch of files, and eventually had a photo library where the dates looked right.\nThen I uploaded everything to Koofr.\nThe duplicate problem # Now I had:\nThe old Koofr photos (two years of backups) The Google Takeout photos (everything, including those same two years) Some overlap with the phone photos already in Immich Koofr has a built-in duplicate detection tool. It found duplicates across the library and let me review and remove them. It took a few passes but eventually I got down to one clean copy of everything.\nThis part wasn\u0026rsquo;t painful exactly. It was just slow and required attention. But it felt good when it was done.\nThe better approach: external library # After going through all of that, I discovered something I wish I\u0026rsquo;d known from the start: Immich supports external libraries.\nInstead of uploading photos into Immich\u0026rsquo;s own storage, you can point it at a folder and tell it to treat that as a read-only library. It scans the photos, does all the AI processing, builds the face recognition and search index, but doesn\u0026rsquo;t move or copy the files. The originals stay wherever they are.\nThis was one of those moments where everything clicks. I\u0026rsquo;d been thinking about this wrong the whole time.\nNow:\nThe actual photo files live in Koofr (mounted on the server via rclone) Immich scans that mounted folder as an external library Immich handles all the AI/ML: faces, smart search, timeline Backing up means backing up to Koofr, not managing a separate Immich library The photos have one authoritative home. Immich is the interface and intelligence layer on top.\nThe backup picture # The actual files live in Koofr, which covers the offsite copy. I sync to an external hard drive occasionally for a local copy:\nrsync -av --progress /media/koofr/photos/ /media/external/photos/ I do back up the Immich database separately, so if the server dies the face tags, albums, and metadata are all recoverable. The photos are in Koofr, the database is backed up. Starting over would just be a restore, not a rescan.\nWhere things stand # Immich is running, photos are there, everything is searchable and organised.\nThe process of getting here was messier than I expected. The Takeout export, the metadata fixing, the duplicates. But it was a one-time effort. Everything going forward is clean: phone backs up to Immich, Immich reads from Koofr, Koofr is the source of truth.\nIf you\u0026rsquo;re considering doing the same thing, my advice: start the Google Takeout export early, expect the metadata process to take time, and set up the external library from day one rather than importing into Immich directly.\n","date":"February 12, 2026","externalUrl":null,"permalink":"/posts/immich-photos/","section":"Posts","summary":" The first thing I installed on my new home server was a photo management app. Not because I had a perfectly thought-out plan. I just wanted to see if this was even possible, and photos were the whole point of starting.\nI ended up testing two: Immich and PhotoPrism. Both are open source Google Photos alternatives. Both run in Docker. I figured I’d try them side by side and see which one I preferred.\n","title":"Getting My Photos Off Google: The Immich Setup and the Painful Takeout Process","type":"posts"},{"content":"This started the way most tinkering projects do: a spare laptop, a vague idea, and stakes low enough that trying seemed worth it.\nI\u0026rsquo;d had the laptop for over five years. A 2013 Dell business laptop I picked up cheap when my previous company was clearing out old hardware. I\u0026rsquo;d upgraded it over the years, used it here and there for small projects, kept it around as a backup laptop, but it never really found a permanent use. Mostly it just sat in various sort of drawer.\nThe setup for a home server didn\u0026rsquo;t look that complicated. Install Linux, install Docker, run some containers. If it didn\u0026rsquo;t work out, it was a drawer laptop anyway. Low risk.\nThe upside felt real too. My photos situation had been nagging at me for a while. I had some on my phone, some backed up to Koofr, some on an external hard drive I hadn\u0026rsquo;t plugged in for months. And everything else in Google Photos, the only place they all existed together, which felt like a fragile position to be in. I\u0026rsquo;m not paranoid (I guess every paranoid person will say this but still), but if I can keep my own copy of something important without too much inconvenience, it\u0026rsquo;s worth trying.\nI\u0026rsquo;d bought a 1TB Koofr lifetime deal a couple of years earlier anyway. I hate subscriptions, so the lifetime deal made sense, and the storage was already there.\nWhere AI tools actually helped # I use the command line at work, so the technical side wasn\u0026rsquo;t intimidating. But self-hosting means constantly running commands you didn\u0026rsquo;t understood (I am sort of noob in using command line and Linux in general), and it doesn\u0026rsquo;t always feel obvious what they\u0026rsquo;re actually doing. That\u0026rsquo;s where AI tools became genuinely useful. To get things running quickly and ask for explanations when something broke or didn\u0026rsquo;t make sense. I had a list of things I wanted to set up and I wanted them all running as fast as possible, and AI helped with that. Some things I had to come back to later and fix properly. But good enough to keep moving, and you learn more along the way than you\u0026rsquo;d expect.\nAnd honestly? Once things started clicking into place, it was just fun. There\u0026rsquo;s something satisfying about getting a service running, opening a URL in your browser, and having it just work. That feeling carried me through the parts that didn\u0026rsquo;t go so smoothly.\nWhat I built # Over a few days of tinkering, I ended up with a home server that handles:\nPhoto management: a self-hosted alternative to Google Photos, with AI features like face recognition and smart search Remote access: so everything works the same whether I\u0026rsquo;m home or travelling Backups: because owning your own server doesn\u0026rsquo;t mean skipping backups Network-wide ad blocking: every device, no app required A few small utilities: document tools, drive health monitoring, a dashboard The rest of this series goes through each piece. The first thing I set up, and the thing that made this whole project feel real, was Immich.\n","date":"February 8, 2026","externalUrl":null,"permalink":"/posts/why-i-started/","section":"Posts","summary":"This started the way most tinkering projects do: a spare laptop, a vague idea, and stakes low enough that trying seemed worth it.\nI’d had the laptop for over five years. A 2013 Dell business laptop I picked up cheap when my previous company was clearing out old hardware. I’d upgraded it over the years, used it here and there for small projects, kept it around as a backup laptop, but it never really found a permanent use. Mostly it just sat in various sort of drawer.\n","title":"I Repurposed an Old Unused Laptop as a Home Server. Here's Why.","type":"posts"},{"content":"","externalUrl":null,"permalink":"/tags/","section":"Tags","summary":"","title":"Tags","type":"tags"}]