Skip to main content
← Back to blog
11 min readSystems & Infra

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 console
  • knowledge.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:

  1. No public IP, can't open ports (home NAT + floating IP)
  2. No cloud budget (the host is this laptop)
  3. 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:

NeedK8sMy approach (Windows Task Scheduler)
Auto-start on bootkubeletBootTrigger
Restart on crashrestartPolicyRestartOnFailure 999x / 1 min
Liveness probelivenessProbe5-minute TimeTrigger watchdog
Concurrency controlMultipleInstancesPolicy: IgnoreNew
Login-less executionS4U principal
Don't stop on batteryDisallowStartIfOnBatteries: 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:

ServiceUserAuth
Portfolioanyonepublic, no auth
Database adminhuman (browser)ngrok-edge Google OAuth (my email only) + app-level password
Knowledge MCPprogram (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's npm 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 sparse unique index ignores "field absent," not null. The MongoDB driver writes undefined as null by default — and null is a legal, indexed value to a sparse index. So the second document without a slug collides on a duplicate key. The fix is ignoreUndefined: 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 .ps1scp it 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 .next cache kept reproducing the same error — you have to clear .next and 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.