I develop on a MacBook Air and deploy to a Linux server. Two facts, one recurring problem: the code that compiles happily in cargo build on my laptop is not guaranteed to compile on the box it actually runs on. And when it doesn’t, I find out the slow way — during a deploy, staring at a build log.
This is a follow-up to How I Joined the Nix Cult. That post was about owning the server with a flake. This one is about the other half: keeping the app’s dev loop native, keeping the flake as the deployment artifact, and — the fun part — making nix build reach out to a Linux machine and prove the build before anything ships.
There are two reasons I got here, and the second is purely practical:
- Confidence. If it builds for Linux before I update the server, the deploy can’t surprise me.
- My MacBook Air is fanless. A cold Rust build of this project (Lance, DataFusion, ONNX) pins the CPU for a long time, makes the machine uncomfortably warm, and eats battery. I’d rather that work happen on a server.
The fast loop is cargo; everything that counts is Nix
It’s all Nix flakes — they just play different roles. Devenv is the local development environment. The app's flake is the deployment artifact: how the app is packaged for the machine that runs it. Nix-servers is the infrastructure — the host, the services, and even the CPU budget a build is allowed to consume. That last part is the nice bit: the same cap governs every build on the box, whether it’s the deploy building the app or me proving the build from inside the app’s repo. Flakes all the way down: environment, build, and infra are the same kind of object, spread across a few repos.
That uniformity is the whole point. When the app and the infra are both flakes exporting derivations, there is no real boundary between them — they compose into a single graph. They are one integrated system, and because the boundaries are only notional, getting them to work together costs nothing: no glue, no adapters, no “integration layer”. Integration isn’t a separate chore when nothing is actually separate.
The only thing I keep plain is the local loop: inside the devenv shell I run cargo build, cargo test, cargo clippy, rust-analyzer directly — re-deriving on every edit would be slower and buy me nothing for a feedback loop. Cargo.toml is still the source of truth for dependencies, and the flake doesn’t replace cargo; it wraps the same cargo build for the machine that actually runs it. So: cargo for the tight loop, Nix for everything that has to be correct — the artifact, the Linux verification, the deploy. The flake isn’t a second build system; it’s the deployment contract for the one cargo already does.
The flake is the contract
The repo’s flake.nix exposes the app as packages — poller (the Rust binary) and a couple of small Python helpers (reddit-stats, reddit-logs):
# reddit_v2/flake.nix
{
outputs = {self, nixpkgs, crane}: let
poller = craneLib.buildPackage (commonArgs // {inherit cargoArtifacts;});
in {
packages.x86_64-linux = {inherit poller reddit-stats reddit-logs;};
};
}
That flake is then consumed as an input by my nix-servers repo, which owns the host config and deploys it with deploy-rs. The app repo owns the build; the infra repo pins the rev and runs it:
# nix-servers/flake.nix
inputs.reddit.url = "github:jozefRudy/reddit_v2";
And how does it run on the server? As a plain systemd service, declared in that same infra repo — no Docker image to build, push, pull, or babysit:
# nix-servers/modules/apps/reddit_poller.nix
systemd.services.reddit-poller = {
wantedBy = ["multi-user.target"];
serviceConfig = {
ExecStart = pkgs.lib.getExe reddit.poller;
Restart = "always";
};
};
The package from the app flake is the deployment artifact; systemd just runs it. Native, no container anywhere in the loop.
Because derivations are content-addressed, the artifact is the same whether it’s built by my Mac, by the deploy host, or by CI. “It builds” becomes a property of the source, not of the machine that happened to run the compiler.
Reaching for a Linux box, without leaving Nix
Here’s the trick. From the app repo — on macOS — I run:
nix build --system x86_64-linux \
--store ssh-ng://hetzner_fsn1 \
--eval-store auto \
--no-link -L .#poller .#reddit-stats
and it compiles on the Linux server, over my normal SSH config, without me thinking about toolchains, sysroots, or containers. The Nix client does the evaluation locally (--eval-store auto), ships the derivation and its inputs to the server, and the server’s own nix-daemon builds it — natively, on the same architecture it’ll be deployed to.
I wrapped it in a devenv script so it’s a single word:
# devenv.nix
scripts.build-linux.exec = ''
nix build --system x86_64-linux --store ssh-ng://hetzner_fsn1 \
--eval-store auto --no-link -L .#poller .#reddit-stats
'';
This is not cross-compilation. Nothing is being compiled for another architecture on the Mac; the compile genuinely happens on the Linux box, natively, and the result lives there.
One machine, kept polite
I don’t run a build fleet. The same box that serves the poller also builds it — which is exactly why the caps matter. Two lines in the infra repo keep a build from trampling the services running beside it:
# nix-servers/hosts/fsn1.nix
nix.settings.cores = 8; # threads per build
nix.settings.max-jobs = 2; # concurrent builds → at most 16 of the box's 24 threads
Those are the server’s settings, not the client’s: with a remote store the client can’t cap anything, so the box decides how much of itself a build may take. Set the budget once and every build I launch from my laptop is a guest on that machine rather than a hostile takeover — no separate build server, no need to babysit load while the poller keeps running.
Two bugs it caught before deploy
The sandbox has no network
The embedding stack pulls in ONNX Runtime through ort-sys, whose build script — by default — downloads a prebuilt runtime at compile time from a CDN. That can’t work under Nix: derivations are pure — a build may only use the inputs its derivation declares, and nothing else — so the build sandbox gives it no network, and it dies on DNS. The fix is to link the ONNX Runtime nixpkgs already provides instead of letting a build script fetch one:
buildInputs = with pkgs; [openssl onnxruntime];
env = {
ORT_LIB_LOCATION = "${pkgs.onnxruntime}/lib";
ORT_PREFER_DYNAMIC_LINK = "1";
};
ort-sys checks ORT_LIB_LOCATION before its download path and links that library instead. I link it dynamically on purpose: nixpkgs ships a shared ONNX Runtime, so the binary picks it up from the closure via rpath — no 300 MB blob vendored into the build, no static archive to coax out of a package that doesn’t ship one.
The compiler is too new
The second catch was sneakier. Nixpkgs’ nixos-unstable had moved to rustc 1.97, and that rustc, built against LLVM 21, emits an intrinsic signature that lance-linalg’s AVX-512 kernels don’t match:
error: intrinsic signature mismatch for `llvm.x86.avx512.vpdpwssd.512`
The fix was to pin the app’s own nixpkgs input to the last rev with rust 1.95. And here’s the part I like: the constraint lives with the code it constrains — the app pins itself, and nix-servers knows nothing about the quirk; it just imports the app’s flake and pins the rev. Exactly one place to relax it when upstream catches up:
# rust >=1.96 (LLVM 21) breaks lance-linalg's avx512 intrinsic.
# Pin the rust-1.95 nixpkgs until fixed.
nixpkgs.url = "github:NixOS/nixpkgs/2f58c85e0c5450129d0851f5e9d9cc35cf0ed5fa";
Both of these are deployment failures. Both were caught by the same one-word command, from my laptop, before anything touched the server.
Why this composes
The nice part is that none of this needed a purpose-built remote-build system — no Bazel remote execution, no distcc, no sccache to wire up. It’s just Nix doing what it already does: hermetic derivations, content-addressed outputs, and a remote store. Point those at a Linux host and you get distributed, cached, cross-machine builds as a side effect.
And because the cache is content-addressed, the two repos share one pool of work: build while verifying from the app repo and the deploy reuses it; build during a deploy and the next local verification is instant. There’s no per-repo cache — just the store, keyed by hash.
Verdict
For a solo project where the dev machine and the deploy target are different architectures, this is the nicest workflow I’ve used. I keep a fast, native, cargo.toml-driven loop on the Mac. I keep a flake that is unambiguously the deployment artifact. And one command reaches out to the Linux box and proves the build — sparing the fanless laptop, and making “it’ll deploy fine” an earned statement instead of a hope.
I’m getting deeper into the cult rabbit hole.
Written with LLM assistance; configs are real and really deployed.