FormalOS

Coming from Nix

How NixOS, nix-darwin and Home Manager constructs map to FormalSpec, with side-by-side examples and what you give up.

FormalSpec keeps the parts of Nix you would miss: a programmable config, one lock for every host, generations and rollback, and clean removal. It drops nixpkgs, the store-wide build graph and hand-written hashes. This page shows the five moves you make first, side by side, then maps the rest of a NixOS, nix-darwin and Home Manager setup construct by construct.

A spec is Starlark, a small dialect of Python, instead of the Nix language. A module is a plain function that returns resources, and a condition is a plain if. There is no module system to merge options, no fixpoint and no laziness to reason about. Hosts and modules covers the structure.

The five common moves

home.packages to pkg

home.packages = with pkgs; [ ripgrep fd jq nodePackages.prettier ];
modules = [
    pkg("ripgrep"),
    pkg("fd", source = github("sharkdp/fd")),
    pkg("jq"),
    pkg("prettier", source = npm("prettier")),
]

pkg("ripgrep") takes the upstream release from the aqua registry. Other sources name where upstream publishes: GitHub releases, npm, crates.io, Flathub, Homebrew or the distro. Then lock the versions:

fspec update

There is no channel to bump. fspec update asks each source for its newest release and writes the version and hash into fspec.lock. Packages covers every source.

home.file to file

home.file.".config/git/ignore".text = ".DS_Store\n";
xdg.configFile."kitty/kitty.conf".source = ./kitty.conf;
file("~/.config/git/ignore", content = ".DS_Store\n"),
file("~/.config/kitty/kitty.conf", src = "kitty.conf"),

The default managed mode writes a real file, not a symlink into a read-only store, so an app that rewrites its config does not fail. fspec reports the edit as drift and puts its content back on the next apply. To link instead, the way mkOutOfStoreSymlink does, set mode = "link". Files covers seed and patch too.

systemd.user.services to service

systemd.user.services.syncthing = {
  Unit.Description = "Syncthing";
  Service = {
    ExecStart = "${pkgs.syncthing}/bin/syncthing serve --no-browser";
    Restart = "on-failure";
  };
  Install.WantedBy = [ "default.target" ];
};
service("syncthing",
        exec = "/opt/fspec/current/bin/syncthing serve --no-browser",
        user = True,
        restart = "on_failure",
        description = "Syncthing")

One service(...) renders for the host's init system: a systemd unit, a launchd agent on macOS, or a runit, OpenRC, dinit or s6 service. /opt/fspec/current/bin is the stable path of every store package, the counterpart of ${pkgs.syncthing}. Put the service in pkg("syncthing", services = [...]) and removing the package removes the service with it. Services covers timers and init-specific settings.

A flake devShell to dev.star

devShells.default = pkgs.mkShell {
  packages = [ pkgs.just pkgs.bun pkgs.cmake pkgs.pkg-config pkgs.openssl ];
  RUST_LOG = "info";
  shellHook = "export DATABASE_URL=postgres://localhost/app";
};
dev(
    packages = [pkg("just"), pkg("bun")],
    libs = [lib("openssl")],
    bins = [bin("cmake")],
    env = {"RUST_LOG": "info"},
    shell_hook = "export DATABASE_URL=postgres://localhost/app",
)

Save it as dev.star at the project root, then lock and enter it:

fspec update
fspec dev

Tools come from FormalSpec's own sources and are pinned in the project's fspec.lock. Native libraries and build tools such as openssl and cmake come from the machine's package manager: fspec update records which package provides each one on each package manager. use fspec in an .envrc is the counterpart of use flake. Project environments covers services and containers.

nixos-rebuild switch --target-host to fspec apply --host

nixos-rebuild switch --flake .#server --target-host alex@server --use-remote-sudo
host(name = "server", platform = "x86_64-linux", system = debian(), ssh = "alex@server",
     modules = [...])
fspec apply --host server

fspec ships the committed spec and the lock to the host over ssh and applies there, with that host's own fspec. It refuses a spec tree with uncommitted changes unless you pass --dirty, so every host applies a commit you can name. fspec apply --all applies every host, remote ones first. Remote hosts covers builders and plan --all.

What you gain

What you give up

Mapping a NixOS and nix-darwin setup

