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
| Field | Type | Description |
|---|---|---|
name | string | Required. Lowercase [a-z0-9-], starting alphanumeric. For a Domain it is a real hostname instead (shop.example.com). |
uid | string | The 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. |
labels | map | Short 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. |
annotations | map | Free-form descriptive metadata — owners, links, tooling hints. Keys are validated; values are arbitrary text. |
Kinds at a glance
| Kind | What it is | Applied |
|---|---|---|
Application | A long-running container workload | 3rd |
Stack | Groups applications into one unit and network | 2nd |
Database | A managed Postgres / MySQL / MariaDB / Redis database | 2nd |
Volume | Persistent storage | 1st |
Secret | A named encrypted value | 1st |
Config | A set of configuration files mounted into apps | 1st |
Registry | A container-registry credential for private images | 2nd |
Route | An HTTP routing rule (host/path → app:port + TLS) | 4th |
Domain | An owned hostname and its default TLS policy | 1st |
Project | Bundles 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"
| Field | Notes |
|---|---|
image | Required. Repository without a tag, e.g. ghcr.io/acme/web. |
tag | Defaults to latest when composing the pull reference. |
digest | A sha256:… pin. CI writes it; GitOps converges the runtime to it. |
registry | Names 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. |
command | Overrides the image's command (argv form). |
stack | Must name a Stack in the same bundle. Members share a network and resolve each other by name. |
externalLabel | Pins the external-access subdomain. Platform-wide unique: if taken, it is ignored and a generated label is used — the apply still succeeds. |
ports | See port exposure. |
env / secretEnv | Every secretEnv key must also appear in env. Values support interpolation. |
mounts | Exactly 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. |
reloadPolicy | restart (default) redeploys the app when a mounted Config's content changes; none leaves it running, for apps that watch their own config file. |
runAsUser | The 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. |
resources | Omitted fields mean unlimited / none. |
containerLabels | Reserved 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 aRouteinstead.publish: true(with optionalhostPort) — binds the container port to a raw port on the node (L4), likedocker -p. Host ports are bounded byMIABI_HOST_PORT_MIN/MAX(1024 and up by default); omithostPortto auto-allocate one from that window. A privileged workspace may request any host port (1–65535), 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
placement | Behaviour |
|---|---|
auto (default) | Reuse a compatible running instance; provision a dedicated one if none exists. |
dedicated | Always provision a fresh instance. Forced for Redis, which has no logical databases. |
shared | Require 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.
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.
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
| Field | Default | Meaning |
|---|---|---|
length | 32 | Characters to generate. |
symbols | false | Include punctuation (!#$%&()*+,-./:;<=>?@[]^_{|}~). Quotes, backslash and backtick are excluded so a value survives being pasted into a shell, a YAML file or a connection string. |
minNumbers | 0 | Minimum digits. |
minSpecial | 0 | Minimum 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: []
| Field | Notes |
|---|---|
data | Required, 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. |
mode | Default octal file mode, 0644 when omitted. A mount's mode overrides it per file. |
sensitive | Content never enters a plan — only the digest and each key's present/absent state. |
delimiters | Exactly 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:
| Form | Behaviour |
|---|---|
${{ 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 literal | Stored 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:
| Reference | Resolves 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.
{{ .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.
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.
Related
- GitOps — continuous reconciliation from a Git source.
- CLI —
miabi apply,miabi delete, and the rest. - Creating a marketplace template — the same resources, packaged with user inputs.