On December 17, 2025, Jake Saunders woke up to an email from Hetzner: his server was attacking someone, he had about four hours to explain himself and fix it, and if he failed, his infrastructure would be blocked. He SSH'd in, ran w, and stared at a load average of 15 on a box whose total daily audience peaked at 20 humans. A process called javae was burning 819% CPU. It had been doing that for ten days. His server was mining Monero for a stranger.
The postmortem he published that day — "I got hacked, my server started mining Monero this morning" — hit 606 points on Hacker News, and deservedly so: it is the rare incident report that is both entertaining and load-bearing. Every self-hosting pitch quietly assumes the box stays yours. This is the canonical story of what happens when it doesn't — and, more usefully, a complete kill chain you can walk link by link, mapping each one to a mitigation a self-hosted PaaS should ship in its node bootstrap by default. Here is that walk.
The kill chain, link by link
The whole incident compresses into five links. Each one worked for a specific, fixable reason:
| # | Link | What happened | Why it worked |
|---|---|---|---|
| 1 | Entry | Unauthenticated RCE into the Umami analytics container via CVE-2025-66478 | A transitive framework the owner didn't know he ran, unpatched a week after the fix shipped |
| 2 | Payload | xmrig 6.24.0 dropped into Next.js server internals, mining to auto.c3pool.org:443 | No egress policy; a workload could phone a mining pool unrestricted |
| 3 | Dwell | Miner ran 10 days (Dec 7–17) at up to 1000%+ CPU | No resource alerting; Grafana and node-exporter were installed but nobody looked |
| 4 | Detection | Hetzner's abuse team noticed outbound network scanning, not the owner | No network-anomaly detection on the box; the hoster was the IDS |
| 5 | Blast radius | Contained to one container; docker rm ended the incident | Non-root user, non-privileged, zero volume mounts — the one link the attacker lost |
Link 1: entry through a framework he didn't know he had. The CVE was CVE-2025-66478, dubbed React2Shell: a CVSS 9.8 unauthenticated remote-code-execution flaw in React Server Components' Flight protocol, where unsafe deserialization of a crafted request let an attacker execute arbitrary code against any App Router endpoint. Saunders' reaction when he first read about it was "lol who cares, I don't run Next.js" — except his Umami analytics container, installed from Coolify's one-click services screen, is built on Next.js. Umami had shipped a fix roughly a week earlier. He hadn't applied it, because as far as he knew, the vulnerability was in someone else's stack.
He was not remotely alone in that exposure. React2Shell affected React 19.0.0 through 19.2.0 and Next.js from 14.3.0-canary.77 through all of 15.x and unpatched 16.x; a stock create-next-app with the recommended App Router defaults produced a vulnerable application. Researchers counted roughly 584,000 React-based systems on Shodan plus another 754,000 running Next.js in the blast radius, 72 proof-of-concept exploits appeared on day one, and active exploitation was confirmed by day four. When the entry point is "any App Router endpoint on the internet," a forgotten analytics sidecar is not an edge case — it is the median victim.
Link 2: the payload. Inside the container, the attacker planted xmrig 6.24.0 at /app/node_modules/next/dist/server/lib/xmrig-6.24.0/ — tucked into what looks like legitimate Next.js server internals — with processes disguised as javae and runnv and, per other victims' reports, killer scripts to murder competing miners. The pool address was auto.c3pool.org:443: stratum-over-TLS on the HTTPS port, which sails through any firewall that only filters by port. Nothing about this payload was exotic, which is the point — commodity miners work because the default posture of a self-managed box lets any workload open any outbound connection.
Links 3 and 4: ten days of dwell, detected by the landlord. The miner started December 7. The owner found out December 17, and only because Hetzner's abuse team caught his box scanning an IP range in Thailand and threatened to block the server by 12:46 CET. He owned Grafana, Prometheus, Loki, cAdvisor, and node-exporter — a better observability stack than most hobby boxes ever get — and it still took an external abuse desk to notice, because dashboards nobody looks at are decoration, not detection.
Ten days at ~819% CPU on hardware he pays to power is also just money burned: Sysdig's threat research famously measured the economics at $53 in victim compute for every $1 the attacker mines (their TeamTNT case study: $8,120 mined against $430,000 in victim bills). On a flat-rate Hetzner box the bill doesn't spike, but the electricity, the degraded neighbors on the box, and the abuse-ticket risk are all real costs.
Link 5: the one the attacker lost. The initial scare — a miner path that looked like the host filesystem — turned out to be Docker's normal behavior of showing container processes in host ps output. The container ran as the non-root nextjs user (UID 1001), non-privileged, with zero volume mounts, so the malware could mine, scan, and peg CPUs, but couldn't touch the host filesystem, install cron persistence, create systemd units, or reach other containers. The fix was docker stop, docker rm, enable UFW, and a short explanation to Hetzner, who closed the ticket within the hour. Compare the Reddit victim Saunders links, whose root-run container let the same class of malware install cron jobs, write anywhere, and survive reboots: identical entry, opposite outcome, decided entirely by container defaults.
The one thing that saved him (and why it isn't enough)
It is worth sitting with link 5, because it cuts two ways. The optimistic reading: boring defaults — non-root user, no privileged flag, no mounts you don't need — contained a CVSS 9.8 RCE to a deletable container. That is defense in depth paying out exactly as designed, and it vindicates every hardening guide that leads with "don't run as root."
The pessimistic reading: containment is not prevention, and Saunders enumerates what luck covered for. There was no firewall until after the incident (UFW went on during the fix: deny incoming, allow SSH/80/443). SSH allowed password auth with no fail2ban. Nothing watched CPU, load, or outbound traffic. The vulnerable service sat unpatched for a week after a fix existed.
Container isolation held the blast radius, but every earlier link in the chain was wide open — and on a multi-tenant PaaS node, "the attacker only got to mine inside one tenant's container for ten days" is still a ten-day incident with someone else's workload inside it.
That gap — contained but undetected, lucky but not hardened — is precisely where a platform earns its keep. An individual can get away with learning this lesson once. A PaaS has to ship the lesson pre-installed, because its tenants will never read the postmortem.
The node-hardening checklist a PaaS should ship by default
Map each kill-chain link to the control that breaks it, and you get a node-bootstrap checklist. The non-negotiable framing, per Saunders' own lessons-learned list: these belong in the image and the controller, not in a README the tenant skims once.
| Kill-chain link | Bootstrap mitigation | What "shipped by default" looks like |
|---|---|---|
| Entry via unpatched transitive framework | CVE-driven patch automation + service inventory | One-click services carry a manifest of their underlying frameworks (Umami ⇒ Next.js x.y.z); the platform watches NVD/GitHub advisories and auto-patches or quarantines stale services instead of hoping the tenant reads security lists |
| Entry via forgotten sidecar | Attack-surface audit on every deploy | The platform knows which tenants run which images and flags EOL or long-unpatched ones; unused services get greyed out, not silently left internet-facing |
| Payload phoning a pool | Workload egress policy | Default-deny or allowlisted outbound per workload; stratum protocol signatures and known pool domains (c3pool and friends) flagged or blocked even over :443, where port-only firewalls are blind |
| 10-day dwell | Resource anomaly alerts | Per-workload CPU/load baselines with paging on sustained pegged cores — Saunders' box sat at load 15 for a box serving 20 users/day, a signal a two-line threshold would have caught on day one |
| Detection by the hoster | Network-anomaly detection on the node | Outbound scan detection (many SYNs to many hosts) and connection-rate alerts so the platform pages itself before Hetzner — or AWS, or whoever owns the wire — pages it with a block deadline |
| SSH brute force (the attempt class he never got around to blocking) | Key-only SSH + fail2ban/CrowdSec in the image | PasswordAuthentication no, root login disabled, fail2ban for local dumb brute force plus CrowdSec for crowd-sourced reputation — complementary layers, both baked into the node image |
| Blast radius | Non-root, non-privileged, minimal mounts as platform policy | The one thing that saved him, enforced rather than accidental: admission policy rejects privileged workloads and root users by default, mounts require justification |
Two rows deserve extra emphasis because they generalize beyond this incident. First, transitive-framework inventory is the lesson Saunders titles his post with: "I don't use X" doesn't mean your dependencies don't use X. A PaaS that offers one-click Umami, Ghost, or anything else is shipping those transitive stacks to tenants who will never audit them — so the platform has to track what's inside and react when the next React2Shell lands. This was not a rare event shape: Google found 86% of a sample of 50 compromised Cloud instances were mining crypto, and cryptojacking was the fastest-growing cybercrime category of 2025 at +63% year over year, with incidents tripling from 67 million to 332 million. The background radiation is miners; the entry varies.
Second, egress beats ingress for this threat class. Saunders hardened inbound with UFW after the fact, and inbound filtering matters — but the miner never needed inbound. It arrived inside an HTTP request to a legitimate app, then initiated outbound to its pool. A default egress posture that treats "web workload opens a long-lived TLS session to a known pool domain" as an alertable event breaks link 2 even when link 1 succeeds. That is the control a port-80/443 firewall cannot express and a workload-aware PaaS can.
The honest managed-PaaS comparison
None of this is an argument that self-hosting is reckless. It is an argument about which work you're signing up for. When you deploy to Render or Railway, a meaningful slice of the table above is absorbed silently: base images get patched, the network edge carries IDS/rate-limiting, abuse complaints land on their NOC instead of your inbox at 8:25 AM, and a workload pegging CPU for ten days gets noticed by someone whose job it is to notice. You pay for that absorption in per-service meters and platform margin — and on flat-rate owned hardware, repricing the same workload without those meters is exactly where self-hosting wins on cost.
But the absorption is real, and "the platform handles hardening" is the managed-PaaS value a self-hosted alternative must reproduce mechanically, in its node bootstrap and controllers — not as documentation, not as optional toggles, and not as a post-incident blog post. Saunders is unusually honest about this in his own write-up: he had the dashboards, the exporter, the log stack. What he didn't have was anything that would tap him on the shoulder. Ten days, 819% CPU, and the landlord noticed first.
The target state for a self-hosted PaaS is straightforward to state: make the default node image and the default workload policy jointly reproduce what the managed platforms absorb — patched services, key-only SSH with brute-force protection, deny-by-default firewall, egress awareness, resource anomaly paging — before the tenant's first deploy, not after their first abuse email. Every row in the checklist above is automatable. The only question is whether your platform ships it or assigns it as homework.
Saunders closed his post by wondering how much Monero he mined for his attacker — "probably enough for them to have a nice lunch." At Sysdig's 53-to-1 ratio, the lunch cost him fifty-three lunches. Ship the checklist, and the next miner starves on an empty table.
Bex.co is the open-source, AI-native Render alternative — push a git repo, get a running HTTPS service on machines you own. Hardened node images, workload-aware defaults, and no per-service meters: star the repo on GitHub or deploy your first app today.



