Skip to content

Release manifest

manifest.yaml is the release contract: everything the manager needs to know about a product, declared by the vendor rather than configured by the operator.

release/
├── manifest.yaml       api_version: selfhost/v1alpha1
├── compose/            service topology
├── templates/          configuration + the secret schema
├── hooks/              migrate · smoke-test · backup · restore · check-db
└── VERSION

The schema is versioned independently of the manager. morzer version --json reports which api_version values a given binary can read; a previously published version stays readable until it is explicitly deprecated.

Unknown fields are an error

Not a warning, and not silently ignored. A typo must not fall back to a default — the failure would surface as behaviour nobody asked for, days later. Vendor-specific keys go under extensions.<namespace>.

Top level

Field Type Required Meaning
api_version string Manifest schema version. selfhost/v1alpha1 is the only one currently supported.
kind string Must be application-release.
metadata table Identity of the release.
providers table Which port implementation to use for each capability.
runtime table The Compose project and its files.
requirements table What the machine must provide.
parameters map Knobs an operator may set, typed and defaulted. See Parameters.
images map Container images, pinned by digest.
configuration list Templates rendered to absolute paths on the host.
secrets table Where the encrypted state lives and where it renders.
operations map Named lifecycle operations: migrate, smoke test, backup, restore.
backup table What the manager may do about the project's volumes.
bundle table What the release says about its own packaging, as distinct from what it needs of the host.
health table How to tell whether the product is working.
compatibility table What this release can be installed over, and rolled back from.
retention table How many releases and backups to keep.
extensions map Namespaced vendor data the manager passes through untouched.

metadata

Field Type Required Meaning
name string Product name. Drives /etc/<name>/, /var/lib/<name>/, /run/<name>/, /opt/<name>/ and the hook environment prefix.
version semver Release version.
description string One line, shown by release show.
vendor string Who publishes this.
release_notes path Bundle-relative path to what changed in this release, conventionally RELEASE.md.
support_url https URL Where an operator goes when something is wrong.

release_notes is declared, not discovered. Every other path a bundle ships is declared and existence-checked, and a declared-but-missing file fails release verify; there is deliberately no fallback to looking for a RELEASE.md that nothing points at, because a convention layered under a declaration reintroduces the ambiguity the declaration removes.

support_url must be https. It is shown by status and by doctor when a check fails — not appended to every error hint, which would put a vendor URL in every log line. The operator of a self-hosted product is usually not its vendor, and "where do I get help" otherwise has no home.

providers

Each entry selects a port implementation by name, with an optional version constraint. New capabilities arrive as new provider names, never as changes to the core.

Field Default Accepted
runtime compose compose
secrets sops-age sops-age
backup hooks hooks
health http

runtime

Field Type Required Meaning
project string Compose project name. Defaults to metadata.name.
files list Base Compose files, release-relative.
profiles map Named deployment topologies; each is a list of additional Compose files.

An unknown profile is an error rather than a silent fall back to the base files. Deploying the wrong topology quietly is worse than refusing.

requirements

Checked in preflight and reported by doctor.

Field Type Meaning
architectures list Accepted machine architectures, e.g. [amd64, arm64].
os list Accepted distributions. Each entry has an id and a version constraint.
tools map Version constraints for external tools, e.g. docker: ">=24".
memory size Minimum RAM, e.g. 2GiB.
disk size Minimum free disk, e.g. 5GiB.
cpus int Logical CPUs the release wants. A cgroup quota is honoured where one is in force, so a containerised manager sees what it may actually use rather than what the host has.
ports list TCP ports the release binds, checked for conflicts. A literal number, or "{{ .Parameters.<name> }}" so the check follows the port the deployment actually publishes.

bundle

What the release says about its own packaging, as distinct from what it needs of the host.

Field Type Meaning
uncompressed_size size What the archive expands to, e.g. 12GiB. Raises the extraction ceiling for a bundle carrying container images.

A separate block from requirements on purpose: everything there describes the host, and this describes the artefact.

It can only ever lower the limit

