djuntgen@juntgen.com
← all posts

Building a Homelab with AI · part 12

Sites, DNS, and MOTDs: A Productive Homelab Session


This is Part 12 of the Building a Homelab with AI series. Previously: Anti-Patterns and War Stories

Some sessions have a single focus. This one did not. We covered personal site architecture, domain registration, DNS cleanup, Cloudflare token hygiene, and a dynamic MOTD rollout — all in one afternoon. Each piece was small enough to ship quickly, and by the end the infrastructure was meaningfully better in three separate dimensions.

Here is what we actually did.

Planning the Site Architecture

The starting point was a question: what goes where? I own example.com and have been using it for homelab services, but I have not thought carefully about the web presence side. We mapped it out:

  • example.com — family landing page, with the homelab blog as a section. The family-facing root.
  • example.me — my personal site. Resume, about page, portfolio. Separate domain so it can stand independently without being subordinate to the family domain.
  • *.example.com — subdomains reserved for the kids’ future sites when they are ready.

The subdomain versus path decision for the kids’ sites was deliberate. A path-based approach would always be a section of my site. A subdomain is their own thing — their own TLS cert, their own Caddy block, their own deploy pipeline when the time comes. Subdomains compose better for eventual independence.

For the static site generator, we chose Astro. The reasoning:

  • Islands architecture — ship zero JavaScript by default, opt in per component. For a personal site with mostly static content but a few interactive pieces, this is exactly right.
  • View Transitions — first-class API for page transition animations without client-side routing overhead.
  • Framework-agnostic — can use React, Svelte, or vanilla components in the same project. No lock-in.

The sites themselves are a future build. The infrastructure to support them is what we tackled today.

Registering example.me

Registered example.me directly through Cloudflare registrar. Cloudflare’s registrar charges at-cost, so there is no markup on the registration fee. More importantly, keeping the domain registration and DNS management in the same place eliminates the propagation delay when you update nameservers — Cloudflare controls both ends of the chain.

New domain, zero DNS records. That changes immediately.

DNS Management via Cloudflare API

Rather than clicking through the Cloudflare dashboard, we used the API directly. This is faster for multiple changes and — more importantly — it is auditable. If you configure DNS through the UI, your only record of what you did is memory and the Cloudflare audit log. If you do it via API calls with a shell session, you have something reproducible.

The Cloudflare API token lives at <caddy-env-file> on the Caddy host. We read it from there for these operations.

Creating Records for example.me

# Create the A record -- proxied through Cloudflare
curl -s -X POST "https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/dns_records" \
  -H "Authorization: Bearer ${CF_TOKEN}" \
  -H "Content-Type: application/json" \
  --data '{
    "type": "A",
    "name": "example.me",
    "content": "<public-ip>",
    "proxied": true
  }'

# Create the www CNAME -- also proxied
curl -s -X POST "https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/dns_records" \
  -H "Authorization: Bearer ${CF_TOKEN}" \
  -H "Content-Type: application/json" \
  --data '{
    "type": "CNAME",
    "name": "www",
    "content": "example.me",
    "proxied": true
  }'

Both proxied through Cloudflare: DDoS protection, origin IP masking, and the CDN layer for free.

Cleaning Up example.com

While reviewing the example.com DNS records, we found stale Namecheap nameserver records still present from before the domain was migrated to Cloudflare. Those should have been removed at migration time. They were not. They had been sitting there doing nothing — except being noise and a potential foothold for confusion if anyone ever tried to troubleshoot DNS issues.

Removed them via API. The zone is clean now.

Flipping ha.example.com to DNS-Only

This one matters operationally. ha.example.com was configured as proxied through Cloudflare. Home Assistant uses WebSockets extensively — long-lived connections for real-time state updates in the frontend. Cloudflare’s proxy tier has a 100-second timeout on WebSocket connections. The result is periodic disconnections in the HA frontend that look mysterious if you do not know the cause.

The fix is straightforward: set the DNS record to DNS-only (orange cloud to grey cloud). Cloudflare still resolves the name, but traffic flows directly to your origin without the proxy in the middle. No timeout, no reconnection drops.

# Update ha.example.com -- proxied: false
curl -s -X PATCH "https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/dns_records/${RECORD_ID}" \
  -H "Authorization: Bearer ${CF_TOKEN}" \
  -H "Content-Type: application/json" \
  --data '{"proxied": false}'

The trade-off: your home IP is now visible to anyone who resolves ha.example.com. For Home Assistant, that is acceptable. HA is already secured with its own authentication layer, and hiding the IP behind Cloudflare while still routing traffic to your home does not meaningfully increase security anyway — it just obscures the origin.

Cloudflare Token Security: Applying Least Privilege

This was the most important fix of the session, even though it is invisible from the outside.

We had a single Cloudflare API token doing two different jobs:

  1. ACME DNS-01 challenges — Caddy uses this to prove domain ownership when obtaining TLS certificates. It needs Zone:DNS:Edit to create and delete _acme-challenge TXT records.

  2. DNS management — the API calls above, zone reviews, record cleanup, record updates. General administrative access.

One token for both. That token lives on the Caddy host at <caddy-env-file>. Think about what that means: a token with DNS management capabilities is sitting on an internet-facing VM. If that VM is ever compromised, an attacker gets the ability to create, modify, and delete DNS records for your entire zone — not just perform ACME challenges.

The fix: split into two tokens with separate scopes.

cloudflare_acme_tokenZone:DNS:Edit, scoped specifically to example.com and example.me. This is the only permission Caddy needs for ACME. It lives in <caddy-env-file> deployed via Ansible, and it is stored in Ansible Vault. If the Caddy VM is compromised, this token can manipulate DNS records for those two zones — bad, but contained.

cloudflare_api_tokenZone:DNS:Settings:Edit for broader administrative use. This token lives in Ansible Vault only. It is never deployed to any server. The only place it exists at rest is the encrypted vault file, accessible on dev (the control node) by the operator with the vault password. When you need to do DNS management work, you pull it from the vault to your local session. When you are done, it is gone from memory.

We updated ansible/inventory/group_vars/all/vault.yml with the new cloudflare_acme_token value and committed.

The key architectural principle: a secret’s scope should match the access it grants, and a secret’s deployment should match the surface area that needs it. The ACME token needs to be on the Caddy host. The management token does not need to be anywhere except in the vault.

Dynamic MOTD via Ansible

The last piece of the session was quality-of-life: replacing the static community-scripts MOTD banner with something actually useful.

The static banner tells you the hostname. That is decorative. What you actually want to know when you SSH into a box is:

  • What host am I on? (Sanity check — especially important when you have eight similar-looking LXC containers.)
  • What is the OS and version?
  • How long has this been up?
  • What is the current load?
  • How is memory looking?
  • Is disk space a concern?
  • How many Docker containers are running? (On docker-host, that matters.)

The new MOTD delivers all of that at login:

───────────────────────────────────────────────────────
  docker-host                ·  10.0.0.12      ·  Ubuntu 24.04
───────────────────────────────────────────────────────
  Uptime :  2 weeks, 3 days, 14 hours      Load :  0.12  0.08  0.05
  Memory :  2.1Gi  / 8.0Gi                Disk :  18G / 100G  18%
  Docker :  7 / 9 containers running
───────────────────────────────────────────────────────

The script lives at /etc/profile.d/00_lxc-details.sh on each host, deployed via playbooks/motd.yml. It uses only standard system utilities — no dependencies. hostname, free, df, uptime, /proc/loadavg, and conditionally docker ps if Docker is present.

Deployed to six hosts in one run: docker-host, caddy, n8n, db, influxdb, and pve. The control node (dev) was updated directly since Ansible does not manage itself.

ansible-playbook playbooks/motd.yml

No failed tasks on the reachable hosts. The next SSH login to any of those hosts gets the new banner.

What This Session Actually Accomplished

  • Planned a coherent multi-site architecture for the family and personal web presence
  • Registered example.me and wired up DNS in under ten minutes
  • Cleaned stale Namecheap NS records that had been sitting in the zone since the migration
  • Fixed the Home Assistant WebSocket disconnection issue at the DNS layer
  • Applied least-privilege discipline to the Cloudflare API token situation — a real security improvement, not a theoretical one
  • Replaced a decorative MOTD with one that delivers actionable system state at login, across six hosts

The Cloudflare token split is the one to point to as highest-value. It is exactly the kind of security improvement that does not look like much from the outside but meaningfully reduces blast radius if something goes wrong. One token on an internet-facing host, scoped to only what that host needs. Everything else stays in the vault.

The MOTD is the one you will notice every day.


Part 12 of the Building a Homelab with AI series.