Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Graft

Graft banner

TOML-driven Podman Quadlet containers, built from the Nix store.

Graft turns small TOML files into rootfs-based Podman Quadlet services for NixOS and Home Manager. You describe container intent; Graft resolves the runtime details; Nix materialises the rootfs and Quadlet output; systemd runs the result like any other service.

Use the GitHub README as the repository landing page, then use this manual for deeper design, reference, and contributor details.

Start here

  • Overview explains the current architecture and data flow.
  • Design documents the boundaries between TOML, CLI, Nix modules, and Quadlet output.
  • Quadlet output describes the generated .container files.
  • Roadmap describes the longer-term direction.
  • Non-goals and deferred scope lists deliberate exclusions.
  • Reference links to the annotated TOML reference and current module options.
  • Development captures contributor workflow and renderer checklists.

Current scope

The current MVP focuses on rootfs-store containers:

  • TOML to resolved JSON stdout
  • NixOS system/rootful Quadlet output
  • Home Manager user/rootless Quadlet output
  • manual start/stop through systemd
  • packages and commands resolved from TOML
  • useful Quadlet rendering for identity, working directory, environment, environment files, published ports, volumes, and service timing
  • clean shutdown through graft-pause

Graft — Overview

Graft runs Podman Quadlet containers from TOML files, built from the Nix store. The user writes Graft TOML; the CLI resolves it to JSON; NixOS and Home Manager materialise rootfs paths and Quadlet .container files.

The goal is a Nix-native container workflow with no image pulls, no ad-hoc package installs inside containers, and no hand-written Quadlet boilerplate.

Core flow

Edit TOML
  ↓
nixos-rebuild switch / Home Manager activation
  ↓
Nix calls the Graft CLI via IFD
  ↓
CLI writes resolved JSON to stdout
  ↓
Nix reads the JSON
  ↓
Nix builds rootfs in the store → /nix/store/xxx-graft-<name>-env
  ↓
NixOS/Home Manager renders a Quadlet .container file
  ↓
target = "system" → /etc/containers/systemd/<name>.container
target = "user"   → ~/.config/containers/systemd/<name>.container
  ↓
systemd knows about the unit; it does not auto-start by default

Responsibilities

TOML

TOML is user intent only. It is not Quadlet and it is not Nix.

version = 1
name = "node-dev"

[config.runtime]
packages = ["nodejs"]

Users do not write rootfs boilerplate, /nix/store mounts, overlay setup, or default keep-alive commands.

CLI

The CLI translates TOML into a resolved JSON spec and writes that JSON to stdout. The CLI owns defaults, dependencies, validation, and translation from Graft concepts to the resolved spec.

Current CLI rules:

  • require version = 1
  • validate container names and supported values before JSON output
  • add graft-pause to every rootfs
  • use /bin/graft-pause when the user did not set a command
  • preserve user commands exactly
  • default deploy.target to system
  • support only rootfs-store today
  • include supported container, environment, filesystem, network, and service fields only when explicitly set
  • include deploy.enable only when explicitly set
  • never invent autostart

Nix modules

The NixOS and Home Manager modules are dumb materialisers. They read resolved JSON and mechanically:

  1. filter for their target (system for NixOS, user for Home Manager)
  2. map package names to Nix packages
  3. build a pkgs.buildEnv
  4. wrap it with real system directories (/etc, /tmp, /var, /run, …)
  5. render the Quadlet .container file
  6. place the file in the matching Quadlet search path

The modules do not decide defaults or interpret TOML semantics.

IFD and JSON stdout

The build integration uses Import From Derivation:

resolvedJson = pkgs.runCommand "graft-resolve-${name}" {} ''
  ${graft}/bin/graft ${tomlFile} > $out
'';

resolved = builtins.fromJSON (builtins.readFile resolvedJson);

The JSON is a Nix store artefact, not a file to commit.

Module-eval checks for this path should be built explicitly, for example with nix build .#checks.x86_64-linux.nixos-module-eval. Because they use IFD, nix flake check may omit them and must not be the only CI or release gate.

Cache behaviour:

TOML unchanged        → same derivation → CLI does not run again
TOML changed          → CLI runs → new resolved JSON
packages changed      → rootfs changes
command/restart only  → Quadlet changes; rootfs may stay cached

graft-pause

graft-pause is a tiny keep-alive binary shipped by the same Rust crate as the CLI.

/bin/graft
/bin/graft-pause

Rules:

no user command → packages = ["graft-pause", ...], command = ["/bin/graft-pause"]
user command    → packages = ["graft-pause", ...], command = user command

graft-pause exits cleanly on SIGTERM and SIGINT, so stopping a Quadlet service should not fall back to SIGKILL or leave a failed unit.

There is no default bashInteractive, no default coreutils, and no default sleep infinity.

Rendered Quadlet example

A TOML without a command resolves to a Quadlet file like:

[Container]
ContainerName=node-dev
Rootfs=/nix/store/xyz-graft-node-dev-env:O
Exec="/bin/graft-pause"
Volume=/nix/store:/nix/store:ro

Supported optional fields render mechanically when configured:

HostName=node-dev.local
User=1000
Group=1000
WorkingDir=/workspace
Environment="GREETING=hello world"
EnvironmentFile="/run/graft/node-dev.env"
PublishPort=127.0.0.1:8080:8080
Volume=/home/me/project:/workspace

[Service]
Restart=on-failure
RestartSec=10s
TimeoutStartSec=2m
TimeoutStopSec=30s

The current modules do not render [Install] by default. A .container file can exist while the container does not start automatically. Future autostart support must be explicit in TOML and resolved JSON before an [Install] section is rendered.

Rootfs-store container model

  • Graft uses Rootfs=, not Image=, for store-based containers.
  • The rootfs is a store path built from Nix packages.
  • Rootfs=...:O gives Podman a writable overlay above the read-only store rootfs.
  • /nix/store from the host is mounted read-only inside the container.
  • Not in the store means not available in the container.
  • No downloads happen at runtime.

System containers (target = "system") use rootful Podman and kernel overlayfs via :O. User containers (target = "user") use rootless Podman and rootless overlay support such as fuse-overlayfs.

Everything is a service

All containers are Quadlet/systemd services. There is no separate shell-container concept in the config model. A container stays alive as long as its resolved Exec= process stays alive.

Other systemd units can trigger the generated service. For example, a user timer can start a rootless one-shot workload generated by Graft. Host policy remains outside TOML: unattended user services need systemd user linger to be enabled by host configuration or loginctl enable-linger <user>, not by the container definition.

Package management

Packages are declared in TOML and resolved at build time via Nix. To add a tool, add it to packages = [...] and rebuild. Do not install packages ad-hoc inside the container.

Current scope

The current MVP proves the rootfs-store path for both NixOS and Home Manager. It renders a useful subset of Quadlet fields, while the TOML schema remains broader than the implemented renderer. See Reference for the current field list and Non-goals for deliberate exclusions.

For the long-term direction, see Roadmap.

Project structure

graft/
  flake.nix
  modules/
    nixos.nix          # NixOS materialisation module
    home-manager.nix   # Home Manager materialisation module
  crates/
    graft/             # Rust package: CLI resolver + graft-pause
  examples/
    reference.toml     # annotated TOML reference
  docs/
    design.md          # design decisions and principles
    overview.md        # this file
    quadlet.md         # Quadlet output notes
    roadmap.md         # roadmap and future direction
    non-goals.md       # deliberate exclusions and deferred scope
    development.md     # contributor workflow and renderer checklist

Flake outputs

  • nixosModules.graft — system containers → /etc/containers/systemd/
  • homeManagerModules.graft — user containers → ~/.config/containers/systemd/
  • packages.<system>.default — Graft CLI + graft-pause

Graft — Design

Principle

Graft has strict layer boundaries:

TOML → CLI → JSON stdout → Nix modules → Quadlet .container
  • TOML is the user-facing Graft language. It captures user intent.
  • CLI resolves that intent into a complete JSON build spec.
  • NixOS/Home Manager modules materialise the resolved spec into rootfs paths and Quadlet .container files.
  • Quadlet/systemd runs the resulting services.

Users do not write Quadlet boilerplate and do not write Nix module boilerplate for each container. The .container file is output.

TOML is user intent

A TOML file should describe what the user wants, not how Podman, Quadlet, or Nix will implement it.

Example:

version = 1
name = "node-dev"

[config.runtime]
packages = ["nodejs"]

That intentionally does not include:

  • rootfs setup
  • /nix/store mounts
  • overlay details
  • default keep-alive commands
  • default restart policy
  • default autostart / [Install] section
  • Quadlet boilerplate

Those details are resolved by Graft and materialised by Nix.

CLI resolve logic

The CLI reads a single TOML file and writes resolved JSON to stdout:

graft <container.toml> > $out

Nix captures stdout through Import From Derivation:

resolvedJson = pkgs.runCommand "graft-resolve-${name}" {} ''
  ${graft}/bin/graft ${tomlFile} > $out
'';

resolved = builtins.fromJSON (builtins.readFile resolvedJson);

The CLI owns business logic:

  • applying Graft defaults
  • adding implicit dependencies
  • selecting the keep-alive command
  • validating supported runtime modes
  • preserving explicit user choices
  • translating TOML/Graft concepts into the JSON shape Nix needs

The CLI does not write JSON files into the repository.

Checks that evaluate this IFD path should be built explicitly, for example with nix build .#checks.x86_64-linux.nixos-module-eval. nix flake check may omit IFD-backed checks, so CI and release gates must not rely on it alone.

graft-pause

graft-pause is a minimal keep-alive binary shipped by the same Rust crate as graft.

/bin/graft
/bin/graft-pause

It is always added to the resolved package list and therefore always present in the generated rootfs.

Rules:

no user command → command = ["/bin/graft-pause"]
user command    → command = user command

packages = ["graft-pause", ...user packages]

graft-pause exits cleanly on SIGTERM and SIGINT, so stopping a generated service should not fall back to SIGKILL or leave a failed unit.

There is no default bashInteractive, no default coreutils, and no default sleep infinity.

Defaults and explicit choices

The CLI may only add defaults that belong to Graft semantics.

FieldRule
versionrequired; currently only 1 is supported
namerequired; must be safe for container and unit output
runtime.packagesalways graft-pause + user packages
runtime.commanduser command, or /bin/graft-pause if missing
deploy.targetdefault system, unless user sets user
runtime.modecurrently only rootfs-store
supported container fieldsno defaults; include only if user sets them
environment, publish, volumesno defaults; preserve deterministic ordering rules
service.restart and timingno defaults; include only if user sets them
deploy.enableno default in JSON; modules render unless explicitly false
autostart / [Install]no default; future support must be explicit

A TOML file existing means Graft may render a .container file. That is not the same as automatically starting the service.

Nix modules are dumb materialisers

The NixOS and Home Manager modules read resolved JSON and do mechanical work:

  1. filter containers for their target (system or user)
  2. map package names to Nix packages
  3. build a pkgs.buildEnv
  4. wrap it with real runtime directories (/etc, /tmp, /var, /run, …)
  5. render the Quadlet .container file
  6. place it in the matching Quadlet search path

They do not decide:

  • which default command to use
  • which implicit package is needed
  • which restart policy applies
  • whether bash or coreutils should be present
  • how TOML concepts merge or validate

That is CLI logic.

Rootfs and Quadlet

Graft uses a rootfs from the Nix store, not container images.

[Container]
ContainerName=node-dev
Rootfs=/nix/store/...-graft-node-dev-env:O
Exec="/bin/graft-pause"
Volume=/nix/store:/nix/store:ro

Important details:

  • Image= is not used for rootfs-store containers.
  • Rootfs=...:O gives Podman a writable overlay above the read-only store rootfs.
  • /nix/store is mounted read-only inside the container.
  • If a package is not in the generated rootfs/store closure, it is not available inside the container.
  • No downloads happen at container runtime.

System containers use rootful Podman and kernel overlayfs through :O. User containers use rootless Podman and rootless overlay support such as fuse-overlayfs.

Build and cache behaviour

Incrementality comes from Nix:

TOML unchanged        → same derivation → CLI does not run again
TOML changed          → CLI runs → new resolved JSON
packages changed      → rootfs changes
command/restart only  → Quadlet changes; rootfs may stay cached

The resolved JSON is a Nix store artefact, not a committed file.

Current boundary

The current implementation focuses on the rootfs-store materialisation path. The TOML schema is broader than what the MVP renders today, but the renderer now covers the common fields listed in Reference.

Currently proven:

  • CLI resolver
  • NixOS IFD materialisation
  • Home Manager IFD materialisation
  • system/rootful Quadlet runtime
  • user/rootless Quadlet runtime
  • useful Quadlet rendering for container identity, working directory, environment, environment files, published ports, volumes, and service timing
  • clean keep-alive shutdown

Future work is tracked in Roadmap. Deliberate exclusions are tracked in Non-goals and deferred scope. Contributor workflow is tracked in Development.

Future CLI control plane

The CLI is currently a deterministic build-time resolver. Later it should also become the user-facing control plane for runtime workflows.

Agreed lifecycle command names:

graft up
graft down

No graft shell command is planned.

Runtime operations must remain separate from pure TOML-to-JSON resolution so Nix evaluation stays deterministic and side-effect free.

Non-goals

The high-level constraints are:

  • TOML should not become raw Quadlet.
  • TOML should not become raw Nix.
  • Nix modules should not contain business logic.
  • Packages should not be installed ad-hoc inside containers.
  • Containers should not auto-start unless explicitly configured.
  • Promote/diff workflows must never promote binaries.
  • Hidden module state should be avoided.

See Non-goals and deferred scope for the current detailed list.

Quadlet — Graft output

Quadlet is a Podman systemd generator. It reads .container files and generates ordinary systemd .service units from them.

In Graft, Quadlet is output. Users write TOML; the CLI resolves it to JSON; the NixOS and Home Manager modules render .container files.

TOML → CLI resolved JSON → NixOS/Home Manager module → .container

File locations

Resolved targetScopePath
systemsystem/rootful/etc/containers/systemd/
useruser/rootless~/.config/containers/systemd/

Symlinks are supported. NixOS can build a file in the store and link it through environment.etc. Home Manager can link a file through xdg.configFile.

Responsibilities

CLI

The CLI translates TOML to resolved JSON:

  • package list
  • command / Exec=
  • deploy target
  • optional service settings
  • Graft defaults and implicit dependencies

The CLI does not render .container files.

Nix modules

The NixOS and Home Manager modules render Quadlet mechanically from resolved JSON:

  • NixOS renders only target = "system".
  • Home Manager renders only target = "user".
  • ContainerName= comes from resolved name.
  • Rootfs= comes from the generated rootfs store path.
  • Exec= comes from resolved runtime.command.
  • Volume=/nix/store:/nix/store:ro is always rendered for store symlinks.
  • Optional [Service] keys are rendered only when resolved JSON contains them.
  • [Container] values that become generated command-line arguments escape % specifiers and $ variables.
  • [Install] is not rendered by default.

Rootfs-store mapping

Graft uses a rootfs from the Nix store, not images.

Quadlet optionSource
ContainerName=resolved name
Rootfs=<path>:Orootfs built from resolved runtime.packages
Exec=resolved runtime.command
Volume=/nix/store:/nix/store:rorequired for Nix store symlinks

Example without a user command:

[Container]
ContainerName=node-dev
Rootfs=/nix/store/xyz-graft-node-dev-env:O
Exec="/bin/graft-pause"
Volume=/nix/store:/nix/store:ro

Example with a user command:

[Container]
ContainerName=web
Rootfs=/nix/store/xyz-graft-web-env:O
Exec="node" "server.js"
Volume=/nix/store:/nix/store:ro

graft-pause

graft-pause is always included in the rootfs:

packages = ["graft-pause", ...user packages]

Exec="/bin/graft-pause" is used only when the user does not set a command. If the user sets a command, that command becomes quoted Exec= argv.

graft-pause exits cleanly on SIGTERM and SIGINT, so systemctl stop and systemctl --user stop can finish without a SIGKILL timeout.

This avoids default dependencies on bashInteractive, coreutils, or sleep infinity.

Optional container keys

Graft renders optional Quadlet container keys only when the resolved JSON contains them:

  • HostName= from config.container.hostname
  • User= from config.container.user
  • Group= from config.container.group
  • WorkingDir= from config.container.workingDir
  • EnvironmentFile= from config.container.environmentFile
  • PublishPort= from config.network.publish
  • Volume= from config.filesystem.volumes

Environment files, published ports, and volumes preserve user order. Environment variables are sorted by key. Environment files and command argv are quoted for systemd argument parsing. Container values render literal % as %% and literal $ as $$ when they become generated command-line arguments.

Environment variables

Environment variables are rendered as sorted, quoted systemd assignments:

Environment="BRACED=pre$${HOME}post"
Environment="DOLLAR=cost $$5"
Environment="GREETING=hello world"
Environment="PERCENT=100%%"
Environment="QUOTED=say \"hi\""

The whole KEY=value assignment is quoted. Double quotes, backslashes, % specifier markers, and literal $ characters are escaped before rendering. Environment values are not a secret transport.

Service settings

Service settings have no Graft defaults.

A [Service] section is rendered only when at least one supported service field is explicitly set. Supported fields currently include Restart=, RestartSec=, TimeoutStartSec=, and TimeoutStopSec=. Service values are copied verbatim into the generated unit and are not %-escaped by Graft.

Example:

[Service]
Restart=on-failure
RestartSec=10s
TimeoutStartSec=2m
TimeoutStopSec=30s

Without explicit service settings, no [Service] section is rendered.

Autostart

A .container file may exist without starting automatically.

The current modules do not generate an [Install] section. That means systemd knows the generated service, but does not enable/start it automatically.

If autostart is supported later, it must flow explicitly through TOML and resolved JSON. It must not be a module default.

Overlay

Rootfs-store containers use a writable overlay above the read-only store rootfs:

lowerdir = /nix/store/xxx-graft-env   (read-only)
upperdir = container storage          (writable)

Writes inside the container go to the upperdir. That upperdir is the future basis for promote/diff workflows.

System containers (target = "system") use rootful Podman with kernel overlayfs through :O. User containers (target = "user") use rootless Podman and rootless overlay support such as fuse-overlayfs.

Lifecycle

Generated containers are normal systemd services.

System container:

sudo systemctl start <name>.service
sudo systemctl stop <name>.service

User container:

systemctl --user start <name>.service
systemctl --user stop <name>.service

A user timer may also trigger a generated user service. If that service must run without an active login session, enable systemd user linger in host configuration or with loginctl enable-linger <user>; Graft TOML does not carry host login policy.

Stopping a service removes the runtime container when Podman runs it with --rm. The Quadlet file remains, so the service can be started again later.

Not used by rootfs-store Graft containers

  • Image= — Graft uses Rootfs= for this mode.
  • image downloads at runtime
  • user-written .container files as input
  • default Restart=on-failure
  • default [Install] WantedBy=...

Graft — Roadmap

This document describes the intended direction for Graft. It is not a promise of exact command names or implementation order, except where noted.

Current MVP

The current implementation proves the core rootfs-store path:

  • TOML is the user-facing Graft DSL.
  • The graft CLI resolves one TOML file to JSON on stdout.
  • NixOS and Home Manager consume that JSON via IFD.
  • Nix builds a rootfs from Nix packages.
  • The modules render Podman Quadlet .container files.
  • target = "system" renders system/rootful containers.
  • target = "user" renders user/rootless containers.
  • Containers start and stop through systemd.
  • graft-pause provides a tiny default keep-alive command.
  • Common Quadlet fields are rendered for container identity, working directory, quoted environment, environment files, published ports, volumes, and service timing.

The MVP intentionally does not cover the full TOML schema yet.

Direction

Graft should become a Nix-native container workflow for both local development and multi-host deployment:

repo TOML + host TOML
  ↓
graft resolve / merge
  ↓
Nix materialisation
  ↓
Quadlet services
  ↓
local dev or server deploy

The same Graft language should describe a local dev container, a user/rootless workspace, and a system/rootful service on a server.

Devenv workflow

Graft should be useful as a project-local development environment.

Goals:

  • A repository can contain Graft TOML describing its dev environment.
  • Developers can bring that environment up without hand-written Quadlet files.
  • User/rootless containers should be a natural fit for local development.
  • Packages are declared in TOML and realised by Nix, not installed ad-hoc inside the container.
  • Explicit autostart can be modelled later for dev sessions, but there is no implicit autostart default.

Agreed lifecycle command names:

graft up
graft down

No graft shell command is planned.

CLI control plane

The CLI should grow from a build-time resolver into the main user interface for Graft workflows, while keeping build-time resolution deterministic.

Likely responsibilities:

  • resolve and inspect TOML configs
  • lint TOML intent before rebuilds
  • run host-aware diagnostics through a future graft doctor command
  • inspect generated Quadlet, systemd, and Podman state
  • start and stop containers through systemd
  • show status and logs
  • coordinate local development flows
  • coordinate deployment flows
  • expose diff/promote workflows

