>_SPENCERLABSECURITY · INFRASTRUCTURE SERVING FROM THE HOMELAB|0 INBOUND PORTS
GitHub
Builtin progress

Domainfolio: one app, a sale page for every domain

A self-hosted domain portfolio: every domain I own points at one Next.js app, which reads the Host header and renders that domain's for-sale page, with an offers inbox, per-domain visit stats and lease-to-own terms. It runs in Docker behind its own Cloudflare Tunnel, and the names are also listed on Afternic.

3 min read
Build log in progress
  1. Domainfolio: lease-to-own terms per domain, Afternic listings, admin redirect fix.
  2. Domainfolio v1 live: host-routed landers, catalog, offers inbox and admin.
Built

Design

Routing on the Host header

Every domain in the portfolio points at the same app. Middleware normalises the request's Host header (lower-case, no www., no port) and looks it up in the inventory:

Host is…Renders
the portfolio's own domainthe catalog: every name for sale, with search and a category filter
a domain marked for_saleits lander: price or minimum offer, lease-to-own terms, the offer form
a domain marked sold or holda simple status page
anything else"not listed"

Adding a domain to the portfolio takes two steps: add it in the admin panel, and point its DNS at the tunnel. The lander appears on the next request. Cloudflare's edge provides HTTPS for each domain, so the app never handles a certificate.

Hosting

The same pattern as everything else here

It runs in docker compose as two containers: the app, published on loopback only, and a cloudflared sidecar that dials out to Cloudflare. As with this site and the Aegis API, nothing on the home network accepts an inbound connection. SQLite lives on a bind mount so it can be backed up as a single file. The data module sits behind a small interface, so it can move to Postgres without touching the pages.

Security

Small surface, on purpose

  • The admin only exists on one host. /admin and the admin API answer only on the portfolio's own domain and on loopback. On any lander domain they return 404, so a for-sale name never exposes a login page.
  • The offer form is the only public write path. It validates the email and amount, caps the message length, has a honeypot field and allows 5 offers per hour per IP.
  • Email can't break it. A new offer is stored first, then emailed, with the buyer as Reply-To. If the mail fails, the offer is still in the inbox and the buyer still gets a success response.
  • Secrets stay out of the image. A .dockerignore keeps .env and the database out of the build. The tunnel token lives in its own git-ignored file.

Bugs found in production

Redirects to 0.0.0.0

After logging in, saving a domain or updating an offer, the browser was sent to https://0.0.0.0:3000/…. The admin routes built their redirects from request.url. Behind the tunnel, that's the address the standalone server is bound to, not the address the visitor used.

◆ Fix

A small seeOther() helper sends a relative Location, so the browser resolves it against the URL it actually used. Behind any proxy, the app can't know its public address unless it's told, so it shouldn't build absolute URLs from what it sees.

▲ And a typo that went on sale

The admin form was "add or update": re-entering an existing domain blanked its price and tagline, and a typo created a stray for-sale listing for a misspelled name. The form is now add-only. An existing domain is rejected with a pointer to its edit page.

Selling

Reaching buyers who aren't looking yet

A lander only reaches people who already type the name. To show up when someone searches for a domain at GoDaddy and its partner registrars, the names are also listed on Afternic, with a written playbook:

  • Keep my own nameservers, so the Domainfolio landers stay up. That costs a 25% commission instead of 15%.
  • Boost off, because it adds another 5%.
  • Lease-to-own terms set per domain on each lander, to match the Afternic terms instead of a fixed 12-month split.
  • Close through escrow. The buyer pays escrow, the domain is pushed to their registrar account, escrow releases the money. Never take card payments for a domain directly.

Status

What's next

live0 inbound ports tests: routing · pricing · validation · http

The visit counts per domain will start to say which names draw traffic, and that's what pricing should follow. A CAPTCHA has a marked slot on the offer form if the honeypot and rate limit stop being enough.

More in Built →