This value is read out of the tar stream before the signature is checked, so it is attacker-controlled input in the strictest sense. The effective limit is min(declared, hard cap) — a declaration is a request for a smaller budget than the manager allows, never permission to exceed it. A bundle needing more than the cap is refused, and raising the cap is a change to morzer rather than something a bundle can ask for.

Omitting it means the default ceiling, not "unbounded". A missing field must never be the permissive reading of anything that gates untrusted bytes.

parameters

What an operator may change, and nothing else. Each entry declares a type, an optional default, and — for an enum — the values it accepts.

parameters:
  http_port:
    type: port
    default: 18080
    description: Port the application is published on
    services: [app]
  log_level:
    type: enum
    values: [debug, info, warn, error]
    default: info
    services: [app]
Field Type Required Meaning
type string One of port, int, bool, string, enum, duration, bytes.
default string Used when the operator sets nothing. Validated against type at release verify.
required bool The operator must supply a value. Declaring it alongside a default is a validation error.
description string Shown when a value is refused, so the operator learns what is accepted.
values list The permitted set. Required for enum, an error for anything else.
services list Services to restart when the value changes. Empty means the change needs a full apply.

A declared parameter reaches three places, all under the same name:

Consumer Form
Compose files <PRODUCT>_PARAM_<NAME>
Configuration templates .Parameters.<name>
Hooks <PRODUCT>_PARAM_<NAME>

Every declared parameter is always exported, holding its default when the operator has set nothing — so a Compose file's :- fallback is belt-and-braces rather than the actual source of the value.

Parameters are not secrets

A parameter's value is visible in docker inspect, in status --json, in the journal, and in installation.yaml in the clear. Anything that must not be is a secret, which has its own declared, audited path and is rendered to tmpfs as a file.

required

Without it, a parameter with no default resolves silently to the empty string — so a vendor could declare a knob that exists to be an operator choice and had no way to say the choice was not optional, while a secret could be required all along.

required: true and a default is refused. It is a vendor saying two contradictory things, and picking a winner silently would leave them believing they had made the choice mandatory.

An unset required parameter fails preflight, so it is refused by init, by config unset, and by apply. That last one is deliberate: a release that introduces a required parameter fails to deploy rather than deploying with an empty value the product then misreads, and the currently running release keeps serving. Set the value first:

morzer config set admin_email=ops@example
morzer update ./demo-1.4.0.tar.zst

See Parameters for the operator's side.

images

A map of logical name to image reference. Every reference must be pinned by digest:

images:
  app: registry.example/demo/app@sha256:0000…0001

A bare tag is rejected. An unpinned image makes a release mutable, and a mutable release makes rollback meaningless — the same version could produce a different system on a different day.

The key is lowercase letters, digits and hyphens, starting and ending with a letter or digit — the same rule parameters carry, and for the same reason: it becomes the tail of <PRODUCT>_IMAGE_<NAME>, which your Compose file interpolates. Dots and underscores are refused because the variable name folds both to _, so web-ui and web.ui would name the same variable and one pinned reference would silently overwrite the other.

Where the bytes come from

An entry may instead be a mapping, which adds one field:

images:
  db: postgres@sha256:0000…0002              # pulled, as above
  app:
    ref: registry.example/demo/app@sha256:0000…0001
    from: bundle                             # travels inside the bundle
Field Type Meaning
ref string The image reference, pinned by digest. Same rule as the scalar spelling.
from string registry (default) or bundle.

Both spellings exist because most images are never bundled, and making every one of them carry a from: to say so is noise in the file you read most.

from: bundle means the image travels in the bundle as an OCI layout under images/, covered by the same SHA256SUMS and signature as every other file — so a customer installs a release containing your private images without ever holding credentials for the registry they came from. Per-image, so a release bundles what is private and keeps pulling postgres from Docker Hub.

ref stays a real image reference in both spellings. It is interpolated into Compose as <PRODUCT>_IMAGE_<NAME>, so it must remain something the daemon can resolve — which is why the source is a separate field rather than a scheme on the reference the way update references are spelled.

