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:
# 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:
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:
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¶
# 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¶
# 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