Declarative and idempotent configuration management without web UI

Your question

Version: v0.16.10

Summary:
Configuring Stalwart declaratively is currently a painful and unreliable experience. This is both a question about whether a better path exists, and feedback on the design.

Core issues:

  1. Singleton coupling — objects like Certificate and Domain are referenced by SystemSettings, which means destroy actions silently fail if the reference isn’t cleared first. There’s no dependency resolution, no clear ordering, and no useful error when this happens. A re-apply of the same plan will partially fail with no clean way to recover.

  2. No true idempotency — there’s no equivalent of terraform apply: a plan that can be safely re-run and converge to the desired state regardless of what currently exists. The destroy + create pattern breaks as soon as objects are linked.

  3. The CLI feels like an afterthought — there’s no way to express the full server configuration as a single declarative file and apply it cleanly.

For reference, this is the plan structure I’m working with:

{"@type":"destroy","object":"Certificate"}
{"@type":"destroy","object":"Domain"}
{"@type":"create","object":"Certificate","value":{"certificate-main":{"certificate":{"@type":"File","filePath":"/opt/stalwart/etc/certs/tls.crt"},"privateKey":{"@type":"File","filePath":"/opt/stalwart/etc/certs/tls.key"}}}}
{"@type":"create","object":"Domain","value":{"dom-main":{"name":"my.domain","certificateManagement":{"@type":"Manual"},"dkimManagement":{"@type":"Automatic"},"dnsManagement":{"@type":"Manual"},"subAddressing":{"@type":"Enabled"},"reportAddressUri":"mailto:postmaster@..."}}}
{"@type":"update","object":"SystemSettings","value":{"defaultDomainId":"#dom-main","defaultCertificateId":"#certificate-main","defaultHostname":"my.host.example","proxyTrustedNetworks":{"10.111.0.0/16":true},"maxConnections":512}}

What would actually fix this:

  • Proper dependency resolution in stalwart-cli apply (unlink from singletons before destroy)
  • Expose the full configuration as a flat file that can be committed to Git and applied atomically — similar to how Grafana handles provisioning via YAML files in /etc/grafana/provisioning, or how tools like Alertmanager and Prometheus are configured entirely through a single config file without any UI interaction required
  • A proper idempotent CLI that computes a diff against current state and applies changes safely, like Terraform does

Question:
Is there currently any supported way to manage the full Stalwart configuration declaratively and idempotently without the web UI? If not, is this planned?

Additionally — are there any currently available workarounds or patterns that others are using to achieve declarative, idempotent configuration management with Stalwart today?

Thank you.

I understand that topics in this category are triaged by a bot first but a human reply will follow up. If I’d prefer a human-only reply, I’ll add the no-ai tag to my topic.

on

The existing documentation directly addresses all three of your pain points. Here’s a summary of what’s actually supported and how to fix your specific plan:


1. objectIsLinked on destroy — ordering fix

The docs explain a common pitfall that matches your situation exactly: writing destroy operations in reverse order (children first) causes apply to re-reverse them to parents-first at runtime, which fails with objectIsLinked. The fix is to write destroys in the same forward order as creates (parents first). The CLI’s reverse pass then executes them children-first automatically.

In your plan, destroy Certificate comes before destroy Domain, but since SystemSettings links to both, you need to first update SystemSettings to clear the references, then destroy. The correct sequence is:

{"@type":"update","object":"SystemSettings","value":{"defaultDomainId":null,"defaultCertificateId":null}}
{"@type":"destroy","object":"Domain","value":{"name":"my.domain"}}
{"@type":"destroy","object":"Certificate"}
{"@type":"create","object":"Certificate","value":{"certificate-main":{...}}}
{"@type":"create","object":"Domain","value":{"dom-main":{...}}}
{"@type":"update","object":"SystemSettings","value":{"defaultDomainId":"#dom-main","defaultCertificateId":"#certificate-main","defaultHostname":"my.host.example",...}}

