Skip to main content

Manifest reference

Every resource Miabi can manage declaratively, and every field its spec accepts. One schema drives all four consumers: the apply API, GitOps reconciliation, the miabi CLI, and the Terraform provider.

Manifests are strictly parsed — an unknown key is an error, not a silently ignored typo.

Document shape

Every document has the same four top-level keys:

apiVersion: miabi.io/v1     # the only accepted value
kind: Application # see the kinds below
metadata:
name: web # identity, unique per kind within the workspace
spec: {} # kind-specific

A file may hold many documents separated by ---, and a Git source may spread them over many files in a directory — they are all parsed into one bundle.

The workspace is not in the manifest. It comes from the target you apply to, so the same file deploys to staging and production unchanged.

metadata

FieldTypeDescription
namestringRequired. Lowercase [a-z0-9-], starting alphanumeric. For a Domain it is a real hostname instead (shop.example.com).
uidstringThe resource's portable Miabi uid. Written on export; matched ahead of name, so renaming a resource in the manifest updates it instead of replacing it. Omit in hand-written manifests.
labelsmapShort identifying key/values for selection and grouping. Keys and values follow the Kubernetes rules (optional prefix/, max 63 chars). Reserved miabi.io/ keys are stripped.
annotationsmapFree-form descriptive metadata — owners, links, tooling hints. Keys are validated; values are arbitrary text.

Kinds at a glance

KindWhat it isApplied
ApplicationA long-running container workload3rd
StackGroups applications into one unit and network2nd
DatabaseA managed Postgres / MySQL / MariaDB / Redis database2nd
VolumePersistent storage1st
SecretA named encrypted value1st
ConfigA set of configuration files mounted into apps1st
RegistryA container-registry credential for private images2nd
RouteAn HTTP routing rule (host/path → app:port + TLS)4th
DomainAn owned hostname and its default TLS policy1st
ProjectBundles the resources above into one unit

Ordering is automatic: dependencies are created before their dependants and torn down after them, so a bundle that creates a database and the app using it converges in a single apply.


Application

A long-running container workload.

apiVersion: miabi.io/v1
kind: Application
metadata:
name: web
spec:
image: ghcr.io/acme/web
tag: "1.4.0"
digest: sha256:# immutable pin; wins over tag
registry: ghcr # credential for a private image
command: ["server", "--port=8080"]
stack: shop # join a Stack (must be declared in the bundle)
externalLabel: shop # pins the public URL to shop.<base-domain>
ports:
- container: 8080
scheme: http # http | https (default http) — how the proxy talks to it
protocol: tcp # tcp | udp (default tcp)
externalAccess: true # public HTTPS URL through the reverse proxy
- container: 9090
publish: true # bind to a host port, like `docker -p`
hostPort: 19090 # omit or 0 to auto-allocate
env:
APP_ENV: production
DATABASE_URL: "{{ .databases.shop-db.uri }}"
secretEnv: # env keys stored encrypted at rest
- DATABASE_URL
mounts:
- volume: web-data # must be a Volume in the same bundle
path: /data
readOnly: false
- config: web-conf # …or a Config — exactly one of volume/config
key: nginx.conf # one file; omit to project the whole set under path
path: /etc/nginx/nginx.conf
mode: "0444"
reloadPolicy: restart # restart (default) | none — on a mounted config's change
runAsUser: "1000:1000" # account the container runs as; omit to keep the image's
resources:
memory: 512Mi # Ki/Mi/Gi; empty = unlimited
cpu: "0.5" # cores; empty = unlimited
gpu: 1 # whole GPU devices
gpuKind: nvidia # narrow to a vendor/model
containerLabels: # stamped on the container for label-reading tools
traefik.enable: "true"
FieldNotes
imageRequired. Repository without a tag, e.g. ghcr.io/acme/web.
tagDefaults to latest when composing the pull reference.
digestA sha256:… pin. CI writes it; GitOps converges the runtime to it.
registryNames a Registry credential. It need not be declared in the same bundle — an undeclared name resolves against the workspace's existing credentials. An unknown name is an error, not a silent anonymous pull.
commandOverrides the image's command (argv form).
stackMust name a Stack in the same bundle. Members share a network and resolve each other by name.
externalLabelPins the external-access subdomain. Platform-wide unique: if taken, it is ignored and a generated label is used — the apply still succeeds.
portsSee port exposure.
env / secretEnvEvery secretEnv key must also appear in env. Values support interpolation.
mountsExactly one of volume or config, and both must be declared in the same bundle. key and mode are valid only with a config — setting them on a volume mount is an error, not a silent no-op. A config mount is always read-only. Privileged host binds are not manifest-expressible.
reloadPolicyrestart (default) redeploys the app when a mounted Config's content changes; none leaves it running, for apps that watch their own config file.
runAsUserThe account the container runs as — uid, uid:gid, name or name:group — like docker run --user. Omit to keep the image's own user. A workspace under the restricted security profile must give a non-root numeric uid; a name is refused there, since the image decides what it maps to. Attached volumes are chowned to it on deploy.
resourcesOmitted fields mean unlimited / none.
containerLabelsReserved namespaces (io.miabi.*, com.docker.*) are stripped rather than rejected. See container labels.

Port exposure

The two exposure knobs are orthogonal, and a port may use either, both, or neither:

  • externalAccess: true — a public HTTPS URL at <externalLabel>.<base-domain>, served through the reverse proxy (L7). Requires a platform base domain. For a custom hostname, use a Route instead.
  • publish: true (with optional hostPort) — binds the container port to a raw port on the node (L4), like docker -p. Host ports are bounded by MIABI_HOST_PORT_MIN/MAX (1024 and up by default); omit hostPort to auto-allocate one from that window. A privileged workspace may request any host port (165535), so infrastructure that has to sit on a fixed port — 25, 53, 443 — can be published from a manifest.

A port with neither is reachable only from inside the app's networks — which is what you want when a label-driven proxy fronts it.


Stack

Groups applications into one logical unit with a shared network, so members resolve each other by name.

apiVersion: miabi.io/v1
kind: Stack
metadata:
name: shop
spec:
description: Storefront — web, worker and its datastores

Database

Requests a managed database. Miabi provisions the instance, or reuses a compatible one, and creates a dedicated logical database with its own credentials.

apiVersion: miabi.io/v1
kind: Database
metadata:
name: shop-db
spec:
engine: postgres # postgres | mysql | mariadb | redis
version: "17-alpine"
placement: auto # auto | dedicated | shared
placementBehaviour
auto (default)Reuse a compatible running instance; provision a dedicated one if none exists.
dedicatedAlways provision a fresh instance. Forced for Redis, which has no logical databases.
sharedRequire an existing compatible instance. Rejected for engines without logical databases.

Reference the result from an app's env with {{ .databases.shop-db.* }} — see interpolation. The database is also attached to the app that references it, so it appears under that app with its scoped connection revealable there.

warning

Engine and version changes are not converged in place — that would recreate the data. Such a change fails the apply rather than destroying the database; migrate with a version upgrade instead.


Volume

Persistent storage, mounted into an application through its mounts.

apiVersion: miabi.io/v1
kind: Volume
metadata:
name: web-data
spec:
size: 5Gi # accepted, but see below

Volumes are compared by presence only — an existing volume never shows as drift, since its attributes are fixed at creation.

caution

spec.size is accepted by the parser but not currently applied: a volume created from a manifest is always unbounded. Set a size through the console or the API if you need one recorded for quota.

Shared (NFS/CIFS) and host-path volumes, and placement on a specific node, are not manifest-expressible — create those through the API or console.


Secret

A named encrypted value, referenced from app env and from credentials.

apiVersion: miabi.io/v1
kind: Secret
metadata:
name: app-key
spec:
value: "s3cr3t" # or:
generate: true # let Miabi generate a strong random value
length: 48 # generated length (default 32)
symbols: true # widen the alphabet beyond letters and digits
minNumbers: 2 # guarantee at least this many digits
minSpecial: 2 # guarantee at least this many symbols
FieldDefaultMeaning
length32Characters to generate.
symbolsfalseInclude punctuation (!#$%&()*+,-./:;<=>?@[]^_{|}~). Quotes, backslash and backtick are excluded so a value survives being pasted into a shell, a YAML file or a connection string.
minNumbers0Minimum digits.
minSpecial0Minimum symbols. Setting it implies symbols: true.

These are the same options the console's generator exposes, drawing from the same alphabet — so a policy written here and the same policy set in the UI produce comparable values. Minimums that exceed length are trimmed rather than silently ignored, digits first.

Secret values are write-only: never read back, never shown in a plan, and never diffed. An existing secret is treated as in sync, so a bundle can safely re-apply without churning values. Rotate through the vault or the API.


Config

A set of named configuration files, mounted into applications as read-only files — the file-shaped counterpart to a Secret. Content is encrypted at rest and rendered like app env before it is stored. See Configuration files for the full model.

apiVersion: miabi.io/v1
kind: Config
metadata:
name: prom-conf
spec:
mode: "0644" # default octal mode for every file
sensitive: false # keep content out of plans; reveal is admin-only
delimiters: ["<<", ">>"] # interpolate on these instead of {{ }}
data:
prometheus.yml: |
global:
scrape_interval: 15s
rules/alerts.yml: |
groups: []
FieldNotes
dataRequired, at least one entry. Keys are relative paths matching ^[A-Za-z0-9]([A-Za-z0-9._-]*)?(/[A-Za-z0-9._-]+)*$ — no leading /, no ... Values are interpolated.
modeDefault octal file mode, 0644 when omitted. A mount's mode overrides it per file.
sensitiveContent never enters a plan — only the digest and each key's present/absent state.
delimitersExactly two distinct, non-empty markers, replacing {{ }} for this config only. Use it for files whose own syntax is {{ }} (Prometheus annotations, Grafana dashboards).

Limits: 256 KB per file, 512 KB total. The per-file cap is what matters — in cluster mode each file becomes a Docker config object, and Docker caps those at 500 KB, so a larger file would validate here and fail at deploy.

Mount it from an Application:

mounts:
- config: prom-conf # every file under a directory
path: /etc/prometheus # → /etc/prometheus/prometheus.yml, /etc/prometheus/rules/alerts.yml
- config: prom-conf # a single file at an exact path
key: rules/alerts.yml
path: /etc/prometheus/rules/alerts.yml
mode: "0444"

A content change redeploys every application mounting the config, unless that app sets reloadPolicy: none. Deleting a config that is still mounted is refused.


Registry

A container-registry credential for pulling private images. Applications select one by name through spec.registry.

apiVersion: miabi.io/v1
kind: Registry
metadata:
name: ghcr
spec:
server: ghcr.io # omit for Docker Hub; host[:port], no scheme
username: acme
password: "${{ secrets.GHCR_TOKEN }}" # or {{ .secrets.ghcr-token }} — see below

Two ways to supply the password, and the difference is when it is read:

FormBehaviour
${{ secrets.NAME }}Stored as a live reference. The value is read from the vault at every pull, so rotating that secret rotates the credential with no re-apply.
{{ .secrets.name }}Rendered at apply time into a stored copy. Rotating the secret needs another apply, which then reports password: (current) → (rotated).
A literalStored encrypted. Avoid in a repository.

Omitting password entirely is valid on an existing credential — the stored value is kept, so a token can be managed out-of-band while the rest stays declarative. Creating one with no password fails.

The password is never read back and never appears in a plan; a rotation is reported through an unreadable fingerprint. Deleting a credential leaves apps running — they fall back to anonymous pulls.


Route

An HTTP routing rule: hostnames (and an optional path) to an application's port, with a TLS mode.

apiVersion: miabi.io/v1
kind: Route
metadata:
name: shop-web
spec:
hosts: # one route can answer on several hostnames
- example.com
- www.example.com
app: web # must be an Application in the same bundle
port: 8080
path: / # default /
tls: acme # acme | custom | off (default acme)
security:
exploitProtection: true # reject common injection/traversal signatures at the gateway
maintenance: # omit to serve normally
enabled: true
statusCode: 503 # 4xx or 5xx; omit for the gateway's 503
message: Back at 14:00 UTC

maintenance parks the route: the gateway answers every request itself and never reaches the backend, so the app keeps running while a migration or a deploy happens behind a deliberate response. Because a manifest is the desired state, removing the block resumes traffic — an apply un-parks a route that was parked by hand in the console. Details, including how the response is rendered as plain text, JSON, or XML depending on the caller, are in Routing & middlewares.

A parked route still reports its sync status as live: the gateway is serving it, it is simply serving your notice.


Domain

An owned hostname or zone: the default TLS policy routes under it inherit, and whether a wildcard certificate covers *.name. The hostname is metadata.name.

apiVersion: miabi.io/v1
kind: Domain
metadata:
name: example.com
spec:
tls: acme # acme | custom (default acme)
wildcard: true # also cover *.example.com (needs a DNS provider)

DNS-ownership verification is a runtime action, not a declarable field: a freshly applied domain starts unverified, and you verify it after apply. Domains carry no ownership label, so a prune never deletes one.


Project

Bundles resources authored in one place. Children may be inlined under spec.resources; the parser flattens them into the document set, so a Project is organisational — it is never itself created, updated or pruned.

apiVersion: miabi.io/v1
kind: Project
metadata:
name: shop
spec:
description: Storefront and its dependencies
resources:
- apiVersion: miabi.io/v1
kind: Volume
metadata: { name: web-data }
spec: { size: 5Gi }
- apiVersion: miabi.io/v1
kind: Application
metadata: { name: web }
spec:
image: ghcr.io/acme/web
mounts:
- volume: web-data
path: /data

Interpolation

Application env values, a Registry password, and a Config's file contents are rendered as templates before they are applied. Four collections are available:

ReferenceResolves to
{{ .databases.<name>.host }}A managed database's connection details. Also .port, .user, .password, .name, .uri (or its alias .url). Bare {{ .databases.<name> }} yields the URI.
{{ .secrets.<name> }}A workspace secret's value, resolved at apply time.
{{ .inputs.<key> }}Marketplace templates only — see creating a template.

Helper functions: randAlphaNum, randHex, base64, default, lower, upper.

An unresolvable reference is a hard error, never a silently empty value. Names containing hyphens work ({{ .databases.shop-db.uri }}).

To address another application, put both in the same Stack and use its name as the hostname — stack members resolve each other by name on the stack network. ({{ .applications.* }} appears in the template grammar but is not resolvable in apply or GitOps.)

A Config whose own file format uses {{ }} sets delimiters to render on different markers, so only the references you meant are substituted.

Two secret syntaxes

{{ .secrets.NAME }} is resolved once, at apply time, and the value is stored. ${{ secrets.NAME }} — the runtime form used in env vars and credentials — is stored as a reference and resolved at every deploy, so rotating the secret takes effect without a re-apply.


What converges, and what doesn't

The plan compares desired state against a live snapshot. Not every field participates, so a converged resource never shows phantom drift:

Diffed — image, tag, digest, command, registry, resource caps, non-secret env, container labels, and per-port exposure (externalAccess / publish as present-or-not).

Not diffed — create-time structure that cannot be mapped back unambiguously: ports themselves, mounts, and stack membership. Change one and the resource is updated on the next apply that touches it for another reason; recreate it to be certain.

Mounts aren't diffed, but a mounted config's content still converges: each app carries a fingerprint of every config it mounts, so editing a file plans as an update of the app itself. An app with reloadPolicy: none carries no fingerprint, which is how that policy is honoured.

Never diffed — secret values, and the secretEnv values in a plan (shown as (secret)). A registry password is compared through a fingerprint, so a rotation converges without the plan carrying anything derived from the token.

Diffed, but never echoed — a Config's files. The plan compares the content digest and reports each changed key as (absent)(present), so you can see which file changed without its content landing in a log. A sensitive: true config reports the digest alone.

The auto-allocated host port and the generated external-access subdomain are live state, not manifest state — they are compared by presence, so they are never churned.

Prune

By default, apply and GitOps only create and update: a resource removed from the manifest is left running. Opt into prune to have removals converge too.

Prune only ever deletes resources this engine created (labelled managed-by: gitops), so a hand-created app or a database provisioned in the console can never be removed by a manifest. Under GitOps it is scoped further, to the project that owns the resource — two sources backed by the same repository don't see each other's apps as orphans.

warning

An empty manifest set with prune enabled would delete everything the source owns. Miabi refuses it unless the source explicitly sets Allow empty, so a wiped directory or a wrong path can't tear down a workspace.

Applying

miabi apply -f stack.yaml --dry-run     # print the plan, change nothing
miabi apply -f stack.yaml # converge
miabi apply -f stack.yaml --prune # converge, and remove what's gone
miabi delete -f stack.yaml # delete exactly what the bundle names

Or over HTTP:

POST /api/v1/workspaces/{workspace}/apply
{ "manifests": "<YAML>", "prune": false, "dry_run": true, "delete": false }

delete is the inverse of apply: it removes exactly the resources the bundle names, regardless of which subsystem owns them, and ignores entries that don't exist. It honours dry_run.