🪝 yoink

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: false

A manifest declares variables and outputs; one or more files are rendered through minijinja.

Manifest reference

FieldTypeRequiredNotes
namestringyesHuman-readable identifier; also used in error messages.
kindaccessory | appnoDefaults 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).
descriptionstringnoOne-liner shown in the confirmation diff.
yoink_min_versionstringnoHard-fails if the running yoink is older. Loose semver; non-numeric suffixes (-rc1) are ignored.
variableslistnoSee Variables below.
fileslistyesAt least one. See Files below.
secretslistnoSee Secrets below.
include_globstringnoIf set, yoink offers to add this glob to the operator's yoink.yaml include: list when the rendered fragments aren't already covered.
notesstringnoMarkdown-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 mode
  • name is the identifier you reference in {{ … }} substitutions throughout the manifest and template files.
  • prompt is what the wizard shows. Falls back to name if absent. Keep it short.
  • default is what the wizard pre-fills, and what yoink add --yes uses without further input. Always provide a default; non-interactive runs fail loud when a variable has neither a default nor a --var override.
  • choices restricts the answer set. Wizard renders a numbered picker; CLI overrides via --var name=X validate against the list.
  • pattern is 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 --yes mode.

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.tmpl
  • dest is the path inside the operator's repo, relative to where their yoink.yaml lives. 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.
  • template is 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:48

Each 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: 1024

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

SymptomLikely causeOverride
Operation not permitted during chmod/chown of a volumecap_drop: [ALL] strips CHOWN/FOWNERcap_add: [CHOWN, DAC_OVERRIDE, FOWNER]
Operation not permitted during user-switch (gosu, su-exec, …)Caps stripped + user: nobody mismatchuser: "0:0" + cap_add: [SETGID, SETUID]
Read-only file system (os error 30) mid-requestApp writes to rootfs (logs, pidfiles, payload buffers)read_only: false
Permission denied writing to a volumeVolume owned by root, container running as nobodyuser: "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:

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 cached branch → SHA mapping 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-templates with subdirectories per template. Consumers use gh: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:

  1. Renders valid YAML: covered by the tests/add_integration.rs test that walks templates/*/template.yaml. The test must still pass after your addition.
  2. Real-host smoke test: at least one round-trip on a real Docker host: yoink inityoink add <yourname>yoink up → exercise the service end-to-end. Capture the output in the PR.
  3. Hardening overrides documented: if you needed user: "0:0" or cap_add: [...] or read_only: false, the comment in the service.yaml.tmpl should explain why (which entrypoint operation, which file). Future readers shouldn't have to re-derive it.
  4. secrets.recipients: reference works: if your template seals a secret, the rendered fragment's env_from_secrets: reference must point at the same name the manifest's secrets[].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>      # optional

For the rendered service fragment, see Config reference; that's the same shape an operator writes by hand in yoink.yaml.

See also

On this page