Per-Role Meta Layout 🗂️

This page describes the on-disk shape of every role’s metadata. All role-owned metadata lives under roles/<role>/meta/<topic>.yml.

File Layout 📁

File

Contents

meta/main.yml

Ansible Galaxy metadata + Ansible dependencies:. No project-internal run_after: / lifecycle: keys, no logo: / homepage: / video: / display:.

meta/services.yml

Per-entity service config. File root IS the services map keyed by <entity_name>. No compose: and no services: wrapper.

meta/server.yml

CSP, domains, status_codes, plus per-role networks.local.{subnet,dns_resolver}. File root IS applications.<app>.server.

meta/rbac.yml

RBAC declarations. File root IS applications.<app>.rbac.

meta/volumes.yml

Compose volumes. File root IS the volumes map keyed by volume name. No compose: and no volumes: wrapper.

meta/addons/<id>.yml

Optional. One file per addon (file root IS the addon spec; filename stem = addon id). See Unified Addons below.

meta/users.yml

Role-local user definitions. File root IS the users map (no users: wrapper).

meta/secrets.yml

Credential schema definitions and runtime credential values.

meta/info.yml

Optional. Descriptive role-level metadata (logo, homepage, video, display). File root IS applications.<app>.info (no info: wrapper).

meta/variants.yml

Optional. Variant overrides deep-merged over the assembled application payload (used by svc-ai-ollama, web-app-phpmyadmin).

Ansible only auto-loads meta/main.yml. Every other meta/<topic>.yml is read by the project’s own loaders (utils/cache/applications.py, utils/cache/users.py, utils/manager/inventory.py).

File-Root Convention 🧷

Every meta/<topic>.yml (except meta/main.yml, which keeps Galaxy semantics, and meta/secrets.yml, which is processed by apply_schema()) follows the rule:

The file’s content IS the value of applications.<app>.<topic>. There is NO wrapping key matching the filename.

So meta/services.yml MUST NOT have a top-level services: key wrapping its content. meta/volumes.yml MUST NOT have a top-level volumes: key, and the same rule applies to meta/server.yml, meta/rbac.yml, meta/users.yml, and meta/info.yml. The filename alone fixes the path prefix in the materialised application tree, which keeps consumer paths short and predictable (no redundant compose.… prefixes).

Materialised Paths 🔗

Consumers read the assembled application payload through lookup('applications', '<role>') or lookup('config', '<role>', '<dotted.path>'). The paths are:

Source

Materialised path

meta/services.yml.<entity>.<…>

services.<entity>.<…>

meta/volumes.yml.<key>

volumes.<key>

meta/services.yml.<primary_entity>.<top-level-key>.<…>

services.<primary_entity>.<top-level-key>.<…>

| meta/secrets.yml.credentials.<key> | secrets.credentials.<key> |

secrets.credentials.* paths are populated by apply_schema() at applications.<app>.secrets.credentials.<…>, so the credential file obeys the same file-root rule as every other meta/<topic>.yml.

Services Inlining Rule 📥

All non-compose top-level keys (everything except compose:, server:, rbac:, and credentials:) MUST be inlined into meta/services.yml under <primary_entity>.<key>, where <primary_entity> is the value returned by get_entity_name(role_name).

Inlined keys observed today (non-exhaustive): plugins, plugins_enabled, email, ldap, accounts, scopes, alerting, languages, company, default_quota, legacy_login_mask, site_name, token, modules, network, performance, preload_models, provision, features.

addons is not inlined under the primary entity: role-level extensions are a first-class, per-file topic under meta/addons/ (see Unified Addons). The same applies to a role that spells the concept plugins/extensions/modules/mu_plugins: those declarations live under meta/addons/.

compose.volumes: is not inlined into services. It lives in its own meta/volumes.yml (volumes are role-wide, not per-service).

Worked Example: web-app-matomo

get_entity_name('web-app-matomo') == 'matomo', so every non-compose top-level key (site_name, performance, …) is inlined under matomo.<key>:

# roles/web-app-matomo/meta/services.yml  (file root IS the services map)
matomo:
  image: matomo
  site_name: "{{ ... }}"
  performance:
    workers: 4

# roles/web-app-matomo/meta/volumes.yml  (file root IS the volumes map)
data: matomo_data

Schema Format: meta/secrets.yml 🗝️

meta/secrets.yml consolidates two structures under the credentials: top-level key:

  1. The credential schema definitions (flat keys, e.g. alerting_telegram_bot_token: { description, algorithm, validation }).

  2. The credential runtime values (nested keys, e.g. recaptcha.key, recaptcha.secret).

The unified schema supports:

  • Nested keys. Both flat and nested credential keys are accepted, so e.g. recaptcha.key and recaptcha.secret remain nested.

  • algorithm: defaults to plain when the field is omitted.

  • type: (optional) is one of string, integer, boolean. A supplied value of another type aborts inventory creation.

  • regex: (optional) is a pattern the supplied value MUST match in full (re.fullmatch). Anchors are implicit; sk-[A-Za-z0-9]{16,} needs no ^ or $.

  • default: (optional) is a Jinja string used as the credential’s value when the inventory does not provide one.

    • default: is NOT rendered at inventory creation time. The literal Jinja string is written verbatim into the inventory so that referenced variables (CAPTCHA.RECAPTCHA.KEY, lookup(...), …) resolve only at deploy/runtime when those variables are actually defined.

    • default: values are NOT validated. validation: only applies to user-provided values, so the schema default is exempt.

    • When default: is present, the credential generator MUST NOT generate a new value via algorithm:. It writes the literal default: string verbatim.

Worked Example: runtime credentials in meta/secrets.yml

# roles/web-app-keycloak/meta/secrets.yml
credentials:
  recaptcha:
    key:
      description: "Google reCAPTCHA site key."
      algorithm:   plain
      default:     "{{ CAPTCHA.RECAPTCHA.KEY | default('') }}"
    secret:
      description: "Google reCAPTCHA secret key."
      algorithm:   plain
      default:     "{{ CAPTCHA.RECAPTCHA.SECRET | default('') }}"

What is checked, and when 🔎

type: and regex: are enforced by validate_supplied_value() in inventory.py at inventory-creation time, on every path where a value arrives from outside:

Source

Checked

--set applications.<app>.secrets.credentials.<path>=<value>

yes

A value already present in the inventory

yes

The literal written from default:

no

A value produced from algorithm:

no

A generated value is exempt because the algorithm that produced it is named by the same schema, and generate_value() takes no schema argument.

An empty string means “not configured” and passes every check, which is what makes an optional provider key expressible: declare the shape, leave the value empty, and the consumer gates on emptiness.

validation.min_length is declared in many role schemas but enforced nowhere. Do not read it as a guarantee.

Worked Example: an optional external key

# roles/svc-ai-litellm/meta/secrets.yml
credentials:
  openai_api_key:
    description: "OpenAI API key; empty leaves the openai/* models unpublished"
    type:        "string"
    regex:       "\\S{20,}"
    default:     "{{ API.openai.api_key }}"

The default: points at the central API block in group_vars/all/18_api.yml, so an operator fills the provider in one place while the role keeps the shape the value must have.

Write a pattern for the paste, not for the vendor 🚫

regex: MUST NOT pin a vendor’s key prefix. OpenAI moved issuance from sk- to sk-proj- without notice, and Anthropic issues sk-ant-api03-; a schema that encoded either would reject a valid key and abort inventory creation, which is a hard failure for a value that was correct.

Write the pattern against the mistakes a paste makes — a trailing newline, an embedded space, a truncated copy, a <placeholder> — and leave the provider’s format alone. A key pasted into the wrong provider’s slot is not caught here; it is caught at deploy time, where the route it publishes answers nothing.

Flat schema entries keep the same shape:

# roles/web-app-prometheus/meta/secrets.yml
credentials:
  alerting_telegram_bot_token:
    description: "Telegram bot token for Alertmanager notifications."
    algorithm:   token
    validation:  non_empty_string

If a single role defines the same credential key in both a schema definition and a runtime value, the loader MUST stop and surface the collision instead of silently merging.

Per-Role Networks: meta/server.yml.networks 🌐

