From f72d24596a5a88b10ed4cfad8002de446f565b86 Mon Sep 17 00:00:00 2001 From: "0m.ax" Date: Mon, 25 May 2026 15:57:32 +0200 Subject: [PATCH] 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. --- README.md | 3 + examples/README.md | 136 +++++++++++++++++++++++++ examples/device-host/configuration.nix | 53 ++++++++++ examples/device-host/flake.nix | 19 ++++ examples/server-host/configuration.nix | 79 ++++++++++++++ examples/server-host/flake.nix | 19 ++++ 6 files changed, 309 insertions(+) create mode 100644 examples/README.md create mode 100644 examples/device-host/configuration.nix create mode 100644 examples/device-host/flake.nix create mode 100644 examples/server-host/configuration.nix create mode 100644 examples/server-host/flake.nix diff --git a/README.md b/README.md index 34a0de7..95d7d46 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,9 @@ arbitrary closures. ## Quickstart (< 10 minutes) +> πŸ‘‰ For a complete copy-pasteable setup with two real NixOS flakes +> (server host + device host), see [`examples/`](./examples/). + ### 1. Generate a signing key on your workstation ```sh diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..2918d24 --- /dev/null +++ b/examples/README.md @@ -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 +``` diff --git a/examples/device-host/configuration.nix b/examples/device-host/configuration.nix new file mode 100644 index 0000000..e407d21 --- /dev/null +++ b/examples/device-host/configuration.nix @@ -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"; +} diff --git a/examples/device-host/flake.nix b/examples/device-host/flake.nix new file mode 100644 index 0000000..9e5df20 --- /dev/null +++ b/examples/device-host/flake.nix @@ -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 + ]; + }; + }; +} diff --git a/examples/server-host/configuration.nix b/examples/server-host/configuration.nix new file mode 100644 index 0000000..efc30c2 --- /dev/null +++ b/examples/server-host/configuration.nix @@ -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"; +} diff --git a/examples/server-host/flake.nix b/examples/server-host/flake.nix new file mode 100644 index 0000000..c473668 --- /dev/null +++ b/examples/server-host/flake.nix @@ -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 + ]; + }; + }; +}