Chapter 13b Clients, Groupware & CLI Admin: #pConfigFile

Configuration as code: export, plan, apply and Git pipeline

The whole server state as YAML files in Git: tenants, domains (DKIM selectors, MTA-STS, routing), mailboxes with aliases, forwarding and out-of-office, distribution lists, shared mailboxes, rooms, GAL and resource rules, shares and delegation, SMTP, smart host, routes, transport rules, spam and virus protection, firewall and auto-ban, TLS, identity sources, API keys (metadata only), webhooks, notifications, ops alerts, POP3, files, booking, archive and DNS failover. nextxch-cli config plan shows like terraform plan what would be created, changed and deleted; apply checks everything first and applies it in a fixed order. Admin: System > Configuration as a file.

Operational Tip & Architecture Note

Secrets never go into the file and are never exported: passwords, tokens and keys are written as secretRef: {env: NAME} or secretRef: {file: path} and resolved at apply time (by nextxch-cli on the CI runner; on the server only NEXTXCH_SECRET_* variables and files below <data>/secrets/). DKIM keys, certificates and password hashes stay on the server. Business feature like API v1; the JSON Schema is free.

1. File format: apiVersion, kind, metadata.name, spec

One file with several documents (separated by ---) or a folder with one file per area (config export --split). Fields missing from the file stay as they are on the server; lists (aliases, members) count as a whole.

Reference / Configuration
# yaml-language-server: $schema=https://nextxch.com/schemas/nextxch-config-v1.schema.json
apiVersion: nextxch/v1
kind: Manifest            # optional: areas in which --prune may delete
metadata: {name: nextxch}
spec: {areas: [domains, accounts, sharing, transport]}
---
apiVersion: nextxch/v1
kind: Domain
metadata: {name: company.com}
spec:
  tenant: default
  catchAll: info@company.com
  dkim: {mode: dual, rsaSelector: s2026, ed25519Selector: ed2026}   # keys stay on the server
  mtaSts: {mode: enforce, maxAgeSeconds: 604800, mxHosts: [mail.company.com]}
---
apiVersion: nextxch/v1
kind: Account
metadata: {name: anna.smith@company.com}
spec:
  displayName: Anna Smith
  role: User                      # User, TenantAdmin, ServerAdmin, ComplianceAuditor
  quotaMb: 10240
  aliases: [anna@company.com]
  forwarding: {address: anna@private.example, keepCopy: true}
  outOfOffice: {enabled: false}
  password: {secretRef: {env: ANNA_PASSWORD}}   # never the password itself
---
apiVersion: nextxch/v1
kind: Account
metadata: {name: info@company.com}
spec: {type: shared, displayName: Info mailbox}   # user, shared, room, equipment
---
apiVersion: nextxch/v1
kind: Share
metadata: {name: "info@company.com/mailbox -> anna.smith@company.com"}
spec: {permissions: [read, write, sendAs]}
---
apiVersion: nextxch/v1
kind: SmtpSettings
metadata: {name: server}             # server-wide settings are always named "server"
spec:
  outboundSmartHost: smtp.provider.example
  outboundSmartPort: 587
  smartHostPassword: {secretRef: {file: secrets/smarthost.txt}}

2. Workflow with nextxch-cli

Export and commit first, then change through pull requests. Example plan output:

Reference / Configuration
export NEXTXCH_URL=https://mail.company.com
export NEXTXCH_API_KEY=smk_...            # key with config.read or config.write
nextxch-cli config export --split nextxch/
nextxch-cli config validate -f nextxch/    # offline: kinds and fields
nextxch-cli config plan -f nextxch/

  ~ Domain company.com
      mtaSts.mode: "testing" -> "enforce"
  ~ Account anna.smith@company.com
      quotaMb: 10240 -> 20480
      aliases: ["anna@company.com"] -> ["a.smith@company.com","anna@company.com"]
      ~ password: (secret changed)
  + Account info@company.com
      type: "shared"
      displayName: "Info mailbox"

Not in the file (1), deleted only with --prune:
    Account old@company.com

Plan: 1 to create, 2 to update, 0 to delete, 41 unchanged.
# exit code 2 = changes

nextxch-cli config apply -f nextxch/ --prune --yes
nextxch-cli config history

3. GitHub Actions: plan on pull request, apply on merge

Complete file with the comment step: deploy/gitops/github-actions.yml in the repository. The API key comes from the repository secrets, never from the file.

Reference / Configuration
name: nextxch config
on:
  pull_request: {paths: ["nextxch/**"]}
  push: {branches: [main], paths: ["nextxch/**"]}