networks: is a top-level section of each role’s meta/server.yml. The file-root convention applies: there is no wrapping server: key, and the file content IS applications.<app>.server.

# roles/<role>/meta/server.yml
# ... existing csp / domains / status_codes ...
networks:
  local:
    subnet: 192.168.101.112/28        # required; CIDR of the role's docker network
    dns_resolver: 192.168.102.29      # optional, only when a fixed DNS resolver IP is needed (today: mailu)

The role’s name is implied by the path. There is NO web-app-<role> key inside the file. The materialised path is applications.<role>.networks.local.{subnet,dns_resolver}.

Per-Entity Ports: meta/services.yml.<entity>.ports 🚪

Ports belong to the service entity that exposes them. All port data lives under <entity>.ports in meta/services.yml (no ports: section in meta/server.yml).

# roles/<role>/meta/services.yml
<entity>:
  image: ...
  version: ...
  ports:
    internal:
      <category>: <int>               # internal container port (category-keyed)
    local:
      <category>: <int>               # localhost-bound host port
    public:
      <category>: <int>               # public-facing port
      relay:                          # for port-ranges (coturn, BBB, nextcloud TURN)
        start: <int>
        end:   <int>
    onion:
      <category>: <bool>              # forward this category over the node onion

internal / local / public Split 🧭

Slot

Meaning

internal

Internal container port. Lives inside the container’s network namespace, addressed by other containers on the same role-local network. NOT a host-bound port. Multiple roles MAY legitimately declare the same value (e.g. several nginx-based apps with internal: { http: 80 }).

local

Localhost-bound host port. Bound on 127.0.0.1 and only reachable through the front-proxy / SSH tunnels. The OS-level binding namespace is shared across all roles, so local values MUST be unique across the whole repo.

public

Public-facing host port. Bound on 0.0.0.0 and exposed to the public internet (or to whatever the operator’s firewall allows). Same uniqueness rule as local.

onion

Onion opt-in, not a port. A category-keyed map of booleans naming which of the entity’s own categories get a HiddenServicePort on the node onion. It declares no numbers: the port is read from local when the category is declared there and from public otherwise, matching what container_ports publishes. Collision detection ignores it.

Always Category-Keyed Maps 🗂️

ports.internal, ports.local and ports.public are always category-keyed maps, even when the map has only one entry. Polymorphic int-or-map values are NOT supported. The category names are: http, database, websocket, oauth2, ldap, ssh, ldaps, stun_turn, stun_turn_tls, federation, plus the structured relay block under public:.

gitea:
  ports:
    internal:
      http: 3000          # category-keyed, even with one entry
    local:
      http: 8002
    public:
      ssh: 2201

relay Port Ranges 📡

ports.public.relay, when present, is a map with two integer keys start and end directly under relay (no nested entity-or-key sub-level), with start < end. Only one relay range per entity is supported.

coturn:
  ports:
    public:
      stun_turn:     3481
      stun_turn_tls: 5351
      relay:
        start: 20000
        end:   39999

onion Forwarding Opt-In 🧅

ports.onion names the categories that answer on the node onion. Only the named categories are forwarded.

The flag is mandatory, enforced by test_onion_port_flag.py: every category declared under local or public needs a true or a false, and every false needs # nocheck: onion-flag plus the reason it is false. Four groups are settled centrally instead and take no flag: the categories a hidden service cannot carry (relay, media, stun_turn, stun_turn_tls), the proxy-fronted http, sso and websocket, the implicit-TLS variants (smtps, imaps, pop3s, ldaps), and any entity that already answers the question through exposed:.

mailu:
  ports:
    public:
      smtp:   25
      smtps:  465
      imap:   143
    onion:
      smtp: true          # 25 gets a HiddenServicePort; 465 and 143 do not

The forward is per-variant: meta/variants.yml may drop the flag, and the role then loses the HiddenServicePort in that variant.

Multi-Entity Roles 🎛️

Each entity carries its own ports block:

# roles/web-app-bluesky/meta/services.yml
api:
  ports: { local: { http: 8030 } }
web:
  ports: { local: { http: 8031 } }
view:
  ports: { local: { http: 8051 } }

Port Bands 📊

The per-category port ranges that the suggester proposes from and that the lint check enforces live as a single PORT_BANDS map in 08_networks.yml. Suggesters and lint pick up new entries automatically, with no second registration step. See cli contributing network ports suggest in port.md.

run_after and lifecycle 🌱

For the semantic meaning of each lifecycle value (and the criteria a role MUST satisfy to claim a given value) see lifecycle.md. This section only covers the on-disk shape of the two fields.

Both fields live on the role’s primary entity in meta/services.yml, where <primary_entity> = get_entity_name(role_name):

# roles/web-app-gitea/meta/services.yml
gitea:
  image: gitea/gitea
  ports: { ... }
  run_after:
    - svc-db-postgres
  lifecycle: stable

For multi-entity roles whose primary entity is not a real compose service (e.g. web-app-bluesky → bluesky), the layout uses a dedicated top-level metadata holder:

# roles/web-app-bluesky/meta/services.yml
bluesky:                      # role-level metadata holder; no compose fields
  run_after:
    - web-app-keycloak
  lifecycle: alpha
api:
  ports: { ... }
  image: ...
web:
  ports: { ... }
  image: ...

Allowed lifecycle Values

The stages listed in lifecycle.md. Unknown values fail the lint.

run_after Rules

  • run_after:, when present, is a non-empty list of role names.

  • Empty run_after: [] is forbidden: omit the key when no constraint exists.

  • At most one entity per role carries run_after and lifecycle. Putting these fields on a non-primary entity fails the lint.

Helper

The helper module utils/roles/meta_lookup.py exposes get_role_run_after(role) -> list[str] and get_role_lifecycle(role) -> str | None. All consumers of these fields use the helper instead of hand-rolled derivations. The helper returns [] / None gracefully when meta/services.yml is absent or when the field is not set.

Descriptive Role-Level Metadata: meta/info.yml 📝

Project-internal descriptive metadata (icon, upstream homepage, demo video, dashboard display flag) lives in an OPTIONAL meta/info.yml, not nested inside galaxy_info:. The file-root convention applies: there is no wrapping info: key, and the file content IS applications.<role>.info.

# roles/web-app-nextcloud/meta/info.yml
logo:
  class: fa-solid fa-cloud
homepage: https://nextcloud.com/
video: https://youtu.be/3jcYJGQgenI?si=FDmoMSrAb9_WvviC

Allowed Fields

Field

Type

Semantics

logo

map

UI icon descriptor. Today only class: (FontAwesome). Future fields (source:, svg:) require an explicit allowlist update in the lint.

homepage

string

Upstream project URL: the canonical landing page of the software the role deploys.

video

string

Upstream demo / overview video URL.

display

bool

Default true. false opts the role out of dashboards / cards / apps grids.

The lint (tests/lint/ansible/roles/meta/test_info.py) rejects any other top-level key so the file does not become a dumping ground.

Optionality

meta/info.yml is OPTIONAL. A role with none of the four fields does not grow the file. Consumers MUST treat a missing file or missing field as absent / default, and display defaults to true.

Materialised Path

applications.<role>.info.{logo,homepage,video,display}

The dashboard’s web-app-dashboard/lookup_plugins/docker_cards.py reads logo.class and display from this location, while description and galaxy_tags continue to come from galaxy_info (Galaxy-spec fields).

Unified Addons: meta/addons/ 🧩

Role-level extensions, whatever a given app calls them natively (addon, plugin, mu_plugin, extension, module, or a network/appservice bridge), are declared through one unified contract under meta/addons/ (requirement 026). The per-app spelling under the primary service entity (addons / plugins / modules / mu_plugins) maps onto this single contract.

Each addon lives in its own file meta/addons/<addon_id>.yml: the file root IS the addon spec, there is NO wrapping <addon_id>: key, and the filename stem supplies the addon id. All files in the directory are collected into applications.<role_id>.addons, keyed by <addon_id>. The materialised path is applications.<role_id>.addons.<addon_id>, read via lookup('config', application_id, 'addons.<addon_id>').

# roles/web-app-friendica/meta/addons/ldapauth.yml  (file root IS the addon spec)
enabled: "{{ lookup('config', application_id, 'services.ldap.enabled') | bool }}"
required: false             # true for core components that must always install
mechanism: addon            # addon | plugin | mu_plugin | extension | module | bridge
source: upstream            # upstream | bundled | vendored | built
bridges:                    # optional; in-repo service keys declared in meta/services.yml
  - ldap
version: ""                 # optional upstream pin; "" tracks the app default
group: optional             # optional grouping label (e.g. odoo core/optional)
update:
  monitored: true           # optional; external tests check latest versions
  catalog: friendica-addons # optional; supported upstream catalog adapter
  upstream_id: ldapauth     # optional; defaults to the addon id
config: {}                  # optional, opaque, role-interpreted runtime payload

Fields

Field

Required

Type

Default

Notes

mechanism

yes

enum

—

addon / plugin / mu_plugin / extension / module / bridge. Selects the install path. bridge denotes a network/appservice bridge addon (distinct from the bridges: field).

source

yes

enum

—

upstream / bundled / vendored / built.

enabled

no

bool | Jinja

false (or true when required: true)

Normalised by the loader. When the addon bridges exactly one service, reference that service’s enabled flag instead of re-deriving group membership.

required

no

bool

false

true = baseline install contract: always installed, MAY omit enabled, MUST NOT set enabled: false. Also gates install-failure: a failed required: true addon hard-fails the play; a failed required: false addon warns, is skipped, and the play continues.

bridges

no

list

—

Non-empty list of in-repo service keys; each MUST resolve to a service block in the same role’s meta/services.yml. Omit when there is no cross-role dependency.

version

no

string

""

A pin MUST be a quoted string, never an unquoted number. "" tracks the app default.

group

no

string

—

Free grouping label (e.g. Odoo core/optional). MUST NOT affect enablement.

update

no

map

—

monitored (bool, default false), catalog (a supported adapter), upstream_id (defaults to the addon id). A version pin MUST also name its version source here, or carry # nocheck: unwatched-version with a reason: either type with its fields, or monitored: true with catalog: github-releases and the owner/repository as upstream_id. See Declared version sources.

config

no

map

—

Opaque, role-interpreted runtime payload. Lint does not constrain its inner shape beyond requiring a mapping. Secrets inside config MUST resolve through lookup('config', application_id, 'secrets.credentials.<name>') and MUST NOT be inlined literally.

Any credential an addon needs is declared in meta/secrets.yml credentials: and read via lookup('config', application_id, 'secrets.credentials.<name>'). There is no new secret store.

Bridges: in-repo dependency vs network bridge

Two distinct meanings of “bridge” MUST NOT be conflated:

  • the bridges: field names an in-repo cross-role service dependency (e.g. an addon that talks to svc-db-openldap via the role’s ldap service block). Each listed key MUST be a service block in the same role’s meta/services.yml; lint fails otherwise.

  • mechanism: bridge marks an addon that is a network/appservice bridge to an external system (e.g. a Matrix mautrix bridge to WhatsApp).

A single addon MAY be both: a mechanism: bridge addon MAY also declare a bridges: dependency on an in-repo service it relies on.

Front-door auth gates (e.g. an oauth2-proxy sso service in front of the vhost) are NOT addon bridges unless the addon itself talks to that service; they stay in meta/services.yml and MUST NOT appear under bridges:.

Loader, lint, and the materialised path

  • The application loader (utils/cache/applications.py) reads every meta/addons/<id>.yml like the other file-rooted meta topics and normalises the enable state: required: true defaults to enabled, an optional addon defaults to disabled, and an explicit enabled value is preserved verbatim. No new repo-wide dictionary is introduced.

  • Schema validation lives in tests/lint/ansible/services/test_addons_schema.py and bridge resolution / parity in test_addons_bridges.py. Suppress with # nocheck: addon-schema / # nocheck: addon-bridge (comment block above the addon key) or # nocheck: addon-secret (on a config leaf).

  • Optional (required: false) addons express their enabled/disabled split as a meta/variants.yml axis so the CI matrix exercises both states.

The Ansible-level plugins/ directory (filters, lookups, modules) is a different concept from the application-level addons topic. See the plugins README for the distinction.