Add worked example: server-host and device-host flakes

Self-contained example under examples/ with full NixOS flakes for both
sides of a deployment (control server + binary cache vs. an agent
device), plus a README walking through the end-to-end install + first
publish.
This commit is contained in:
0m.ax 2026-05-25 15:57:32 +02:00
parent 42b2ce4d1d
commit f72d24596a
6 changed files with 309 additions and 0 deletions

136
examples/README.md Normal file
View file

@ -0,0 +1,136 @@
# Worked example: standing up nix-ota
This walks through deploying a real fleet with two boxes:
- **`ota.example.com`** — runs `nix-ota-server` (control plane) and
`nix-serve` (binary cache). One machine, public DNS.
- **`fridge-007`** — a NixOS device that pulls updates. No flake, no
Nix evaluation. Could be an RPi, a kiosk, an edge node, anything.
You drive everything from your laptop. The laptop holds the signing
key and runs `nix-ota publish` to ship updates.
```
laptop ──signed manifest──► ota.example.com ◄──poll── fridge-007
│ (server + cache) │
└────nix copy closure───────────────┘ │
└────nix copy closure─┘
```
---
## 0. One-time: generate the manifest signing key
On your laptop:
```sh
nix run git+https://linus.dyrehytten.dk/max/nix-ota#nix-ota -- keygen --out ~/.config/nix-ota/sign.key
# prints the public key — save it, you'll bake it into every device.
```
Keep `sign.key` somewhere you trust (password manager, hardware token,
or a sealed CI secret). The server **never sees this key**.
---
## 1. The server host
See [`server-host/`](./server-host/) for a complete flake.
What you need on the server:
- `services.nix-ota-server` — the control plane (HTTP API + dashboard)
- `services.nix-serve` — the binary cache (or use Attic / S3 / Cachix)
- A reverse proxy with TLS in front of both (nginx, Caddy, traefik...)
- A bearer token for publishes, stored in `/run/secrets/...` (sops-nix
or agenix; the example uses a plain file for clarity)
Deploy it however you normally deploy a NixOS box (nixos-rebuild,
deploy-rs, colmena — yes, you can use any of those *to deploy the
nix-ota server itself*; we're only replacing them for the fleet).
After it's up:
```sh
curl https://ota.example.com/healthz # -> ok
curl https://ota.example.com/ # dashboard
```
---
## 2. The device
See [`device-host/`](./device-host/) for a complete flake.
What goes on each device:
- `services.nix-ota-agent` — the polling agent (a single static binary
driven by a systemd timer)
- The matching ed25519 **public key** (so the device rejects manifests
not signed by your key)
- The binary cache's URL **and public key** (so Nix accepts store paths
fetched from it)
You deploy this flake to the device **once**, manually. From then on
you never touch the device's config: subsequent updates ride on top
through `nix-ota publish`.
---
## 3. Publishing your first update
From your laptop:
```sh
# 1. Build the device's system closure.
nix build .#nixosConfigurations.fridge-007.config.system.build.toplevel
# 2. Push it to the cache.
nix copy --to 'https://ota.example.com/cache?secret-key=/path/to/cache.key' \
./result
# 3. Publish a signed manifest pointing at it.
nix run git+https://linus.dyrehytten.dk/max/nix-ota#nix-ota -- publish \
--server https://ota.example.com \
--token "$(cat ~/.config/nix-ota/publish-token)" \
--key ~/.config/nix-ota/sign.key \
--channel prod \
--store-path "$(readlink -f ./result)" \
--substituter https://ota.example.com/cache
```
Within `interval` seconds (default 60), `fridge-007` polls, verifies
the signature, copies the closure from your cache, switches into it,
runs your health check, and check-ins. Open
`https://ota.example.com/` to watch it happen.
Rolling back is just publishing the previous store path again — bump
the revision and ship it.
---
## What you do NOT need
- ❌ SSH access from the server to the devices.
- ❌ The flake on the device. Once the agent + initial config are
installed, you can drop your nix-ota flake reference from the device
entirely (subsequent updates carry it).
- ❌ Per-device builds. Build once, publish, every device on that channel
picks it up.
- ❌ A Nix daemon talking to the control server. Devices talk to the
*cache*; the control server only hands out signed pointers.
---
## File map
```
examples/
├── README.md (this file)
├── server-host/
│ ├── flake.nix full flake for the control-plane host
│ └── configuration.nix
└── device-host/
├── flake.nix full flake for a device
└── configuration.nix
```

View file