permissions: {contents: read, pull-requests: write}
concurrency: {group: nextxch-config, cancel-in-progress: false}
env: {NEXTXCH_IMAGE: "nextxch/server:latest"}
jobs:
  plan:
    if: github.event_name == 'pull_request'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Plan
        env:
          NEXTXCH_URL: ${{ vars.NEXTXCH_URL }}
          NEXTXCH_API_KEY: ${{ secrets.NEXTXCH_CONFIG_READ_KEY }}
        run: |
          set +e
          docker run --rm -e NEXTXCH_URL -e NEXTXCH_API_KEY -e NO_COLOR=1 -v "$PWD:/work" -w /work \
            --entrypoint nextxch-cli "$NEXTXCH_IMAGE" config plan -f nextxch/ --prune > plan.txt 2>&1
          code=$?; cat plan.txt; [ "$code" -ne 1 ]
      # danach: actions/github-script postet plan.txt als Kommentar (siehe deploy/gitops)
  apply:
    if: github.event_name == 'push'
    runs-on: ubuntu-latest
    environment: production
    steps:
      - uses: actions/checkout@v4
      - env:
          NEXTXCH_URL: ${{ vars.NEXTXCH_URL }}
          NEXTXCH_API_KEY: ${{ secrets.NEXTXCH_CONFIG_WRITE_KEY }}
          SMARTHOST_PASSWORD: ${{ secrets.SMARTHOST_PASSWORD }}
        run: |
          docker run --rm -e NEXTXCH_URL -e NEXTXCH_API_KEY -e SMARTHOST_PASSWORD -v "$PWD:/work" -w /work \
            --entrypoint nextxch-cli "$NEXTXCH_IMAGE" config apply -f nextxch/ --prune --yes

4. GitLab CI: plan as merge request note, apply on the default branch

Complete file: deploy/gitops/gitlab-ci.yml. The keys are masked, protected CI/CD variables; resource_group prevents two applies at once.

Reference / Configuration
stages: [validate, plan, apply]
variables: {CONFIG_DIR: nextxch, NO_COLOR: "1"}
.nextxch-cli:
  image: {name: "nextxch/server:latest", entrypoint: [""]}
validate:
  extends: .nextxch-cli
  stage: validate
  script: [nextxch-cli config validate -f "$CONFIG_DIR"]
plan:
  extends: .nextxch-cli
  stage: plan
  rules: [{if: '$CI_PIPELINE_SOURCE == "merge_request_event"'}]
  variables: {NEXTXCH_API_KEY: $NEXTXCH_CONFIG_READ_KEY}
  script:
    - set +e
    - nextxch-cli config plan -f "$CONFIG_DIR" --prune > plan.txt 2>&1; code=$?
    - cat plan.txt; test "$code" -ne 1
  artifacts: {when: always, paths: [plan.txt]}