An unrecognised from is refused rather than defaulted. The two plausible typos — bundled, and from under the wrong image — both fail towards a release you believe ships its own bytes and does not, which surfaces as a credential failure on your customer's machine.

See bundled images for the layout, what release verify checks, and how to produce one.

configuration

A list of templates rendered onto the host.

Field Type Required Meaning
template path Release-relative template file.
target path Absolute destination on the host.
mode quoted octal File mode. Default "0640".

Rendered files contain paths to secrets, never secret values.

The quotes on mode are load-bearing. YAML reads an unquoted 0640 as the decimal number 416, which is the permission 0416 — owner read-only, group execute-only, other read/write. Unquoted modes are refused rather than applied, so a manifest that gets this wrong fails to load instead of installing a file with permissions nobody chose.

secrets

Field Type Meaning
source path Absolute path to the encrypted state, e.g. /etc/demo/secrets.sops.yaml.
render_to path Absolute tmpfs directory the decrypted values are written to.
schema path Release-relative secret schema.

operations

A map of well-known name to how it runs. The manager looks up migrate, smoke_test, backup, restore and preflight by convention.

Field Type Meaning
kind enum hook — an executable in the release, run under the hook ABI; or runtime-service — a one-shot container from the Compose project.
command list For hook: argv, release-relative. Not allowed for runtime-service.
service string For runtime-service: the Compose service to run. Not allowed for hook.
timeout duration Time budget. Default 10m.

The two kinds are not collapsed into one field because their failure semantics differ: a hook runs on the host and sees the host's filesystem, a runtime service runs on the application network and sees the container's.

backup

What the manager may do about the project's named volumes, which it captures itself rather than through the backup hook.

Field Type Meaning
volumes map Keyed by the volume's name in the Compose volumes: block.

Each entry takes one field, and it is required — an entry that names a volume without saying anything about it is refused at load. Leaving the volume out of the map entirely is how you ask for the default:

Field Type Required Meaning
consistency enum cold, hot or exclude.
Value Meaning
cold The services mounting the volume are stopped for the copy. The default, and correct for every volume.
hot The vendor claims a copy taken while the product runs is usable. True for write-once files, false for anything with a write-ahead log.
exclude The manager never reads or writes this volume. Expected for a database's storage, which the backup hook owns.
backup:
  volumes:
    uploads:    { consistency: hot }
    caddy_data: { consistency: hot }
    pgdata:     { consistency: exclude }

The map is partial: declare only the volumes that need something other than the default of cold. A volume named here that the project does not declare is ignored by the backup, and reported by morzer doctor — a typo in this map otherwise does nothing at all, which for an intended exclude means the manager captures a database volume the hook already owns.

hot is a claim about the product, not a performance hint — see declaring volume consistency for what it commits a vendor to. Bind mounts are never captured and have no declaration.

health

checks is a list; each entry has a unique name, a type, a timeout (default 120s) and an optional start_period.

type Additional field Meaning
http url Considered healthy on a 2xx response.
tcp address Considered healthy when the connection is accepted.
command command Considered healthy on exit 0. Release-relative argv.

timeout bounds a single attempt. start_period is different: it is how long the check may keep failing before the failure means anything.

health:
  checks:
    - name: api
      type: http
      url: http://127.0.0.1:18080/health
      timeout: 5s
      start_period: 90s

Without it, a product with a ninety-second first boot and a product that is dead are the same observation, and the only lever is a timeout long enough to delay noticing the second. With it, apply stops waiting once every still-failing check has outlived its own declared period, and says so in different words from a plain timeout — "the vendor said how long this takes and it took longer" is acted on differently from "we ran out of time".

Omitted means the waiter keeps trying for as long as the operation allows, which is what it has always done. A check that declares none also holds the wait open for the checks beside it, so adding start_period to one check never shortens the wait for another.

compatibility

The declarations that make update and rollback decidable without running anything.

Field Type Meaning
database_schema_min int Oldest schema this release can read.
database_schema_max int Newest schema this release can read.
database_schema_produces int What this release's migrations leave the database at. Optional; see below.
rollback_safe bool Whether this release's migrations can be undone by returning to the previous one.
min_manager_version semver Oldest manager that may install this release.
upgrade_from constraint Which currently-installed versions this may be installed over, e.g. ">=1.0.0 <2.0.0".

