Skip to content

Your first bundle

Start with a skeleton that already verifies:

morzer release new ./my-product --vendor example
morzer release verify ./my-product

It writes a bundle that passes with no edits, carrying the conventions this page explains — the schema modeline, templates named .yaml.tmpl, the secret schema outside templates/. It deploys nothing useful, deliberately: a generated bundle that guessed your architecture would be work to un-write. Everything below explains what it wrote and what to change.

Every fragment on this page is extracted from testdata/bundle/ at build time. That bundle is installed, updated, backed up, restored and rolled back against real Docker on every CI run, so what you are reading is what passes.

The manifest

Start with identity and the runtime:

manifest.yaml
# yaml-language-server: $schema=https://morzecrew.github.io/morzer/schemas/selfhost-v1alpha1-manifest.json
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

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

That first line is worth typing. It is a comment, so a manifest without it behaves identically and an editor that ignores it loses nothing — but every editor that speaks the language-server protocol reads it and gives you completion, hover documentation and inline validation against the schema this project generates from the types that enforce the manifest. There is an equivalent line for the secret schema, pointing at selfhost-v1alpha1-secrets.json.

It would have caught the unquoted-mode trap that decoded to the wrong permission for months.

metadata.name is load-bearing: it becomes /etc/demo, /var/lib/demo, /run/demo, /opt/demo and the DEMO_* prefix every hook sees. It is validated as a path component, not merely as a string, because it comes from your file and lands in the operator's filesystem.

Profiles are how one bundle serves "database included" and "bring your own database" without two bundles. Each names additional Compose files layered over the base.

Images

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

Digest-pinned, always. The manager exports each as DEMO_IMAGE_APP, DEMO_IMAGE_DB and so on, which is how your Compose file gets them:

compose/compose.yaml
services:
  app:
    image: ${DEMO_IMAGE_APP:-registry.example/demo/app@sha256:…}

Write it that way. Hardcoding the digest in both places means two things to keep in agreement, and the manifest is the one the manager verifies.

Requirements

requirements:
  architectures: [amd64, arm64]
  os:
    - {id: ubuntu, version: ">=22.04"}
    - {id: debian, version: ">=12"}
  tools:
    docker: ">=24"
    compose: ">=2.30"
  memory: 2GiB
  disk: 5GiB
  ports: [18080]

Checked in preflight, before anything is written, and reported by doctor. Be honest here: a requirement you did not declare is a support ticket, and one you declared too tightly is an installation that refuses for no reason.

Declaring ports is what lets the manager tell an operator their port is taken before the containers fight over it.

Configuration templates

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

The .tmpl suffix is a convention, not a requirement — the manifest names the path, so morzer has no business dictating filenames it was handed the location of. It is worth following: a Go template named .yaml makes every editor's YAML language server parse {{- range .Domains }} and report errors that are not errors. The double extension keeps the output language inferable, and it is what Helm, Hugo and envsubst users already recognise. Associate *.yaml.tmpl with Go templates in your editor and the noise stops.

Rendered with the installation's own facts. Secrets appear as paths, never as values — there is a secretFile helper for exactly this:

templates/application.yaml.tmpl
secrets:
  db_password_file: {{ secretFile .Secrets "db_password" }}
  session_key_file: {{ secretFile .Secrets "session_key" }}

A rendered config in /etc that contained a credential would be a credential in every backup, every support bundle and every cat an operator ever runs.

Compatibility

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"

The four declarations that make an update decidable without running it:

  • upgrade_from — which installed versions this may be installed over.
  • database_schema_min/max — which schemas this release can read. The manager knows the running schema because your migrate hook reported it.
  • rollback_safe — whether returning to the previous release is safe.
  • min_manager_version — the oldest manager that may install this.

Set rollback_safe: false when your migrations are one-way. The manager will refuse the rollback and name the pre-update backup instead, which is the honest answer and the one your users need.

The secret schema

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]

It sits at the bundle root rather than under templates/, because it is not a template: the manager reads it, nothing renders it. Like the suffix above this is a convention — secrets.schema in the manifest names wherever you put it.

Declaring a generator means the manager can create the value itself at init and rotate it on request. Declaring services means a rotation restarts only those, rather than the whole product. Declaring rotation_period means doctor reminds an operator — advisory, and never a failure.

Validate as you go

morzer release verify ./my-product

Reports every violation in one pass rather than the first, with the line and column from your YAML. Run it in your own CI; it needs no installation on the machine. It parses every template you declare, so an unterminated action fails here rather than during someone else's apply.

Once your templates reference secrets and parameters, add the render pass:

morzer release verify ./my-product --render-check

It renders each template against invented values, which catches the mistake parsing cannot see — {{ secretFile .Secrets "db_passwrod" }} is valid syntax and a broken deployment. It is a smoke test rather than a promise about a customer's machine; what it does and does not check is worth reading once before you rely on it.

There is also a JSON Schema for editor completion — see Validating a manifest.

The complete manifest

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