Skip to content

Configuration

Schema version 1 project configuration for mm.

mm discovers the shared project file, .config/mm.yaml, starting at --root (or the current directory) and searching its parents. Any top-level key except schema-version can instead live in its own file under .config/mm/ (see split a large configuration). If present, .config/mm.overrides.yaml beside that file supplies checkout-local settings.

The current schema is version 1:

text
schema-version: 1

Unknown fields are errors throughout the typed schema. YAML must be valid UTF-8, each configuration file must be a regular file no larger than 1 MiB (4 MiB across all of them), and neither they nor configured project paths may contain symlink components. On Unix, files mm edits must be owned by the current user and not writable by group or others; project directories have the equivalent ownership and write restrictions.

Paths in configuration are project-relative unless a field explicitly says otherwise. Absolute paths and .. traversal are rejected. Compose files and module directories must exist. Build contexts and Dockerfiles are relative to their module; build.extra-inputs are relative to the project root. A development mount target is instead an absolute POSIX container path.

Local overrides

Create .config/mm.overrides.yaml with just the settings to change locally:

yaml
build:
  platforms: [linux/arm64]

The override uses the same field names as the shared file. Maps merge recursively; scalars and lists replace the shared value; omitted keys inherit it. An empty map leaves inherited keys intact. null is an explicit value: it clears nullable fields, such as default-environment, and is invalid for non-nullable fields. Use {} for an empty override file. The shared configuration must remain complete and valid on its own; defaults are applied when deserializing the merged result. Both sources use schema version 1, and the override need not repeat schema-version.

All project commands use the effective configuration, including validation, builds, deployment and setup inspection. Existing CLI and private target/runtime preference precedence still applies. Paths keep their usual project-relative meaning. An invalid override is an error, rather than a fallback to shared values. Changing either source requires a new review for configuration-dependent plans. Config-editing commands write the shared file that declares the key, either .config/mm.yaml or its split file; a local override still takes precedence over an edited shared setting.

mm init includes /.config/mm.overrides.yaml in the project .gitignore as part of its reviewed setup. Existing projects should add and commit this rule once. Keep the override untracked. See local build platforms.

Split a large configuration

When one key grows large, or needs its own reviewers, move it to its own file. mm config split reviews the move first, like other config edits:

sh
mm config split stacks                        # prints the plan and its hash
mm config split stacks --plan HASH --yes      # applies exactly that plan
mm config sources                             # shows where each key lives

Expected result: .config/mm/stacks.yaml holds the value of stacks, with its comments, and .config/mm.yaml no longer has a stacks: key. The plan is refused unless the project means exactly the same thing before and after the move. mm config join stacks moves the key back.

A split file holds only the key's value, without the key itself:

yaml
# .config/mm/stacks.yaml
app:
  files: [compose.yaml]

You can also create or edit these files by hand. The rules:

  • .config/mm.yaml is still required. mm finds the project by it, and it keeps schema-version. Every other top-level key can be split, including project.
  • Each key lives in exactly one place. A key in both .config/mm.yaml and its split file is an error that names both; nothing is merged.
  • File names are the exact key plus .yaml, such as .config/mm/environment-delivery.yaml. Any other file, a .yml extension, a subdirectory, or a symlink in .config/mm/ is an error. Hidden files and names ending in ~ are skipped, so editor swap files don't break loading.
  • An empty split file is an error. Write {} for an empty map, or delete the file.
  • Paths inside a split file are relative to the project root, as in .config/mm.yaml. Errors name the split file and the line.
  • .config/mm.overrides.yaml overlays the combined result, as before.
  • Commit split files. Remote environments load configuration from a commit, so mm config validate warns (MM_CONFIG_SOURCE_IGNORED) when git ignores one.
  • Splitting changes the configuration fingerprint, so plans reviewed before the move must be reviewed again. Projects that split nothing keep their existing fingerprint.

To give a key its own reviewers, list its file in CODEOWNERS:

text
/.config/mm/environments.yaml  @acme/platform

Editors can validate a split file with its own schema. mm config schema KEY prints it, and the docs publish one per key at /schema/mm/KEY.schema.json, for example /schema/mm/stacks.schema.json.

Top-level fields

New to the file? Start with where configuration belongs or a runnable example. The sections below are a field reference; YAML fragments show the relevant settings and are not complete projects unless noted.

FieldRequiredShape and default
schema-versionyesInteger 1. Missing, zero, and every other value are errors.
projectyes{id, name, development-environment?}. id is a UUID.
default-environmentnoEnvironment selected when neither --environment nor MM_ENVIRONMENT is set.
targetyesDefault target name.
runtimeno{driver?}; driver is podman-compose, docker-compose, or docker-swarm.
buildno{platforms, tag-strategy}; defaults to linux/amd64, linux/arm64, and auto. immutable is also valid.
networksnoMap of named empty objects. Stacks and modules attach names from this catalog.
stacksyesMap of Compose stack definitions.
modulesnoMap of source modules and their services.
secretsnoMap of logical secret declarations; never put secret material in this file.
bindingsnoProject-wide environment bindings.
environment-deliveryno{default?, development-default?} using direct or env-file.
environmentsyesMap of environment overlays; at least one is required.
ingressnoProject automatic-local and middleware policy.
development-mountsno{sensitive-patterns: [...]}.
development-mailno{enabled?, bindings?}.
jobsnoMap of named one-shot command declarations.
schedulesnoMap of durable five-field cron triggers referring to jobs.
gatesnoMap of declarative provider and ingress references.
usersnoWho may reach each remote environment. See remote access.
agentsnoWhat an AI agent may do, per environment or *. Default view. See agents.
targetsnoRemote targets: {host, port?, host-key, daemon-fingerprint, members?} or {nodes, swarm, setup?}. See the remote daemon guide.
registriesnoImage registry logins by host: {username, password: {secret}}. See prepare a deployment environment.

Names used for projects, stacks, modules, services, networks, jobs, schedules, and gates contain only ASCII letters, digits, -, and _. More restrictive engine-specific checks also apply where relevant.

Stacks and ingress

yaml
networks:
  frontend: {}

stacks:
  app:
    project-name: myapp-{environment}-{stack} # optional; {project-id} also works
    files: [compose.yaml, compose.local.yaml]
    networks: [frontend]
    shared-volumes: [uploads] # Swarm: every node can mount these
    ingress:
      - domain: app
        route: / # default
        service: web # optional when unambiguous
        port: 8080 # optional
        https: true # default
        environments: [local, staging] # empty means all

ingress:
  automatic-local: true
  middlewares:
    redirects:
      file: .config/traefik/redirects.yaml
      environments: [local]

ingress under a stack accepts either one route mapping or a list. Domain keys resolve through the selected environment's domains. Route paths start with /, contain no traversal or repeated slash, and have no trailing slash except for /. Middleware files are bounded, project-relative regular files; the currently supported document shape is http.middlewares with replacePathRegex definitions.

shared-volumes lists top-level Compose volume names from that stack. On a multi-node Docker Swarm, mm stores them on its built-in shared storage so a service can run on any node and see the same files. Do not set driver, driver_opts, or external on them; mm sets those. Other runtimes and single-node Swarm treat them as ordinary volumes. See Run on a multi-node Swarm.

Modules, services, and builds

yaml
modules:
  api:
    path: apps/api
    networks: [frontend]
    env:
      template: .env.example
    build:
      enabled: true # default
      context: . # optional
      dockerfile: Dockerfile # optional
      image: ghcr.io/acme/api # required when enabled
      default-variant: production
      targets: { production: "", debug: debug }
      args: { APP_MODE: production }
      secrets: { npm_token: npm.token }
      credential-only-secrets: [npm_token] # optional
      depends-on: { base: MM_BASE_IMAGE }
      extra-inputs: [package-lock.json]
    services:
      web:
        stack: app
        role: http
        http-port: 8080
        automatic-local-ingress: true
        local-https: true
        development-mail: true
        networks: [frontend]
        placement: edge # Swarm only: node, group, `managers`, or a list
        env:
          APP.DEBUG: false
        secret-files:
          database-password:
            secret: database.password
            uid: "0" # defaults shown
            gid: "0"
            mode: 292 # YAML integer for 0444

Service roles are http, database, queue, worker, scheduler, and job. Infrastructure and background roles do not receive automatic ingress.

placement chooses where a service runs on a multi-node Docker Swarm. Use a node hostname (mgr-1), a placement group created with mm target swarm group set, managers, or a list whose entries must all hold. Compose runtimes and single-node Swarm ignore it, so the same file works on a laptop. On a cluster, a service that keeps data in a non-shared named volume must be placed on exactly one node.

Set environments on a service to run it only in the named environments. Omitted or empty lists mean all environments; every name must be configured:

yaml
modules:
  api:
    services:
      scheduler:
        stack: app
        role: scheduler
        environments: [integration, uat, production]

For other environments, mm omits that service from the generated Compose files, environment/secret delivery, managed networks and automatic ingress. Build and image declarations remain module-level; a deployment needs published images only for modules with active services. Deployment and stack-compilation plans list excluded services. Applying a deployment removes previously deployed excluded services; a stack with no remaining services is removed as a whole.

Active services cannot depend on excluded ones through depends_on, links, volumes_from, or service: namespace sharing (network_mode, ipc, pid). Optional depends_on entries with required: false are removed. Dependency checks apply to each source Compose file; remove conflicting references from earlier files as well as later overrides. Explicit ingress routes must allowlist compatible environments. Jobs and schedules targeting an excluded service are inactive in that environment.

Build args are public values; names that look secret are rejected and belong under build.secrets.

credential-only-secrets lists keys of build.secrets that only authenticate fetching inputs, such as a package registry token. They are still mounted, but they are left out of the build fingerprint, so they do not prevent reusing an unchanged image or promoting a build to another environment. Any other build secret disables both. See build secrets.

