Graft
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
.containerfiles. - 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-pauseto every rootfs - use
/bin/graft-pausewhen the user did not set a command - preserve user commands exactly
- default
deploy.targettosystem - support only
rootfs-storetoday - include supported container, environment, filesystem, network, and service fields only when explicitly set
- include
deploy.enableonly when explicitly set - never invent autostart
Nix modules
The NixOS and Home Manager modules are dumb materialisers. They read resolved JSON and mechanically:
- filter for their target (
systemfor NixOS,userfor Home Manager) - map package names to Nix packages
- build a
pkgs.buildEnv - wrap it with real system directories (
/etc,/tmp,/var,/run, …) - render the Quadlet
.containerfile - 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=, notImage=, for store-based containers. - The rootfs is a store path built from Nix packages.
Rootfs=...:Ogives Podman a writable overlay above the read-only store rootfs./nix/storefrom 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
.containerfiles. - 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/storemounts- 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.
| Field | Rule |
|---|---|
version | required; currently only 1 is supported |
name | required; must be safe for container and unit output |
runtime.packages | always graft-pause + user packages |
runtime.command | user command, or /bin/graft-pause if missing |
deploy.target | default system, unless user sets user |
runtime.mode | currently only rootfs-store |
| supported container fields | no defaults; include only if user sets them |
| environment, publish, volumes | no defaults; preserve deterministic ordering rules |
service.restart and timing | no defaults; include only if user sets them |
deploy.enable | no 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:
- filter containers for their target (
systemoruser) - map package names to Nix packages
- build a
pkgs.buildEnv - wrap it with real runtime directories (
/etc,/tmp,/var,/run, …) - render the Quadlet
.containerfile - 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
bashorcoreutilsshould 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 forrootfs-storecontainers.Rootfs=...:Ogives Podman a writable overlay above the read-only store rootfs./nix/storeis 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 target | Scope | Path |
|---|---|---|
system | system/rootful | /etc/containers/systemd/ |
user | user/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 resolvedname.Rootfs=comes from the generated rootfs store path.Exec=comes from resolvedruntime.command.Volume=/nix/store:/nix/store:rois 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 option | Source |
|---|---|
ContainerName= | resolved name |
Rootfs=<path>:O | rootfs built from resolved runtime.packages |
Exec= | resolved runtime.command |
Volume=/nix/store:/nix/store:ro | required 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=fromconfig.container.hostnameUser=fromconfig.container.userGroup=fromconfig.container.groupWorkingDir=fromconfig.container.workingDirEnvironmentFile=fromconfig.container.environmentFilePublishPort=fromconfig.network.publishVolume=fromconfig.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 usesRootfs=for this mode.- image downloads at runtime
- user-written
.containerfiles 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
graftCLI 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
.containerfiles. target = "system"renders system/rootful containers.target = "user"renders user/rootless containers.- Containers start and stop through systemd.
graft-pauseprovides 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 doctorcommand - 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/childrengraph 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
.networkand.volumeunits - 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
.volumeor.networkunit generation - no automatic firewall, DNS, or network alias management
- no systemd timespan parser for service timing values
- no
[Install], autostart, service type,remainAfterExit, orrestartIfChangedrendering - 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=valuesVolume=strings assembled from TOML parts- systemd service timing values such as
RestartSec=andTimeoutStartSec=
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:
- #11: Design named instances and dynamic hostname strategy
- #12: Design context-aware template variables for repo branch worktree and agent
- #13: Backlog: reduce Nix module rendering complexity
- #27: Design Git-aware copied workspace workflow
- #100: Add graft lint for TOML diagnostics
- #101: Add graft doctor for local environment diagnostics
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
];
};
}
| Option | Type | Default | Description |
|---|---|---|---|
services.graft.enable | bool | false | Enable system/rootful Graft containers. |
services.graft.package | package or null | null | Package providing graft and graft-pause; required when configRoot or configRoots is set. |
services.graft.configRoot | path or null | null | Directory containing *.toml container definitions. |
services.graft.configRoots | list 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
];
};
}
| Option | Type | Default | Description |
|---|---|---|---|
programs.graft.enable | bool | false | Enable user/rootless Graft containers. |
programs.graft.package | package or null | null | Package providing graft and graft-pause; required when configRoot or configRoots is set. |
programs.graft.configRoot | path or null | null | Directory containing *.toml container definitions. |
programs.graft.configRoots | list 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 = 1is required.nameis required and must be a safe container name.deploy.targetdefaults tosystem.deploy.enable = falseprevents rendering.config.container.hostnameis rendered as QuadletHostName=when explicitly set.config.container.useris rendered as QuadletUser=when explicitly set.config.container.groupis rendered as QuadletGroup=when explicitly set together withconfig.container.user.config.container.workingDiris rendered as QuadletWorkingDir=when explicitly set.config.container.environmentis rendered as sorted, quoted QuadletEnvironment="KEY=value"lines when explicitly set.config.container.environmentFileis rendered as ordered, quoted QuadletEnvironmentFile="..."lines when explicitly set.config.filesystem.volumesis rendered as ordered QuadletVolume=lines when explicitly set.config.network.publishis rendered as ordered QuadletPublishPort=lines when explicitly set.config.runtime.modesupports onlyrootfs-store.config.runtime.packagesare mapped to Nix packages.graft-pauseis always added to the package list.- missing
config.runtime.commandbecomes['/bin/graft-pause']. - explicit
config.runtime.commandis preserved. config.service.restartis rendered only when explicitly set.config.service.restartSec,timeoutStartSec, andtimeoutStopSecare 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:
targetis required by the TOML schematargetmust not be empty or whitespace-onlytargetmust not contain control characterstargetmust not contain:because Graft assembles colon-separatedVolume=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-separatedVolume=text - optional
mode, when present, requiressource - 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-separatedVolume=text - no path existence validation is performed
- no mode allowlist is applied yet
- no Quadlet
.volumeunits 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
.networkunits 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, andrestartIfChangedare 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:
- read the issue and related docs or code
- confirm scope before editing
- use one branch per issue
- keep changes focused
- update tests and docs with the code
- run the relevant checks
- 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.mdexamples/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.environmentFileconfig.network.publishconfig.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=valuesVolume=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:
modewithoutsource - 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.mdexamples/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 orphansfor the library and standalonegraft-pausebinary - 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.