Supported loopback-only first provisioning on macOS — v0.16.23 / v0.16.24

Your question

Hello,

We are evaluating the unmodified official macOS arm64 binary of Stalwart v0.16.23
for a single-user, single-domain local installation. During initial provisioning,
we require listeners to remain on loopback from the first bind, including IPv6.
We will use supported management interfaces and opaque official backups only.

The documentation updated September 18 describes starting recovery mode, applying
configuration via the CLI, then restarting without recovery variables. In the
v0.16.23 source, the recovery listener binds [::]:port; the documented environment
variable changes the port but not the address. STALWART_PUBLIC_URL changes
discovery URLs, not listener bindings. A DataStore config with an empty registry
also does not establish loopback listeners: normal startup inserts default
listeners before binding. We have not started this unconfigured server on a
network-connected host.

We also compared the registry initialization, local configuration loader, boot,
defaults and recovery listener source files in v0.16.24 (September 27). All five
are byte-identical to v0.16.23. We subsequently ran bounded local tests of the
official v0.16.24 binary (JMAP/DAV, Sieve, idle memory, STARTTLS and supervision)
on prepared synthetic stores. Those tests do not establish a safe virgin bootstrap.

Could you provide an officially supported procedure and applicable released
binary version for either loopback-only bootstrap/recovery or initial offline
configuration through an object-level interface? In particular:

  1. Is there a supported bind-address option beyond the documented port variable?

  2. Is there a way to apply initial management objects before opening any listener,
    without writing raw database records or modifying the server?

  3. If neither is available, is there an official planned version or recommended
    supported isolation method for this requirement?

  4. Which supported backup/restore interfaces cover accounts, mail, contacts,
    calendars, server configuration and pending transport queues?
    On the pinned v0.16.23 binary with CLI v1.0.12, an empty QueuedMessage create
    is explicitly rejected with “Queued messages cannot be created”; the source
    rejects all creates in that handler. Which supported recovery procedure
    preserves pending recipient states, retry schedules and their message blobs
    without resubmitting messages? Is there a supported routine backup interface
    for the embedded backend, distinct from the migration-only export/import?

  5. How can we start a restored server with message delivery already suspended,
    before any queue worker processes due entries? In an offline synthetic test,
    PauseMtaQueue prevented a due local delivery in the source process, but after
    a cold export/import the target delivered that message without ResumeMtaQueue.
    The pinned queue manager initializes is_paused to false. Is there a supported
    persistent/startup hold that covers local and remote delivery, not an action
    issued after startup when delivery may already have begun?

  6. Our service launches with a minimal explicit environment. A responding JMAP
    session can still advertise endpoints rejected by our same-origin local client.
    Supplying STALWART_PUBLIC_URL (HTTP loopback, port 18080) in the isolated lab makes
    the client work. What supported persistent configuration allows a local
    administration client while retaining correct public discovery URLs for
    external mail/DAV clients? We will not silently follow foreign endpoints.
    We checked the September 26 environment-variable documentation and the
    v0.16.23 network loader and HTTP session handler: they use a single
    http.url_https base, including for authenticated sessions. We therefore
    cannot ship the lab loopback override as the global public URL. Is there a
    supported local administration transport or per-listener discovery mechanism,
    or should clients preserve the public origin while explicitly routing the
    connection to a trusted local listener?

    Subsequent bounded tests on prepared v0.16.23 stores succeeded with a public
    discovery base and explicitly loopback-routed HTTPS requests: public Host,
    certificate validation for the public hostname, no redirects and no network
    certificate fetching. The Swift client, health monitor and management readers
    passed these local tests using a synthetic certificate and the default server
    certificate. This is a candidate solution for our single-domain transport,
    not a safe virgin bootstrap procedure or a claim of multi-domain SNI support.
    We would appreciate confirmation of any supported constraints on that approach.

We distinguish CLI apply --dry-run (validation only) from actual provisioning,
and server --import of an opaque export from applying a declarative JSON plan.
An offline VM was used for our synthetic lab setup; it is not currently part of
our proposed customer product. No shared lab credentials or data will be shipped.

References (condensed to respect the forum’s new-user link limit):

Thank you,
Kevin — LorisLabs

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

Thanks for the detailed writeup. Based on what’s actually in the docs and source at this tag, here’s the state of each point.