# plan-comment: curl auf /merge_requests/:iid/notes mit plan.txt (siehe deploy/gitops)
apply:
  extends: .nextxch-cli
  stage: apply
  resource_group: nextxch-config
  environment: production
  rules: [{if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'}]
  variables: {NEXTXCH_API_KEY: $NEXTXCH_CONFIG_WRITE_KEY}
  script: [nextxch-cli config apply -f "$CONFIG_DIR" --prune --yes]

5. JSON Schema in the editor and REST call

VS Code (Red Hat "YAML" extension) and JetBrains IDEs check the files while typing. Every server also serves the schema at /api/v1/config/schema.

Reference / Configuration
# .vscode/settings.json
{
  "yaml.schemas": {
    "https://nextxch.com/schemas/nextxch-config-v1.schema.json": ["nextxch/*.yaml"]
  }
}

# Plan ohne CLI, direkt per REST (YAML als Body):
curl -s -X POST "$NEXTXCH_URL/api/v1/config/plan?prune=true" \
  -H "Authorization: Bearer $NEXTXCH_API_KEY" \
  -H "Content-Type: application/yaml" --data-binary @nextxch.yaml

6. Secrets in lists and ACME without a restart

MTA hooks with auth header and HMAC secret from the pipeline, an alert target with a webhook secret and the switch to Let's Encrypt with DNS-01: one apply, no restart.

Reference / Configuration
apiVersion: nextxch/v1
kind: MtaFilters
metadata: {name: server}
spec:
  hooks:
    - name: spamcheck
      url: https://hooks.company.com/check
      stages: [data]
      authHeaderName: Authorization
      authHeaderValue: {secretRef: {env: HOOK_TOKEN}}   # in the plan: hooks[spamcheck].authHeaderValue
      hmacSecret: {secretRef: {file: secrets/hook-hmac.txt}}
---
apiVersion: nextxch/v1
kind: OpsAlerts
metadata: {name: server}
spec:
  targets:
    - name: pager
      type: webhook
      address: https://pager.company.com/hook
      secret: {secretRef: {env: PAGER_SECRET}}
---
apiVersion: nextxch/v1
kind: TlsSettings
metadata: {name: server}
spec:
  acme:
    enabled: true
    directoryUrl: https://acme-v02.api.letsencrypt.org/directory
    contactEmail: postmaster@company.com
    challenge: Dns01
    renewBeforeExpiryDays: 30

Settings, Default Values & Best Practices

Setting / Location Description Default Value Production Best Practice
config export [--area a,b] [--split ordner/] [-o datei] Exports the server state deterministically: fixed document order, sorted lists, no timestamps. Exporting twice gives the same bytes, Git diffs show only real changes. alle Bereiche Use --split for repositories (one file per area: domains.yaml, accounts.yaml ...); small pull requests stay easy to read.
config plan -f datei|ordner [--prune] Shows per object + create, ~ update (field: old -> new) and - delete. Exit code 0 = no changes, 2 = changes, 1 = error. Secrets appear only as "(secret changed)". ohne Löschen Run it on pull requests and post it as a comment (examples below).
config apply -f ... [--prune] [--yes] [--force] [--dry-run] Checks everything first (fields, references, secrets, safety rules), then creates and updates in a fixed order (tenants, domains, accounts, lists, rules, shares, transport ...), deletes in reverse order. If a step fails, the steps already done are undone. Every change goes to the audit log. fragt nach Always pass --yes in pipelines; without a terminal apply refuses otherwise. --dry-run equals plan.
--prune und Manifest Deletes happen only with --prune and only in the areas the Manifest document names (without a Manifest: only the kinds present in the file). Server settings are never deleted. aus Export writes the Manifest; if you only manage parts, narrow areas down.
Schutz: letzter Admin, Domain mit Postfächern A plan that would leave no active server admin and deleting a domain that still has mailboxes are refused. --force overrides it (the domain is then switched off instead of deleted). The own API key cannot revoke itself. aktiv Never hard-code --force in a pipeline.
secretRef: {env: NAME} | {file: pfad} Reference to a secret instead of its value. nextxch-cli resolves it on the runner (any variable, file relative to the configuration). The server itself only reads NEXTXCH_SECRET_* and files below <data>/secrets/. Re-applying the same value is no change. - Pass secrets from the GitHub/GitLab secret store to the job as environment variables.
Scopes config.read / config.write REST: GET /api/v1/config/export, POST /api/v1/config/plan, POST /api/v1/config/apply, GET /api/v1/config/history, GET /api/v1/config/schema (free). Keys: tenant integration keys; they work only while their creator is still an administrator. Keys of a server admin see the whole server, keys of a tenant admin only that tenant. config.read is enough for export and plan, apply needs config.write. - Two keys: config.read for plans in pull requests, config.write only for the apply job on main, restricted to the runner IPs if possible.
Mandanten-Admins A tenant admin (session or one of their keys) exports, plans and applies only objects of their tenant: Domain (change only, no create or delete), Account, DistributionList, Share, GalVisibilityRule, ResourceAccessRule and Webhook. Server settings, tenants, identity sources and API keys stay with the server admin; accounts with the role ServerAdmin or ComplianceAuditor are off limits. --prune deletes only inside the own tenant, history shows only the own applies. - For resellers and departments: each tenant keeps its own repository with its own key. A "tenant" field may be left out; it is filled with the own tenant.
secretRef in Listen List entries take secrets through secretRef too: MtaFilters hooks[].authHeaderValue and hooks[].hmacSecret, OpsAlerts targets[].secret (HMAC of the webhook target). The plan names the entry by its id or its name, for example hooks[spamcheck].authHeaderValue; a plain value in the list is refused. - Entries without an id get one on the first apply; after an export keep the ids in the file, then secrets stay with the right entry when the list is reordered.
ACME zur Laufzeit TlsSettings.acme (enabled, directoryUrl, contactEmail, challenge Http01/TlsAlpn01/Dns01, renewBeforeExpiryDays, reusePrivateKeyForDane) applies without a restart: appsettings.json stays the base, differences live in <data>/acme-settings.json and apply to the whole cluster. Changing the directory URL creates a new ACME account on the next order. appsettings.json Test with the Let's Encrypt staging directory first, then switch to production.
Arten (kinds) Tenant, Domain, Account, DistributionList, GalVisibilityRule, ResourceAccessRule, Share, SmtpSettings, Route, TransportRules, SpamFilter, MtaFilters, FirewallRule, TlsSettings, IdentitySource, ApiKey, Webhook, Notifications, OpsAlerts, DnsFailover, Pop3, FileStorage, Booking, ArchivePolicy. All fields: JSON Schema. - API keys are not created from files (their secret exists only once); a file changes label and scopes and revokes with --prune. Room and booking settings are the same on every cluster node.