graft lint should stay mostly pure and TOML-focused. graft doctor may check local host state such as user linger, generated units, mounted paths, and Podman state, but it should report diagnostics rather than mutate host policy implicitly.

Implementation detail: runtime operations should stay separate from pure TOML-to-JSON resolution so Nix evaluation stays deterministic and side-effect free.

Merge workflow across repositories

Graft should support definitions from multiple sources:

  • host/system container TOMLs
  • project/repository TOMLs
  • shared base TOMLs
  • environment-specific overlays

Goals:

  • deterministic merge order
  • explicit parents / children graph resolution
  • clear conflict detection
  • useful validation errors
  • no hidden state between modules
  • repo-level container intent without host-specific boilerplate

The CLI owns merge semantics. Nix modules should continue to materialise only resolved JSON.

Multi-server deployment

Graft should be able to describe and deploy containers across multiple NixOS machines.

Goals:

  • one declarative language for local and server containers
  • per-host materialisation of resolved containers
  • CLI-assisted deployment to one or more hosts
  • compatibility with normal NixOS rebuild/deploy workflows
  • explicit target selection for system vs user containers

The deployment layer should not turn TOML into a second NixOS module language. TOML remains user intent; Nix remains the materialisation substrate.

Promote / diff workflow

Rootfs-store containers use writable overlay state above a read-only Nix store rootfs. That overlay can become the basis for review workflows.

Goals:

  • inspect changes made inside a running container
  • diff overlay upperdir against the generated rootfs
  • promote state/config changes back into declarative files
  • never promote binaries or package-manager output
  • keep packages managed by TOML + Nix rebuilds

Promote should help users capture intentional configuration/state changes, not turn containers into mutable images.

Security hardening

The current MVP proves the flow, not the final isolation model.

Planned hardening:

  • userns=auto
  • per-container limited UIDs
  • workdir-only write access
  • explicit mount policies
  • explicit network policies
  • secrets support
  • resource limits

System containers and user containers may need different defaults, but those rules should be resolved by the CLI and materialised mechanically by the Nix modules.

Broader Quadlet coverage

The TOML schema already contains more concepts than the MVP renders. The current renderer covers useful basics, but later phases should map more of the schema into resolved JSON and Quadlet output.

Remaining areas include:

  • additional mount types beyond basic Volume= entries
  • Quadlet .network and .volume units
  • secrets and credentials
  • resources and health checks
  • labels and annotations
  • DNS, aliases, and network policy
  • podman args / explicit escape hatches

Escape hatches must not override keys owned by Graft.

Non-goals and constraints

The detailed list of deliberate exclusions lives in Non-goals and deferred scope.

The short version:

  • TOML should not become raw Quadlet.
  • TOML should not become raw Nix.
  • Nix modules should not contain business logic.
  • Packages should not be installed ad-hoc inside containers.
  • Containers should not auto-start unless explicitly configured.
  • Promote should never promote binaries.
  • Hidden module state should be avoided.

Non-goals and deferred scope

Graft keeps a visible list of deliberate non-goals so small renderer issues do not silently become product decisions.

A non-goal is not necessarily rejected forever. It means the current phase should not implement it without a separate issue or design pass.

Current v0.2 scope

The v0.2 renderer work focuses on safe, useful Quadlet output from resolved TOML. It intentionally does not try to cover the full TOML schema or every Podman, Quadlet, and systemd feature.

Deferred for v0.2:

  • no port syntax parser for PublishPort= values
  • no filesystem path existence checks for volumes
  • no volume mode allowlist beyond line-safety validation
  • no Quadlet .volume or .network unit generation
  • no automatic firewall, DNS, or network alias management
  • no systemd timespan parser for service timing values
  • no [Install], autostart, service type, remainAfterExit, or restartIfChanged rendering
  • no supplemental groups, UID/GID mapping, or user namespace policy from the group renderer
  • no secrets materialisation or host environment passthrough
  • no generated environment files

Architecture boundaries

These boundaries are intentional:

  • TOML is user intent, not raw Quadlet.
  • TOML is not a second NixOS module language.
  • The CLI owns defaults, validation, dependency resolution, and semantic decisions.
  • NixOS and Home Manager modules are dumb materialisers.
  • Nix evaluation must stay deterministic and side-effect free.
  • Hidden state between modules or commands should be avoided.
  • Packages are declared in TOML and realised by Nix; they are not installed ad-hoc inside containers.

Runtime and product workflow deferred scope

The current renderer work does not yet implement the larger Graft product flow. Deferred topics include:

  • Git-aware copied workspace workflows
  • context-aware template variables
  • named instances and dynamic hostname strategy
  • promote and diff workflows
  • CLI runtime control beyond the agreed future command direction
  • local TOML linting and host-aware doctor diagnostics
  • host login policy in TOML, such as enabling systemd user linger from a container definition
  • dedicated security hardening defaults such as userns=auto, limited UIDs, workdir-only writes, resource limits, and secrets support

The agreed future lifecycle command names remain:

graft up
graft down

No graft shell command is planned.

Literal passthrough policy

Some upstream syntaxes are broad and already validated by Podman, Quadlet, or systemd. For those fields, Graft should prefer line-safe passthrough until a separate policy issue exists.

Current examples:

  • PublishPort= values
  • Volume= strings assembled from TOML parts
  • systemd service timing values such as RestartSec= and TimeoutStartSec=

