All writing

Self-hosting a website on a home PC

How this blog is served from a desktop at home with Node.js and a Cloudflare Tunnel, without opening a single port.

This blog is served from an ordinary desktop at home. No VPS, no static hosting service. Here's how it's wired up.

The pieces#

Piece Job
Node.js Runs a small web server that turns Markdown posts into pages
cloudflared Keeps an outgoing tunnel open to Cloudflare
Cloudflare Owns the DNS for meyo.dev, handles HTTPS and sends visitors down the tunnel
Task Scheduler Starts everything at login; a watchdog restarts anything that crashes

Why a tunnel instead of port forwarding?#

The classic way is to open a port on the router and point the domain at your home IP address. A tunnel flips that around: the PC makes an outgoing connection to Cloudflare, and visitors come in through it.

  • No ports open on the router
  • The home IP address stays private
  • HTTPS certificates are handled automatically
  • It still works behind CGNAT, where port forwarding is impossible

The web server only listens on 127.0.0.1, so nothing on the local network can reach it directly either. The tunnel is the only way in.

Setting it up#

Install cloudflared and link it to a Cloudflare account:

powershell
winget install --id Cloudflare.cloudflared
cloudflared tunnel login

Create a named tunnel and point the domain at it:

bash
cloudflared tunnel create meyo-dev
cloudflared tunnel route dns meyo-dev meyo.dev
cloudflared tunnel route dns meyo-dev www.meyo.dev

Then tell the tunnel where to send traffic, in ~/.cloudflared/config.yml:

yaml
tunnel: <tunnel-id>
credentials-file: ~/.cloudflared/<tunnel-id>.json

ingress:
  - hostname: meyo.dev
    service: http://127.0.0.1:8080
  - hostname: www.meyo.dev
    service: http://127.0.0.1:8080
  - service: http_status:404

And start it:

bash
cloudflared tunnel run meyo-dev

The web server#

The server is a single Node.js file built on the standard http module. Posts are Markdown files with a few lines of front matter on top. They're read from disk on every request, so publishing a post is just saving a file:

js
const posts = loadPosts(POSTS_DIR);
const post = posts.find(p => p.slug === slug);
sendPage(res, 200, views.post(post, renderMarkdown(post.body)));

The catch#

The site is only up while the PC is. If it sleeps, reboots or loses power, meyo.dev goes down with it. For a personal blog, that's a fair trade.

↑↓ move↵ openEsc close