@ -0,0 +1,53 @@
# Device configuration — what gets installed on `fridge-007`.
#
# You build + install this ONCE (e.g. via `nixos-rebuild --target-host`
# or by flashing an SD image). From then on, you ship updates to this
# machine by publishing new closures through nix-ota; you do not need
# to redeploy this flake to bump packages.
{ config, pkgs, lib, ... }:
{
imports = [
# Replace with your hardware config.
# ./hardware-configuration.nix
];
networking.hostName = "fridge-007";
services.nix-ota-agent = {
enable = true;
# Where the control server lives.
server = "https://ota.example.com";
channel = "prod";
# Unique identifier for this device. Use the MAC, serial number,
# whatever you have. Must be unique within your fleet.
deviceId = "fridge-007";
# Paste the ed25519 PUBLIC key you generated with `nix-ota keygen`.
# If this doesn't match the key the manifest is signed with, the
# agent will refuse to apply it — that's the point.
publicKey = "REPLACE_WITH_MANIFEST_PUBLIC_KEY_BASE64";
# Where the agent fetches closures from. Nix verifies the per-path
# signatures against `cachePublicKey` below.
cacheUrl = "https://ota.example.com/cache";
cachePublicKey = "ota.example.com-1:REPLACE_WITH_CACHE_PUBLIC_KEY";
# How often to poll. The systemd timer also fires once on boot.
interval = 60;
# Optional health check. Runs after switch-to-configuration.
# If it exits non-zero, the agent rolls back to the previous
# generation and reports `rolled_back`.
healthCmd = "systemctl is-system-running --wait";
};
# Standard NixOS stuff for a real device.
services.openssh.enable = true;
users.users.root.openssh.authorizedKeys.keys = [
"ssh-ed25519 AAAA... your-key-here"
];
system.stateVersion = "24.05";
}

View file

@ -0,0 +1,19 @@
{
description = "fridge-007 a nix-ota-managed device";
inputs = {
nixpkgs.url = "github:nixos/nixpkgs?ref=nixos-unstable";
nix-ota.url = "git+https://linus.dyrehytten.dk/max/nix-ota";
nix-ota.inputs.nixpkgs.follows = "nixpkgs";
};
outputs = { self, nixpkgs, nix-ota, ... }: {
nixosConfigurations.fridge-007 = nixpkgs.lib.nixosSystem {
system = "x86_64-linux";
modules = [
nix-ota.nixosModules.agent
./configuration.nix
];
};
};
}

View file

@ -0,0 +1,79 @@
# Server host — runs nix-ota-server AND nix-serve (the binary cache),
# fronted by nginx with Let's Encrypt TLS.
#
# Layout under https://ota.example.com/ :
# / -> nix-ota-server dashboard + API (port 8080)
# /cache/ -> nix-serve binary cache (port 5000)
#
# The cache and the control plane are on the same DNS name so devices
# only need one URL. They can be split if you prefer.
{ config, pkgs, lib, ... }:
{
imports = [
# Replace with your hardware config.
# ./hardware-configuration.nix
];
networking.hostName = "ota";
networking.firewall.allowedTCPPorts = [ 80 443 ];
###########################################################################
# 1. The control server.
###########################################################################
services.nix-ota-server = {
enable = true;
listen = "127.0.0.1:8080";
# Put your real secret here. With sops-nix:
# publishTokenFile = config.sops.secrets."nix-ota/publish-token".path;
# For the demo we just write a literal file; replace this in production.
publishTokenFile = pkgs.writeText "publish-token" "CHANGE-ME-LONG-RANDOM-STRING";
};
###########################################################################
# 2. The binary cache (nix-serve). Skip this if you use Attic / S3 / Cachix.
###########################################################################
services.nix-serve = {
enable = true;
port = 5000;
bindAddress = "127.0.0.1";
# Generate once with:
# nix-store --generate-binary-cache-key ota.example.com-1 \
# /var/lib/nix-serve/key /var/lib/nix-serve/pub.key
# Then commit the .pub file (it's public) and keep `key` secret.
secretKeyFile = "/var/lib/nix-serve/key";
};
# Tell the local Nix daemon to trust paths signed by our cache so
# `nix copy --to` from operators works without --no-check-sigs.
nix.settings.trusted-public-keys = [
# paste the contents of /var/lib/nix-serve/pub.key here
"ota.example.com-1:REPLACE_WITH_PUBLIC_KEY"
];
###########################################################################
# 3. Reverse proxy + TLS.
###########################################################################
services.nginx = {
enable = true;
recommendedProxySettings = true;
recommendedTlsSettings = true;
virtualHosts."ota.example.com" = {
enableACME = true;
forceSSL = true;
locations."/" = {
proxyPass = "http://127.0.0.1:8080";
};
locations."/cache/" = {
proxyPass = "http://127.0.0.1:5000/";
# nix-serve doesn't need any special headers.
};
};
};
security.acme = {
acceptTerms = true;
defaults.email = "ops@example.com";
};
system.stateVersion = "24.05";
}

View file

@ -0,0 +1,19 @@
{
description = "ota.example.com nix-ota control plane + binary cache";
inputs = {
nixpkgs.url = "github:nixos/nixpkgs?ref=nixos-unstable";
nix-ota.url = "git+https://linus.dyrehytten.dk/max/nix-ota";
nix-ota.inputs.nixpkgs.follows = "nixpkgs";
};
outputs = { self, nixpkgs, nix-ota, ... }: {
nixosConfigurations.ota = nixpkgs.lib.nixosSystem {
system = "x86_64-linux";
modules = [
nix-ota.nixosModules.server
./configuration.nix
];
};
};
}