My Server Charges in the Living Room: Constraint-Driven Self-Hosting
My server has no static public IP, no port forwarding configured on the router, and isn't a cloud VM I rent. It's a Windows laptop sitting at home, on 24/7, that a family member occasionally closes the lid on by accident.
It serves three public domains at once, plus one admin panel deliberately kept off the internet:
www.example.com— the portfolio site (Next.js 16; four themes, bilingual, blog on MongoDB, tech news auto-ingested from RSS)database.example.com— a general-purpose MongoDB admin consoleknowledge.example.com— a knowledge-base MCP backend (for programmatic clients)- (local only)
portfolio-admin— the content backend for the site
Each has its own repo, its own domain, its own auth. They talk to each other only through data contracts and webhooks. The last mile to the public internet is entirely ngrok custom domains, with DNS on GoDaddy.
This isn't a self-hosting tutorial. "How to set up ngrok / click through GoDaddy" is all over Google. I want to talk about something more interesting: what shape an architecture is forced into when your constraint is "a home laptop someone might close the lid on" — and how DNS, the Windows Task Scheduler, and a MongoDB index each taught me a lesson along the way.
1. Start with the constraints: why not just rent a VPS?
An entry-level VPS costs a few dollars a month and would save me all the trouble that follows. So let me answer honestly: renting a VPS is easier, but what I wanted wasn't "a website that runs" — it was to design and operate the boundaries, lifecycle, and security model of a whole set of services by hand, once. I happened to have an idle laptop, a domain I'd already bought, and a paid ngrok account. That set the constraints:
- No public IP, can't open ports (home NAT + floating IP)
- No cloud budget (the host is this laptop)
- The host can go offline any time (sleep, forced Windows Update reboots, a closed lid)
Almost every design decision below points back to one of these three.
2. The big picture: one service, one domain, one auth model
User / MCP client
│
GoDaddy DNS (example.com)
│
ngrok edge (custom domain + TLS + traffic-policy)
├── www.example.com ........ Portfolio (Next.js) :3000 ─┐
├── database.example.com ... Mongo admin (OAuth+RL) :3300 ─┤
└── knowledge.example.com .. Knowledge MCP (Ed25519) │
▼
portfolio-admin (local only :3400) ───────────────► MongoDB :27017 (loopback, shared)
[ everything runs on one Windows laptop ]
I didn't cram the four services into one Next.js project. I split them by trust boundary, not by framework. The reason is blast-radius control: if the database admin is breached, the portfolio isn't affected; if the knowledge base's signing key leaks, the OAuth line is untouched; each service deploys, versions, and authenticates independently.
An honest caveat: "low coupling" here means low coupling of repo / deployment / domain / auth. It does not mean high availability — they still share one host, one home network, one ngrok agent, and (see below) one MongoDB. That's a coupling I deliberately accepted to avoid running a second mongod.
3. The last mile: ngrok custom domains + GoDaddy DNS
ngrok makes the whole "dynamic IP + NAT + no open ports" problem disappear: the agent initiates a tunnel from the laptop out to ngrok's edge; inbound traffic enters at *.example.com and is pushed back down to the laptop. GoDaddy only does two things: name resolution, and apex redirection. The time sink was two DNS gotchas:
1. You can't put a CNAME on the apex (naked domain). DNS doesn't allow a CNAME at the zone apex, and ngrok custom domains are wired up with a CNAME. The fix: point the www subdomain's CNAME at ngrok, and have the naked example.com use GoDaddy's HTTP domain forwarding to return a 301 to https://www.example.com.
One common misconception worth clearing up: the 301 is an HTTP behavior, not a DNS one. It's GoDaddy's forwarding server returning a 301 at the HTTP layer — not "DNS 301-ing the naked domain." (If you ever want the naked domain to point directly at ngrok, the right answer is moving to a DNS provider with ALIAS / CNAME flattening, like Cloudflare or Route 53.)
2. GoDaddy's free Website Builder locks your DNS. At first I couldn't edit the www CNAME at all — it was read-only. The free site builder had taken over the @ and www records via Domain Connect. I had to disconnect the site from the domain before DNS unlocked. The kind of "it's my domain but I can't edit it" trap you only hit when self-hosting.
4. Keeping it alive: the Task Scheduler as an init system
Constraint #3 (the host goes offline) drove the whole ops design: services must auto-start on boot, run without a login, and self-heal on crash. No Docker, no PM2 — I squeezed the Windows Task Scheduler into a poor-man's systemd / K8s. One task per service (site, tunnel, mongod, RSS), composing these reliability primitives:
| Need | K8s | My approach (Windows Task Scheduler) |
|---|---|---|
| Auto-start on boot | kubelet | BootTrigger |
| Restart on crash | restartPolicy | RestartOnFailure 999x / 1 min |
| Liveness probe | livenessProbe | 5-minute TimeTrigger watchdog |
| Concurrency control | — | MultipleInstancesPolicy: IgnoreNew |
| Login-less execution | — | S4U principal |
| Don't stop on battery | — | DisallowStartIfOnBatteries: false |
The point isn't "I set up a scheduled task" — it's that I'm reasoning about the abstractions of reliability, not the location of a button in some tool. This table also surfaces a hole I have to own honestly —
"The process is alive" ≠ "the service is available." My watchdog is currently process-level: it confirms the launcher process is still running, but if the Node process is alive while the event loop is stuck, or the port is taken, the scheduler thinks everything is fine. The rigorous approach is to hit an HTTP health endpoint. This is a real limitation I know about and have on the to-do list.
(Another S4U wrinkle: the token it produces has no network credentials, so it can only run purely local services — fine, since everything I have is on localhost. And under an SSH session USERDOMAIN is empty, so the task principal must be set by SID, not DOMAIN\user.)
5. Security isn't one door — it's several different trust boundaries
Instead of "one password guards everything," I differentiated auth by user type — the auth model is derived from "who's using it," not copy-pasted:
| Service | User | Auth |
|---|---|---|
| Portfolio | anyone | public, no auth |
| Database admin | human (browser) | ngrok-edge Google OAuth (my email only) + app-level password |
| Knowledge MCP | program (no browser) | Ed25519 signature + nonce (replay protection) + rate limit |
OAuth is natural for a browser admin; but the MCP backend is a programmatic client with no browser to run an OAuth flow, so it uses signatures. Both stack a second layer, forming defense in depth.
Why deliberately not an IP allowlist? The surface reason is "my IP changes." The deeper reason: an IP allowlist binds identity to a network location, which is the wrong abstraction the moment you work on the move — this is the core argument of zero-trust / BeyondCorp. Identity should be bound to "who you are" (OAuth email, signing key), not "where you connect from."
The security cost of self-hosting must be stated too: TLS terminates at the ngrok edge, meaning ngrok can technically see my services' plaintext traffic (including the admin password). That's not a bug; it's the inevitable price of outsourcing the entry point. Which is exactly why the knowledge line uses Ed25519 end-to-end signatures — an integrity guarantee that doesn't depend on the edge. The contrast highlights two different threat models nicely.
6. Deployment: private repo → a laptop with no GitHub credentials
My release flow has explicit gates: write + test on localhost → I confirm → commit/push to GitHub (private) → only then to the laptop. No git pull on the server.
One detail worth mentioning: I deliberately keep no long-lived GitHub credentials on the laptop. The laptop is the machine most exposed to physical access; I don't want a key on it that can reach all my private repos. So code reaches the laptop via git bundle — commits packed into a single file, scp'd over, cloned from the bundle on the laptop. Deployment becomes a one-way, offline data transfer. A little convenience traded for a much smaller credential exposure. That's security thinking, not laziness.
7. What actually ate the time: these failures (root causes, not just fixes)
A laundry list of "gotchas" is boring; what's valuable is the mental model each one corrected:
- "An SSH session is a clean execution environment" — wrong. It's a long-lived shell that accumulates state. Deploying the first app I set
NODE_ENV=production; it lingered into the second app'snpm install, which skipped devDependencies, and the build died. The fix isn't "remember to unset" — it's every service uses a wrapper script that declares its full environment explicitly and inherits nothing. - A
sparseunique index ignores "field absent," notnull. The MongoDB driver writesundefinedasnullby default — andnullis a legal, indexed value to a sparse index. So the second document without aslugcollides on a duplicate key. The fix isignoreUndefined: true. That precise distinction is far more insightful than "undefined becomes null and breaks." - SSH into Windows to run PowerShell, and long / non-ASCII output truncates and garbles. Rather than fight the stream, write the logic locally as a
.ps1→scpit up → run it remotely into a log → read the log back. Decouple "execution" from "output." - A failed Turbopack build leaves a poison cache. After one failed build, even with dependencies fixed, the
.nextcache kept reproducing the same error — you have to clear.nextand rebuild clean.
8. I know where it breaks
Proactively listing your own architecture's weaknesses is, to me, the clearest divide between senior and junior. This setup bought me full control and one thorough learning experience, but here's what it gives up, and I'm aware of all of it:
- ngrok is a single point of failure, and a trust boundary. If the ngrok agent or service itself goes down, all four services drop at once, and my watchdog can't help (it's not on my machine). Plus vendor lock-in, bandwidth billing, and home uplink as a bottleneck.
- The shared MongoDB is the one common-failure component. If it dies, the portfolio and the admin go down together; a schema change means editing two repos. It's the classic shared-database anti-pattern — perfectly fine for a personal project, but I know it's a coupling point.
- The data layer is still bare. A single
mongod(bound to loopback, RBAC not yet enabled), no replica set, no PITR. "System DBs read-only + type-to-confirm on drop" is a UX guardrail, not a security boundary — the real boundary should be MongoDB account privileges. Backups and RBAC are on the to-do list. - Health checks run on the very machine that might already be down. I need an external uptime monitor (a dead man's switch) so I find out when the laptop drops off the network.
- The physical cost of a laptop-as-server: forced Windows Update reboots, sleep and fast startup, the lid, and heat / battery swelling from being plugged in long-term. The countermeasures (disable fast startup, set active hours, never-sleep power plan) are themselves evidence it has actually run 24/7.
Closing: the value of self-hosting is understanding boundaries
If you only look at the outcome, a $3/month VPS could do what this laptop does. But the real product of self-hosting isn't "saving a VM" — it's drawing every boundary by hand, once: the network boundary (how ngrok changes the failure domain rather than removing it), the lifecycle boundary (a live process isn't an available service), the trust boundary (identity is who you are, not where you connect from), and the line between convenience and security.
When would I move to the cloud? When availability needs exceed what "single machine + self-healing" can offer, or when the data matters too much to sit on one SSD. But until then, this laptop charging in the living room has already helped me think through every boundary of a whole system — and that's what I actually wanted.