Merge pull request 'feat: Raspberry Pi 5 host (Docker host + nginx reverse proxy)' (#32) from feat/rpi5-host into main
CI / flake (push) Successful in 3m50s

Reviewed-on: #32
This commit was merged in pull request #32.
This commit is contained in:
2026-06-16 14:12:48 +01:00
9 changed files with 258 additions and 14 deletions
+8 -5
View File
@@ -8,11 +8,12 @@ single flake.
Defined in the host table in [`flake.nix`](./flake.nix):
| Configuration | System | Machine |
| --------------------- | ---------------- | --------------------------------------------------------------------------- |
| --------------------- | ---------------- | -------------------------------------------------------------------------------------------------------------------- |
| `lyrathorpe-mbp` | `aarch64-linux` | MacBook Pro (Apple Silicon, Asahi) |
| `lyrathorpe-t400` | `x86_64-linux` | ThinkPad T400 — [install notes](./system/machine/T400/README.md) |
| `lyrathorpe-macpro31` | `x86_64-linux` | Mac Pro 3,1, desktop — [install notes](./system/machine/MacPro31/README.md) |
| `emmathorpe-edaas` | `x86_64-linux` | Work WSL box (NixOS-WSL) |
| `lyrathorpe-rpi5` | `aarch64-linux` | Raspberry Pi 5 headless server: Docker host + nginx reverse proxy — [install notes](./system/machine/RPi5/README.md) |
| `lyrathorpe-mac` | `aarch64-darwin` | macOS (nix-darwin) |
Shared layers: `lyrathorpe/home` (home-manager: shell, git, editor),
@@ -41,11 +42,13 @@ darwin-rebuild switch --flake .#lyrathorpe-mac
## Login / greeter
Graphical (Sway) hosts log in through a Wayland greeter — `greetd` running
ReGreet inside the `cage` kiosk compositor — configured centrally in
ReGreet inside the `cage` kiosk compositor — implemented in
[`lyrathorpe/swaywm.nix`](./lyrathorpe/swaywm.nix), gated on
`features.swayDesktop.enable`. The greeter is forced to Dvorak to match the
console and Sway session. Hosts with `features.swayDesktop.enable = false` (the
WSL work box) keep plain TTY login. The target account needs a password
`features.swayDesktop.enable` (the option is declared in
[`system/modules/features.nix`](./system/modules/features.nix), so headless hosts
can leave it off without importing `swaywm.nix`). The greeter is forced to Dvorak
to match the console and Sway session. Headless hosts (the WSL work box and the
Raspberry Pi server) keep plain TTY login. The target account needs a password
(`passwd <user>`) before it can log in.
## MacBook (Asahi) firmware
+17
View File
@@ -114,6 +114,7 @@
baseModules = [
./lyrathorpe/user.nix
./system/modules/common-nixos.nix
./system/modules/features.nix
commonModule
home-manager.nixosModules.home-manager
{
@@ -287,6 +288,22 @@
./lyrathorpe/home/work.nix
];
};
lyrathorpe-rpi5 = {
system = "aarch64-linux";
username = "lyrathorpe";
fullName = "Lyra Thorpe";
portable = false;
# Headless server: Docker host + nginx reverse proxy. No swaywm.nix
# (no desktop); the raspberry-pi-5 profile supplies kernel/firmware,
# ssh.nix adds key-only sshd.
modules = [
./system/machine/RPi5/configuration.nix
inputs.nixos-hardware.nixosModules.raspberry-pi-5
./system/modules/ssh.nix
];
homeModules = [ ./lyrathorpe/home ];
};
};
# Darwin host table — macOS machines built via mkDarwinHost. The shared
+3 -3
View File
@@ -11,9 +11,9 @@ let
ctp = import ./catppuccin-mocha.nix;
in
{
options = {
features.swayDesktop.enable = lib.mkEnableOption "Enable Sway Desktop";
};
# The features.swayDesktop.enable option is declared in
# system/modules/features.nix (so headless hosts can read/set it without
# importing this module). This module only provides its implementation.
config = lib.mkIf cfg.enable {
programs.sway = {
enable = true;
+68
View File
@@ -0,0 +1,68 @@
# Raspberry Pi 5 (`lyrathorpe-rpi5`)
Headless `aarch64-linux` server with two roles:
- **Docker host** — daemon exposed over the network (`docker.nix`).
- **nginx reverse proxy** — declarative `virtualHosts` (`reverse-proxy.nix`).
## Install
1. Flash a NixOS `aarch64` SD image (or USB) and boot the Pi. The
`raspberry-pi-5` profile from `nixos-hardware` (wired in the flake host table)
supplies the kernel, firmware and device tree; boot is U-Boot + extlinux.
2. Partition/mount the target, then **regenerate the hardware config on the
device** and replace the committed placeholder:
```sh
nixos-generate-config --root /mnt
# copy /mnt/etc/nixos/hardware-configuration.nix over
# system/machine/RPi5/hardware-configuration.nix in this repo, then commit
```
`hardware-configuration.nix` in this directory is a **placeholder** committed
only so the host evaluates in CI. The machine will not boot correctly until it
is replaced with the generated one.
3. Set the host name to match the flake attribute (already done in
`configuration.nix`: `lyrathorpe-rpi5`) and build:
```sh
sudo nixos-rebuild switch --flake .#lyrathorpe-rpi5
# or, once the hostname is live:
nh os switch
```
4. Give the login user a password (`passwd lyrathorpe`) and confirm the key in
`system/modules/ssh.nix` is the one you will connect with.
## Docker socket (security)
The daemon listens on **plain TCP `2375`, no TLS, no auth**. Access is
root-equivalent on this host. The only protection is the nftables rule in
`docker.nix`, which accepts `2375` **only** from the trusted LAN subnet
(`10.187.1.0/24` by default — change it to match your network). Do not widen
that subnet to anything untrusted.
From a LAN client:
```sh
export DOCKER_HOST=tcp://lyrathorpe-rpi5:2375
docker info
```
The secure upgrade path is mutual TLS on `2376` (`--tlsverify` with a CA and
client certs); it needs out-of-band cert provisioning and is intentionally not
wired here.
## Adding a reverse-proxy site
Each proxied service is a Nix entry in `reverse-proxy.nix`:
```nix
services.nginx.virtualHosts."app.example.lan" = {
# enableACME = true; forceSSL = true; # once a DNS name + cert exist
locations."/" = {
proxyPass = "http://127.0.0.1:8080"; # e.g. a local container
proxyWebsockets = true;
};
};
```
The example vhost is HTTP-only by design. Turn on `enableACME`/`forceSSL`
per-vhost once the host has a real DNS name and the ACME challenge can be met;
`443` is already open in the firewall.
+40
View File
@@ -0,0 +1,40 @@
# Raspberry Pi 5 (aarch64) headless server. Two roles, split into submodules:
# ./docker.nix (Docker host with a network socket) and ./reverse-proxy.nix
# (native nginx). The raspberry-pi-5 nixos-hardware profile (kernel, firmware,
# device tree) and key-only sshd (../../modules/ssh.nix) are layered on in the
# flake host table. Install notes: see ./README.md.
{ ... }:
{
imports = [
./hardware-configuration.nix
./docker.nix
./reverse-proxy.nix
];
# Match the flake's nixosConfigurations attribute name so `nh os switch`
# (which selects by the local hostname) resolves without an explicit -H flag.
networking.hostName = "lyrathorpe-rpi5";
# Headless server: the Sway desktop is intentionally not set up. swaywm.nix is
# not imported and features.swayDesktop.enable defaults to false (declared in
# system/modules/features.nix), so this host keeps plain TTY/SSH login.
# Raspberry Pi boots via U-Boot + extlinux, not GRUB/systemd-boot. The
# raspberry-pi-5 nixos-hardware profile supplies the kernel, firmware and
# device tree.
boot.loader.grub.enable = false;
boot.loader.generic-extlinux-compatible.enable = true;
# Remote administration. Key-only policy and the authorized key come from
# ../../modules/ssh.nix; here we just enable the daemon and open the port.
services.openssh.enable = true;
# Default-deny inbound. Open only SSH here; the Docker and nginx submodules
# open their own ports (Docker via a source-restricted nftables rule, nginx
# via 80/443). List-valued, so these merge with the submodule definitions.
networking.firewall.enable = true;
networking.firewall.allowedTCPPorts = [ 22 ];
# See `man configuration.nix` / the stateVersion docs before changing.
system.stateVersion = "26.05";
}
+34
View File
@@ -0,0 +1,34 @@
# Docker host with the daemon socket exposed over the network.
#
# SECURITY: the daemon listens on plain TCP 2375 with NO TLS and NO auth. Access
# to that port is root-equivalent on this host (the Docker API can mount the
# host filesystem and run privileged containers). The ONLY thing protecting it
# is the nftables rule below, which accepts 2375 solely from the trusted LAN
# subnet. Do not widen that subnet to anything you do not fully trust. The
# secure upgrade path is mutual TLS on 2376 (--tlsverify with client certs);
# that needs out-of-band cert provisioning and is intentionally not wired here.
{ ... }:
{
virtualisation.docker.enable = true;
# Expose the daemon over TCP by extending systemd socket activation rather than
# setting daemon.settings.hosts. The NixOS docker unit starts dockerd with
# `-H fd://` and takes its listeners from this socket; putting `hosts` in
# daemon.json as well would conflict with that and dockerd would refuse to
# start. Adding the TCP listener here keeps a single source of truth.
# The leading "" resets the unit's default (unix-socket-only) ListenStream list.
systemd.sockets.docker.socketConfig.ListenStream = [
""
"/run/docker.sock"
"0.0.0.0:2375"
];
# Source-restricted firewall rule for the Docker TCP port. 2375 is deliberately
# NOT added to networking.firewall.allowedTCPPorts (that would open it to every
# source); instead nftables accepts it only from the trusted subnet. Adjust the
# CIDR to match the LAN that should reach the Docker API.
networking.nftables.enable = true;
networking.firewall.extraInputRules = ''
ip saddr 10.187.1.0/24 tcp dport 2375 accept
'';
}
@@ -0,0 +1,31 @@
# PLACEHOLDER hardware configuration for the Raspberry Pi 5.
#
# This file is NOT the real generated config -- it exists only so the host
# evaluates in CI before the Pi is provisioned. The machine will not boot from
# it as-is. On first install, regenerate this file on the device with
# nixos-generate-config --root /mnt
# and replace this placeholder with the output (commit it). See ./README.md.
#
# Like every hardware-configuration.nix in this repo, this file is excluded from
# the formatter and linters (see the pre-commit/treefmt excludes in flake.nix).
{ modulesPath, ... }:
{
imports = [ (modulesPath + "/installer/scan/not-detected.nix") ];
nixpkgs.hostPlatform = "aarch64-linux";
# The Raspberry Pi 5 boots from an SD card / USB with a FAT firmware partition
# and an ext4 root. Labels match the conventional sd-image layout; the real
# generated config will use by-uuid device paths instead.
fileSystems."/" = {
device = "/dev/disk/by-label/NIXOS_SD";
fsType = "ext4";
};
fileSystems."/boot/firmware" = {
device = "/dev/disk/by-label/FIRMWARE";
fsType = "vfat";
};
swapDevices = [ ];
}
+39
View File
@@ -0,0 +1,39 @@
# Native nginx reverse proxy. The proxy configuration is declarative Nix:
# every proxied service is an entry under services.nginx.virtualHosts, so the
# whole routing table lives in this file and is built/version-controlled with
# the rest of the system.
#
# To add a proxied service, add another virtualHosts."<host>" entry following
# the example below. To serve it over HTTPS, uncomment enableACME + forceSSL on
# that vhost once it has a real DNS name and the ACME HTTP-01/DNS-01 challenge
# can be satisfied (see security.acme for the account/email and DNS settings).
{ ... }:
{
services.nginx = {
enable = true;
recommendedProxySettings = true; # sane proxy_set_header defaults (Host, X-Forwarded-*)
recommendedTlsSettings = true;
recommendedOptimisation = true;
recommendedGzipSettings = true;
virtualHosts = {
# Example reverse-proxy vhost. Replace the name and upstream with a real
# service (e.g. a container published by the Docker host on this machine).
"example.lan" = {
# enableACME = true; # request a Let's Encrypt cert for this host
# forceSSL = true; # redirect HTTP -> HTTPS once the cert exists
locations."/" = {
proxyPass = "http://127.0.0.1:8080";
proxyWebsockets = true; # forward Upgrade/Connection for WebSocket apps
};
};
};
};
# Public reverse-proxy ports. 443 is opened now so flipping a vhost to TLS
# needs no firewall change.
networking.firewall.allowedTCPPorts = [
80
443
];
}
+12
View File
@@ -0,0 +1,12 @@
# Feature-flag option declarations shared by every NixOS host (imported via
# baseModules in flake.nix). Declaring the flags here -- rather than inside the
# module that implements them -- means a host can read or set a flag without
# importing the (often large) implementation module. In particular,
# features.swayDesktop.enable is read by lyrathorpe/user.nix on every host, but a
# headless host (e.g. the Pi) must be able to leave it at its default without
# pulling in lyrathorpe/swaywm.nix. The implementation lives in swaywm.nix,
# gated on this flag.
{ lib, ... }:
{
options.features.swayDesktop.enable = lib.mkEnableOption "the Sway desktop";
}