1–2. Bind address for bootstrap/recovery, and pre-listener object loading
There is no supported option to change the recovery/bootstrap listener’s bind address. Per source (crates/common/src/config/server/listener.rs), when is_recovery_mode() is true, Stalwart hard-codes the bind to [::]:<STALWART_RECOVERY_MODE_PORT or 8080> — only the port is configurable via env var, confirming what you found. The documented workflow (config.json → STALWART_RECOVERY_MODE=1 + STALWART_RECOVERY_ADMIN → stalwart-cli apply → restart normally) is the only officially described sequence, and it does not offer a loopback-only bind during that phase. There is also no supported mechanism to load initial management objects before any listener opens — the recovery listener itself is what CLI apply talks to.

3. Planned support / recommended isolation
Nothing in current docs states a planned change here or a recommended network-isolation method for this scenario. I can’t confirm a roadmap item, so I won’t speculate on one.

4. Backup/restore interfaces
The only built-in database-level tool is the export/import utility (--export / --import), explicitly documented as migration-only, not a backup solution: “It’s important to understand that the import/export utility is not a substitute for proper backup procedures… Users should continue to rely on their database backend’s native tools.” It does dump/restore the queue family (via EXPORT_TYPES=queue or full export) along with data, blob, registry, etc., so queued messages, retry state and blobs are included in a normal export/import — that part is supported. There is no separate “routine backup” API distinct from this for the embedded backend.

5. Persistent pause across restart
Correct: PauseMtaQueue/ResumeMtaQueue are runtime actions, and in source QueueManager::new always initializes is_paused: false — pause state is in-memory, not persisted to the store, so it does not survive an export/import or a restart. There is no documented startup flag or persistent object that starts the queue pre-paused; PauseMtaQueue must be issued after the server (and its queue manager) is already up, which is exactly the race you observed.

6. Local admin transport vs. public discovery URL
Confirmed by docs: STALWART_PUBLIC_URL replaces “the scheme, host, port, and optional path prefix in every published discovery URL” globally — it’s a single base for all JMAP/OAuth/OIDC discovery documents, not a per-listener override. There’s no documented per-listener discovery/admin-transport split. The only documented pattern for local-only administration during setup is the recovery/bootstrap HTTP listener itself (port 8080, unauthenticated-to-directory via STALWART_RECOVERY_ADMIN), not a permanent parallel admin origin with a differing discovery base. Your approach of keeping the public origin and routing the connection to the trusted local listener while validating against the public hostname is a client-side workaround, not something the docs describe as an officially supported mode — I can’t confirm or deny additional constraints beyond what STALWART_PUBLIC_URL’s single-base behavior already implies.

Given points 1, 2, 5 and part of 6 aren’t addressed by any documented option, and no planned-version statement exists for them:

This isn’t covered in the current docs. A community member or staff member will follow up.

Reference used: Declarative deployments | Stalwart, Recovery mode | Stalwart, Bootstrap mode | Stalwart, Environment variables | Stalwart, Database Migration | Stalwart

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

No released version has a bind address for the recovery listener. STALWART_RECOVERY_MODE_PORT is the only knob, it always binds [::], and 1.0.0 doesn’t change that.

You can still keep every normal start on loopback. Default listeners are only created when no NetworkListener objects exist and the server isn’t in recovery or bootstrap mode, so if you start in recovery mode and stalwart-cli apply a plan whose listeners bind 127.0.0.1 and [::1], the first normal start binds those and nothing else (Declarative deployments | Stalwart). The recovery HTTP port, which needs the recovery admin credentials, is then the only thing ever exposed, and isolating it is a job for the OS: a pf rule, or provisioning on an offline machine and moving the stopped data directory across. Nothing accepts objects with no listener open at all.

Account data (mail, contacts, calendars, Sieve, identities, files) is covered by Vandelay archives, and server configuration by stalwart-cli snapshot. Neither covers the queue, and QueuedMessage can’t be created through the API, so recipient state, retry schedules and their blobs survive only in a copy of the whole store; with the embedded backend that means stopping the server and copying or snapshotting the data directory. --export/--import is for migrations and shouldn’t stand in for backups.

PauseMtaQueue isn’t persisted. A hold that’s in place from startup can be done with a cluster role: create a ClusterRole with outboundMta disabled and start with STALWART_ROLE=<name>. A node in that role never starts the queue manager, and since local delivery goes through the queue too, both stay held until you restart without the variable (Roles | Stalwart). It isn’t a documented single-node pattern, so verify it in your lab before you rely on it.

For the local admin client, what you tested is what I’d do. Discovery has a single public base URL with nothing per listener, so keeping the public origin and certificate name while routing the connection to a loopback listener is correct. The only thing the server treats differently on loopback is the exemption from the allowedEndpoints rule.