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
| Situation | Use 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
| Placeholder | What 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--renderuntouched 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.tgzmy-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:
--set key=value— CLI flags.--values file.yaml— extra files (later files override earlier).RUNE_VALUES_*— environment variables (RUNE_VALUES_api__replicas=5setsapi.replicas;__nests).values/*.yaml— bundled defaults, merged in filename order.defaults:— inruneset.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.