Skip to content

Franz Franz

goFranz // personal journal all writing
Tools · rust

My Laptop Is Never Idle, So I Rented One That Is

A cold Rust build has to fight two watch loops and a Guix build for cores on my machine. Here's what it costs to move it to a Hetzner box that isn't doing anything else - and how long the round trip actually takes.

Here’s uptime on my laptop, on an ordinary evening:

$ uptime
 00:43:55  up 7 days,  8:58,  1 user,  load average: 4.54, 5.82, 8.75

Load of 8.75 over the last fifteen minutes, on twelve threads. What’s running: a guix build compiling a GTK app with -C lto -C codegen-units=1, a watchexec loop running cargo run on the backend I’m working on, and a second one rebuilding the frontend on every save. None of that is unusual. That’s just what the machine looks like while I’m working.

So when I kick off a cold release build of the Formshive backend - 660 dependencies - it isn’t getting a quiet laptop. It’s getting whatever’s left over. And the thing about a sixteen-minute compile is that it makes everything else on the machine worse while it runs, including the watch loops I actually need responsive.

The interesting question isn’t “is my CPU fast enough”. It’s “why is this competing with my editor at all”.

Meet crunch

crunch is a drop-in cargo replacement that rsyncs your project to a remote machine, runs the cargo command there, and brings the artifacts back. That’s the whole idea. It’s already packaged in Guix, so there’s nothing to install:

$ guix shell crunch -- crunch --exclude "target,.git" build --release

The remote requirements are refreshingly small: a Debian-ish box with Rust on it, and an SSH host entry called crunch in your ~/.ssh/config. The detail worth reading twice is the connection pooling - ControlMaster auto and ControlPersist 5m - because without it you pay a fresh SSH handshake on every rsync.

By default crunch puts the project in ~/crunch-builds on the remote and leaves it there. That persistence is the entire point; the remote target/ is the only build cache in the loop, since your local one is excluded from the transfer.

What the boxes cost you in time

I tried three sizes of Hetzner’s CCX line - the dedicated-vCPU plans, where you get actual physical cores rather than a share of someone else’s. All three landed on AMD EPYC Milan in Falkenstein. Same project, same commit, genuinely cold each time; I wiped ~/crunch-builds and the cargo registry between runs.

  physical cores RAM cold build warm rebuild
ccx23 2 15 GB 627s 1s
ccx33 4 30 GB 357s 1s
ccx43 8 61 GB 277s 1s

The scaling is the useful part. Doubling from two cores to four buys 1.76x. Doubling again, from four to eight, buys only 1.29x - the critical path through the dependency graph and the serial link phase stop caring how many cores you throw at them. ccx43 costs twice what ccx33 does and finishes 22% sooner.

So ccx33 is the sweet spot, and that’s what I’ve defaulted to.

For reference, the same build on my laptop took 964s - but I’m not going to present that as a benchmark result, because it isn’t one. That run happened while a Guix source build was chewing through cores in the background. It measures my laptop as it actually is on a working evening, which is genuinely the number I care about, but it says nothing about the hardware. A clean comparison would need me to stop working first, which rather defeats the purpose.

Renting by the minute

Hetzner bills these hourly, so the sensible shape is to create a box when you need one and delete it when you’re done. There’s one gotcha that catches people, and it’s in Hetzner’s own docs: powering a server off does not stop the billing. Resources stay allocated to it. You pay until you delete it.

So the automation is a small script - crunch-server - with a few verbs:

$ crunch-server up      # create, provision, wire up ssh
$ crunch-server status  # what's running, and what it's cost so far
$ crunch-server down    # delete it (this is the part that stops billing)

up does the boring sequence: pulls the API token out of pass, uploads my SSH public key to the project if it isn’t already there, creates the server with a cloud-init that installs the toolchain, waits for cloud-init to actually finish (not just for sshd to answer, which is the easy mistake), and then writes a Host crunch block into ~/.ssh/config with the right pooling settings. If a box is already up, it reuses it rather than complaining - each fresh up rounds up to a new billed hour, so reuse is free and cycling isn’t.

Two details that took a second pass. Host keys go to a separate ~/.ssh/known_hosts.crunch, wiped on teardown - every new box gets a recycled IP, so sharing the main file means a host-key warning every single time. And the native dependencies have to be in the cloud-init: this project pulls pq-sys and openssl-sys, so libpq-dev and libssl-dev need to be there or the build dies twelve minutes in.

The token lives in pass, not in a config file:

$ pass insert hetzner/api-token

The script falls back to $CRUNCH_TOKEN_CMD for anything else that prints a secret on stdout, so swapping backends later is one variable rather than an edit. I went with pass over KeePassXC’s Secret Service integration for a boring reason: pass works headless. No GUI process, no unlocked database, no D-Bus - which matters, because half my tooling runs inside guix shell --container, where there’s no D-Bus at all.

How long the round trip takes

This is the number that decides whether the whole thing is livable, so I measured it rather than guessing:

  • Bring-up: 61 seconds. Server creation, apt, a rustup toolchain, and the SSH config written - from nothing to a box that answers cargo --version.
  • Teardown: 18 seconds.

I’d expected three or four minutes and was ready to build a base snapshot to avoid it. At a minute, that’s not worth the complexity; the box is disposable and I can just make another one.

Worth remembering that Hetzner rounds hourly usage up, so a minute-long server still costs a full hour. Spinning one up three times in an afternoon costs three hours, not three minutes. Keep one and reuse it.

The honest caveats

One project, one run per configuration. No repeats, no error bars. This tells me the shape of the thing, not a precise figure.

The “warm rebuild” column is a no-op - nothing changed between runs, so cargo had nothing to do. It confirms the remote cache survives and that rsyncing 5 MB of source is basically free, but it is not a measurement of the actual edit-compile loop. That’s the number I still want, and the one that’ll decide whether I keep using this.

I also hit dedicated core limit exceeded on the first ccx43 attempt; new projects have a cap on dedicated vCPUs that support will raise, but it’s a surprise if you meet it mid-benchmark.

And there’s a decent argument I’ve picked the wrong hardware. crunch’s author recommends prioritising fewer fast cores over many slow ones, and runs a Hetzner AX102 - a Ryzen 9 7950X3D dedicated box - rather than anything from the cloud line. These Milan EPYCs at around 2 GHz are precisely the “many slower cores” shape he’s warning about, and the sublinear scaling above four cores is that warning showing up in my own numbers.

That’s the next thing to try. For now the build happens somewhere else, my watch loops stay responsive, and the whole experiment cost about a euro.