#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.
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.
# 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:
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.
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.
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.
# .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.
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. |