NixFormalSpec
flake.nix inputs, flake.lockfspec.lock
nixosConfigurations.<host>host(...) in spec.star
mixins and profilesModules: functions that return resources
lib.mkIf cfg.enableA plain if in Starlark
home.packages, environment.systemPackagespkg(...)
home.file, xdg.configFilefile(...), managed mode
mkOutOfStoreSymlinkfile(..., mode = "link")
Seed-once activation scriptsfile(..., mode = "seed")
A JSON file you rewrite with jq on activationfile(..., mode = "patch")
overrideAttrs with patchespkg(..., patches = [...], build_flags = [...])
systemd.services, systemd.user.services, launchd.agentsservice(...)
homebrew.brews, homebrew.casks, homebrew.tapspkg(..., source = brew(..., tap = "owner/repo"))
home.packages apps under ~/Applications/Home Manager Apps, mac-app-utilpkg(..., apps = ["Foo.app"]), copied into /Applications
system.defaultsdefaults(...), with system = True for /Library/Preferences
power.sleep, system.activationScripts that run pmset, systemsetup or socketfilterfwsetting("pmset", "sleep", 0) and the other setting(...) tools
agenixsecret(...)
boot.kernelPackages, boot.kernelParams, the NVIDIA moduleimage(...) with kernel_args, or tumbleweed(kernel_args = [...])
services.flatpakpkg(..., source = flatpak())
nixos-rebuild switch --target-hostfspec apply --host
nix.buildMachines, max-jobs = 0host(builders = [builder("<host>", routes = [...])])
environment.systemPackages that the distro shipsThe distro's package list, such as tumbleweed(packages = [...]), before any git(...) build
fileSystems.<path>A .mount (and .automount) unit as a file(...) plus a service(...)
programs.ssh.knownHostsfile("/etc/ssh/ssh_known_hosts", ...)
networking.firewallfirewalld zone files in /etc/firewalld/zones
nix.gc.automatictimer(...) that runs fspec gc --older-than
A Home Manager if host == ...A <host>/home/ tree laid over the shared home/
nix-collect-garbage --delete-older-thanfspec gc --older-than
programs.neovim.pluginsvim.pack.add in init.lua, with nvim-pack-lock.json as a managed file next to it
nvim-treesitter.withAllGrammarsnvim-treesitter's own install(), which compiles parsers with tree-sitter-cli
launchd.daemons, launchd.user.agents with StartCalendarIntervaltimer(...)
A login item (open -g -j -a App.app at load)service(..., user = True) with the default restart = "no"
users.users.<u>.openssh.authorizedKeysA file(...) in /etc/ssh/authorized_keys.d/<u>, named by AuthorizedKeysFile. fspec writes real files, so nix-darwin's AuthorizedKeysCommand /bin/cat is not needed
security.sudo.extraConfigfile("/etc/sudoers.d/...", perms = "0440")
fonts.packages with vendored filesfile("/Library/Fonts/<font>", src = ...) on macOS
launchd serviceConfig keys (StartInterval, WatchPaths, ThrottleInterval, StandardOutPath, ProcessType)service(..., launchd = {...})
mkOutOfStoreSymlink to a directory or a path outside the repositoryfile(..., mode = "link", target = "...")
homebrew.tapsbrew(tap = "owner/repo") on the pkg that needs it
${pkg}/lib/... named by absolute path (a PAM or Lua module)/opt/fspec/current/pkgs/<name>/lib/...
power.sleep.computer, power.restartAfterFreeze, networking.applicationFirewall, pmset or systemsetup in activation scriptssetting("pmset" | "systemsetup" | "firewall", key, value)
system.defaults for a root domain, or defaults write /Library/Preferences/... in activationdefaults(..., system = True)
Extra fetchurl sources unpacked into $out in installPhase (Discord's modules)pkg(..., parts = {"<dir>": url(...)})
A Syncthing folder that carries an app's profile or tokens between machinessync(...), with the keyring item that unlocks it. See Sync between hosts
$out/Applications/Foo.app with mac-app-util or copyAppspkg(..., apps = ["Foo.app"]), from a release .dmg asset where upstream ships one

A three-host example

Take a Mac dev machine on nix-darwin and two Hyprland laptops on NixOS, one of them with an NVIDIA GPU.

The Mac ports most directly. Homebrew stays Homebrew, launchd agents become service(...), and system.defaults, Dock pins and login items become defaults(...) and files. A PAM module built from source for macOS becomes a git(...) package you maintain.

The NVIDIA laptop moves to openSUSE Tumbleweed with NVIDIA's openSUSE repository. Its GPU switching service, asusd and tuned become files and service(...) units. Packages come from zypper first, and from git(...) only where no repository or release has them. Hyprland's config and its theme templates are managed files with the same content as today. A desktop shell and a Bluetooth daemon with local patches become git(...) packages that CI builds. Steam and the other game launchers become Flatpaks in self_managed mode.

The other laptop uses the same modules without NVIDIA, and builds its git(...) packages on the NVIDIA laptop through builders.

All three apply the same commit. fspec plan --all shows drift on every host at once.