Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
fa50de9
feat: add live box ISO (hosts/live) + Makefile build targets
phorcys420 Jun 5, 2026
4b24ec2
refactor: rename live ISO target + add persistent-disk image (qcow2/raw)
phorcys420 Jun 5, 2026
7079c43
refactor: unify image build targets under appliance/<format>[/<arch>]
phorcys420 Jun 5, 2026
da8c228
appliance/iso: name the ISO coder-box-appliance.iso
phorcys420 Jun 5, 2026
078ac55
fix(make): bare appliance/* targets build for the builder's native arch
phorcys420 Jun 5, 2026
1ba7de1
make: emit appliance images into ./out via --out-link (no copy)
phorcys420 Jun 5, 2026
3389584
appliance/iso: include arch in the ISO file name
phorcys420 Jun 5, 2026
dc91f64
appliance/{raw,qcow2}: include arch in the disk image file name
phorcys420 Jun 5, 2026
f0989b2
chore: gitignore appliance image artifacts (*.iso, *.qcow2, *.raw)
phorcys420 Jun 5, 2026
20ef1f1
appliance/iso: boot-menu label 'NixOS <version> - Coder Box Appliance'
phorcys420 Jun 5, 2026
b013190
refactor: rename appliance host dirs to hosts/_appliance_iso and host…
phorcys420 Jun 5, 2026
3140f83
feat: default hostname to coder-box centrally; appliances inherit it
phorcys420 Jun 5, 2026
4983902
fix: stop appliance ISO growing every build (filter build artifacts f…
phorcys420 Jun 5, 2026
8e4047f
docs: mark qcow2/raw disk appliances as untested; fix stale host names
phorcys420 Jun 8, 2026
9b0b1de
docs: finish renaming stale live/persistent-disk host refs in README
phorcys420 Jun 8, 2026
b84b169
refactor: move appliance modules under nixos/_appliance/; 'Live ISO' …
phorcys420 Jun 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,9 @@ hosts/*/local.nix
*.tfstate*
.terraform/
*.bak

# Appliance image build outputs (Makefile --out-link target dir + artifacts)
out/
*.iso
*.qcow2
*.raw
76 changes: 76 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# Coder box — appliance image build targets.
#
# An "appliance" is the box prebuilt as a bootable image (no nixos/install.sh):
# it boots straight into the fully-configured Coder box. Three formats:
#
# make appliance/iso # appliance ISO (tmpfs overlay; state wiped on reboot)
# make appliance/qcow2 # disk image (persistent; boots in QEMU/libvirt)
# make appliance/raw # disk image (persistent; dd-able to a drive)
#
# Each format also takes an architecture suffix; short names are normalized to
# a *-linux triple (e.g. aarch64 -> aarch64-linux):
#
# make appliance/iso/x86_64-linux
# make appliance/qcow2/aarch64-linux
# make appliance/raw/aarch64
#
# Requires Nix with flakes enabled (nix-command + flakes). All builds run on
# Linux only; cross-arch builds need a matching builder (native remote builder
# or binfmt/QEMU emulation). qcow2/raw additionally boot a QEMU VM during the
# build (disko image builder), so they want KVM to be fast.
#
# Outputs land in ./result (printed out-path). Flash a raw image or the ISO to
# a drive with e.g.
# sudo dd if=result/...img of=/dev/sdX bs=4M status=progress oflag=sync

NIX ?= nix
FLAKE ?= .

# Normalize an arch token to a *-linux triple: $(call norm_arch,aarch64) -> aarch64-linux
norm_arch = $(if $(filter %-linux,$(1)),$(1),$(1)-linux)

# Single build helper used by every target. extendModules lets us override
# nixpkgs.hostPlatform (per-arch) and the disko image format from one recipe,
# so adding a format/arch is just a thin target below — no duplicated nix
# plumbing. We ALWAYS pin nixpkgs.hostPlatform: when no arch is given we use
# `builtins.currentSystem` (the builder's native arch), otherwise the bare
# `appliance/<format>` targets would inherit configuration.nix's
# `nixpkgs.hostPlatform = lib.mkOptionDefault "x86_64-linux"` and always build
# x86_64 even on an aarch64 host. `--impure` is what makes currentSystem
# available.
# $(1) = host (nixosConfigurations.<host>)
# $(2) = system.build.<attr> (isoImage | diskoImages)
# $(3) = extra module fields (nix attrset body, may be empty)
# $(4) = arch token (empty = builder's native arch)
# The built image lives in /nix/store (always — that's how Nix works), but
# `--out-link` plants a GC-root symlink to it under ./out (named after the
# target, e.g. out/appliance-iso, out/appliance-raw-aarch64-linux). That's the
# native, non-copy way to surface the result in the repo: ./out/<link> points
# straight at the store path, and being a GC root it won't be garbage-collected.
# ./out is gitignored.
define box_build
@mkdir -p out
$(NIX) build --impure --no-write-lock-file --print-out-paths \
--out-link 'out/$(subst /,-,$@)' --expr \
'let f = builtins.getFlake (toString ./.); in (f.nixosConfigurations.$(1).extendModules { modules = [ { nixpkgs.hostPlatform = "$(if $(4),$(call norm_arch,$(4)),$${builtins.currentSystem})"; $(3) } ]; }).config.system.build.$(2)'
endef

.PHONY: appliance/iso appliance/qcow2 appliance/raw

# ── appliance/iso — ephemeral appliance ISO (hosts/_appliance_iso) ───────────
appliance/iso:
$(call box_build,_appliance_iso,isoImage,,)
appliance/iso/%:
$(call box_build,_appliance_iso,isoImage,,$*)

# ── appliance/qcow2 — persistent disk image (hosts/_appliance-disk) ──────────
appliance/qcow2:
$(call box_build,_appliance-disk,diskoImages,disko.imageBuilder.imageFormat = "qcow2";,)
appliance/qcow2/%:
$(call box_build,_appliance-disk,diskoImages,disko.imageBuilder.imageFormat = "qcow2";,$*)

# ── appliance/raw — persistent disk image, dd-able (hosts/_appliance-disk) ────
appliance/raw:
$(call box_build,_appliance-disk,diskoImages,disko.imageBuilder.imageFormat = "raw";,)
appliance/raw/%:
$(call box_build,_appliance-disk,diskoImages,disko.imageBuilder.imageFormat = "raw";,$*)
105 changes: 101 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ NixOS configuration for Coder demo and workshop boxes.
flake.nix # entry point: nixosConfigurations.<host> per machine
flake.lock # pinned nixpkgs / disko / nixos-facter-modules
configuration.nix # shared NixOS config (all machines)
Makefile # appliance build targets: appliance/{iso,qcow2,raw}[/<arch>]
local.nix.example # template copied to hosts/<host>/local.nix by install.sh
.gitignore # ignores hosts/*/local.nix
nixos/
Expand All @@ -38,6 +39,9 @@ nixos/
k3s-sysbox.nix # k3s + sysbox-runc runtime class
k3s-podman.nix # k3s + rootless Podman socket
screenconnect.nix # optional ScreenConnect remote access client
_appliance/ # prebuilt-appliance modules (ISO + persistent disk)
box-turnkey.nix # shared turn-key bits for appliances (login + Coder bootstrap)
live-iso.nix # ephemeral appliance ISO module (hosts/_appliance_iso)
pkgs/
coder.nix # custom Coder server package
coderd-provider.nix # terraform-provider-coderd package
Expand All @@ -49,6 +53,10 @@ hosts/
local.nix # gitignored: admin creds, secrets, SSH users
templates/
nook-android/ # Workspace: build trmnl-nook-simple-touch APK
_appliance_iso/ # `_appliance_iso` host: ephemeral appliance ISO (no disk install)
default.nix # imports nixos/_appliance/live-iso.nix (no disko/facter/hardware-config)
_appliance-disk/ # `_appliance-disk` host: persistent qcow2/raw disk image
default.nix # imports disko-standard.nix + nixos/_appliance/box-turnkey.nix
coderd/
main.tf # manages all Coder templates via coderd Terraform provider
templates/
Expand All @@ -60,10 +68,15 @@ coderd/

This repo is a Nix flake. `flake.nix` auto-discovers every subdirectory of
`./hosts/` that contains a `default.nix` and exposes it as
`nixosConfigurations.<folder-name>`. The folder name is the hostname, so
`nixos-rebuild switch --flake .` auto-selects the right config on the
running box. Adding a new host means creating a host folder, no flake.nix
edit. The installer does this for you.
`nixosConfigurations.<folder-name>`. For normal install hosts the folder name
is also the hostname, so `nixos-rebuild switch --flake .` auto-selects the
right config on the running box. Adding a new host means creating a host
folder, no flake.nix edit. The installer does this for you.

