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 Removed in 0.3.0. A manifest carrying it is refused; see runtime (removed in 0.3.0).
runtimes map ✅ Which runtimes this release supports, keyed by runtime name, and the files each is declared with.
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.
tasks map Commands an operator runs by name with morzer task run.
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

runtimes

Which runtimes this release supports. The keys are the declaration: a release declaring one runtime installs only where that runtime is present, and one declaring several carries a set of files for each.

runtimes:
  compose:
    files: [compose.yaml]
    profiles:
      external-db: [compose.external-db.yaml]
Field Type Required Meaning
files list ✅ The files this runtime is declared with, release-relative.
profiles map Named deployment topologies; each is a list of additional files.
options map Settings this runtime understands and the manager does not. Keys are lower-case identifiers, values one short line.

One files field for every runtime, rather than a differently named key per runtime. What the files mean is the runtime's business — a Compose file and a systemd unit are both paths into the release from here — and a manifest whose shape changed per runtime could only be validated by asking which runtime it was, which is the coupling the manager does not have.

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.

Options

The manager carries options and never reads them. It checks the shape — a key is a lower-case identifier, a value is one line of at most 200 characters, because these are handed to a runtime that puts them in a command line — and the runtime refuses any key it does not understand. A mistyped option is an error, not a setting that quietly does nothing.

The compose runtime understands one:

Option Meaning
project The Compose project name: the namespace containers, networks and volumes are created in. Defaults to metadata.name.

Changing it renames every volume. Compose prefixes a named volume with the project, so project: alpha and project: beta are two different sets of storage. An installation records the options it was created with, and a release that changes them is refused rather than applied — the deployment would come up against volumes nothing has ever written to, with the real data still on the disk and nothing referring to it. If you mean it, the path is a backup, a fresh init and a restore.

What is compared is the value the runtime resolves, not the line you typed. Omitting project and setting it to your product's name are the same deployment, so a release that starts spelling out the default is applied rather than refused, and so is one that stops. Only a value that actually changes the namespace is a change. The runtime decides this, which is why an option a runtime does not understand is compared exactly as written — the manager has no way to know what it would have meant.

Options are per runtime because their meaning is. A project is Compose's idea of grouping; another runtime answers the same question differently or not at all, which is why this is a map the manager passes through rather than a field it defines.

Which runtime an installation uses is fixed when it is created and never transitions. The state directory records volume names and image references that belong to one runtime, so changing it is a migration of everything the manager knows rather than a setting. A release that does not declare the runtime an installation was created against is refused rather than run on a substitute.

runtime (removed in 0.3.0)

The original single-runtime block. It is no longer read. A manifest carrying it is refused, naming what to write instead — the manager will not install a release whose runtime declaration it cannot read, because doing so would bring the deployment up with no Compose files at all.

It was deprecated rather than removed at first, on the understanding that a release would exist in which both spellings worked. None did: runtimes shipped in 0.3.0 and no earlier manager ever read it, so 0.1.0 through 0.2.0 read only runtime: and 0.3.0 reads only runtimes:. There was no version that read both and so no migration window to preserve, which is why the removal came a release earlier than first announced.

Migrating. Move the files under runtimes.compose.files, move project to runtimes.compose.options.project, delete the old block, and raise compatibility.min_manager_version to 0.3.0. The old block's shape was:

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

The version floor is not optional bookkeeping: runtimes is an unknown field to anything older, and under strict decoding an unknown field refuses the whole manifest — so without the floor your customer gets a report about a typo instead of an upgrade requirement.

Do not drop project on the way. It is the namespace every volume, network and container of a running deployment lives in, so a release that loses it points existing installations at storage that does not exist. A project: left behind left behind beside runtimes: is refused with the rest of the old block, for the same reason, and a manager that has recorded what an installation was created with refuses the change as well.

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 operation name to how it runs. The manager runs four operations, migrate, smoke_test, backup and restore, and refuses a manifest that declares any other name, since an operation under a misspelled or invented name would never run.

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, for migrate only. smoke_test, backup and restore must be hooks.
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.

A runtime service starts the services it depends_on first, and waits for any condition it names, so a migration service can reach the database on a first install, before the deployment's other services have ever started. On an update, a dependency whose configuration changed is recreated at that point, so migrations run against the new release's database service. Exit 2 means "nothing to do", as it does for a hook. Any other non-zero exit fails the step, and the error quotes the last few lines the container printed, on either stream, because the container is removed when it exits and its full output is shown only under --verbose. It is told which operation is running it and which version that replaces, through the hook ABI variables a container can use.

Several migrate steps

