Package a runeset

Bundle multiple services + secrets + configmaps into a single, templated, versioned package.

A runeset is to Rune what a chart is to Helm — but smaller, opinionated, and built into the CLI. Reach for one when "deploy my app" means more than one resource.

When to use a runeset

SituationUse a runeset?
One service, one YAML.No.
One service + one configmap + one secret.Probably no.
API + worker + scheduler + secrets + configs.Yes.
Multi-environment (dev / staging / prod values).Yes.
You want a shareable artifact with a version.Yes.

Layout

my-app/
├── runeset.yaml          # metadata + default values
├── values/
│   └── values.yaml       # values (every *.yaml here is merged)
├── casts/                # the templated resource files (required)
│   ├── api.yaml
│   ├── worker.yaml
│   └── secrets.yaml
├── templates/            # optional partials and embedded files
│   └── common-labels.yaml.tmpl
└── README.md

Two directories matter: casts/ holds the resource YAML that gets rendered and applied (it is required), and values/ holds the default values. templates/ is just a convention for files you pull in with {{ template: ... }} or {{ file: ... }}.

runeset.yaml

name: my-app
version: 1.2.0
description: API + worker + redis
namespace: default
defaults:
  image:
    tag: "1.2.0"

name is the default release name and namespace the default target namespace — both overridable at install time (--release, -n). defaults is an optional values block at the lowest precedence; most runesets keep their values in values/values.yaml instead.

values/values.yaml

image:
  tag: "1.2.0"
api:
  replicas: 2
  env:
    LOG_LEVEL: info
    FEATURE_FLAGS: beta-search
worker:
  replicas: 4

Every *.yaml file in values/ is merged in filename order, so don't put per-environment files here — keep those outside the runeset and pass them with --values (see below).

A cast

casts/api.yaml:

service:
  name: api
  image: "ghcr.io/example/api:{{ values:image.tag }}"
  scale: {{ values:api.replicas | default: 2 }}
  ports:
    - name: http
      port: 8080
  envFrom:
    - configmap: {{ context:releaseName }}-config
  health:
    liveness:
      type: http
      path: /healthz
      port: 8080

Placeholders

PlaceholderWhat it resolves to
{{ values:path.to.key }}Merged values (defaults + values/ + files + --set).
{{ values:key | default: x }}x when the value is missing. The only built-in filter.
{{ context:releaseName }}Set via --release (defaults to the runeset name).
{{ context:namespace }}Target namespace (-n, else namespace: in the manifest).
{{ context:mode }}apply, render, or dry-run.
{{ context:runeset.name }}The runeset's name from runeset.yaml.
{{ context:runeset.version }}The runeset's version.
{{ secret:name/key }}A secret value — resolved server-side at apply time.
{{ configmap:name/key }}A configmap value — resolved server-side at apply time.
{{ file:path }}Embeds a file (relative to the runeset root) as a block scalar.
{{ template:path }}Includes and renders another file at the current indentation.

Three behaviors worth knowing:

  • Missing values fail the render. A {{ values:... }} with no match and no | default: aborts with an error — typos surface before anything reaches the cluster.
  • Whole-node insertion. When a placeholder is the entire value (env: {{ values:api.env }}), maps and lists expand into proper YAML, not a stringified blob.
  • Secret refs don't render. {{ secret:... }} and {{ configmap:... }} pass through --render untouched and resolve on the server at apply time, so secret material never lands in rendered output.

A partial via {{ template: ... }} — the include renders with the same values and lands at the placeholder's indentation:

# casts/worker.yaml
service:
  name: worker
  image: "ghcr.io/example/worker:{{ values:image.tag }}"
  scale: {{ values:worker.replicas | default: 1 }}
  dependencies:
    - service: api
  {{ template: templates/common-labels.yaml.tmpl }}
# templates/common-labels.yaml.tmpl
labels:
  app: "{{ context:runeset.name }}"
  version: "{{ context:runeset.version }}"

Render — see what you'll deploy

rune cast ./my-app --render
rune cast ./my-app --render \
  --set=image.tag=1.3.0 \
  --set=api.replicas=5 \
  --values=production.values.yaml

