2026.07.03 on fullmetal975

Getting this blog and its own email address live: a full night of self-inflicted problems

Registering a domain sounds like a five-minute task. Getting this actual blog live, plus a working self-hosted mailbox on that domain, took most of a night — almost none of it because anything was genuinely hard, and almost all of it because I kept making one small wrong assumption at a time. Writing the whole thing down, mistakes included, because that’s more useful than pretending it went smoothly.

Buying the domain

Landed on vramdiary.com — specific, a little funny, honest about what half these posts are actually about. Checked out on Namecheap, skipped the upsells (PremiumDNS, Stellar Web Hosting, all of it — none of it does anything a self-hosted setup needs).

The Cloudflare detour

First instinct was to route DNS through Cloudflare — free proxy, hides the home IP, DDoS protection. Went through the whole flow: onboard the domain, copy the two Cloudflare nameservers, switch Namecheap to Custom DNS, wait for propagation, add A/CNAME records in Cloudflare’s dashboard.

Then stopped and actually thought about why I was doing that. The entire point of self-hosting is not routing my own traffic through someone else’s infrastructure. Cloudflare’s free tier doesn’t cost money, but it does put their proxy in the middle of every request to my own server — which defeats a real part of the purpose. Reverted cleanly: switched Namecheap back to BasicDNS, deleted the Cloudflare zone, re-added the A and CNAME records directly on Namecheap instead. Same pattern my wife’s WordPress site already uses. One third party removed from the stack, no real cost.

Lesson: DNS-only Cloudflare doesn’t see your web traffic if you leave proxying off, but the second you turn that orange cloud on, it does. Worth knowing which one you’re actually signing up for.

Deploying the site itself

Hugo site, custom-built theme, Docker multi-stage build. The build kept failing at the exact same step in three different ways before it actually worked:

  • First failure: the Hugo binary downloaded fine but the shell said hugo: not found immediately after — even calling it by absolute path. Real cause: I’d pulled the extended Hugo build, which needs CGO/glibc for Sass support. Alpine only has musl libc. The binary existed, the OS just couldn’t execute it. Switched to the plain (non-extended) Hugo release, which is pure Go and statically linked — problem gone, since this site doesn’t use Sass anyway.
  • Second failure: docker-compose.yml referenced a Docker network name (npm_default) I’d guessed at instead of checked. docker network ls showed the real one: nginx-proxy-manager_default.
  • Third failure: a stray theme = "" line left over in config.toml from the original scaffold — newer Hugo treats an empty theme string as a module reference and goes looking for it. One-line delete fixed it.

Once all three were sorted, the build finished in under a second (cached layers) and the container came up clean.

NPM and the cert

Straightforward once DNS was actually pointed at the right place — proxy host, forward to the container by name, Let’s Encrypt via plain HTTP challenge (no DNS challenge, no API tokens, matching the exact pattern already used for kristentlorenzo.com). Live in a few minutes.

Then: email

Wanted a mailbox — info@vramdiary.com — self-hosted, not another Gmail account, nothing routed through a third party. Since I only cared about receiving mail, not sending, most of the usual self-hosted-email horror stories (port 25 blocks, residential IP blacklisting, SPF/DKIM fights) didn’t actually apply. Checked that my ISP doesn’t block port 25, added one new port-forward rule on the router (additive only — didn’t touch the two existing working rules), added an MX record, and deployed Poste.io.

This is where the night actually got long.

Redirect loop, round one. Poste.io tries to manage its own HTTPS internally by default. NPM was already terminating SSL and forwarding plain HTTP, and the two fought each other into an infinite redirect. Tried disabling Poste.io’s TLS via an environment variable — turns out TLS_FLAVOR=notls isn’t a real value, and even the correct one (off) didn’t fully stop the app’s own hardcoded redirect on its setup wizard specifically. The actual fix: leave Poste.io’s own self-signed HTTPS running internally, and point NPM’s proxy host at its HTTPS port instead of HTTP. NPM doesn’t care that the backend cert is self-signed — it just needs a path that doesn’t redirect.

The 500 that wasn’t actually a 500. Setup wizard threw a genuine-looking 500 error page. Assumed it failed. It hadn’t — users.db inside the container already had real, fresh data when I checked. The browser’s request had gotten interrupted while the backend was still finishing, but the backend finished anyway. Lesson: check the actual state before assuming an error page means nothing happened.

The permission maze. Reset the admin password via the container’s CLI — but that first attempt ran as root by default, silently changing ownership of some log/cache files inside the persistent data volume. Every command after that which needed to run as the app’s internal user (UID 8) started failing on writes to those now-root-owned files. Fixed by explicitly chown-ing the affected paths back.

The wipe that didn’t wipe. Eventually decided the data directory had just accumulated too much inconsistent state and tried to start clean: rm -rf the whole thing, recreate, reinstall. Except rm -rf from my own user account silently failed on nearly every file — because those files were owned by root, mail, www-data, and other users created inside the container, which my regular host account has no permission to delete. Every “clean” attempt after that was actually still running against the old, half-broken data the whole time. Only realized this by checking file timestamps and noticing they were hours older than they should’ve been. Real fix: point the container at a brand-new volume path instead of trying to force-delete the old one.

The false-positive phishing flag. Right after finally getting a working setup wizard submission, Chrome flagged mail.vramdiary.com as a “Dangerous site” — full-page Safe Browsing block, not just a warning badge. Best guess: all the redirect-loop traffic from earlier in the night looked like spoofing/redirect-chain abuse to Google’s automated scanner, on a domain that’s hours old with zero reputation history yet. Reported it as a false positive and moved on — the mailbox itself was already confirmed created via CLI (email:list) before the warning even appeared, so functionally nothing was actually broken.

What’s actually running now

  • vramdiary.com — this blog, self-hosted, DNS on Namecheap directly, no third-party proxy in the path
  • info@vramdiary.com — a real mailbox, self-hosted via Poste.io, checked through its own webmail UI, not forwarded through Gmail or anyone else

The actual lesson

Almost nothing here was a hard problem. A wrong Hugo build variant. A guessed network name. A stray config line. An environment variable that wasn’t a real option. A rm -rf that failed silently because of a permissions boundary I didn’t think to check. Each one, alone, is a two-minute fix once you see it. Stacked on top of each other across one long session, they’re exhausting — mostly because a failing command that looks like it did nothing sometimes actually did something, and the only way to know for sure is to go check the real state directly instead of trusting the error message on its face.