The key insight: singletons like SystemSettings cannot be destroyed, so you unlink them first via update, then destroy the objects they referenced.

2. Idempotency

The docs confirm that apply does not diff state – it executes the plan as written. The supported pattern for idempotent re-runs is exactly what you’re doing (paired destroy + create), but the ordering must be correct. A filtered destroy ({"value":{"name":"my.domain"}}) is safer than an unfiltered one, which would wipe every domain on the server.

3. Full declarative config as a file

This is fully supported. The stalwart-cli snapshot command exports a live deployment as an NDJSON plan file (the same format apply consumes), which can be committed to Git. The apply command is the integration surface for Ansible, Terraform, NixOS, Pulumi, and CI/CD – with worked examples for all of them in the docs.

4. --dry-run for validation

stalwart-cli apply --file plan.ndjson --dry-run validates the plan and prints it without sending any requests to the server. Recommended for PRs/CI gates.

Relevant docs:

This is an automated reply from the Stalwart Help Bot. Other community members may follow up if this answer is incomplete or wrong.

  1. i tried apply this plan, but:
   {"@type":"update","object":"SystemSettings","value":{"defaultDomainId":null,"defaultCertificateId":null}}
   {"@type":"destroy","object":"Domain","value":{"name":"..."}}
   {"@type":"destroy","object":"Certificate"}
   {
   "@type": "create",
   "object": "Certificate",
   "value": {
   "certificate-main": {
   "certificate": {
   "@type": "File",
   "filePath": "/opt/stalwart/etc/certs/tls.crt"
   },
   "privateKey": {
   "@type": "File",
   "filePath": "/opt/stalwart/etc/certs/tls.key"
   }
   }
   }
   }
   {
   "@type":"create",
   "object":"Domain",
   "value":{
   "dom-main": {
   "name":"...",
   "certificateManagement":{"@type":"Manual"},
   "dkimManagement":{"@type":"Automatic"},
   "dnsManagement":{"@type":"Manual"},
   "subAddressing":{"@type":"Enabled"},
   "reportAddressUri":"mailto:postmaster@..."
   }
   }
   }
   {
   "@type":"update",
   "object":"SystemSettings",
   "value":{
   "defaultDomainId":"#dom-main",
   "defaultCertificateId":"#certificate-main",
   "defaultHostname":"...",
   "proxyTrustedNetworks":{"10.111.0.0/16":true},
   "maxConnections":512
   }
   }

result:

stalwart-cli apply --stdin --continue-on-error
Plan: 2 destroy, 2 update, 2 create (2 objects)
✓ destroyed Certificate (1)
✗ destroy Domain: Domain: destroy failed for id k: error: objectIsLinked |   Object id:   Domain#k |   Linked by:   SystemSettings#singleton
✗ update SystemSettings: SystemSettings: update failed for id singleton: error: invalidPatch |   Invalid path for Id, expected a string value |   Properties: defaultDomainId
✓ created Certificate (1)
✗ create Domain: Domain: create failed for `dom-main` (operation #5): error: primaryKeyViolation |   Properties: name |   Object id:   Domain#k
✗ update SystemSettings: jmap error: invalidResultReference: Id reference "dom-main" not found.
Done: 1 destroyed, 0 updated, 1 created (4 failed)
error: apply completed with 4 failed operation(s)

2–3. I appreciate the clarification, though I still personally feel the current approach introduces unnecessary overhead — having to reason about object ordering and linked references is complex compared to most open-source tools, where a single config file is the source of truth applied atomically (mail servers included). It would be much more convenient if static settings (listeners, TLS, trusted networks) lived in a plain config file, while dynamic objects (domains, certificates, roles..) were managed via the CLI.

Domains are management objects, not settings, they should not be treated as idempotent objects. You should not create a plan that deletes domains as these are referenced by multiple other objects such as Accounts, DKIM keys, etc. Creating the default domain should only be done once during the initial deployment.

Please, read my reply on the other topic - I’ve shared my experience there in more detail: Idempotent apply of non-singleton objects - #8 by jaam8