Line-safe passthrough means:

  • reject empty or whitespace-only values where the field is present
  • reject control characters
  • render mechanically
  • do not add a parser, allowlist, or policy by accident

When to update this page

Update this page when implementation or review finds a repeated phrase like:

  • “out of scope”
  • “not yet”
  • “deferred”
  • “no parser yet”
  • “no policy yet”
  • “separate design issue”

If a non-goal later becomes planned work, create or link the issue and move the specific item out of this page when the implementation lands.

Related tracking issues:

Reference

The annotated TOML schema lives in examples/reference.toml.

This page summarises the currently implemented module options and resolver behaviour. The TOML schema is broader than the MVP renderer; many fields are parse-only today and do not yet affect Quadlet output.

NixOS module

{ inputs, pkgs, ... }:
{
  imports = [ inputs.graft.nixosModules.graft ];

  services.graft = {
    enable = true;
    package = inputs.graft.packages.${pkgs.stdenv.hostPlatform.system}.default;
    configRoot = ./containers;
    configRoots = [
      ./containers/common
      ./hosts/my-host/containers
    ];
  };
}
OptionTypeDefaultDescription
services.graft.enableboolfalseEnable system/rootful Graft containers.
services.graft.packagepackage or nullnullPackage providing graft and graft-pause; required when configRoot or configRoots is set.
services.graft.configRootpath or nullnullDirectory containing *.toml container definitions.
services.graft.configRootslist of paths[]Additional directories containing *.toml container definitions, read after configRoot in list order.

The NixOS module renders only resolved containers with target = "system" and places files under /etc/containers/systemd/.

configRoot is kept for single-root configurations. When both configRoot and configRoots are set, Graft reads configRoot first and then each configRoots entry in order. Configured roots must exist. Duplicate TOML filenames across roots fail evaluation, and duplicate resolved container names within the same target fail evaluation.

Home Manager module

{ inputs, pkgs, ... }:
{
  imports = [ inputs.graft.homeManagerModules.graft ];

  programs.graft = {
    enable = true;
    package = inputs.graft.packages.${pkgs.stdenv.hostPlatform.system}.default;
    configRoot = ./containers;
    configRoots = [
      ./containers/common
      ./hosts/my-host/containers
    ];
  };
}
OptionTypeDefaultDescription
programs.graft.enableboolfalseEnable user/rootless Graft containers.
programs.graft.packagepackage or nullnullPackage providing graft and graft-pause; required when configRoot or configRoots is set.
programs.graft.configRootpath or nullnullDirectory containing *.toml container definitions.
programs.graft.configRootslist of paths[]Additional directories containing *.toml container definitions, read after configRoot in list order.

The Home Manager module renders only resolved containers with target = "user" and places files under ~/.config/containers/systemd/.

configRoot and configRoots use the same ordering and collision rules as the NixOS module.

Current TOML behaviour

Implemented today:

  • version = 1 is required.
  • name is required and must be a safe container name.
  • deploy.target defaults to system.
  • deploy.enable = false prevents rendering.
  • config.container.hostname is rendered as Quadlet HostName= when explicitly set.
  • config.container.user is rendered as Quadlet User= when explicitly set.
  • config.container.group is rendered as Quadlet Group= when explicitly set together with config.container.user.
  • config.container.workingDir is rendered as Quadlet WorkingDir= when explicitly set.
  • config.container.environment is rendered as sorted, quoted Quadlet Environment="KEY=value" lines when explicitly set.
  • config.container.environmentFile is rendered as ordered, quoted Quadlet EnvironmentFile="..." lines when explicitly set.
  • config.filesystem.volumes is rendered as ordered Quadlet Volume= lines when explicitly set.
  • config.network.publish is rendered as ordered Quadlet PublishPort= lines when explicitly set.
  • config.runtime.mode supports only rootfs-store.
  • config.runtime.packages are mapped to Nix packages.
  • graft-pause is always added to the package list.
  • missing config.runtime.command becomes ['/bin/graft-pause'].
  • explicit config.runtime.command is preserved.
  • config.service.restart is rendered only when explicitly set.
  • config.service.restartSec, timeoutStartSec, and timeoutStopSec are rendered only when explicitly set.

Renderer escaping

Rendered [Container] values use systemd-safe escaping while preserving literal TOML semantics. Command argv and EnvironmentFile= entries are rendered as quoted systemd arguments, escaping double quotes and backslashes. Literal % characters are written as %% so systemd does not treat them as specifiers after Quadlet places them in generated service command lines. Values that become generated command-line arguments also write literal $ as $$ so systemd does not perform environment variable substitution. Quoted Environment="KEY=value" lines also escape double quotes and backslashes for systemd syntax. [Service] values are rendered verbatim because Quadlet copies them into the generated unit service section.

Container field validation

config.container.hostname is treated as a literal value for Quadlet HostName=.

Current hostname validation:

  • must not be empty or whitespace-only
  • must not contain control characters
  • no template expansion is performed
  • no DNS/FQDN validation is performed yet

config.container.user is treated as a literal value for Quadlet User=.

Current user validation:

  • must not be empty or whitespace-only
  • must not contain control characters
  • no UID syntax validation is performed yet

config.container.group is treated as a literal value for Quadlet Group=. It requires config.container.user because Quadlet rejects Group= without User=.

Current group validation:

  • requires config.container.user
  • must not be empty or whitespace-only
  • must not contain control characters
  • no GID syntax validation is performed yet
  • GroupAdd=, supplemental groups, UID/GID maps, user namespaces, and security hardening defaults are not rendered

config.container.workingDir is treated as a literal value for Quadlet WorkingDir=.

Current working directory validation:

  • must not be empty or whitespace-only
  • must not contain control characters
  • no path existence validation is performed
  • no automatic directory creation is performed
  • no workspace copy or host disk mount is created

Future copied workspace support belongs under config.workspace; workingDir only sets the process working directory inside the container.

config.container.environment is rendered as quoted Quadlet Environment= assignments. Output is sorted by key for deterministic builds. The whole KEY=value assignment is double-quoted so values may contain spaces or =. Double quotes, backslashes, % specifier markers, and literal $ characters are escaped for systemd syntax and command-line substitution.

Current environment validation:

  • keys must not be empty or whitespace-only
  • keys must not contain control characters
  • keys must not contain whitespace or =
  • values may be empty
  • values may contain whitespace or =
  • values must not contain control characters
  • no secret handling is performed
  • no environment file generation or host environment passthrough is performed

config.container.environmentFile is treated as literal Quadlet EnvironmentFile= entries. Entries are rendered as quoted systemd arguments so paths may contain spaces or backslashes. User order is preserved.

Current environment file validation:

  • entries must not be empty or whitespace-only
  • entries must not contain control characters
  • no env file generation is performed
  • no secrets materialisation is performed
  • no host environment passthrough is performed

config.filesystem.volumes is treated as literal Quadlet Volume= entries. User order is preserved. Entries are rendered mechanically as target, source:target, or source:target:mode.

Current filesystem volume validation:

  • target is required by the TOML schema
  • target must not be empty or whitespace-only
  • target must not contain control characters
  • target must not contain : because Graft assembles colon-separated Volume= text
  • optional source, when present, must not be empty or whitespace-only
  • optional source, when present, must not contain control characters
  • optional source, when present, must not contain : because Graft assembles colon-separated Volume= text
  • optional mode, when present, requires source
  • optional mode, when present, must not be empty or whitespace-only
  • optional mode, when present, must not contain control characters
  • optional mode, when present, must not contain : because Graft assembles colon-separated Volume= text
  • no path existence validation is performed
  • no mode allowlist is applied yet
  • no Quadlet .volume units are generated
  • no tmpfs, device, raw mount, workspace, home, or promote semantics are rendered

config.network.publish is treated as literal Quadlet PublishPort= entries. User order is preserved.

Current published port validation:

  • entries must not be empty or whitespace-only
  • entries must not contain control characters
  • no port syntax validation is performed yet
  • no Quadlet .network units are generated
  • no DNS settings or network aliases are rendered
  • no automatic firewall rules are managed

config.service.restartSec, timeoutStartSec, and timeoutStopSec are treated as literal systemd service timing values. A [Service] section is rendered when at least one supported service field is set. Service values are rendered verbatim and are not %-escaped by Graft.

Current service timing validation:

  • values must not be empty or whitespace-only
  • values must not contain control characters
  • no systemd timespan parsing is performed yet
  • [Install], autostart, service type, remainAfterExit, and restartIfChanged are not rendered

Not all fields from the annotated TOML reference are rendered yet. Fields that are parsed but not listed above should be treated as reserved/roadmap fields. See Roadmap for planned coverage.

Development

This page captures the working rules for changing Graft. It is mainly for contributors and release preparation.

Core workflow

For every issue:

  1. read the issue and related docs or code
  2. confirm scope before editing
  3. use one branch per issue
  4. keep changes focused
  5. update tests and docs with the code
  6. run the relevant checks
  7. note follow-up work in the issue or pull request

Keep implementation decisions aligned with the main architecture:

TOML → CLI → JSON stdout → NixOS/Home Manager modules → Quadlet .container

The CLI owns defaults, validation, dependency resolution, and semantic decisions. The Nix modules should stay dumb materialisers.

Quadlet renderer checklist

Apply this checklist whenever adding or changing a rendered Quadlet field.

Before implementation, decide and document:

  • whether the field is already in the TOML schema
  • the resolved JSON shape
  • omitted behaviour
  • empty list or map behaviour, if applicable
  • invalid empty or whitespace-only values
  • control-character handling
  • ordering: sorted output or user order
  • literal passthrough versus parser/policy
  • whether NixOS and Home Manager must render the same output
  • whether runtime verification is needed after merge

During implementation, update:

  • Rust resolver tests
  • NixOS module rendering
  • Home Manager module rendering
  • Nix module-eval assertions in flake.nix
  • docs/reference.md
  • examples/reference.toml
  • other manual pages when the user-facing behaviour changes

Ordering policy

Choose ordering deliberately.

Map-like values with unstable source order should be sorted for deterministic output. Current example:

  • config.container.environment → sorted by key

List-like or precedence-sensitive values should preserve user order. Current examples:

  • config.container.environmentFile
  • config.network.publish
  • config.filesystem.volumes

Document the choice in docs/reference.md.

Literal passthrough policy

Some upstream syntaxes are broad and already validated by Podman, Quadlet, or systemd. Do not accidentally replace those syntaxes with a narrow parser.

For broad syntaxes, prefer line-safe passthrough:

  • reject empty or whitespace-only values when the field is present
  • reject control characters
  • render mechanically with field-appropriate renderer escaping
  • do not add a parser, allowlist, or policy without a dedicated issue