rollback_safe: false is how a vendor says "going back needs a restore". The manager takes it literally and refuses the rollback, naming the backup instead.

database_schema_produces is what opts a release into unattended updates

compatibility:
  rollback_safe: true
  database_schema_min: 12
  database_schema_max: 14
  database_schema_produces: 14   # what my migrations leave the database at

The other three fields describe what a release can read. This one states what it writes, and it exists so the manager can answer a question before an update rather than after one: if this release is installed and then has to be rolled back, would the previous release still understand the database?

Omitting it is a valid and conservative choice. A release that does not declare it is never installed unattended — the manager will fetch it, verify it, stage it and notify the operator, and stop there. If you do not want customers running your releases without a human present, declare nothing and nothing changes for you.

Declaring it is not sufficient on its own. See unattended updates for the whole gate, and for what the promise is and is not.

retention

Field Type Default Meaning
releases int 3 Non-active releases kept in the store. Must be at least 1.
backups int 7 Backups kept. Must be at least 1.

extensions

Namespaced free-form data, passed through untouched:

extensions:
  example.com/telemetry:
    endpoint: https://telemetry.example/ingest

The namespace must contain a . or a /, so a typo'd core field cannot hide inside a vendor block.


The secret schema

The document secrets.schema names — secrets.schema.yaml at the bundle root by convention — declares what secrets exist, so init can provision them and doctor can audit them without the manager knowing anything about the product.

Field Type Meaning
api_version string Same version vocabulary as the manifest.
secrets list The declarations below.

Each declaration:

Field Type Meaning
name string Secret name, as morzer secret refers to it.
description string Shown by secret list.
required bool Whether doctor reports its absence as a failure.
generator table How the manager may produce it: kind, length, alphabet.
file string Filename under the render directory. Defaults to name.
services list Compose services that consume it. A rotation restarts exactly these.
rotation_period duration Advisory; doctor warns when a secret is older.

Generator kind is one of password, hex, base64, uuid, age-key, or absent — meaning the operator must supply the value.

The default password alphabet excludes characters that are ambiguous read aloud or that need shell quoting. Secrets get copied by humans more often than anyone plans for.


Validating a manifest

A JSON Schema for selfhost/v1alpha1 ships with every release, generated from the Go types that enforce the contract rather than written alongside them:

schemas/selfhost-v1alpha1-manifest.json
schemas/selfhost-v1alpha1-secrets.json

Point an editor at it for completion and typo-catching, or validate in CI without installing the manager. It is regenerated by just schemas, and a test fails the build when the checked-in copy no longer matches the types — two descriptions of one contract disagree eventually, and the one a vendor validates against is usually the more permissive.

The schema describes shape, not rules. Images must be pinned by digest, paths may not escape the release root, an unknown field is an error — none of that is expressible here, and all of it is enforced by the loader. The schema catches a typo in an editor; morzer release verify catches everything:

morzer release verify ./bundle

A complete example

This is testdata/bundle/manifest.yaml, included from the repository rather than transcribed: the manifest loader, the step engine and the contract suites all run against it, so it cannot drift from what the manager actually accepts.

testdata/bundle/manifest.yaml
# yaml-language-server: $schema=https://morzecrew.github.io/morzer/schemas/selfhost-v1alpha1-manifest.json
# Example release bundle exercised by the test suite.
#
# It is a real, valid bundle: the manifest loader, the step engine and the
# contract suites all run against it, so a change that breaks the release
# contract fails a test rather than a deployment.
api_version: selfhost/v1alpha1
kind: application-release

metadata:
  name: demo
  version: 1.2.0
  description: Demo self-hosted bundle used by the test suite
  vendor: example
  release_notes: RELEASE.md
  support_url: https://support.example/demo

providers:
  runtime: {name: compose, version: ">=2.30"}
  secrets: {name: sops-age}
  backup: {name: hooks}
  health: {name: http}

