This template appears in the catalog, and can be installed, only when
APP_EXPERIMENTAL=mail is set on the control plane. With the flag off it is hidden from the listing and refused at install.mail-mox template runs the official mox image (MIT) as a normal app. It is experimental: the control plane manages it from Settings, Mail only with APP_EXPERIMENTAL=mail (see Experimental features). The template is named “Mail server (mox, experimental)” and is shown only when that flag is on. Read Deliverability before you move real mail onto it.
Stalwart stays the separate, optional groupware template (stalwart, with JMAP and its own management console). This template does not replace or change it.
Install
One command, with your mail domain as a setting:templates deploy mail-mox stops with a message that points to templates install, because the domain cannot be guessed. If the domain is missing, the container logs one line saying so and exits.
What it installs
One container, pinned by image digest, with two named volumes:mail_config at /mox/config and mail_data at /mox/data. On first start it runs mox quickstart non-interactively for your hostname and admin address, sets the web admin password from a generated secret, and writes mox.conf from your inputs. Later starts rebuild only mox.conf; domains.conf belongs to the control plane and is never rewritten.
mox starts as root only to bind its ports, then runs as numeric uid 1000. The config and data volumes are owned by that uid.
The admin password is the generated secret
SERVICE_PASSWORD_MAILADMIN on the app. The admin API listens on container port 8080 and is not published on the host. The webmail and account UI are published as 8025 (container port 8081). The control plane builds the adapter address from the service’s container address on the app network and port 8080 (override with APP_MAIL_ADMIN_PORT). Queue operations are hold and release.
Ports
The template declares 25, 465, 587 and 993 as published host ports with a private bind (App ports and exposure), so right after install they answer on 127.0.0.1 only. Each port has its own bind: making the mail ports public never publishes the web listener.
The journey is:
- Install the template.
- Open the mail ports:
levelrail-cli mail ports open --confirm, or Open mail ports on the Mail page. This sets a public bind on exactly 25, 465, 587 and 993, adds a host firewall allow rule for each, recreates the container and is audited. It asks you to confirm it’s you, like other exposure changes. Running it again changes nothing.levelrail-cli mail portsshows the bind and firewall state of each port. - Set up DNS (Mail DNS).
Resources
The container is capped atAPP_MAIL_DEFAULT_MEMORY_MB (default 256) MiB and APP_MAIL_DEFAULT_CPUS (default 1) CPUs, read by the control plane when it loads the catalog. An idle instance uses tens of MiB.
Pick a node
The template has no node field of its own. Choose the node in the deploy dialog (or the node option of the deploy command) like for any app. Pick a node whose provider allows outbound port 25 and lets you set reverse DNS. See Multi-node for placement.TLS and restarts
The first start works with no manual steps. mox makes a self-signed certificate itself in the config volume (certs/selfsigned.pem) and the log says so. It is valid for localhost only, so it is for testing: mail clients and other servers will not trust it for your mail host name. Replace it before sending real mail.
If you set only one of the two certificate inputs, or point them at files that do not exist, mox exits with one line saying which files to add.
To use a real certificate, put the chain and key in the config volume (for example certs/cur.pem and certs/cur.key), set the two inputs and restart. Existing files are never overwritten. mox has no reload: restart after every renewal. The built-in ACME client is not offered because ports 80 and 443 are not published.
Check the state with levelrail-cli mail status (installed, container, admin API, certificate kind and the next step).
Check and upload a certificate
levelrail-cli mail cert statusconnects to the mail server’s private submission port (465) and shows the served certificate: subject, issuer, expiry, days left, and whether it is self-signed. It sends no credentials and only probes the container’s private address.levelrail-cli mail cert upload --cert chain.pem --key key.peminstalls your own certificate. The chain must start with the leaf and list each issuer after the certificate it signed, cover the mail host name and be valid now. The leaf key must be RSA 2048 or larger, ECDSA P-256 or larger, or Ed25519, and if the leaf lists extended key usages, server authentication must be one of them. The key must match and must not be encrypted. Each of the two fields is capped byAPP_MAIL_CERT_MAX_PEM_KB(default 64). It asks for your password.
- Sealed in the control plane secrets store (
mail-tls-cert/<app>), encrypted with the master key. It is never shown again, logged or written to audit. The sealed copy is deleted when the mail app is deleted. - In plaintext in the mail config volume as
certs/managed.key(mode0600), next tocerts/managed.pem. The previous managed pair is kept ascerts/managed.prev.pemandcerts/managed.prev.key. mox reads these files, so anything that can read that volume can read the key: the mox container itself, root on the node, and a user with exec into the mail container. - The volume files API and dashboard file browser refuse to read, overwrite, delete or restore any
certs/managed.*file of the mail config volume (403), including through a symbolic link, a different letter case or extra slashes. Listing still shows the file names and sizes, never the contents. - Generic volume backups of the mail config volume leave out
certs/managed*.keyand upload temp files. After restoring such a backup, runmail cert uploadagain, because mox points at a key that is no longer there. Mail backups made withmail backupare age-encrypted streams of mox’s own backup.
- The new pair is first written under temporary names (
certs/managed.new.*) and read back, so an agent that cannot write0600fails before the live files change. Then the live pair is replaced back to back, mox is restarted through the normal restart path and probed until the served leaf has the new SHA-256 fingerprint. If it does not, withinAPP_MAIL_CERT_VERIFY_SECONDS(default 90), the previous files and settings are restored on a fresh deadline (APP_MAIL_CERT_ROLLBACK_SECONDS, default 120) and mox restarts again. - One install runs at a time per mail app. A journal (
certs/managed.journal) marks the install; a second upload gets 409 until it ends or its lease runs out (APP_MAIL_CERT_INSTALL_LEASE_SECONDS, default three verify periods plus the rollback period plus a minute). If the control plane stopped mid install, the next upload first finishes the old one: a pair already served is kept, anything else goes back to the previous pair. - If restoring the previous pair also fails, the outcome is
rollback_failedin the API, the audit log and the CLI. The mail server may then serve a missing or mismatched pair; the next upload retries the rollback first. - With
APP_MAIL_RESTART_WINDOWset (for example02:00-05:00, UTC), uploads outside the window are refused unless you pass--restart-now. - If
MAIL_TLS_CERT_FILEpoints at your own files, upload refuses and never touches them. Clear both inputs first. mail cert obtain(a certificate through the platform’s ACME) is not yet implemented and says so.
0600. The upload then stops before the live files change and deletes the staged key; if that delete fails, it says the key may be readable and must be deleted by hand.
Relay mode
SetMAIL_SMARTHOST_HOST to send through a provider. The template writes a relay1 transport (Submissions for port 465, Submission otherwise, with Auth when a user is set) and a default route to it in domains.conf if no route exists yet. Adding or changing a transport needs a container restart.
Backup
Back up both volumes together. For a consistent copy runmox backup <dir> inside the container and copy the result; the per-volume pre-backup hook is a policy set per app with levelrail-cli backups volumes policy set <app> <volume> --pre-hook CMD, not part of a template, so set it yourself. A raw volume copy of a busy mailbox store can be inconsistent. Restoring across a mox upgrade is one way: mox has no downgrade path.
Deliverability reality
Running your own mail server is mostly DNS and reputation work. You need a static IP with a clean history, reverse DNS that matchesMAIL_HOSTNAME, outbound port 25 open, and the MX, SPF, DKIM, DMARC, MTA-STS and TLS-RPT records for each domain (see Mail DNS). Large providers may still junk or delay mail from a new IP. Relay mode is the safer start for outbound mail.
Security
What the template does:- The admin UI, admin API and metrics sit on their own listener (container ports 8080 and 8010) that is never published on the host. Webmail, the account UI and the web API sit on a separate listener (8081), which serves no admin pages:
/admin/answers 403 there. Reaching webmail does not expose the admin login. - The TLS certificate and key are required. There is no fallback to a built-in certificate client that could never succeed.
- mox starts as root only to bind ports and then runs as uid 1000.
- The image is pinned by digest and the container is capped on memory and CPU.
- The platform drops all Linux capabilities from every app container and keeps a small set (CHOWN, DAC_OVERRIDE, FOWNER, FSETID, KILL, NET_BIND_SERVICE, SETGID, SETUID), and sets no-new-privileges. mox starts and drops to uid 1000 under that policy; the template cannot ask for fewer, because the compose layer rejects
cap_add,cap_drop,read_onlyandtmpfs.
- The admin password reaches the container as an environment variable, so anyone with Docker access on the node (
docker inspect) can read it. Secrets cannot be delivered as files by a template today. Keep Docker access on the node limited to operators. - The root filesystem stays writable, and the platform keeps a wider capability set than mox needs. Tested with mox v0.0.17: it runs with only CHOWN, DAC_OVERRIDE, FOWNER, NET_BIND_SERVICE, SETGID and SETUID, and also with a read-only root filesystem and a tmpfs at
/tmp, but a template cannot request either today. - Ports 25, 465, 587 and 993 serve the internet once you make them public, with the usual exposure of any mail server. Keep the admin and metrics ports private and firewalled.
- The relay password, when set, is also an environment variable with the same exposure.
Local demo mode
To try the Mail page without a mail container, DNS provider or domain, runscripts/dev-local.sh. It sets APP_MAIL_DEMO=1 next to APP_DEV_MODE=1; set LOCAL_MAIL_DEMO=0 to opt out.
Demo mode needs both variables. APP_MAIL_DEMO alone is ignored with one warning in the log, and release builds never enable it.
The page shows a DEMO DATA badge. A fake mail server holds two domains, five mailboxes, aliases and a queue with a failing message.
Plan and apply write the real planned records (the same builder as production) into an in-memory zone, never a provider.
The check resolves against that zone: SPF and DMARC fail first, and each record turns green after apply.
Opening the mail ports only flips a fake flag. Use Reset demo on the badge to return to the seeded state.
The default domain is demo.example, the mail IP is a documentation address, and every secret is an obviously fake string.
Nothing demo ever reaches a real DNS provider or mail engine.
Readiness and status
The web listener answers 403 on/admin/ by design, so the template declares its own platform probe: GET /webmail/ on the routed port 8081, expecting 2xx. The container healthcheck still checks the private admin listener and SMTP.
levelrail-cli mail status reports ready and the controller’s reconcile reason (for example ReadinessFailed). A running container with a failing readiness check is flagged in next.
Installs made before this fix keep the old probe on their service row, so the app keeps failing readiness. Run levelrail-cli templates install mail-mox --name <app> once, or use Redeploy, to store the new probe.