Kheeper

Configuring Images

Configurable images let you build one container image and customize it per host at deploy time. Instead of baking configuration into each image, you include template files that are rendered with host-specific values when you create a release.

Concepts

  • Template files (*.khtmpl) — files with Go template syntax that are replaced with rendered files when configured for a host
  • Annotation (kheeper.configurable=1) - this annotation must be set when you build the image to signal to the registry that this image can be configured for a host
  • Schema (schema.json) — an optional JSON Schema that validates configuration values

When you create a release for a host, you provide a JSON config file. The templates are rendered with your config values and the result is encrypted with the host's public key and injected as the top layer of the image the host will pull.

Creating a configurable image

1. Add template files

Any file with a .khtmpl extension is treated as a template. Templates use Go's text/template syntax. When rendered, the .khtmpl suffix is stripped from the filename.

For example, /etc/caddy/Caddyfile.khtmpl in the image becomes /etc/caddy/Caddyfile on the host after rendering.

Caddyfile.khtmpl:

:{{ .Port }} {
    respond "Hello from {{ .Hostname }}"
}

Templates have access to all keys in your config JSON. If your config file is:

{
  "Port": 8080,
  "Hostname": "web-01"
}

The rendered file will be:

:8080 {
    respond "Hello from web-01"
}

2. Add a schema (optional)

Place a JSON Schema file at /etc/kheeper/schema.json in the image. The schema validates configuration values when creating a release, catching errors early.

schema.json:

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "type": "object",
  "properties": {
    "Port": {
      "type": "integer",
      "minimum": 1,
      "maximum": 65535
    },
    "Hostname": {
      "type": "string"
    }
  },
  "required": ["Port", "Hostname"],
  "additionalProperties": false
}

2b. Add a layered schema (optional)

If your image is built FROM another configurable image, both images can contribute their own schemas. Drop fragments at:

  • /etc/kheeper/schema.d/<name>.json — adds a sub-object named <name> to the merged schema's properties.
  • /etc/kheeper/starter.d/<name>.json — adds a sub-object named <name> to the merged starter.

The registry merges all fragments (across all layers) into one canonical schema.json and starter.json at push time. Each fragment is wrapped under its filename's basename. Pick a <name> that identifies your contribution (e.g. base, metrics, kheeper-ssh).

Example. A base image ships:

// /etc/kheeper/schema.d/base.json
{
  "type": "object",
  "properties": { "SSHKey": { "type": "string" } },
  "required": ["SSHKey"]
}

A leaf image ships its own top-level schema:

// /etc/kheeper/schema.json
{
  "type": "object",
  "properties": { "Port": { "type": "integer" } },
  "required": ["Port"]
}

The merged schema (what kheeper releases create validates against) is:

{
  "type": "object",
  "properties": {
    "Port":  { "type": "integer" },
    "base":  { "type": "object", "properties": { "SSHKey": { "type": "string" } }, "required": ["SSHKey"] }
  },
  "required": ["Port", "base"]
}

User config now looks like:

{
  "Port": 8080,
  "base": { "SSHKey": "ssh-ed25519 AAAA..." }
}

Templates in the base reference {{ .base.SSHKey }}; templates in the leaf reference {{ .Port }} directly.

Rules:

  • Each schema.d/<name>.json must validate as a JSON Schema. Each starter.d/<name>.json must parse as a JSON object.
  • Fragment basenames must not collide with top-level property names in schema.json (or top-level keys in starter.json).
  • Two layers writing the same fragment file with different contents fails the push. To intentionally replace a base's fragment, RUN rm /etc/kheeper/schema.d/<name>.json in your leaf Containerfile before copying the new one.

3. Build and push

Mark the image as configurable with the kheeper.configurable=1 annotation:

podman build --annotation kheeper.configurable=1 -t us.kheeper.com/myorg/webapp:v1 .
kheeper push myorg/webapp:v1

Verify the image was recognized as configurable:

kheeper images get myorg/webapp:v1

The output should include a ConfigImage field.

Example Containerfile

FROM quay.io/fedora/fedora-bootc:44

RUN dnf -y install caddy
RUN systemctl enable cadddy.service

# Caddy stores ACME certs under its data dir, but the caddy RPM ships no
# tmpfiles.d rule for it. On a bootc host /var is reconstructed from
# tmpfiles.d on each boot, so without this rule /var/lib/caddy never exists
# and Caddy fails TLS with "mkdir /var/lib/caddy: permission denied".
RUN printf 'd /var/lib/caddy 0750 caddy caddy - -\n' > /usr/lib/tmpfiles.d/caddy.conf

COPY Caddyfile /etc/caddy/Caddyfile.khtmpl
COPY schema.json /etc/kheeper/schema.json

RUN bootc container lint

Creating releases

Once your configurable image is pushed, create releases for individual hosts:

# Generate a starter config from the image's schema, then edit it
kheeper releases start config.json --image us.kheeper.com/myorg/webapp:v1

# Create the release
kheeper releases create myorg/web-01:v1 \
  --image us.kheeper.com/myorg/webapp:v1 \
  --config-file config.json

If the image has a schema, the config is validated before the release is created.

Activating releases

After creating a release, activate it to tell the host which version to run:

kheeper hosts activate myorg/web-01:v1

The host polls for changes and applies the new release automatically.

Versioning and rollback

Each release is tagged (e.g. v1, v2). To roll back, activate a previous tag:

kheeper hosts activate myorg/web-01:v1

You can create multiple releases from different images or different configs:

# New config
kheeper releases create myorg/web-01:v2 \
  --image us.kheeper.com/myorg/webapp:v1 \
  --config-file updated-config.json

# Or new image version
kheeper releases create myorg/web-01:v3 \
  --image us.kheeper.com/myorg/webapp:v2 \
  --config-file config.json

List all releases for a host:

kheeper releases list myorg/web-01

Template syntax reference

Templates use Go's text/template package. Common patterns:

# Simple value substitution
{{ .Key }}

# Default values
{{ if .Key }}{{ .Key }}{{ else }}default{{ end }}

# Conditionals
{{ if eq .Env "production" }}
workers = 16
{{ else }}
workers = 2
{{ end }}

# Iterating over lists
{{ range .Servers }}
upstream {{ . }};
{{ end }}

See the Go text/template documentation for the full syntax.

Security

Configuration is encrypted with the host's ECDSA P-256 public key before being uploaded to the registry. Only the host can decrypt its own configuration using its private key. The Kheeper registry never has access to decrypted configuration values.