Current examples:

  • PublishPort= values
  • Volume= strings assembled from TOML parts
  • systemd service timing values such as RestartSec=

If an implementation repeatedly says “out of scope”, “not yet”, or “no parser yet”, update Non-goals and deferred scope or link a tracking issue.

Matrix tests

Fields with valid combinations need matrix tests. Cover both valid combinations and impossible combinations.

Examples:

  • volume rendering: target, source:target, source:target:mode
  • invalid volume rendering: mode without source
  • service rendering: restart-only, timing-only, restart plus timing
  • container identity: user-only, invalid group-only, user plus group, neither

Prefer small resolver tests for semantic combinations and module-eval assertions for generated Quadlet text.

Documentation parity

Resolver rules must be mirrored in user-facing docs.

If the resolver rejects or requires something, update:

  • Rust tests
  • docs/reference.md
  • examples/reference.toml

If the behaviour affects generated Quadlet output, also update docs/quadlet.md. If the behaviour changes the visible project scope, update README.md, docs/index.md, docs/overview.md, or docs/roadmap.md as appropriate.

Runtime verification

Local unit/module tests prove evaluation and rendering. Runtime-sensitive features should also be validated after merge through a real NixOS → Quadlet → Podman path.

Run privileged runtime checks on a local test machine where the operator can approve privilege escalation directly. Do not require maintainer-specific sessions, sockets, or other local-only workflow details in the public manual.

Runtime test output should check the generated unit text and the Podman runtime state where possible.

Dead-code and module-boundary hygiene

The baseline already has several hard gates for unused or dead code:

  • Rust warnings, including compiler dead-code warnings, via clippy with -D warnings -D clippy::pedantic
  • unused Rust dependencies via cargo machete
  • orphaned Rust source files via cargo modules orphans for the library and standalone graft-pause binary
  • unused Nix code via deadnix --fail
  • missing Rust test coverage via cargo llvm-cov --fail-under-lines 80

Do not add unstable or noisy hygiene gates without a focused design issue. cargo-udeps currently requires nightly-only rustc flags in this environment, so track it as an advisory/local-only candidate in #96 and the later local quality workflow in #23. Treat tokei/scc output as refactor signals, not CI thresholds; resolve.rs splitting is tracked in #97. Public API usage remains a manual visibility review until a low-noise tool path is proven; track that in issue #98.

Standard checks

For Rust changes:

nix develop .#ci -c bash -lc 'cd crates/graft && cargo fmt --check && mkdir -p target/nextest && cargo nextest run --profile ci && cargo test --doc'
nix develop .#ci -c bash -lc 'cd crates/graft && cargo clippy --all-targets -- -D warnings -D clippy::pedantic'
nix develop .#ci -c bash -lc 'cd crates/graft && cargo machete'
nix develop .#ci -c bash -lc 'cd crates/graft && NO_COLOR=1 cargo modules orphans --lib && NO_COLOR=1 cargo modules orphans --bin graft-pause'

The nextest run writes JUnit test results to crates/graft/target/nextest/ci/junit.xml for Codecov uploads.

Generate Rust coverage locally and enforce the 80% line threshold:

nix develop .#ci -c bash -lc '
  set -euo pipefail
  cd crates/graft
  mkdir -p target/coverage
  export LLVM_COV="$(command -v llvm-cov)"
  export LLVM_PROFDATA="$(command -v llvm-profdata)"
  cargo llvm-cov --workspace --all-features --fail-under-lines 80 --lcov --output-path target/coverage/lcov.info
'

For dependency security and policy checks:

nix develop .#ci -c bash -lc 'cd crates/graft && cargo-audit audit'
nix develop .#ci -c cargo deny --manifest-path crates/graft/Cargo.toml check --config deny.toml

For secret scanning, copy tracked files to a temporary directory so ignored local files stay out of scope:

nix develop .#ci -c bash -lc '
  set -euo pipefail

  scan_root=$(mktemp -d)
  cleanup() {
    rm -rf "${scan_root}"
  }
  trap cleanup EXIT

  git ls-files -z | tar --null --files-from=- -cf - | tar -xf - -C "${scan_root}"
  gitleaks dir --no-banner --no-color --redact "${scan_root}"
'

For workflow changes:

nix develop .#ci -c actionlint
nix develop .#ci -c zizmor --no-progress --color never --min-confidence high .github/workflows/*.yml .github/actions/setup-nix/action.yml

For Nix/module/docs changes:

nix develop .#ci -c bash -lc 'git ls-files "*.nix" -z | xargs -0 nixfmt --check'
nix develop .#ci -c bash -lc 'git ls-files "*.toml" -z | xargs -0 taplo format --check'
nix develop .#ci -c bash -lc 'git ls-files "*.toml" -z | xargs -0 taplo lint --no-schema'
nix develop .#ci -c bash -lc 'git ls-files "*.md" -z | xargs -0 markdownlint-cli2 --config .markdownlint.jsonc'
nix develop .#ci -c statix check .
nix develop .#ci -c deadnix --fail .
nix build \
  .#checks.x86_64-linux.nixos-module-eval \
  .#checks.x86_64-linux.home-manager-module-eval \
  --print-out-paths
nix flake check
nix develop .#ci -c mdbook build
git diff --check

The module-eval checks use IFD, so build them explicitly. nix flake check may omit them and must not be the only Nix module gate.