Templates (`yoink add`)
Drop-in service fragments with sealed secrets — how to use the bundled set and how to author your own.
yoink add <name> is the "poor man's helm" for accessories and full apps: fetch a vetted template from GitHub, fill in a few variables, get a sealed secret + a working service fragment, optionally deploy. Same code path serves bundled templates and arbitrary 3rd-party repos.
Looking for the operator-side commands? This page is for authors: how to publish your own template that anyone can yoink add. If you just want to use the bundled set or a 3rd-party template, see Adding a service via yoink add.
A complete example
Concrete first. This is a real working clickhouse template; paste into a fresh repo, push, and yoink add gh:you/yourrepo/clickhouse works against it.
templates/
└── clickhouse/
├── template.yaml
└── service.yaml.tmpl# templates/clickhouse/template.yaml
name: clickhouse
kind: accessory
description: ClickHouse with sealed credentials and a named volume.
yoink_min_version: "0.12.0"
variables:
- name: service_name
prompt: Service name
default: clickhouse
pattern: "^[a-z][a-z0-9-]*$"
- name: version
prompt: ClickHouse major version
default: "24"
choices: ["23", "24"]
- name: memory
prompt: Memory limit
default: 1g
files:
- dest: "services/{{ service_name }}.yaml"
template: service.yaml.tmpl
secrets:
- name: "{{ service_name | upper }}_PASSWORD"
generate: random:32
include_glob: "services/*.yaml"
notes: |
Connect from another service:
depends_on: [{{ service_name }}]
env:
CLICKHOUSE_URL: tcp://{{ service_name }}:9000
env_from_secrets:
CLICKHOUSE_PASSWORD: {{ service_name | upper }}_PASSWORD# templates/clickhouse/service.yaml.tmpl
services:
- name: {{ service_name }}
image: clickhouse/clickhouse-server
tag: "{{ version }}-alpine"
env:
CLICKHOUSE_USER: app
CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT: "1"
env_from_secrets:
CLICKHOUSE_PASSWORD: {{ service_name | upper }}_PASSWORD
run:
volumes:
- clickhouse-{{ service_name }}-data:/var/lib/clickhouse
options:
memory: {{ memory }}
# ClickHouse needs root briefly to chown its data volume.
user: "0:0"
cap_add: [CHOWN, DAC_OVERRIDE, FOWNER]
read_only: falseA manifest declares variables and outputs; one or more files are rendered through minijinja.
Manifest reference
| Field | Type | Required | Notes |
|---|---|---|---|
name | string | yes | Human-readable identifier; also used in error messages. |
kind | accessory | app | no | Defaults to accessory. app flips the "Deploy now?" prompt to default-yes (apps are usually deployed immediately; accessories often added before the dependent service is configured). |
description | string | no | One-liner shown in the confirmation diff. |
yoink_min_version | string | no | Hard-fails if the running yoink is older. Loose semver; non-numeric suffixes (-rc1) are ignored. |
variables | list | no | See Variables below. |
files | list | yes | At least one. See Files below. |
secrets | list | no | See Secrets below. |
include_glob | string | no | If set, yoink offers to add this glob to the operator's yoink.yaml include: list when the rendered fragments aren't already covered. |
notes | string | no | Markdown-ish blob shown after a successful add. Rendered through minijinja so you can interpolate variables. |
Variables
variables:
- name: service_name # required — used in {{ service_name }}
prompt: Service name # what the wizard shows; defaults to `name`
default: clickhouse # always provide one — `--yes` (CI) needs it
pattern: "^[a-z][a-z0-9-]*$" # anchored regex; wizard re-prompts on miss
- name: version
default: "24"
choices: ["23", "24"] # numbered picker in interactive modenameis the identifier you reference in{{ … }}substitutions throughout the manifest and template files.promptis what the wizard shows. Falls back tonameif absent. Keep it short.defaultis what the wizard pre-fills, and whatyoink add --yesuses without further input. Always provide a default; non-interactive runs fail loud when a variable has neither a default nor a--varoverride.choicesrestricts the answer set. Wizard renders a numbered picker; CLI overrides via--var name=Xvalidate against the list.patternis a small anchored regex (yoink ships a tiny matcher; supports literals,., character classes like[a-z0-9-],*,+, top-level alternation like^(true|false)$). Mismatches re-prompt in the wizard, error out in--yesmode.
Variable values are passed through to minijinja. Common filters: {{ name | upper }}, {{ name | lower }}, {{ name | default("fallback") }}. Conditionals: {% if domain %}domain: {{ domain }}{% endif %}.
Files
files:
- dest: "services/{{ service_name }}.yaml"
template: service.yaml.tmpl
- dest: "config/{{ service_name }}.toml"
template: config.toml.tmpldestis the path inside the operator's repo, relative to where theiryoink.yamllives. Rendered through minijinja, so{{ service_name }}lets the same template produce per-instance files. yoink rejects rendered destinations that escape the repo root (..or absolute paths), so don't try to write outside the project.templateis the file inside your template directory whose contents get rendered. Same minijinja rules.
The rendered file must parse as a yoink ConfigFragment; yoink validates each one before writing, so a bad template fails closed instead of producing a broken config.
Secrets
secrets:
- name: "{{ service_name | upper }}_PASSWORD"
generate: random:32
- name: "{{ service_name | upper }}_ADMIN_TOKEN"
generate: random:48Each entry generates a random base32 string locally and seals it into the operator's secrets.age before the value ever leaves the process. Random bytes; not derived from anything. Only random:N is supported today (8 ≤ N ≤ 512).
The name is rendered through minijinja, so secrets-per-instance work: adding two postgres instances doesn't collide on a shared POSTGRES_PASSWORD. The convention is {{ service_name | upper }}_<KIND> so the env-var-side reference reads naturally.
If the operator hasn't configured secrets: in their yoink.yaml, yoink add offers to bootstrap age; you don't need to handle that case in the template.
Hardening overrides: what bites in practice
yoink's container defaults are deliberately strict:
options:
user: "65534:65534" # nobody
cap_drop: [ALL]
read_only: true # rootfs only; volumes are still writable
security_opt: [no-new-privileges]
tmpfs: { /tmp: { exec: false } }
pids_limit: 1024For most templates these are right. Some popular images need overrides, usually because their entrypoint expects to start as root, write to the rootfs, or chown a fresh volume. The pattern is "fail closed, then add back what's strictly needed."
| Symptom | Likely cause | Override |
|---|---|---|
Operation not permitted during chmod/chown of a volume | cap_drop: [ALL] strips CHOWN/FOWNER | cap_add: [CHOWN, DAC_OVERRIDE, FOWNER] |
Operation not permitted during user-switch (gosu, su-exec, …) | Caps stripped + user: nobody mismatch | user: "0:0" + cap_add: [SETGID, SETUID] |
Read-only file system (os error 30) mid-request | App writes to rootfs (logs, pidfiles, payload buffers) | read_only: false |
Permission denied writing to a volume | Volume owned by root, container running as nobody | user: "0:0" (image's entrypoint usually drops privileges itself) |
Most bundled templates carry a few lines of overrides for exactly these reasons; read them before authoring a similar template:
templates/postgres/service.yaml.tmpl: root entrypoint, gosu privilege drop, initdb chown.templates/meilisearch/service.yaml.tmpl: root + writable rootfs for ingest payload buffers.templates/redis/service.yaml.tmpl: secure-by-default works as-is, with a tmpfs for/data.templates/rustfs/service.yaml.tmpl: uid 10001 baked into the image;/logstmpfs because the daemon writes operational logs there withread_only: true.templates/restic-backups/service.yaml.tmpl: go-cron writes a lockfile to/run/lock; tmpfs at the leaf path because mounting/runalone leaves/locknonexistent.
Test on a real host before declaring a template done. First-deploy permission failures don't show up in the manifest validator or the rendered-YAML linter; they only surface when the container actually starts. The pattern when you hit one: docker logs <container> for the error string, then check the table above.
Local development loop
Templates are fetched from a GitHub commit, so iterating means committing and pushing. The cycle:
# 1. Edit your template files locally
# 2. Push to a feature branch
git push -u origin feat/clickhouse-template
# 3. Add against the branch SHA (or branch name + --refresh)
yoink add gh:you/yourrepo@feat/clickhouse-template/templates/clickhouse \
--refresh --yes
# 4. Deploy to a sacrificial host (or local docker daemon)
yoink up --service clickhouse--refresh forces yoink to re-resolve the ref to the latest commit SHA, bypassing the branch → sha cache. Without it, yoink trusts the cached mapping and you'd serve a stale version of your template.
The cache lives at ~/.cache/yoink/templates/<owner>__<repo>__<sha>/. Wipe it (rm -rf ~/.cache/yoink/templates) if anything looks weirdly stuck; content-addressed by SHA, re-fetching is harmless.
Versioning and pinning
- Branch refs (
@main,@feat/foo) get re-resolved on--refresh. Without--refresh, the cachedbranch → SHAmapping wins. - Tag refs (
@v1.0.0) work the same way: resolved to a SHA once and cached. - SHA refs (
@a1b2c3d) are content-addressed; never re-resolved, never re-fetched once cached.
For published templates, document the recommended pin in your README. Most operators want either main (move-fast, accept upstream changes) or a specific tag (stability).
If your template starts requiring a yoink feature added after some version, set yoink_min_version: "X.Y.Z" in the manifest. Yoink hard-fails with a clear "upgrade or pin to an older template" message, which is better than the template silently rendering garbage on an older binary.
Publishing patterns
Any GitHub repo works. Common shapes:
- One repo per template:
yourname/yoink-template-clickhouse. Easy to advertise, easy to vendor, easy to fork. - A templates monorepo:
yourname/yoink-templateswith subdirectories per template. Consumers usegh:yourname/yoink-templates/clickhouse. Lower overhead if you maintain several. - A directory in your existing project:
yourname/your-app/templates/your-app. Useful when the template is specific to your project and lives next to the code it deploys.
Templates are fetched as gzipped tarballs via https://codeload.github.com/.... yoink caps the download at 50 MB and the unpacked size at 200 MB; large-monorepo templates work fine, multi-GB ones don't. (If you hit the cap, the template repo probably wants to be split out.)
Submitting to the bundled set
The six bundled templates (postgres, redis, meilisearch, rustfs, restic-backups, openclaw) live in this repo at /templates/ and are what yoink add postgres resolves to without a gh: prefix. PRs adding new bundled templates are welcome. The bar is "common enough that yoink shipping it directly saves real users from re-deriving the hardening overrides."
Acceptance criteria for a PR:
- Renders valid YAML: covered by the
tests/add_integration.rstest that walkstemplates/*/template.yaml. The test must still pass after your addition. - Real-host smoke test: at least one round-trip on a real Docker host:
yoink init→yoink add <yourname>→yoink up→ exercise the service end-to-end. Capture the output in the PR. - Hardening overrides documented: if you needed
user: "0:0"orcap_add: [...]orread_only: false, the comment in theservice.yaml.tmplshould explain why (which entrypoint operation, which file). Future readers shouldn't have to re-derive it. secrets.recipients:reference works: if your template seals a secret, the rendered fragment'senv_from_secrets:reference must point at the same name the manifest'ssecrets[].name:produces. The integration test catches mismatches.
For 3rd-party templates published in your own repo, none of these are required, but they're still good practice.
Schema reference
For the manifest:
name: <string> # required
kind: accessory | app # default: accessory
description: <string> # optional
yoink_min_version: <semver> # optional; hard-fail when violated
variables:
- name: <ident> # required
prompt: <string> # default: name
default: <string> # always provide for --yes runs
choices: [<string>...] # optional; restricts to a closed set
pattern: <regex> # optional; anchored
files:
- dest: <path-template> # required; relative to operator's repo
template: <path> # required; relative to your template dir
secrets:
- name: <string-template> # required; rendered through minijinja
generate: random:<N> # required; 8 ≤ N ≤ 512
include_glob: <glob> # optional
notes: <markdown-template> # optionalFor the rendered service fragment, see Config reference; that's the same shape an operator writes by hand in yoink.yaml.
See also
- Adding a service via
yoink add: operator side. - Configuration reference: schema for the rendered service fragment.
- Secrets: how the secrets that templates seal get decrypted at deploy time.
Secrets
AGE-sealed secrets committed to the repo, plus provider:command recipes for Doppler, Vault, 1Password, AWS SM, Infisical, and more.
Driving yoink from an AI agent
CLI and YAML-driven, deterministic exit codes, and patterns for driving yoink from Claude Code, Cursor, Aider, and GitHub Copilot Workspace.