Hosts whose folder name starts with an underscore (`_appliance_iso`,
`_appliance-disk`) are image/appliance builds, not per-machine installs: they
do **not** get the folder-name hostname and instead inherit the central
default `networking.hostName = "coder-box"` (set in `configuration.nix`).

Two community tools do the heavy lifting:

Expand Down Expand Up @@ -108,6 +121,90 @@ The installer generates `hosts/<hostname>/{default.nix,local.nix,facter.json}`,
> ```
> And use a BIOS-compatible disko layout instead of `disko-standard.nix`.

## Prebuilt images (The Box™ without `install.sh`)

Sometimes you don't want to run the installer; you just want The Box™. Two
image flavours build the *exact same* configured system — KDE Plasma, the Coder
server, k3s, Podman, the bundled templates — with admin bootstrap and template
deploy happening on boot just like a real install. Neither is an installer.

These prebuilt images are called **appliances** (the box, prebuilt — no
`install.sh`). Build them with `make appliance/<format>`:

| Format | Host | State | Status | Build |
|---|---|---|---|---|
| **iso** (live, ephemeral) | `_appliance_iso` | tmpfs overlay — wiped on reboot | verified | `make appliance/iso` |
| **qcow2** (persistent disk) | `_appliance-disk` | persists across reboots | ⚠️ untested | `make appliance/qcow2` |
| **raw** (persistent disk) | `_appliance-disk` | persists across reboots | ⚠️ untested | `make appliance/raw` |

All builds need a Linux machine with Nix + flakes. Every target also takes an
architecture suffix (short names are normalized to `*-linux`); cross-arch
builds need a matching builder (native remote builder or binfmt/QEMU):

```sh
make appliance/iso/aarch64-linux
make appliance/qcow2/aarch64-linux
make appliance/raw/x86_64
```

Each target drops a `--out-link` (GC-root symlink) in `./out/` named after the
target — e.g. `out/appliance-iso`, `out/appliance-raw-aarch64-linux` — pointing
straight at the built image in the Nix store (no copy; `./out` is gitignored).
The ISO is then at `out/appliance-iso/iso/coder-box-appliance-*.iso`, and a disk
image at `out/appliance-raw/coder-box-appliance-*.raw` (or
`out/appliance-qcow2/coder-box-appliance-*.qcow2`). All names carry the arch,
e.g. `coder-box-appliance-aarch64-linux.iso`.

The turn-key login + Coder admin bootstrap shared by both flavours live in
[`nixos/_appliance/box-turnkey.nix`](nixos/_appliance/box-turnkey.nix): autologin to the `coderbox`
desktop, and admin `admin@coder.com` / `PleaseChangeMe1234`. Coder comes up at
`http://<hostname>.local:3000` (or the `*.try.coder.app` tunnel URL in
`/etc/motd`). Change these before sharing an image by dropping a gitignored
`hosts/<host>/local.nix` (same shape as `local.nix.example`).

### Appliance ISO (`_appliance_iso`)

The appliance root filesystem is the squashfs + tmpfs overlay from nixpkgs'
`iso-image.nix`, so there's no partition to format or mount and **all state is
discarded on reboot**. `hosts/_appliance_iso/default.nix` imports
[`nixos/_appliance/live-iso.nix`](nixos/_appliance/live-iso.nix) (which pulls in `box-turnkey.nix`) —
**no** `disko-standard.nix`, `hardware-configuration.nix`, or `facter.json`.
The installed-machine `systemd-boot` / EFI-variable settings are forced off; the
ISO carries its own GRUB-EFI + isolinux loader (BIOS boot is x86-only, so the
aarch64 ISO is EFI-only). Flash it (it's isohybrid) and boot:

```sh
sudo dd if=out/appliance-iso/iso/coder-box-appliance-*.iso of=/dev/sdX bs=4M status=progress oflag=sync
```

### Persistent disk image (`_appliance-disk`)

> [!WARNING]
> **Untested.** The `qcow2` and `raw` disk-image builds evaluate cleanly and
> produce a valid build plan, but they have not yet been built end-to-end or
> boot-tested. The live `appliance/iso` is the only flavour verified to build
> and boot so far. Treat the disk images as experimental until someone confirms
> a working build + boot.

Built with [disko](https://github.com/nix-community/disko)'s image builder, so
it carries the real on-disk GPT layout from `nixos/disko-standard.nix` (1 GB
ESP + ext4 root) and **state survives reboots**, exactly like a machine you ran
`install.sh` on. `hosts/_appliance-disk/default.nix` imports
`disko-standard.nix` + `box-turnkey.nix`.

- **`qcow2`** — boot it directly in QEMU/libvirt/UTM. A qcow2 is a container
format, so it can **not** be `dd`'d to a drive as-is — convert first
(`qemu-img convert -O raw box.qcow2 box.img`) or build the raw image instead.
- **`raw`** — a plain disk image you can `dd` straight onto a physical drive:
```sh
sudo dd if=result/*.img of=/dev/sdX bs=4M status=progress oflag=sync
```

Both image hosts are completely separate from the disk-install flow above
(`nixos/install.sh`, `nixos-facter`); adding them changes nothing for normal
installs. The `_appliance-disk` host shares only the disk *layout*
(`disko-standard.nix`) with real installs, never the install process itself.

## After install

The installer auto-creates the admin user, mints a long-lived API token to
Expand Down
7 changes: 7 additions & 0 deletions agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -184,10 +184,17 @@ sudo k3s kubectl describe pod -n coder-workspaces <pod-name>
k3s-sysbox.nix # k3s + sysbox runtime
k3s-podman.nix # k3s + rootless Podman socket
screenconnect.nix # ScreenConnect remote access client
_appliance/ # prebuilt-appliance modules (ISO + persistent disk)
box-turnkey.nix # shared turn-key bits for appliances (login + Coder bootstrap)
live-iso.nix # ephemeral appliance ISO module (imported by hosts/_appliance_iso)
pkgs/
coder.nix # Coder server package derivation
coderd-provider.nix # terraform-provider-coderd derivation
hosts/
_appliance_iso/ # `_appliance_iso` host: ephemeral live "Box" ISO; no disko/facter/hardware-config
# build: make appliance/iso (or appliance/iso/<arch>)
_appliance-disk/ # `_appliance-disk` host: persistent qcow2/raw disk image (disko image builder)
# build: make appliance/qcow2 | make appliance/raw (or .../<arch>)
coder-thinkcentre/ # folder name = hostname; default.nix has a hardware-model header comment
default.nix # host module: imports facter/legacy + local.nix + thinkcentre-only services
hardware-configuration.nix # legacy fallback (used until facter.json exists)
Expand Down
16 changes: 13 additions & 3 deletions configuration.nix
Original file line number Diff line number Diff line change
Expand Up @@ -106,9 +106,19 @@ in
zramSwap.enable = lib.mkDefault true;

# ── Networking ────────────────────────────────────────────────────────────
# networking.hostName is set by flake.nix's mkHost to the host folder
# name; per-host modules can override with lib.mkForce in
# hosts/<host>/local.nix or default.nix.
# Central default hostname. Install hosts override this: flake.nix's mkHost
# injects `networking.hostName = lib.mkDefault <folder-name>` for every
# non-underscore host (so coder-thinkcentre stays coder-thinkcentre, etc.).
# Underscore-prefixed image/appliance hosts (_appliance_iso, _appliance-disk)
# get no injection and so inherit "coder-box".
#
# Priority 1250 (mkOverride) is deliberately BETWEEN mkDefault (1000) and
# mkOptionDefault (1500): it beats the option's own built-in default
# ("nixos", which nixpkgs sets at mkOptionDefault and would otherwise tie
# and error), while still losing to flake.nix's mkDefault folder-name
# injection on install hosts. A host's local.nix/default.nix can override at
# normal (100) priority or mkForce.
networking.hostName = lib.mkOverride 1250 "coder-box";
networking.networkmanager.enable = true;

# mDNS: every box reachable as <hostname>.local on the LAN
Expand Down
23 changes: 17 additions & 6 deletions flake.nix
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,12 @@
forAllSystems = lib.genAttrs systems;

# Each subdirectory of ./hosts that contains a default.nix becomes a
# nixosConfigurations entry. The folder name IS the hostname, so
# `nixos-rebuild switch --flake .` auto-selects the right config on
# the running box without needing `.#<attr>`. Adding a new host means
# just creating ./hosts/<hostname>/default.nix; no flake.nix edit.
# nixosConfigurations entry. For install hosts the folder name IS the
# hostname, so `nixos-rebuild switch --flake .` auto-selects the right
# config on the running box without needing `.#<attr>`. Adding a new host
# means just creating ./hosts/<hostname>/default.nix; no flake.nix edit.
# (Underscore-prefixed folders like _appliance_iso are image builds that
# skip the folder-name hostname; see mkHost below.)
hostNames = lib.attrNames (lib.filterAttrs
(name: type:
type == "directory"
Expand All @@ -58,8 +60,17 @@
disko.nixosModules.disko
nixos-facter-modules.nixosModules.facter
(./hosts + "/${hostname}")
{ networking.hostName = lib.mkDefault hostname; }
];
]
# Install hosts use their folder name as the hostname so
# `nixos-rebuild switch --flake .` auto-selects the right config on the
# running box. Underscore-prefixed folders (e.g. _appliance_iso,
# _appliance-disk) are image/appliance builds whose names aren't valid
# hostnames and aren't installed per-machine; they fall through to the
# central default (networking.hostName = "coder-box" in
# configuration.nix). mkDefault here (1000) overrides that central
# mkOptionDefault (1500) for install hosts.
++ lib.optional (!lib.hasPrefix "_" hostname)
{ networking.hostName = lib.mkDefault hostname; };
};
in {
nixosConfigurations =
Expand Down
57 changes: 57 additions & 0 deletions hosts/_appliance-disk/default.nix
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# Persistent "Box" disk image host — "it's just The Box™" on a real disk.
#
# Folder name = nixosConfigurations attribute (see flake.nix host
# auto-discovery), so this host is exposed as `nixosConfigurations._appliance-disk`.
# Unlike the appliance ISO (hosts/_appliance_iso), this builds a *persistent* disk
# image (qcow2 or raw) using disko's image builder: it carries the real on-disk
# GPT layout (1 GB ESP + ext4 root from nixos/disko-standard.nix) and state
# survives reboots, exactly like a machine you ran nixos/install.sh on.
#
# Build (the format is chosen at build time, see Makefile / README):
#
# make appliance/qcow2 # qcow2 for this machine's arch
# make appliance/raw # raw (dd-able straight to a drive)
# make appliance/qcow2/aarch64-linux # cross-arch (needs a matching builder)
#
# # without make, e.g. a raw image:
# nix build .#nixosConfigurations._appliance-disk.config.system.build.diskoImages
# # (override disko.imageBuilder.imageFormat = "qcow2" for qcow2)
#
# This host is independent of nixos/install.sh; it shares the disk LAYOUT with
# real installs (disko-standard.nix) but is never itself part of the install
# flow. The turn-key login + Coder admin bootstrap (shared with the appliance ISO)
# live in nixos/_appliance/box-turnkey.nix.

{ lib, pkgs, ... }:

{
imports = [
../../nixos/disko-standard.nix # 1 GB ESP + ext4 root single-disk layout
../../nixos/_appliance/box-turnkey.nix # shared turn-key config (login + Coder bootstrap)
] ++ lib.optional (builtins.pathExists ./local.nix) ./local.nix;

# No networking.hostName here on purpose: underscore-prefixed image hosts get
# no folder-name injection from flake.nix and inherit the central default
# "coder-box" (configuration.nix). Override in local.nix if you need another.

# disko writes the image for this device node; /dev/vda is the virtio disk a
# built image is partitioned against. The on-disk filesystems mount by LABEL
# (see disko-standard.nix), so the image still boots if the runtime device
# node differs (sda/nvme0n1/etc.).
disko.devices.disk.main.device = lib.mkForce "/dev/vda";

# Output file name: disko defaults imageName to the disk attr name ("main"),
# which would produce main.raw / main.qcow2. Name it after the appliance and
# include the arch (like the ISO's image.baseName) so the built image is
# coder-box-appliance-<arch>.raw / .qcow2 — arch visible, and x86_64/aarch64
# images don't collide in ./out.
disko.devices.disk.main.imageName =
lib.mkForce "coder-box-appliance-${pkgs.stdenv.hostPlatform.system}";

# The image is built offline in a VM with no EFI variable store, so install
# the bootloader without touching EFI variables. systemd-boot (enabled by
# default in configuration.nix) also writes the removable EFI fallback path
# (EFI/BOOT/BOOTX64.EFI), so the image still boots on firmware that has no
# pre-existing boot entry.
boot.loader.efi.canTouchEfiVariables = lib.mkForce false;
}
Loading