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:
parent
42b2ce4d1d
commit
f72d24596a
6 changed files with 309 additions and 0 deletions
136
examples/README.md
Normal file
136
examples/README.md
Normal 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
|
||||
```
|
||||
53
examples/device-host/configuration.nix
Normal file
53
examples/device-host/configuration.nix
Normal 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";
|
||||
}
|
||||
19
examples/device-host/flake.nix
Normal file
19
examples/device-host/flake.nix
Normal 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
|
||||
];
|
||||
};
|
||||
};
|
||||
}
|
||||
79
examples/server-host/configuration.nix
Normal file
79
examples/server-host/configuration.nix
Normal 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";
|
||||
}
|
||||
19
examples/server-host/flake.nix
Normal file
19
examples/server-host/flake.nix
Normal 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
|
||||
];
|
||||
};
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue