Skip to content

Repository files navigation

creidhne

release license podman

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.

Install

Creidhne runs on Linux with systemd and Podman 4.4+ (Quadlet's minimum).

Release binary

Pick your architecture from the latest release:

Mise (recommended)

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"

Script

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/crei

From source

go install github.com/lugoues/creidhne/cmd/crei@latest   # Go 1.25+

Quick start

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 files

A 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/.

Writing quadlets

#Quadlet is the top-level wrapper: a name and a units block. CUE validates every field against the Podman Quadlet spec.

Primary vs. additional units

  • Primary units are #-prefixed fields (#container, #pod, #volume, ...). One per type; the file is named after the quadlet, so name: "app" + #container gives app.container.
  • Additional units are plural maps (containers, volumes, ...) keyed by a handle. The file is <quadlet>-<name>, where name defaults to the key — so volumes: data: {...} gives app-data.volume, and volumes: data: {name: "cache"} gives app-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

Helper modules

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.json

The 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.

List nesting

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 list

Exactly one level: helpers emit flat lists, and deeper nesting stays a type error.

Label helpers

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.

Helper checks

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.

Cross-references

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]
        }
    }
}

External dependencies

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"].

Secrets

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 prompted

crei 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 references

create 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.

Telemetry log labels

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.

Inline Containerfile & Context

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.#ref

Globs 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
                    """
            }
        }
    }
}

Supported sections

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.

Type safety

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.

CLI

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.

Migrating from docker-compose

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.yaml

URLs 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-names when migrating an existing deployment so the compose-era volumes (and their data) are reused. external: true resources are always adopted by name.
  • depends_on becomes After=+Requires=. A service with a healthcheck: gets the full notify wiring (Notify=healthy, Type=notify, NotifyAccess=all, and a derived TimeoutStartSec when the check math exceeds systemd's 90s default), so systemctl start waits for healthy, not just started, and condition: service_healthy ordering is actually enforced by systemd.
  • deploy.resources limits land in the systemd [Service] block (MemoryMax, CPUQuota, TasksMax); the rest of deploy.* is swarm and is skipped with a warning.
  • ${VAR} references are not resolved by default: they are lifted into an env: struct that crei validate forces you to fill. Pass --env-file/--env to resolve and bake values at import time instead, or --resolve to bake using only the file's own ${VAR:-default} values. Files that interpolate inside structured fields (like ports:) 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 list shows 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=false to skip).

Recorded state and crei status

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, or foreign (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.

Diff output

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 (0255); 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.

How it works

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.

Project structure

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

Development

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-run

Golden 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.

Name

Creidhne the artificer of the Tuatha Dé Danann pronounced KRAY-nyuh; the binary is crei, the sounded first syllable, "cray".

About

Generate Podman Quadlet systemd units from typed, validated CUE, and reconcile them against your quadlet directory.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages