A solopreneur’s story of ₹0 infrastructure, WireGuard tunnels, and the laptop nobody wanted.
There’s a 2014 HP Notebook sitting on my desk right now. Intel i5-4210U, 16GB RAM, 238GB SSD. Until this morning it was doing absolutely nothing — the kind of machine that’s too old to be your daily driver but too functional to throw out. You know the one. Maybe you have five of them, like I do.
Today that laptop became a server.
It’s running OpenObserve, an open-source observability platform. It’s reachable from anywhere in the world through an encrypted WireGuard tunnel. I can RDP into its desktop from my Mac via my Linode instance in the US. And it replaced Better Stack — a SaaS I was paying for to ingest logs from quizwrap.com.
Total additional spend: ₹0.
This is the story of how I got there, why I did it, and what it unlocked that I didn’t expect.
Table of Contents
The Problem With Paying for Things You Already Own
I run quizwrap.com — an educational quiz platform that’s organically grown to 314 post-launch users across 60 countries and 28 institutions — entirely without paid acquisition. I’m a solopreneur. I run everything on a single Linode server. I watch costs carefully.
Better Stack was doing log ingestion for me on their free tier. It worked well — clean UI, SQL queries, good retention. I have nothing against them; it’s a solid product. Then this morning I got an email: “Your organization cyberaka.com is at 80% of your plan quota. Any new data will be dropped once you hit the limit.” I clicked through to the upgrade page. The entry-level paid plan starts at $38/month.
I closed the tab.
To put that number in context: I pay $72/month for an 8GB dedicated Linode that runs five production websites. Being asked to pay half my entire server bill just for log ingestion — on top of everything else — didn’t make sense. Better Stack is a great product for teams that need it. I’m a solopreneur who needed to do the math.
I had compute sitting idle at home. I had a public-IP Linode that could act as a coordination hub. The only thing I lacked was the motivation to set it up. The upgrade prompt provided it.
Log ingestion is two things: a shipper that collects logs and sends them somewhere, and a store that holds and queries them. Better Stack bundled both behind a nice UI. But the open-source world has perfectly good equivalents — I just needed to wire them together.
The Stack I Chose
After evaluating options, I landed on:
- OpenObserve — single binary (or container), SQL-based querying, built-in UI, runs on 512MB RAM. The closest open-source match to the Better Stack experience.
- Vector — log shipper from the folks at Datadog (open-source). Reads nginx access and error logs and ships them over HTTP.
- WireGuard — encrypted peer-to-peer tunnel. My Linode is the hub; the laptop is a peer.
- Docker Desktop on Windows — to run OpenObserve as a container with persistent storage.
The architecture is simple:
quizwrap.com traffic
↓
Nginx on Linode
↓
Vector (ships logs over HTTP)
↓
WireGuard encrypted tunnel
↓
OpenObserve on old HP laptop at home
↓
Dashboard in browser
The Laptop Nobody Wanted
Let me tell you about this HP Notebook. BIOS date: May 2016. Factory-installed OS: Windows 10. It had been sitting unused because the internal Wi-Fi chip had died — Hardware not present in Device Manager. I’d plugged a TP-Link USB Wi-Fi dongle into it as a workaround, which is the kind of solution that works until it doesn’t.
Before I could use it as a server, I needed to fix a few things:
First: enable Intel VT-x in BIOS. The HP Insyde BIOS had virtualization disabled by default — their own help text says “HP recommends this feature remain disabled unless specialized applications are being used.” Docker Desktop needs VT-x. Into the BIOS I went (F10 on boot), navigated to System Configuration, toggled Virtualization Technology from Disabled to Enabled. One reboot.
Second: install WSL2 and Docker Desktop. With VT-x now enabled, wsl --install worked cleanly. Docker Desktop 4.77.0 installed with the WSL2 backend. The first launch timed out — a known issue on older Haswell hardware where the WSL2 VM takes longer to initialise than Docker’s default timeout. Running wsl --shutdown and relaunching Docker Desktop a second time fixed it. docker run hello-world printed its success message.
Third: kill sleep forever. A server cannot sleep. Power & Sleep → all dropdowns to Never. powercfg /hibernate off in PowerShell. Control Panel → Power Options → lid close action → Do nothing on both battery and plugged in. This is the step most people forget, and it’s the one that kills your uptime at 3am when nobody’s watching.
Fourth: plug in ethernet. The USB Wi-Fi dongle is a single point of failure. I have two internet connections at home. The fast one via ethernet — now plugged directly into the laptop. The USB dongle stays connected for local network RDP access within the home. Two separate interfaces, two separate purposes, route metrics set so ethernet wins for internet traffic.
WireGuard: The Networking Problem That Wasn’t
The obvious challenge with running a server at home is that your laptop is behind NAT. It has no public IP. Port-forwarding your home router to expose port 5080 to the internet is both fragile (dynamic home IPs) and a security liability.
WireGuard solves this elegantly. The Linode — which has a static public IP — becomes the hub. The home laptop dials outbound to the Linode on UDP 51820. Because the connection is initiated from the laptop side, it punches through NAT without any port forwarding. PersistentKeepalive = 25 keeps the NAT mapping open so the Linode can initiate connections back through it.
The setup:
- Linode gets tunnel IP
10.10.0.1 - Laptop gets tunnel IP
10.10.0.2 - OpenObserve listens on
10.10.0.2:5080 - Vector on Linode ships logs to
http://10.10.0.2:5080
Key generation on Linode:
wg genkey | sudo tee /etc/wireguard/linode_private.key | \
wg pubkey | sudo tee /etc/wireguard/linode_public.key
The Windows WireGuard GUI generates its own keypair — just add the config and activate. Within seconds: Latest handshake: 14 seconds ago. Transfer: 92 B received, 180 B sent. Tunnel confirmed.
One gotcha: my Linode has a cloud firewall managed at the dashboard level, not via ufw on the box. I’d opened ports there for SSH, HTTP, and HTTPS — but not WireGuard’s UDP 51820. Add that rule and save, or handshakes never complete. The Linode dashboard shows a pending indicator (↺) until you hit Save Changes — easy to miss.
OpenObserve in Five Minutes
version: '3.3'
services:
openobserve:
image: public.ecr.aws/zinclabs/openobserve:latest
container_name: openobserve
restart: always
environment:
- ZO_ROOT_USER_EMAIL=your@email.com
- ZO_ROOT_USER_PASSWORD=yourpassword
ports:
- "5080:5080"
volumes:
- C:\openobserve\data:/data
docker compose up -d and thirty seconds later http://localhost:5080 shows the OpenObserve home screen: No Data Ingested. Exactly right. The pipeline just needs to be connected.
One follow-up command worth running immediately:
docker update --restart always openobserve
This ensures OpenObserve restarts automatically after a Windows Update reboot, without needing you to manually run docker compose up.
The Unexpected Unlock: RDP From Anywhere
Here’s the thing I didn’t plan for but got for free.
Once you have WireGuard connecting your Linode to your home laptop, you can use that same tunnel for anything TCP. Including RDP.
One command on your Mac:
ssh -L 3389:10.10.0.2:3389 -L 5080:10.10.0.2:5080 your-user@your-domain.com -N
This creates two local port forwards over the SSH connection:
localhost:3389→ Windows laptop RDP (via WireGuard tunnel)localhost:5080→ OpenObserve dashboard (via WireGuard tunnel)
Open Microsoft’s Windows App (the new name for Microsoft Remote Desktop), connect to localhost, enter Windows credentials — and you’re looking at the desktop of a laptop sitting in your home, tunnelled through a server in the US, from wherever you are in the world.
The security picture is clean. Port 3389 is not exposed to the internet at all. Only traffic that arrives through your SSH tunnel (protected by your SSH key) can reach the WireGuard tunnel, which then reaches the laptop. There’s no attack surface for random scanners.
One prerequisite I’d missed: AllowTcpForwarding in /etc/ssh/sshd_config was set to no on the Linode. The error channel 2: open failed: administratively prohibited tells you exactly what’s wrong once you know what to look for. Set it to yes, sudo systemctl restart ssh, done.
“It Posts 200s” Is Not “It’s Done”
When I came back to wire up the last piece — Vector actually shipping to OpenObserve — I found it was already working. docker logs openobserve had the smoking gun:
POST /api/default/quizwrap/_json HTTP/1.1" 200 ... "Vector/0.56.0"
Logs were arriving. Pipeline complete, right? Not even close. “Bytes are landing in the database” is the demo. The gap between that and something I’d actually rely on took longer than everything before it — and it’s the part nobody blogs about.
Raw lines are useless; parse at the edge
The logs landing in OpenObserve were raw nginx strings stuffed into a single message field:
104.22.24.138 - - [14/Jun/2026:12:18:05 +0000] "GET / HTTP/1.1" 200 1978 "-" "curl/8.5.0"
You can full-text search that, but you can’t ask the questions that matter: show me every 5xx, which IPs are hammering /api, what’s my 404 rate. For that you need structured fields — and the cleanest place to create them is at the shipper, before they ever hit storage.
Vector has a built-in VRL function for exactly this. I dropped a remap transform between the file source and the HTTP sink:
transforms:
parse_access:
type: remap
inputs: [nginx_access]
source: |
parsed, err = parse_nginx_log(.message, "combined")
if err == null {
. = merge(., parsed)
.log_type = "nginx_access"
} else {
.log_type = "nginx_access_unparsed"
.parse_error = err
}
The if err == null branch matters: a malformed line still gets shipped (tagged _unparsed) instead of silently vanishing, and the raw text stays in message, so nothing is ever lost. Same pattern for the error log with format "error".
After that, the same request arrives as:
{ "client": "104.22.24.138", "status": 200, "request": "GET / HTTP/1.1",
"size": 1978, "agent": "curl/8.5.0", "log_type": "nginx_access" }
Now status >= 500 is a query, not a grep.
And the payoff was immediate. Within minutes of structured logs flowing, I spotted this in the stream:
GET /.git/refs/heads/main → 404
GET /.env.orig → 404
PROPFIND / → 405
Bots, constantly probing for an exposed git repo or a leaked secrets file. All 404’d — nothing leaked — but I’d never have caught the pattern in a wall of raw text. The observability I built to save money immediately paid off as a security signal.
A service that dies when you close the terminal isn’t a service
Vector was running only because I’d typed vector --config ... into an SSH session. The moment that session closed — or the Linode rebooted — logging would stop, silently. The fix is a systemd unit:
[Service]
Type=exec
User=cyberaka
ExecStart=/home/cyberaka/.vector/bin/vector --config /home/cyberaka/.vector/config/vector.yaml
Restart=always
RestartSec=10
systemctl enable --now vector and it now starts on boot, restarts on crash, and survives me disconnecting.
But there was a trap I nearly walked into. My setup notes said to clear the old process with pkill -f vector. Before running it I checked what was actually running — and found two other vector processes owned by root, with a binary path that didn’t even exist on disk. For a second I thought the box was compromised. They turned out to be Vector instances running inside other Docker containers (a staging and prod telemetry stack I’d forgotten about) — the binary “didn’t exist” because it lived inside the container’s filesystem namespace. A blanket pkill -f vector would have killed those too.
Lesson: never pattern-kill on a name as generic as “vector.” I scoped the kill to the exact config path — pkill -f 'vector --config /home/cyberaka/...' — so it could only ever match the one process I meant.
Surviving a reboot, hands-off
This was the real test, and the first reboot failed it — usefully. After restarting the laptop the tunnel was up (the WireGuard service started fine) but logs stopped. The culprit: Docker Desktop is a user application, not a boot service. It doesn’t start until someone logs into Windows, and the OpenObserve container can’t restart if Docker itself isn’t running.
The full hands-off chain needed three independent things to auto-start:
- WireGuard tunnel — installed as a Windows service so it comes up before login:
wireguard /installtunnelservice "C:\wireguard\linode-hub.conf"(Start type
Automatic, confirmed withGet-Service.) - Docker Desktop — Settings → General → Start Docker Desktop when you sign in. The container’s
restart: alwaysthen brings OpenObserve back on its own. - Windows auto-login — the piece that closes the loop. Because Docker needs a login, an unattended box has to log itself in.
netplwiz→ uncheck Users must enter a user name and password. Now a power-cut at 3am recovers without me.
The chain after any reboot:
power on → Windows auto-logs-in → Docker Desktop starts → OpenObserve container restarts → WireGuard already up → Vector ships → logs flow
I tested it the dumb, honest way: rebooted and walked away. Two minutes later, querying from the Linode, fresh logs were landing. I even shut the lid (lid action set to Do nothing) to confirm it keeps serving with the screen closed — it does.
In practice, though, I run it lid-open. A laptop running 24/7 sheds heat far better with the deck exposed than clamshelled with the screen trapping it on top. On a 15W i5 under light load the difference is small, but it’s free: lid open, on a hard flat surface (never carpet or a bed — that blocks the bottom air intake, the number-one cause of laptop overheating), slightly elevated for airflow, and the display set to sleep on idle so the panel isn’t burning power for no one. It’s sitting on a shelf now, and it just works.
One quiet hero in all this: Vector’s disk buffer. While OpenObserve was down during that failed first reboot, Vector didn’t drop anything — it spooled logs to a 256 MB on-disk buffer (when_full: block) and flushed the backlog the instant the tunnel came back. The outage cost me zero log lines.
And one last knob so the SSD never fills: I capped retention at 45 days on the stream. Old data gets compacted away automatically; disk usage stays bounded forever.
The rotation I chose not to do
A couple of secrets — the OpenObserve password, a WireGuard key — passed through notes and terminals during setup. The reflexive security advice is “rotate everything.” I didn’t, and I think that’s the right call: OpenObserve is never exposed to the internet (only reachable over the tunnel and my LAN), and this is a single-user home lab. The threat model doesn’t justify the churn. What does matter — and what I did verify — is that the OpenObserve password isn’t reused on anything internet-facing. Security is a math problem too; spend the effort where the threat is real.
The Philosophy: Spare Compute Is a Private Cloud
I have five old laptops at home. Until today they were liabilities — things to dust, to not throw away, to feel vaguely guilty about not using.
The mental model shift is this: treat spare home compute as tier-2 infrastructure, not as failed hardware.
Your Linode (or any VPS) is tier-1: always-on, public-facing, static IP, runs your websites and anything customer-facing. The home laptops are tier-2: always-on internally, reachable via tunnel, run internal services that don’t need public access.
The tier-2 laptops win on cost for anything that would otherwise require upsizing your VPS or adding cloud services. A ₹2000/month managed log ingestion service, replaced by a laptop that was already paid for and electricity that’s already being consumed. A ₹800/month additional VPS for running n8n automations, replaced by laptop number two. Cloud GPU credits for LLM experiments, replaced by laptop number four running inference locally.
The pattern that makes this work:
- WireGuard on every machine, Linode as hub
- Docker + Docker Compose on each laptop
- Everything defined in a
docker-compose.ymlin git - State backed up to Linode or object storage
- If a laptop dies,
docker compose upon another one
The laptops become disposable. The services become portable. The cost stays zero.
This is what I’ve started calling Vibe Driven Infrastructure — building your stack iteratively by feel, shipping incrementally, letting real constraints (cost, hardware availability, actual traffic) drive architectural decisions rather than imagined future scale. The same philosophy I apply to product development, applied to the infrastructure underneath it.
What I’d Do Differently
Start with the tunnel. I spent time evaluating Cloudflare Tunnel and Tailscale before landing on plain WireGuard. For a solopreneur with a VPS that has a public IP, WireGuard hub-and-spoke is the simplest, cheapest, and most private option. No third party in the data path. No commercial-use licensing questions. Just keys and configs.
Ship OpenObserve natively first, Docker second. OpenObserve provides a single .exe for Windows that runs without Docker at all. For a dedicated single-purpose machine, the native binary + NSSM service wrapper is simpler than the Docker path. I went Docker because I wanted the flexibility for future services on this machine — which turned out to be the right call — but if you just want OpenObserve running in ten minutes, skip Docker entirely.
Fix the Vector config before starting it. Vector ships with a demo config that generates fake syslog data. The first time I started it, I watched beautiful fake logs scroll by for thirty seconds before realising none of it was from my nginx. Always cat the config before starting a service.
Budget for the last 10%. Getting logs to arrive took an afternoon. Making the pipeline survive reboots, disconnects, and a closed lid — structured parsing, a systemd service, three separate layers of Windows auto-start — took longer than the entire happy path before it. The demo is the easy part. Plan for the hardening, because that’s the part that decides whether you have a server or a science project.
The Current State and What’s Next
The pipeline is live and, more to the point, durable. nginx logs are parsed into structured fields, shipped over the tunnel, stored in OpenObserve, capped at 45 days so the SSD never fills, and queryable from a browser. Vector runs as a systemd service on the Linode; the laptop recovers from a full reboot with nobody in the loop. I’ve stopped thinking about it — which is the only honest measure of “done” for infrastructure.
Beyond that, the roadmap for the home lab:
- Laptop 2: n8n for workflow automation (Quizwrap notifications, Sage platform webhooks)
- Laptop 3: Staging environment for Quizwrap and Sage
- Laptop 4: LLM/RAG experiments and the BigQuery dashboard backend
- Laptop 5: Cold spare
All of it behind the same WireGuard hub. All of it accessible via SSH port forwards. All of it running on hardware that was otherwise doing nothing.
The thrown-away laptop isn’t thrown away anymore. And neither are the other four.
The Stack, For Reference
| Component | Tool | Where It Runs |
|---|---|---|
| Log shipper | Vector 0.56.0 (systemd service, nginx parsing) | Linode |
| Log store + UI | OpenObserve (Docker, 45-day retention) | Home laptop |
| Encrypted tunnel | WireGuard | Linode hub + laptop peer |
| Remote desktop | Windows App (Microsoft) | Mac → Linode → Laptop |
| Container runtime | Docker Desktop 4.77.0 | Home laptop |
| Server OS | Ubuntu 24.04 | Linode |
| Laptop OS | Windows 10 Pro | Home laptop |
Total monthly cost added to my infrastructure bill: ₹0.
If you’re a solopreneur running lean, I’d love to hear what spare compute you have lying around and what you’re doing with it. Find me on LinkedIn or drop a comment below.