Generate Podman Quadlet systemd units from typed, validated CUE, and reconcile them against your quadlet directory.
Creidhne (crei) is a single-binary CLI that generates Podman Quadlet systemd unit files from typed, validated CUE definitions and reconciles them against your quadlet directory (plan / diff / apply). It covers all 8 Quadlet unit types: Container, Pod, Volume, Network, Kube, Build, Image, and Artifact.
The binary embeds the CUE evaluator and schema, so you don't need cue (or anything else) installed to use it. Write CUE, run crei apply.
Creidhne runs on Linux with systemd and Podman 4.4+ (Quadlet's minimum).
Pick your architecture from the latest release:
This is by far the easiest way and it verifies the cosign signatures automatically. Folow the instructions at https://mise.jdx.dev/getting-started.html to get setup. Then create the file below and run mise install to install.
# my-quadlets/.mise/config.toml
[env]
QUADLET_DIR = "/etc/containers/systemd"
# DIFF_TOOL = "delta"
[tools]
"github:lugoues/creidhne" = "2.10.0"This script will download the latest binary, verify it's signatures, and install crei into /usr/local/bin.
ver=2.10.0
arch=amd64 # or arm64
base=https://github.com/lugoues/creidhne/releases/download/v$ver
curl -fsSLO "$base/crei_${ver}_linux_$arch"{,.sha256,.sigstore.json}
# integrity
echo "$(cat crei_${ver}_linux_$arch.sha256) crei_${ver}_linux_$arch" | sha256sum -c -
# provenance: verify it was built by this repo's release workflow (keyless cosign)
cosign verify-blob \
--bundle crei_${ver}_linux_$arch.sigstore.json \
--certificate-identity-regexp '^https://github.com/lugoues/creidhne/' \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
crei_${ver}_linux_$arch
install -m755 crei_${ver}_linux_$arch /usr/local/bin/creigo install github.com/lugoues/creidhne/cmd/crei@latest # Go 1.25+mkdir my-quadlets && cd my-quadlets
crei init # scaffolds cue.mod, main.cue, .crei/config.toml
$EDITOR main.cue # define your quadlets
crei plan # preview changes against the quadlet dir
crei apply # write the unit filesA quadlet with a container and a volume:
package quadlets
import "github.com/lugoues/creidhne"
app: creidhne.#Quadlet & {
name: "app"
units: {
#container: {
Container: {
Image: "docker.io/myapp:latest"
ContainerName: "app"
Environment: ["APP_ENV=production"]
Volume: ["app-data.volume:/data"]
PublishPort: ["8080:8080"]
}
Service: {
Restart: "always"
MemoryMax: "1G"
}
Install: WantedBy: ["multi-user.target"]
}
volumes: data: {
Volume: {
VolumeName: "app-data"
}
}
}
}crei apply writes app.container and app-data.volume into your quadlet directory (default ~/.config/containers/systemd), then tells you to run systemctl --user daemon-reload (or does it for you with --reload-systemd).
For a realistic, copyable setup (a Traefik pod with volumes, networks, and external unit dependencies), see example/.
#Quadlet is the top-level wrapper: a name and a units block. CUE validates every field against the Podman Quadlet spec.
- Primary units are
#-prefixed fields (#container,#pod,#volume, ...). One per type; the file is named after the quadlet, soname: "app"+#containergivesapp.container. - Additional units are plural maps (
containers,volumes, ...) keyed by a handle. The file is<quadlet>-<name>, wherenamedefaults to the key — sovolumes: data: {...}givesapp-data.volume, andvolumes: data: {name: "cache"}givesapp-cache.volume.
Mix both freely; a primary #container plus additional volumes: data: {...} is a common pattern.
#Quadlet
├── name: string # e.g. "traefik"
└── units: #Units
├── #container?: #Container # primary → traefik.container
├── #volume?: #Volume # primary → traefik.volume
├── ... # all 8 types
├── containers: {...} # additional → traefik-<key>.container
└── volumes: {...} # additional → traefik-<key>.volume
Shared helpers (your own app specs, community modules) can live in their own CUE module and be vendored for offline use, exactly like the embedded schema:
crei vendor github.com/you/quadlet-helpers@v0.2.0
crei vendor --check # offline drift check against cue.mod/crei-vendor.jsonThe module path doubles as the git URL (--source for private remotes or
local paths); the resolved commit and a tree hash are pinned in
cue.mod/crei-vendor.json. A vendored module may import the CUE standard
library, itself, the creidhne schema, and modules already vendored in the
project; crei never fetches dependencies transitively, so vendor a module's
dependencies before the module. Use the same creidhne import form (@v0) in
helper modules as in your project; mixing forms is rejected at load.
Every list field accepts one level of nesting and flattens it, so a helper can hand you a block of values that you splice in place:
Label: ["app=web", #mySpec.#labels] // renders as one flat Label listExactly one level: helpers emit flat lists, and deeper nesting stays a type error.
Label fields also accept structs that carry a pre-rendered value in
#rendered: one "key=value" string or a whole list of them, spliced in
place. #rendered is a definition field, so helpers work from any package,
including vendored helper modules:
#DockTail: creidhne.#Rendered & {
#value: {...}
#rendered: [for k, v in #value {"docktail.\(k)=\(v)"}]
}
Container: Label: [
"app=web",
#DockTail & {#value: {enable: true, port: 8080}},
]#JSONLabel (JSON payload in a single label, quoted and escaped) is built on
the same contract.
Helpers (and quadlets themselves) can register invariants that fail
validate, render, plan, and apply with a real message, instead of
silently doing nothing when a mixin's config was never filled:
#WebSpec: {
#cfg: port!: int
#checks: "web/cfg": {
require: [#cfg.port] // must be concrete to render
why: "fill #cfg when mixing #WebSpec" // shown on failure
}
...
}Error: quadlet app: check "web/cfg" failed: fill #cfg when mixing #WebSpec
A check may also carry assert: <expr>, which must evaluate to true.
Checks never appear in rendered units or crei.state.
Every unit has computed #ref and #service fields for type-safe references. #ref is the Quadlet filename (e.g. proxy.volume) used in fields like Volume; #service is the systemd service Quadlet generates (e.g. proxy-volume.service) used in Unit fields like After/Requires.
Container, pod, volume, and network units also expose #containerName / #podName / #volumeName / #networkName, the runtime resource name podman assigns: the explicit ContainerName/etc. if you set one, otherwise podman's systemd-<stem> default. Use it where a real object name is required, e.g. Network: ["container:\(db.units.#container.#containerName)"].
proxy: creidhne.#Quadlet & {
name: "proxy"
units: {
#container: {
Container: {
Image: "docker.io/nginx:latest"
Volume: ["\(units.#volume.#ref):/etc/nginx/certs:ro"]
}
Unit: Requires: [units.#volume.#service]
}
#volume: {Volume: {}}
}
}
// units.#volume.#ref → "proxy.volume"
// units.#volume.#service → "proxy-volume.service"For cross-quadlet references, reference the other quadlet's units directly:
app: creidhne.#Quadlet & {
name: "app"
units: #container: {
Container: Image: "docker.io/myapp:latest"
Unit: {
After: [db.units.#container.#service]
Requires: [db.units.#container.#service]
}
}
}For systemd units not managed by this config, use #ExternalUnits:
externals: creidhne.#ExternalUnits & {
targets: "network-online": _
services: tailscaled: _
sockets: podman: _
}
app: creidhne.#Quadlet & {
name: "app"
units: #container: {
Container: Image: "docker.io/myapp:latest"
Unit: After: [
externals.targets["network-online"].#ref,
externals.services.tailscaled.#ref,
]
}
}Well-known systemd targets (default, multi-user, network-online, graphical, ...) are pre-populated. Or skip the helper and use raw strings: Unit: After: ["network-online.target"].
Declare the podman secrets your quadlets use in a #SecretRegistry, then reference each entry's #ref handle in a container's Secret field, adding the consumption details (the handle and the consumption fields unify into one secret reference; the entry itself is a management record and cannot be used directly):
secrets: creidhne.#SecretRegistry & {
db_password: _ // podman secret name defaults to the key
tls_cert: {name: "tls-cert"} // or set it explicitly
}
app: creidhne.#Quadlet & {
name: "app"
units: #container: {
Container: {
Image: "docker.io/myapp:latest"
Secret: [
secrets.db_password.#ref & {type: "env", target: "DB_PASSWORD"},
secrets.tls_cert.#ref & {type: "mount", target: "/etc/ssl/cert.pem", mode: "0400"},
]
}
}
}crei can also own the registry for you. crei secret create <name> registers
the secret in registries/secrets.cue (the crei-owned counterpart to
registries/images.cue) and creates its value in podman, one step — podman's
own vocabulary, one verb. Secret material never lives in CUE. Reference a
crei-owned entry through its #ref handle (like the image and asset
registries), adding the consumption fields there:
Container: Secret: [reg.secrets.db_password.#ref & {type: "env", target: "DB_PASSWORD"}]crei secret create db_password --length 40 # record a generate policy + create
crei secret create tls_cert --manual # record a manual entry; value promptedcrei secret reconciles the registry (the crei-owned registries/secrets.cue
and the hand-authored top-level secrets field, unioned) against podman's
secret store:
crei secret list # present/missing, crei-managed, created/updated
crei secret create db_password # prompt (enter or generate); the choice is recorded
crei secret create -a # walk through every secret missing from podman
crei secret adopt # label pre-existing registry secrets as crei-managed
crei secret prune # delete crei-created secrets nothing referencescreate prompts for a value (hidden input) or generates a random one; a generated value is shown once so you can save it. Use --replace to overwrite an existing secret. The hand-authored registry is read from the top-level secrets field by default; override with secrets_field in .crei/config.toml.
Everything create makes carries a creidhne.managed=true label, and prune
only ever considers labeled secrets: what crei didn't create, it never
deletes. A secret counts as referenced if it's in the registry or named by
any Secret= entry on a container or build unit. adopt migrates secrets
created before labeling: it re-creates registry-declared ones in place,
value preserved byte-exact (read via inspect --showsecret, piped straight
back). Secrets referenced only inside .kube YAML aren't parsed; declare
them in the registry to protect them.
Every container is rendered with journald log labels so its logs carry queryable metadata journald doesn't expose on its own:
LogDriver=journald
LogOpt=label=QUADLET=bookorbit
LogOpt=label=QUADLET_UNIT_NAME=bookorbit-web
LogOpt=label=IMAGE=ghcr.io/bookorbit/bookorbit:2.3.0
LogOpt=label=IMAGE_DIGEST=sha256:6339…QUADLET_UNIT_NAME is the clean unit name behind journald's systemd--prefixed
CONTAINER_NAME; IMAGE_DIGEST appears only when the image is pinned. Query
them with journalctl QUADLET=bookorbit. LogDriver defaults to journald
because the label option is journald-only; set it explicitly (e.g.
LogDriver: "k8s-file") to opt a container out — the labels only render under
journald.
The field reaches journald only on podman >= 6.0.2 (podman#26203) with a
matching conmon; on older versions the label is written to the .container file
and shows in podman inspect, but conmon doesn't emit it, so journalctl QUADLET=… stays empty until the runtime catches up. See
docs/design/telemetry-log-labels.md.
Craei supports inlining Containerfiles and their context within a Build unit. Context files will be placed next to the Containerfile when being build so COPY . / is all you need to pull your context in. This is useful when you want only minor changes to the original image (such as installing packages).
For files too large or numerous to inline (grafana dashboards, provisioning
trees), declare an asset glob in registries/assets.cue and reference it
from the context. ** recursive globs are supported; the matched files expand
at load time, preserving their structure under the context key, and their bytes
feed the build content hash — so editing a dashboard flags the build and its
consumers stale, same as an inline edit:
// registries/assets.cue (hand-authored; crei never rewrites it)
assets: creidhne.#AssetRegistry & {
grafana_dashboards: source: "assets/grafana/dashboards/**/*.json"
}
// in the quadlet:
#build: Context: dashboards: reg.assets.grafana_dashboards.#refGlobs are project-relative (no ..), matches are sorted for deterministic
output, executable files keep their exec bit, and a glob matching nothing is a
load error rather than a silently empty context. Asset refs work only in build
contexts — a bind mount would put undeclared host state behind the handle and
break build determinism. See docs/design/asset-registry.md.
traefik: creidhne.#Quadlet & {
name: "traefik"
units: {
// Build the traefik image from an inline Containerfile.
#build: {
Build: {
BuildArg: ["TRAEFIK_VERSION=3.6.11"]
ImageTag: ["localhost/traefik:quadlet"]
}
ContainerFile: """
ARG TRAEFIK_VERSION
FROM ghcr.io/traefik/traefik:${TRAEFIK_VERSION}
COPY . /
"""
Context: {
"etc/traefik/traefik.yml": """
domain: mydomain.dev
api:
dashboard: true
insecure: false
"""
}
}
}
}
Every unit type supports the standard systemd sections plus its own:
| Section | Description |
|---|---|
Unit |
Dependencies, ordering, conditions (After, Requires, Wants, ...) |
Service |
Restart policy, resource limits, exec hooks, environment |
Install |
WantedBy, RequiredBy, Alias, ... |
Quadlet |
DefaultDependencies toggle |
Plus the unit section ([Container], [Pod], ...). Every field from podman-systemd.unit(5) is supported and documented with inline comments in the CUE source.
Fields are validated when you crei validate (or crei plan/apply):
#container: Container: {
PublishPort: ["8080:80"] // validated port mapping format
Memory: "512m" // validated podman byte size
Pull: "always" // enum: always | missing | never | newer
UserNS: "keep-id:uid=1000" // validated user namespace mode
}Mutual exclusivity is enforced: Image/Rootfs and ReloadCmd/ReloadSignal cannot both be set.
| Command | Description |
|---|---|
crei init |
Scaffold a project (cue.mod, main.cue, .crei/config.toml, .crei/config.schema.json) and vendor the CUE schema for editor/LSP support. |
crei render |
Render all unit files to stdout. |
crei plan |
Show what apply would add/update/remove, as an inline diff (--no-diff for the compact list). |
crei diff |
Show detailed diffs against the live files. |
crei apply |
Write/remove files. --reload-systemd runs daemon-reload (default from reload_systemd in .crei/config.toml, else on); -y skips the prompt. |
crei status [quadlet...] |
One table of desired vs recorded vs disk vs runtime state per unit; name quadlets to drill in. --problems shows only rows needing attention, --format json for scripts, --check for cron/CI exit codes. Read-only. |
crei validate |
Type-check the CUE without rendering. |
crei import compose [file...] |
Convert a docker-compose project into a creidhne CUE file (see below). |
crei vendor [module[@ref]] |
Vendor a git-hosted CUE helper module into cue.mod/usr for offline use (--check verifies against the lock). |
crei config |
Show the resolved configuration and where each value came from. |
crei secret list |
List the secret registry and whether each secret exists in podman (alias: ls). |
crei secret create |
Create a podman secret and register it in registries/secrets.cue in one step (--length/--charset record a generate policy, --manual a prompted one; -a walks every missing one). |
crei start |
Start quadlets' runnable units (containers/pods/kubes; deps start on their own). Idempotent; requires names or --all. |
crei stop |
Stop quadlets' runnable units (infra left alone — stopping a shared network cascades to other quadlets; confirm; -y skips; names or --all). |
crei secret rotate |
Regenerate crei-owned secrets (per their generate policy) and replace them in podman. |
crei secret remove |
Unregister a secret from registries/secrets.cue (--delete also removes it from podman; alias: rm). |
crei version |
Print version info. |
crei import compose converts a compose project into one #Quadlet:
crei import compose # discovers compose.yaml like docker compose does
crei import compose -o - ... # print to stdout instead of <project>.cue
crei import compose https://github.com/docker/awesome-compose/blob/master/nginx-golang/compose.yamlURLs are fetched (GitHub/GitLab browser links are rewritten to their raw
form), and the project name derives from the URL directory (here:
nginx-golang) unless the file sets name: or --name is given. Relative
paths in a fetched file refer to the source repository layout, so run the
result from a checkout.
- Services become containers, named volumes/networks become units referenced
via
#self,build:sections become build units. Volumes and networks get fresh systemd-* names by default; pass--preserve-nameswhen migrating an existing deployment so the compose-era volumes (and their data) are reused.external: trueresources are always adopted by name. depends_onbecomesAfter=+Requires=. A service with ahealthcheck:gets the full notify wiring (Notify=healthy,Type=notify,NotifyAccess=all, and a derivedTimeoutStartSecwhen the check math exceeds systemd's 90s default), sosystemctl startwaits for healthy, not just started, andcondition: service_healthyordering is actually enforced by systemd.deploy.resourceslimits land in the systemd[Service]block (MemoryMax,CPUQuota,TasksMax); the rest ofdeploy.*is swarm and is skipped with a warning.${VAR}references are not resolved by default: they are lifted into anenv:struct thatcrei validateforces you to fill. Pass--env-file/--envto resolve and bake values at import time instead, or--resolveto bake using only the file's own${VAR:-default}values. Files that interpolate inside structured fields (likeports:) cannot be preserved symbolically; the error tells you which resolve mode to use.- Compose secrets map onto the secret registry; values are never imported.
The conversion report lists how to load each one (
crei secret listshows what is still missing). - Anything unmappable is listed in the report, never dropped silently, and
the source compose file is embedded at the bottom of the emitted CUE as a
comment block for reference (
--embed-source=falseto skip).
apply records what it wrote in a crei.state file next to the quadlet files
(the kubectl last-applied analogue): the evaluated manifest plus a hash of
every file. crei status compares four layers per unit and prints one table:
QUADLET UNIT DISK LOADED RUNTIME
app app.container synced ok ● running 2d4h
app app.volume pending reload needed -
db db.container tampered ok ✗ failed
- DISK:
synced,pending(your CUE edit, run apply),tampered(the deployed file changed outside crei),missing,orphan, orforeign(a file crei never wrote; never touched). - LOADED: whether systemd's generator has picked the file up (
ok,reload needed,not loaded). - RUNTIME: the service's state and uptime;
(stale)marks a process started before the file's last apply, i.e. running the old config.
Every layer degrades independently (broken eval falls back to recorded state;
no systemd just blanks the runtime columns), and --check exits non-zero
unless everything is synced, loaded, and healthy. Reconciliation itself never
consults runtime state: files are the substrate, systemd is observability.
Configuration is resolved as flags > environment > .crei/config.toml > defaults:
| Setting | Flag | Env | .crei/config.toml |
Default |
|---|---|---|---|---|
| Project dir | -C, --dir |
n/a | n/a | . |
| Quadlet dir | --quadlet-dir |
QUADLET_DIR |
quadlet_dir |
~/.config/containers/systemd |
| Diff tool | --diff-tool |
DIFF_TOOL |
diff_tool |
built-in unified diff |
| Diff style | n/a | n/a | diff_style |
highlight |
| Reload systemd after apply | --reload-systemd |
n/a | reload_systemd |
true (on, like podman quadlet install) |
| Secrets field | n/a | n/a | secrets_field |
secrets |
| Diff run truncation | --verbose (off) |
n/a | context_lines |
10 |
| Truncation threshold | n/a | n/a | context_threshold |
auto (2*context_lines+4) |
Run crei config to print the resolved values and where each came from.
The config lives in .crei/config.toml. crei init also writes a JSON Schema (.crei/config.schema.json) and a #:schema directive at the top of config.toml, so editors with TOML support (e.g. Even Better TOML / Taplo) validate and autocomplete the config offline.
Writing to a system path like /etc/containers/systemd requires elevated privileges, so run sudo crei apply. The CLI never escalates on its own; if a write is denied it tells you to re-run with sudo.
plan, diff, and apply render a Terraform-style inline diff of each change: a bold # <file> header, a +/- gutter, collapsed unchanged regions (# (N unmodified lines hidden)), and highlighting of what changed within a line. Pass --no-diff to plan/apply for just the compact +/~/- change list.
Long added/removed runs in images/ artifacts (a new 9k-line asset file) are
capped at context_lines head/tail lines (default 10) around a
# (N more lines; --verbose shows all) marker; unit-file diffs always render
whole. context_threshold sets the run length at which the cap engages
(default auto: 2*context_lines+4); set context_lines = 0 or pass
--verbose to dump everything.
How a modified line renders is set by diff_style in .crei/config.toml:
diff_style |
A modified line shows as |
|---|---|
highlight (default) |
- old / + new pair, with the changed span highlighted on each |
plain |
- old / + new pair, whole lines colored |
inline |
a single ~ line (word-diff): the removed run struck through, the added run in the add color |
Colors are truecolor by default and degrade automatically to 256/16-color or plain (honoring NO_COLOR and non-TTY output). Restyle any element under a [style] table — each entry is a color string (foreground) or a table of fg/bg plus bold/italic/underline/reverse/strikethrough/faint:
[style]
header = { bold = true } # the "# <file>" header
text = "" # normal text (empty = terminal default)
context = "#6E7681" # unchanged context lines
inline_context = "" # unchanged text in a modified row (empty = inherit text)
add = "#3FB950" # added lines / "+"
remove = "#F85149" # removed lines / "-"
add_char = { fg = "#3FB950", bold = true } # added inline span (defaults to add)
remove_char = { fg = "#F85149", bold = true } # removed inline span (defaults to remove)Colors are hex (#3FB950) or an ANSI index (0–255); an unknown attribute or unparseable color is reported when the config loads. An external diff_tool (e.g. delta) formats its own output, so diff_style and [style] apply only to the built-in differ.
CUE validates and exports your unit definitions as plain data; Go does the rendering. Each #Quadlet exposes a manifest (the typed data plus computed filenames/service names); the CLI evaluates it via the embedded cuelang.org/go library (resolving your import "github.com/lugoues/creidhne" from the schema baked into the binary, with no network or registry), then renders each unit through a Go text/template and reconciles the result against your quadlet directory.
Your editor's CUE tooling resolves the import from a vendored copy that crei init writes into cue.mod/usr/ (and that the binary keeps in sync), so the LSP works offline, with nothing to fetch from a registry.
cmd/crei/ # CLI entrypoint
internal/eval/ # CUE evaluation (cue/load + overlay) → manifest
internal/render/ # text/template execution → unit files
internal/reconcile/ # plan / diff / apply against the quadlet dir
internal/cli/ # cobra commands
creidhne/ # CUE schema module (github.com/lugoues/creidhne)
templates/ # Go text/templates, one per unit type
testdata/ # golden fixtures (input.cue + expected/)
example/ # a realistic multi-quadlet project
Toolchain (go, cue) and dev tasks are managed by mise.
mise run build # build ./bin/crei
mise run test # go test ./... + cue vet (schema)
mise run lint # golangci-lint
mise run snapshot # local goreleaser dry-runGolden tests render every testdata/<case> fixture and assert byte-equality against its expected/ tree. After an intentional output change, run mise exec -- go test . -run TestGolden/<case> to see the diff, then update the files under expected/.
The crei binary is the only release artifact (the schema and templates are embedded in it), and GoReleaser builds it on v* tags.
Creidhne the artificer of the Tuatha Dé Danann pronounced KRAY-nyuh; the binary is crei, the sounded first syllable, "cray".