--render prints the final YAML and exits without applying — your "diff before deploy" loop. (rune lint works on plain cast files, not runeset directories; for a runeset, --render and --dry-run are the validation path.)

Install

rune cast ./my-app \
  --release=my-app-prod \
  --namespace=prod \
  --create-namespace \
  --values=production.values.yaml

--release is the install identity — the release name. Re-running with the same release upgrades in place; different release names give you parallel installs (e.g., my-app-prod and my-app-staging).

A cast is plan → confirm → apply. Rune renders the runeset, computes a 3-way plan against what the release currently owns, and shows it before changing anything:

Release "my-app-prod" → revision 2   (namespace: prod)
  + create    1  (service/scheduler)
  ~ update    2  (service/api, service/worker)
  - prune     1  (configmap/old-flags)        ⚠ destructive

A plan that prunes or adopts prompts for confirmation; pass --yes (or run in CI) to skip it. Preview without applying using --dry-run.

Operate the release

Installing a runeset produces a tracked release. Manage it with rune release:

rune release list                       # all releases + revision + status
rune release status my-app-prod         # owned resources, references, revision
rune release history my-app-prod        # every revision
rune release diff my-app-prod           # what a re-cast would change
rune release rollback my-app-prod --to 2  # roll forward to a prior revision
rune release delete my-app-prod --yes   # uninstall everything it owns

Uninstall removes only what the release owns, in reverse dependency order — shared StorageClass and Namespace resources are referenced, not owned, so they survive. Pass --keep-volumes to retain volume data. By default a soft uninstalled tombstone is kept so the release can be reinstalled or rolled back; --purge forgets it.

Package and distribute

rune pack ./my-app -o my-app-1.2.0.runeset.tgz --sha256

Outputs:

  • my-app-1.2.0.runeset.tgz
  • my-app-1.2.0.runeset.tgz.sha256

Install from any location Rune can fetch:

# HTTPS
rune cast https://example.com/my-app-1.2.0.runeset.tgz \
  --release=my-app-prod
 
# Local archive
rune cast ./my-app-1.2.0.runeset.tgz --release=my-app-prod
 
# Git
rune cast github.com/example/[email protected] --release=my-app-prod

Values precedence

Highest wins:

  1. --set key=value — CLI flags.
  2. --values file.yaml — extra files (later files override earlier).
  3. RUNE_VALUES_* — environment variables (RUNE_VALUES_api__replicas=5 sets api.replicas; __ nests).
  4. values/*.yaml — bundled defaults, merged in filename order.
  5. defaults: — in runeset.yaml.

Patterns

Secrets in casts

# casts/secrets.yaml
secret:
  name: redis-credentials
  type: static
  data:
    password: {{ values:redis.password | default: change-me }}

Pass the password at install time:

rune cast ./my-app \
  --release=my-app-prod \
  --set=redis.password=$REDIS_PASSWORD

Or keep a separate untracked file:

rune cast ./my-app --values=secrets.local.yaml --release=my-app-prod

Other casts consume it without ever seeing the plaintext — {{ secret:... }} refs resolve server-side at apply time:

# casts/worker.yaml (excerpt)
  env:
    REDIS_PASSWORD: {{ secret: redis-credentials/password }}

Per-environment values files

my-app/
├── runeset.yaml
├── values/
│   └── values.yaml         # defaults
├── casts/
│   └── ...
└── envs/                   # NOT values/ — these are opt-in per env
    ├── dev.yaml
    ├── staging.yaml
    └── prod.yaml
rune cast ./my-app --values=envs/prod.yaml --release=my-app-prod

The env files live outside values/ on purpose: everything inside values/ is merged automatically on every cast.

Anti-patterns

  • Templating everything. If a value never changes, hardcode it. Less surface area.
  • Cross-runeset references. A runeset shouldn't reach into another runeset's resources by name. Use stable namespace conventions and standalone resources for shared state.
  • Big monolithic runesets. If your runeset has 30 services across 5 teams, split it.