Jobs and schedules

yaml
jobs:
  migrate:
    module: api
    service: web
    command: ["php", "artisan", "migrate", "--force"]
    environment: production # optional fixed environment
    timeout: 10m
    retry: { attempts: 3, backoff: 5s, max-backoff: 5m }
    output-limit-bytes: 65536
    history-limit: 100

schedules:
  framework:
    job: migrate
    cron: "0 2 * * *"
    timezone: America/New_York
    enabled: true
    starts-at: 2026-01-01T00:00:00Z
    ends-at: 2027-01-01T00:00:00Z
    jitter: 30s
    concurrency: forbid # allow, forbid, replace
    misfire: run-once # skip or {bounded-catch-up: {maximum: 10}}
    retry: { attempts: 2, backoff: 10s, max-backoff: 1m } # optional override
    timeout: 5m # optional override

Durations use one compact suffix: ms, s, m, h, or d. Commands are argument vectors and never shell strings. A job reuses the named service's image, environment, networks, mounts, and secret delivery contract. See Jobs and durable schedules for timing, recovery, and operational semantics.

A module can also contain development-mount:

yaml
development-mount:
  source: .
  target: /workspace
  mode: read-write # or read-only
  services: [web]
  preset: node # laravel, node, dotnet, or rust
  preserve: [{ path: /workspace/node_modules }]
  excludes: [dist]
  protected: [.env]
  safe-exceptions: []

Bindings, environments, and secrets

Binding keys use dot-separated uppercase names such as DB.HOST. A binding is exactly one of these untagged shapes:

yaml
bindings:
  APP.DEBUG: { value: false }
  DB.PASSWORD: { secret: database.password }
  APP.URL: { cast: url, value-ref: domains.app }

environments:
  local:
    domains: { app: app.example.test }
    bindings:
      APP.DEBUG: { value: true }
    runtime: { driver: podman-compose }
    target: local
    build-variant: debug
    environment-delivery: env-file
    automatic-local-ingress: true

Environment bindings override project bindings. direct passes operation-scoped interpolation values to the runtime; env-file uses a private, operation-scoped file. With a declared development environment, the built-in default is env-file for that environment and direct elsewhere. Explicit environment, development, and general policy values override those defaults.

yaml
providers:
  staging-secrets:
    driver: openbao
    scope: project
    mode: external
    address: https://bao.example.test:8200
    storage-mount: mm-kv
    automation-auth-mount: mm-approle
    trust-bundle: company-ca.pem
    expected-trust-sha256: sha256:HEX_DIGEST

secrets:
  database.password:
    storage:
      provider: staging-secrets # age, env, or a declared provider instance
      location: projects/example/db
    input: { prompt: Database password }
    rotation:
      strategy: manual # manual, replace-and-redeploy, coordinated
      interval-days: 90

  app.key:
    storage: { provider: age, location: app.key }
    generate: { preset: bytes, length: 32, encoding: base64-url }

Generator presets are password (length 32), token (48), bytes (32 and base64-url), laravel-app-key, and uuid. Length overrides must be 16–4096. Other byte encodings are base64 and hex. Provider instance names are stable and independent of driver/mode. Credentials and recovery material remain private mm state, not project configuration.

Development mail, jobs, schedules, and gates

yaml
development-mail:
  enabled: true
  bindings:
    MAIL_HOST: smtp-host
    MAIL_PORT: smtp-port
    MAIL_URL: smtp-url
    MAIL_UI: ui-url

jobs:
  migrate:
    module: api
    service: web
    command: [php, artisan, migrate, --force]
    environment: staging # optional

schedules:
  nightly-migrate:
    job: migrate
    cron: "0 2 * * *"
    enabled: true # default

gates:
  admin:
    provider: password-cap
    ingress: [admin]

Jobs require an existing module/service and a non-empty argument vector. Schedules require an existing job and exactly five cron fields. A gate declaration names a gate driver such as password-cap and optional ingress references. Operational policies, users, sessions, and credentials are private state rather than project configuration. The --secret-provider accepted by mm access add separately selects storage for access secrets; it is not this driver field.

Schema and validation

mm config schema prints the generated JSON Schema without loading a project or probing a container engine:

sh
mm config schema > mm.schema.json
mm config validate

The same generated schema is published as /schema/mm.schema.json, with one schema per top-level key for split files under /schema/mm/. Core tests compare that file byte-for-byte with mm config schema, while the hand-written sections above explain cross-resource behavior that JSON Schema alone cannot express.

./scripts/generate-artifacts.sh is the only checked-in generation path. The schema carries its source and generation command and must not be manually edited.

Schema 1 is the complete current project contract. Missing or different schema versions are rejected rather than guessed or rewritten.

mm config validate resolves the selected environment and performs cross-resource, path, file, ingress, build, environment, and secret-reference checks without inspecting containers.