<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="4.4.1">Jekyll</generator><link href="/feed.xml" rel="self" type="application/atom+xml" /><link href="/" rel="alternate" type="text/html" /><updated>2026-06-22T23:30:17+00:00</updated><id>/feed.xml</id><title type="html">Just Another Geek Blog</title><subtitle></subtitle><entry><title type="html">Members Deep-Dive: My Reverse-Proxy &amp;amp; Hardening Kit</title><link href="/2026/06/22/members-deep-dive-my-reverse-proxy-hardening-kit.html" rel="alternate" type="text/html" title="Members Deep-Dive: My Reverse-Proxy &amp;amp; Hardening Kit" /><published>2026-06-22T20:00:00+00:00</published><updated>2026-06-22T20:00:00+00:00</updated><id>/2026/06/22/members-deep-dive-my-reverse-proxy-hardening-kit</id><content type="html" xml:base="/2026/06/22/members-deep-dive-my-reverse-proxy-hardening-kit.html"><![CDATA[<p><em>A members-only companion to the public homelab posts — the actual files, not just the story. Thanks for supporting the work.</em></p>
<h3 id="the-traefik-label-set-i-put-on-every-service">The Traefik label set I put on every service</h3>
<pre><code>labels:
  - traefik.enable=true
  - "traefik.http.routers.APP.rule=Host(`app.example.com`)"
  - traefik.http.routers.APP.entrypoints=websecure
  - traefik.http.routers.APP.tls.certresolver=le
  - traefik.http.services.APP.loadbalancer.server.port=PORT
  - traefik.http.routers.APP.middlewares=crowdsec@file,sec-headers@file</code></pre>
<h3 id="crowdsec-the-traefik-bouncer">CrowdSec + the Traefik bouncer</h3>
<p>CrowdSec runs as its own container reading Traefik's access logs; the bouncer (a forward-auth middleware) checks each request against CrowdSec's decisions plus the community blocklist, pulled in stream mode so blocking is near-instant.</p>
<h3 id="the-egress-firewall-the-part-people-skip">The egress firewall (the part people skip)</h3>
<pre><code># Block an untrusted container network from reaching prod, metadata, and SMTP
iptables -I DOCKER-USER -s 172.30.0.0/24 -d 169.254.169.254 -j DROP   # cloud metadata
iptables -I DOCKER-USER -s 172.30.0.0/24 -p tcp --dport 25 -j DROP    # SMTP
iptables -I DOCKER-USER -s 172.30.0.0/24 -d 172.18.0.0/16 -j DROP     # prod network
iptables -A DOCKER-USER -s 172.30.0.0/24 -j ACCEPT                    # allow the rest</code></pre>
<p>Adapt the subnets to your own networks. Questions? <a href="mailto:contact@paulhitt.com">contact@paulhitt.com</a>.</p>]]></content><author><name></name></author><category term="Homelab" /><category term="Homelab" /><category term="Members" /><summary type="html"><![CDATA[The actual configs behind the public homelab posts — members only.]]></summary></entry><entry><title type="html">A Whole SaaS Suite on One Box</title><link href="/2026/06/22/a-whole-saas-suite-on-one-box.html" rel="alternate" type="text/html" title="A Whole SaaS Suite on One Box" /><published>2026-06-22T09:15:00+00:00</published><updated>2026-06-22T09:15:00+00:00</updated><id>/2026/06/22/a-whole-saas-suite-on-one-box</id><content type="html" xml:base="/2026/06/22/a-whole-saas-suite-on-one-box.html"><![CDATA[<p>The <a href="https://extant2000.com">Extant 2000</a> products — nine-plus separate apps — all run on a single VPS. Not for lack of budget, but because one well-organized box is easier to reason about than a fleet.</p>
<h3 id="the-layout">The layout</h3>
<p>Each app is a container; Traefik routes them by subdomain; a shared Postgres backs the data; Let's Encrypt handles every cert. The whole suite lives in version-controlled compose files, so the box is reproducible.</p>
<h3 id="isolation-where-it-counts">Isolation where it counts</h3>
<p>"One box" doesn't mean "one blast radius." Untrusted or experimental workloads get their own network segments and egress rules, so a problem in one place can't wander into another. Prod apps, the media stack, and the retro lab share metal but sit in separate lanes.</p>
<h3 id="operationally-calm">Operationally calm</h3>
<p>One place to deploy, one to back up, one to look when something's off. Vertical beats horizontal until you genuinely outgrow the machine — and a modern VPS holds a <em>lot</em> before you do.</p>
<p>The least glamorous architecture decision I've made, and one of the best.</p>]]></content><author><name></name></author><category term="Homelab" /><category term="Homelab" /><category term="Self-Hosting" /><summary type="html"><![CDATA[Nine-plus apps, one VPS, and why vertical beats horizontal.]]></summary></entry><entry><title type="html">Read-Only Metabase Over a Production Database</title><link href="/2026/06/21/read-only-metabase-over-a-production-database.html" rel="alternate" type="text/html" title="Read-Only Metabase Over a Production Database" /><published>2026-06-21T20:40:00+00:00</published><updated>2026-06-21T20:40:00+00:00</updated><id>/2026/06/21/read-only-metabase-over-a-production-database</id><content type="html" xml:base="/2026/06/21/read-only-metabase-over-a-production-database.html"><![CDATA[<p>I wanted dashboards over my app's production data without giving a BI tool any way to <em>change</em> that data. <strong>Metabase</strong> plus a carefully-scoped database role does exactly that.</p>
<h3 id="the-principle">The principle</h3>
<p>Never point BI at your app's main database user. Create a dedicated role that can read and nothing else:</p>
<pre><code>CREATE ROLE metabase_ro LOGIN PASSWORD '********';
GRANT USAGE ON SCHEMA public TO metabase_ro;
GRANT SELECT ON ALL TABLES IN SCHEMA public TO metabase_ro;
ALTER DEFAULT PRIVILEGES IN SCHEMA public
  GRANT SELECT ON TABLES TO metabase_ro;</code></pre>
<h3 id="the-row-level-security-wrinkle">The row-level-security wrinkle</h3>
<p>My tables have row-level security policies written for the app's auth context, not a reporting role — so a plain read-only role would see almost nothing. Granting the role <code>BYPASSRLS</code> lets it read every row for analytics while still being unable to write. A deliberate trade: the BI role sees all data, but physically cannot mutate it.</p>
<h3 id="connecting">Connecting</h3>
<p>Metabase runs in its own container with its own metadata database and connects out as <code>metabase_ro</code>. Dashboards build against real data; a compromised Metabase can, at worst, read.</p>]]></content><author><name></name></author><category term="Homelab" /><category term="Homelab" /><category term="Self-Hosting" /><summary type="html"><![CDATA[Dashboards over real data, with zero write access — and the RLS wrinkle.]]></summary></entry><entry><title type="html">Ironfall: I Designed a Play-by-Mail Game</title><link href="/2026/06/20/ironfall-i-designed-a-play-by-mail-game.html" rel="alternate" type="text/html" title="Ironfall: I Designed a Play-by-Mail Game" /><published>2026-06-20T19:00:00+00:00</published><updated>2026-06-20T19:00:00+00:00</updated><id>/2026/06/20/ironfall-i-designed-a-play-by-mail-game</id><content type="html" xml:base="/2026/06/20/ironfall-i-designed-a-play-by-mail-game.html"><![CDATA[<p>A thousand years ago the magical Ferric Empire collapsed in a single afternoon — every artifact and bound spell shattering at once in an event called the <strong>Ironfall</strong>. A thousand years later, you lead a Warband emerging into the shattered realm. That's the setup for my first game: <strong>Ironfall: Chronicles of the Shattered Realm</strong>.</p>
<h3 id="whats-play-by-mail">What's play-by-mail?</h3>
<p>PBM had a golden age from roughly 1983 to 1995 — games like Alamaze, VENOM, and Land of Karrus, played by submitting written orders each week and receiving a narrative report of what happened across the whole world. Slow, social, deeply strategic. Nothing digital has quite replaced it.</p>
<h3 id="what-ironfall-is">What Ironfall is</h3>
<p>Twelve to twenty-four players, one of eight asymmetric factions each (the Iron Legion, the Stormcallers, the Duskborn, and more), thirty-plus weekly turns. Each turn you submit orders — where armies move, what your Heroes quest for, who you ally with, what daring special action you attempt — and the GM (human or AI-assisted) resolves everyone's orders simultaneously and writes you a story back. A living, procedurally-seeded map, fog of war, ancient vaults, and an Ironfall Clock ticking toward world-reshaping Cataclysms. Win by Conquest, Ascension, Accord, or Legacy.</p>
<h3 id="a-real-design-pedigree">A real design pedigree</h3>
<p>It's distilled from a deep dive through <em>Paper Mayhem</em> archives — the leading PBM magazine of the genre's golden age — borrowing the best ideas from the classics and tuning them for modern email and, eventually, a web portal.</p>
<h3 id="want-to-play">Want to play?</h3>
<p>The design is complete and ready to playtest. A seat in a live Ironfall campaign is the top membership tier — see the Join page. Command a faction, send your orders, and find out what the patient, narrative style of PBM does that no app can.</p>]]></content><author><name></name></author><category term="Ironfall" /><category term="Ironfall" /><category term="Gaming" /><summary type="html"><![CDATA[Chronicles of the Shattered Realm — an 8-faction PBM strategy game.]]></summary></entry><entry><title type="html">Migrating This Blog from WordPress to Ghost — Headless, via the Admin API</title><link href="/2026/06/18/migrating-this-blog-from-wordpress-to-ghost-headless-via-the-admin-api.html" rel="alternate" type="text/html" title="Migrating This Blog from WordPress to Ghost — Headless, via the Admin API" /><published>2026-06-18T22:15:00+00:00</published><updated>2026-06-18T22:15:00+00:00</updated><id>/2026/06/18/migrating-this-blog-from-wordpress-to-ghost-headless-via-the-admin-api</id><content type="html" xml:base="/2026/06/18/migrating-this-blog-from-wordpress-to-ghost-headless-via-the-admin-api.html"><![CDATA[<p>This blog used to run on WordPress. It now runs on <strong>Ghost</strong>, and I migrated it almost entirely through APIs — no clicking through import wizards. Here's how, and the sharp edges.</p>
<h3 id="exporting-from-wordpress">Exporting from WordPress</h3>
<p>The cleanest path is the official <code>@tryghost/migrate</code> tool, which reads the WordPress REST API and produces a Ghost-importable bundle — posts, pages, tags, authors, and downloaded images:</p>
<pre><code>migrate wp-api --url https://example.com --pages true</code></pre>
<p>First gotcha: the tool needs <strong>Node 22+</strong> (it uses the built-in <code>node:sqlite</code> module). Node 20 throws <code>ERR_UNKNOWN_BUILTIN_MODULE</code>.</p>
<h3 id="importing-into-ghost">Importing into Ghost</h3>
<p>Second gotcha: don't feed the zip to Ghost's importer — extract the <code>ghost-import.json</code> and POST <em>that</em> to <code>/ghost/api/admin/db/</code>. The zip silently imports nothing. Images get copied into Ghost's content folder separately.</p>
<h3 id="the-2fa-wall">The 2FA wall</h3>
<p>Ghost 5 emails a verification code on admin sign-in. With no SMTP configured yet, the session endpoint just returns 500. If you're automating against a fresh install, disable <code>staffDeviceVerification</code> until mail is wired up.</p>
<p>The rest — editing posts and pages — is all <code>?source=html</code> on the Admin API, which converts your HTML into Ghost's native format. Past the gotchas, Ghost is a genuinely pleasant thing to script against.</p>]]></content><author><name></name></author><category term="Homelab" /><category term="Homelab" /><category term="Ghost" /><category term="Tech" /><summary type="html"><![CDATA[Leaving WordPress for Ghost, almost entirely through APIs — and the sharp edges I hit.]]></summary></entry><entry><title type="html">Dragon Warrior — Free With Nintendo Power, First in Our Hearts</title><link href="/2026/06/17/dragon-warrior-free-with-nintendo-power-first-in-our-hearts.html" rel="alternate" type="text/html" title="Dragon Warrior — Free With Nintendo Power, First in Our Hearts" /><published>2026-06-17T15:30:00+00:00</published><updated>2026-06-17T15:30:00+00:00</updated><id>/2026/06/17/dragon-warrior-free-with-nintendo-power-first-in-our-hearts</id><content type="html" xml:base="/2026/06/17/dragon-warrior-free-with-nintendo-power-first-in-our-hearts.html"><![CDATA[<p>For a huge number of American kids, <strong>Dragon Warrior</strong> (1989 here; <em>Dragon Quest</em> in Japan) was the <em>first RPG they ever played</em> — because Nintendo literally gave it away free to <em>Nintendo Power</em> subscribers to seed the genre in the West.</p>
<p>It's slow by modern standards: one hero, one enemy at a time, lots of grinding levels in the fields outside Tantegel Castle. But it taught a generation the loop — fight, gain levels, buy better gear, push a little farther — and the joy of finally being strong enough to face the Dragonlord.</p>
<p>It walked so Final Fantasy could run. A foundational cartridge, and a clever giveaway that paid off for decades.</p>]]></content><author><name></name></author><category term="NES" /><category term="NES" /><category term="Retro Gaming" /><summary type="html"><![CDATA[The RPG Nintendo gave away — and that hooked a generation.]]></summary></entry><entry><title type="html">Bionic Commando — The Hero Who Couldn’t Jump</title><link href="/2026/06/15/bionic-commando-the-hero-who-couldnt-jump.html" rel="alternate" type="text/html" title="Bionic Commando — The Hero Who Couldn’t Jump" /><published>2026-06-15T14:45:00+00:00</published><updated>2026-06-15T14:45:00+00:00</updated><id>/2026/06/15/bionic-commando-the-hero-who-couldnt-jump</id><content type="html" xml:base="/2026/06/15/bionic-commando-the-hero-who-couldnt-jump.html"><![CDATA[<p>Capcom's <strong>Bionic Commando</strong> (1988) built an entire game around a missing verb: you <em>can't jump</em>. Instead you traverse with a bionic grappling arm — swinging across gaps, hauling yourself up ledges, latching onto enemies.</p>
<p>It forces you to think about movement completely differently, and once it clicks, the swinging feels incredible. The game is also surprisingly dark for its era, with a famously explosive ending involving a certain WWII dictator that the localization didn't entirely sand off.</p>
<p>Inventive, tough, and unlike anything else on the system. The no-jump gimmick should have been a limitation; instead it's the whole appeal.</p>]]></content><author><name></name></author><category term="NES" /><category term="NES" /><category term="Retro Gaming" /><summary type="html"><![CDATA[Capcom built a whole game around a missing verb.]]></summary></entry><entry><title type="html">Extant 2000: Building a Product Family Solo</title><link href="/2026/06/13/extant-2000-building-a-product-family-solo.html" rel="alternate" type="text/html" title="Extant 2000: Building a Product Family Solo" /><published>2026-06-13T11:45:00+00:00</published><updated>2026-06-13T11:45:00+00:00</updated><id>/2026/06/13/extant-2000-building-a-product-family-solo</id><content type="html" xml:base="/2026/06/13/extant-2000-building-a-product-family-solo.html"><![CDATA[<p><a href="https://extant2000.com">Extant 2000</a> is my umbrella for a family of products — hosting, HR, CRM, systems engineering, e-signatures, accounting, a helpdesk, and more. Building that breadth solo sounds absurd. Here's how it's possible.</p>
<h3 id="share-the-foundation">Share the foundation</h3>
<p>Every product sits on the same foundation: a common auth and data layer, a shared component library, one deployment pipeline. A new product isn't a new stack — it's a new app on rails that already exist.</p>
<h3 id="one-box-many-fronts">One box, many fronts</h3>
<p>They all run as containers on the same infrastructure (the stack), each routed by hostname. Adding a product is a compose service and a subdomain, not a new server.</p>
<h3 id="boring-on-purpose">Boring on purpose</h3>
<p>The only way to hold this much surface area solo is to be ruthless about consistency — same patterns, same libraries, same conventions everywhere — so switching between products is cheap. The interesting work goes into the products; the plumbing is deliberately uniform.</p>
<p>Shared foundations turn "ten products" into "one platform with ten faces."</p>]]></content><author><name></name></author><category term="Sites" /><category term="Sites" /><summary type="html"><![CDATA[How one person maintains hosting, HR, CRM, e-sign, accounting, and more.]]></summary></entry><entry><title type="html">Zoda’s Revenge: StarTropics II — The Late Sequel</title><link href="/2026/06/12/zodas-revenge-startropics-ii-the-late-sequel.html" rel="alternate" type="text/html" title="Zoda’s Revenge: StarTropics II — The Late Sequel" /><published>2026-06-12T15:30:00+00:00</published><updated>2026-06-12T15:30:00+00:00</updated><id>/2026/06/12/zodas-revenge-startropics-ii-the-late-sequel</id><content type="html" xml:base="/2026/06/12/zodas-revenge-startropics-ii-the-late-sequel.html"><![CDATA[<p><strong>Zoda's Revenge: StarTropics II</strong> (1994) brought back Mike Jones for a time-traveling adventure, hurling him across history to collect magic tablets and face the returning villain Zoda.</p>
<p>By 1994 the NES was well past its prime — the SNES had long since taken over — so this sequel arrived quietly, a late entry many players missed entirely. That makes it a neat curio: a polished continuation of the cult original, released almost out of time.</p>
<p>It keeps the top-down action-adventure feel of the first game while sending Mike everywhere from ancient Egypt to the Wild West. A charming, overlooked footnote to the NES story.</p>]]></content><author><name></name></author><category term="NES" /><category term="NES" /><category term="Retro Gaming" /><summary type="html"><![CDATA[A 1994 follow-up that arrived as the NES era closed.]]></summary></entry><entry><title type="html">Tecmo Super Bowl — Bo Knows the NES</title><link href="/2026/06/11/tecmo-super-bowl-bo-knows-the-nes.html" rel="alternate" type="text/html" title="Tecmo Super Bowl — Bo Knows the NES" /><published>2026-06-11T14:30:00+00:00</published><updated>2026-06-11T14:30:00+00:00</updated><id>/2026/06/11/tecmo-super-bowl-bo-knows-the-nes</id><content type="html" xml:base="/2026/06/11/tecmo-super-bowl-bo-knows-the-nes.html"><![CDATA[<p><strong>Tecmo Super Bowl</strong> (1991) is the gold standard of NES sports games and, for many, the best football game ever made. It had the full NFL <em>and</em> NFLPA licenses, so real teams and real, named players with real stats — a season mode that tracked everything, injuries and all.</p>
<p>And then there was <strong>Bo Jackson</strong>. His in-game stats were so absurd that a skilled player could reverse-field the entire defense and run for a touchdown every single play. "Tecmo Bo" is a legend unto himself.</p>
<p>Deep, replayable, and endlessly argued over at sleepovers. People still run Tecmo Super Bowl leagues today. That's staying power.</p>]]></content><author><name></name></author><category term="NES" /><category term="NES" /><category term="Retro Gaming" /><summary type="html"><![CDATA[Real NFL players, deep simulation, and an unstoppable running back.]]></summary></entry></feed>