runtime:
  project: demo
  files:
    - compose/compose.yaml
  profiles:
    embedded: [compose/compose.embedded.yaml]
    external-db: [compose/compose.external-db.yaml]

requirements:
  architectures: [amd64, arm64]
  os:
    - {id: ubuntu, version: ">=22.04"}
    - {id: debian, version: ">=12"}
  tools:
    docker: ">=24"
    compose: ">=2.30"
  memory: 2GiB
  disk: 5GiB
  cpus: 1
  # Follows the parameter rather than restating it: preflight has to check the
  # port this deployment will actually publish, not the one the vendor chose.
  ports: ["{{ .Parameters.http_port }}"]

# What an operator may change, and nothing else. Each reaches Compose as
# <PRODUCT>_PARAM_<NAME>, templates as .Parameters.<name>, and hooks the same
# way as Compose. Parameters are not secrets: their values are visible in
# `docker inspect` and in `status --json`.
parameters:
  http_port:
    type: port
    default: 18080
    description: Port the application is published on
    services: [app]
  log_level:
    type: enum
    values: [debug, info, warn, error]
    default: info
    description: Application log verbosity
    services: [app]

images:
  app: registry.example/demo/app@sha256:0000000000000000000000000000000000000000000000000000000000000001
  db: registry.example/demo/db@sha256:0000000000000000000000000000000000000000000000000000000000000002

configuration:
  - template: templates/application.yaml.tmpl
    target: /etc/demo/application.yaml
    mode: "0640"

secrets:
  source: /etc/demo/secrets.sops.yaml
  render_to: /run/demo/secrets
  schema: secrets.schema.yaml

operations:
  migrate:
    kind: hook
    command: ["hooks/migrate"]
    timeout: 10m
  smoke_test:
    kind: hook
    command: ["hooks/smoke-test"]
    timeout: 5m
  backup:
    kind: hook
    command: ["hooks/backup"]
    timeout: 60m
  restore:
    kind: hook
    command: ["hooks/restore"]
    timeout: 120m

# What the manager may do about this project's named volumes, which it captures
# itself rather than through the backup hook above.
#
# A volume left out of this map is captured *cold*: the services that mount it
# are stopped for the copy. That is correct for every volume and slow for some,
# so a vendor declares only where they want something else.
#
# `consistency: hot` is a claim that a copy taken while the product is running
# is a usable one -- true for files written once and never modified, false for
# anything with a write-ahead log. It is the vendor's claim, not the manager's
# guess, and every backup manifest records which one applied.
backup:
  volumes:
    uploads: {consistency: hot}

health:
  checks:
    # Follows the parameter too. A probe on a port the deployment does not
    # publish is an `apply` that fails at "wait for health" on a system that
    # is working.
    - {name: api, type: http, url: "http://127.0.0.1:{{ .Parameters.http_port }}/health/ready", timeout: 30s}
    - {name: db, type: command, command: ["hooks/check-db"], timeout: 15s}

compatibility:
  database_schema_min: 10
  database_schema_max: 12
  rollback_safe: true
  min_manager_version: "0.0.0"
  upgrade_from: ">=1.0.0 <2.0.0"

retention:
  releases: 3
  backups: 7

extensions:
  example.com/telemetry:
    endpoint: https://telemetry.example/ingest

And its secret schema:

testdata/bundle/secrets.schema.yaml
# yaml-language-server: $schema=https://morzecrew.github.io/morzer/schemas/selfhost-v1alpha1-secrets.json
# Declares what secrets exist, so `init` can provision them and `doctor` can
# audit them without the manager knowing anything about the product.
api_version: selfhost/v1alpha1

secrets:
  - name: db_password
    description: PostgreSQL password for the application role
    required: true
    generator:
      kind: password
      length: 32
    services: [app, db]
    rotation_period: 2160h

  - name: session_key
    description: Key used to sign session cookies
    required: true
    generator:
      kind: hex
      length: 64
    services: [app]

  - name: smtp_password
    description: Password for outbound mail; optional
    required: false
    services: [app]