migrate alone may list several steps instead of one. They run in order, on apply and on every update, after images are pulled and before services start:

compatibility:
  min_manager_version: "0.4.0"

operations:
  migrate:
    - kind: runtime-service
      service: flyway
      timeout: 15m
    - kind: runtime-service
      service: bootstrap

Every step is a migration as far as failure goes. If any of them fails, the ones before it have already changed the database, so the operation stops for manual intervention (exit 12) rather than rolling back. Write each step so that running it again is harmless: a step after a failed one runs again on the next attempt, and every step runs again on every apply.

A list needs min_manager_version: "0.4.0" or later, and a manifest with a list that declares less is refused. A manager older than 0.4.0 cannot read the list. The declaration is what makes it refuse the bundle as needing a newer manager, instead of reporting a malformed operation.

tasks

A map of task name to a command an operator runs on demand with morzer task run: filling a sandbox with demo data, unlocking a user, rebuilding a search index, printing a diagnostic report. Each runs as a one-shot container of a service from the release's runtime configuration, through the image's entrypoint, as an operation under the deployment lock.

compatibility:
  min_manager_version: "0.8.0"

tasks:
  seed:
    description: Fill an empty database with demo data
    service: backend
    command: [seed]
    args: true
    modes: [dev]
    timeout: 10m
Field Type Required Meaning
service string ✅ The service to run. One behind a Compose profile that is not active is run too, as compose run starts it when named.
command list ✅ The command, replacing the service's own; the first entry names the program. The image's entrypoint still runs, so a wrapper that loads *_FILE secrets does its work.
description string One line of at most 200 characters, with no control characters, shown by morzer task list.
args bool Whether an operator may append arguments after --. Default false, and arguments to a task without it are refused.
modes list The machine modes the task may run on: dev, production. Absent means every mode.
timeout duration Time budget. Default 10m.

Names are lowercase letters, digits and dashes, starting with a letter, so a name is typed on a command line without quoting.

modes is a refusal, not a warning. A task listing [dev] is refused on a production machine by name, before anything runs, and --force does not override it. A machine's mode is fixed when it is created.

A task runs whether or not the deployment is up: the container starts the services it depends_on first, as a migration service does. It gets the same hook ABI variables, with OPERATION_TYPE and PHASE set to task. Any non-zero exit fails it, 2 included; "nothing to do" belongs to migrations.

Tasks need min_manager_version: "0.8.0" or later, and a manifest declaring tasks with less is refused. A manager older than 0.8.0 cannot read tasks:, and the declaration makes it refuse the bundle as needing a newer manager instead of reporting an unknown field.

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.

For a release that migrates with a runtime-service step, it is also the schema the manager records. A container cannot report the schema it leaves, so when a migration service succeeds and no step reported one, the declared value is recorded as what the database is at. It is how rollback, update and the unattended gate learn the schema at all; without it they skip their schema checks, and release verify warns. A schema a hook reports always wins over the declaration.

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. It does at init, and at the update whose release first declares the secret.
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.
owner string Numeric uid, or uid:gid, the rendered file is given to. Absent, the file is the manager's. See below.

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.

owner

A Compose file secret is a bind mount of the rendered file as it is. Compose ignores uid, gid and mode on a file secret, so the file's owner on the host decides who can read it inside the container. Every file is 0400, so that is its owner and nobody else. A service whose image runs as a user other than root reads its secret only when the declaration gives the file to that user:

secrets:
  - name: backend_key
    generator: {kind: hex, length: 64}
    owner: "65532:65532"
    services: [backend]
  • Numeric, and quoted. One or two decimal ids, each from 0 to 4294967294, as a string. A name is refused: it would resolve through the host's users, and the user that reads the file is the one in the image.
  • One owner per secret. Two services running as different users need two declarations with the same value.
  • Without a gid, the group is left as the manager's. The file is 0400, so its group cannot read it either way.
  • Applied on every render: apply, an update and its restore, secret render, and the re-render after secret set, generate, rotate and edit. A release that drops owner renders the file back to the manager.
  • A manager that cannot give the file away refuses to render it, exit 6, naming the secret. It never falls back to a file it owns, which would fail later as Permission denied inside the container. The manager runs as root wherever it installs units, and only root can give a file to another user.
  • A manager on macOS refuses every owner. What Docker Desktop shows a container for a file given to another user has not been measured yet (RFC 0029).

A schema that declares owner needs the manifest to declare compatibility.min_manager_version: "0.6.0" or later. An older manager then says it is too old, instead of calling owner an unknown field.


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}

runtimes:
  compose:
    options:
      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]