I wanted to set up a mailer for potential future services like Forgejo and also experience, what it is like to run a mailserver in 2026. I previously used Simple NixOS Mailserver, but went back to using hosted Mail by OVH. I want to keep using the hosted mail, since I also have other users on it and also don’t yet want to go through the troubles of having t-online accept my mail.
Setting up a mailserver involves configuring a lot of DNS records, which the Stalwart mailserver can automate. So let’s try that on a subdomain.
The most recent major version of Stalwart, 0.16, changed its way of configuration, and migration from 0.15 in NixOS 26.05 would likely require additional effort.
Making nixpkgs-unstable available on a stable release
I decided to go straight to 0.16, which required to pull in the stalwart packages from nixos-unstable.
My flakes.nix now looks like this:
{
inputs = {
nixpkgs = {
url = "github:NixOS/nixpkgs/nixos-26.05";
};
nixpkgs-unstable.url = "github:NixOS/nixpkgs/nixos-unstable";
disko = {
url = "github:nix-community/disko";
inputs.nixpkgs.follows = "nixpkgs";
};
sops-nix = {
url = "github:Mic92/sops-nix";
inputs.nixpkgs.follows = "nixpkgs";
};
};
outputs =
inputs@{ self, nixpkgs, ... }:
let
system = "x86_64-linux";
pkgs-unstable = import inputs.nixpkgs-unstable {
inherit system;
};
in
{
nixosConfigurations = {
madalena = nixpkgs.lib.nixosSystem {
inherit system;
specialArgs = { inherit pkgs-unstable; };
modules = [
./configuration.nix
inputs.disko.nixosModules.disko
inputs.sops-nix.nixosModules.sops
];
};
};
};
}Here, specialArgs makes pkgs-unstable available to all modules in the modules list and their imported modules. Usage will be seen later in stalwart.nix.
A module for Stalwart 0.16
Unfortunately, even nixos-unstable does not have a service module for Stalwart 0.16, and as I found out, there is no single obvious way how to write one. In the open issue for the update to 0.16, ungeskriptet posted their approach, which got me to learn about git-filter-repo.
The primary way to configure Stalwart 0.16 is by a simple json file that configures the type of Data Store and its configuration. In case of RocksDB, that looks like this:
{
"@type": "RocksDb",
"path": "/var/lib/stalwart/db"
}Everything else is loaded from that store or set with the CLI or the WebUI during runtime, using the JMAP API. The reason given for that change is that this makes it easier to keep configurations consistent in cluster deployments. Initial configuration is meant to be done either through Boostrap Mode, that prints admin credentials on standard output, or Recovery Mode, where admin credentials are set through environment variables.
For various reasons, I ended up not using the module of ungeskriptet and tried another approach. One tricky part for my usecase is that I already have Kanidm running and want to use its directory through LDAP. For a single user system this may be more trouble than it’s worth, but I’m also doing this to learn. Now, I would have to set credentials for recovery mode, use that to configure an admin user, which would also have to be in the LDAP directory and then use those credentials in normal mode to apply configurations in a declarative way.
If I were to apply parts of the configuration at every service startup, as ungeskriptet does, that surely would include setting up the LDAP directory, but it would not work without LDAP working anyway. As Stalwart offers MANY configuration options, keeping them in sync with nix just to have a more declarative deployment just seemed too laborious for my taste.
The solution I came up with, involves two conflicting systemd services. One for normal operation and one for Recovery Mode for initial configuration and for, well, recovery, should anything break. I started with minimal service definitions, generated by Gemini 3.6 Flash, that I later extended with most of the options found in the NixOS module for stalwart 0.15, once I had it working.
Here is the module, that I put in ./modules/stalwart.nix:
{
config,
lib,
pkgs,
...
}:
let
cfg = config.services.stalwart;
format = pkgs.formats.json { };
commonServiceConfig = {
# Upstream service config
Type = "simple";
LimitNOFILE = 65536;
KillMode = "process";
KillSignal = "SIGINT";
Restart = "on-failure";
RestartSec = 5;
SyslogIdentifier = "stalwart";
User = "stalwart";
Group = "stalwart";
CacheDirectory = "stalwart";
StateDirectory = "stalwart";
StateDirectoryMode = "0700";
AmbientCapabilities = [ "CAP_NET_BIND_SERVICE" ];
CapabilityBoundingSet = [ "CAP_NET_BIND_SERVICE" ];
# Hardening
DeviceAllow = [ "" ];
LockPersonality = true;
MemoryDenyWriteExecute = true;
PrivateDevices = true;
PrivateUsers = false; # incompatible with CAP_NET_BIND_SERVICE
ProcSubset = "pid";
PrivateTmp = true;
ProtectClock = true;
ProtectControlGroups = true;
ProtectHome = true;
ProtectHostname = true;
ProtectKernelLogs = true;
ProtectKernelModules = true;
ProtectKernelTunables = true;
ProtectProc = "invisible";
ProtectSystem = "strict";
RestrictAddressFamilies = [
"AF_INET"
"AF_INET6"
"AF_UNIX"
];
RestrictNamespaces = true;
RestrictRealtime = true;
RestrictSUIDSGID = true;
SystemCallArchitectures = "native";
SystemCallFilter = [
"@system-service"
"~@privileged"
];
UMask = "0077";
};
in
{
options.services.stalwart = {
enable = lib.mkEnableOption "Stalwart Mail Server";
package = lib.mkOption {
type = lib.types.package;
default = pkgs.stalwart_0_16;
defaultText = lib.literalExpression "pkgs.stalwart_0_16";
description = "The Stalwart package to use.";
};
settings = lib.mkOption {
type = format.type;
default = {
"@type" = "RocksDb";
path = "/var/lib/stalwart/db";
};
description = "Configuration options converted to `/etc/stalwart/config.json`.";
};
recovery = {
user = lib.mkOption {
type = lib.types.str;
default = "admin";
description = "Administrator username used for recovery mode.";
};
passwordFile = lib.mkOption {
type = lib.types.path;
description = "Path to the file containing the recovery password.";
};
port = lib.mkOption {
type = lib.types.port;
default = 8080;
description = "Port used by Stalwart during recovery mode.";
};
logLevel = lib.mkOption {
type = lib.types.str;
default = "info";
description = "Log level used during recovery mode.";
};
};
};
config = lib.mkIf cfg.enable {
# 1. User & Group Creation
users.users.stalwart = {
isSystemUser = true;
group = "stalwart";
description = "Stalwart Mail Server daemon user";
};
users.groups.stalwart = { };
# 2. Config File Generation
environment.etc."stalwart/config.json".source = format.generate "stalwart-config.json" cfg.settings;
# 3. Main Systemd Service
systemd.services.stalwart = {
description = "Stalwart Mail Server";
wantedBy = [ "multi-user.target" ];
after = [ "network.target" ];
conflicts = [ "stalwart-recovery.service" ];
environment = {
LD_PRELOAD = "${pkgs.mimalloc}/lib/libmimalloc.so.3";
};
serviceConfig = commonServiceConfig // {
ExecStart = "${lib.getExe cfg.package} --config /etc/stalwart/config.json";
Restart = "on-failure";
};
};
# 4. Recovery Systemd Service (Not started by default)
systemd.services.stalwart-recovery = {
description = "Stalwart Mail Server (Recovery Mode)";
wantedBy = [ ]; # Not automatically started
after = [ "network.target" ];
conflicts = [ "stalwart.service" ];
environment = {
LD_PRELOAD = "${pkgs.mimalloc}/lib/libmimalloc.so.3";
STALWART_RECOVERY_MODE = "true";
STALWART_RECOVERY_MODE_PORT = toString cfg.recovery.port;
STALWART_RECOVERY_MODE_LOG_LEVEL = cfg.recovery.logLevel;
};
serviceConfig = commonServiceConfig // {
LoadCredential = [ "recoveryPassword:${cfg.recovery.passwordFile}" ];
};
script = ''
if [ -f "$CREDENTIALS_DIRECTORY/recoveryPassword" ]; then
PASSWORD=$(cat "$CREDENTIALS_DIRECTORY/recoveryPassword")
export STALWART_RECOVERY_ADMIN="${cfg.recovery.user}:$PASSWORD"
else
echo "Error: Could not load recoveryPassword credential." >&2
exit 1
fi
exec ${lib.getExe cfg.package} --config /etc/stalwart/config.json
'';
};
};
}Note that I am not using the stalwart.webui or stalwart.spam-filters packages. Stalwart will download those at runtime. I would have prefered to use the the Nix packages, but those would have to be configured at runtime. I am looking forward to a more elegant module than mine, upstream. Here is the usage in ./stalwart.nix, where I use nixpkgs-unstable:
{
config,
pkgs-unstable,
...
}:
{
disabledModules = [ "services/mail/stalwart.nix" ];
imports = [ ./modules/stalwart.nix ];
networking.firewall.allowedTCPPorts = [
25 # smtp
465 # submissions
993 # imaps
995 # pop3s
4190 # ManageSieve
];
sops.secrets = {
"stalwart/recovery_password" = {
mode = "0400";
owner = "stalwart";
group = "stalwart";
};
};
services.stalwart = {
enable = true;
package = pkgs-unstable.stalwart_0_16;
recovery = {
user = "recovery-admin";
passwordFile = config.sops.secrets."stalwart/recovery_password".path;
};
};
services.nginx.virtualHosts."madalena.filts.net" = {
forceSSL = true;
useACMEHost = "filts.net";
locations."/" = {
proxyPass = "http://[::1]:8080";
};
};
services.nginx.virtualHosts."m.filts.net" = {
forceSSL = true;
useACMEHost = "filts.net";
locations."/" = {
proxyPass = "http://[::1]:8080";
};
};
environment.memoryAllocator.provider = "scudo";
environment.systemPackages = [
pkgs-unstable.stalwart_0_16
pkgs-unstable.stalwart-cli
];
}Since I am using Stalwart with its DNS autoconfiguration, I also let it generate its own certificates using the ACME DNS-01 challenge type. The reverse proxy responds on two subdomains, m.filts.net, that I use as the subdomain for E-Mail, and madalena.filts.net, which is the Reverse DNS I configured for that VPS. Using the Fully Qualified Domain Name set in Reverse DNS is important for SMTP greetings. The correspoinding setting in Stalwart is Default Hostname in General Network Settings.
Later, Stalwart will point the DNS records to madalena.filts.net and serve the JMAP endpoint and the WebUI from there. I assumed that this would be all I need to set, but later testing revealed, that the Thunderbird mail client looks for autoconfiguration records under the mail domain, m.filts.net, so I proxied that as well.
It turned out, that this configuation is broken. I need to proxy at least https://mta-sts.m.filts.net. I describe how I fixed that using a SNI Proxy in a later article. The whole repository can now be viewed online, so I’m leaving that part, as the gist is easier to grasp that the whole SNI proxy setup.
The line environment.memoryAllocator.provider = "scudo"; is a quick fix to work around segmentation faults, that currently happen with Stalwart’s default memory allocator. LD_preloading just in the service definitions should work as well, and would be cleaner, but I set it system-wide for the time being, to be able to also easily launch Stalwart on the command line for debugging.
For the sops setup, see a previous article. Add a new entry to secrets.yaml like this:
stalwart:
recovery_password: XXXXXXXXXThe hierarchy is a leftover of my previous plan to provide many more secrets for LDAP and DNS through sops.
Configuring Kanidm
Speaking of LDAP, it is now time to configure Kanidm. To my previous conffguration, i added these lines to enable LDAPS on Kanidm:
{
networking.firewall.allowedTCPPorts = [ 636 ];
services.kanidm.server.settings = {
# …
ldapbindaddress = "[::]:636";
# …
};
}I have not set up any administration users in Kanidm and am perfectly fine with using the builtins, the few times I have to work on it. Recall, that the admin credentials can be obtained by root by issuing
kanidmd scripting recover-account idm_admin
kanidm login --name idm_adminWe need to setup a service account for stalwart to be able to gain elevated privileges to search the LDAP directory. To create a service account, Kanidm demands a group that manages that account. As there are some groups already built in, I tried to find a matching one, browsing and searching through
kanidm group listI found the group idm_service_account_admins, that looks appropriate for that purpose:
class: builtin
class: group
class: memberof
class: object
description: Builtin Service Account Administration Group.
directmemberof: idm_high_privilege@id.filts.net
entry_managed_by: idm_admins@id.filts.net
member: idm_admins@id.filts.net
memberof: idm_high_privilege@id.filts.net
name: idm_service_account_admins
spn: idm_service_account_admins@id.filts.net
uuid: 00000000-0000-0000-0000-000000000046Create the service account named stalwart and issue an API token:
kanidm service-account create stalwart "Stalwart mailserver" idm_service_account_admins
kanidm service-account api-token generate stalwart "LDAP bindSecret"Success: This token will only be displayed ONCE
<VERY LONG TOKEN>I decided to save that token in secrets.yaml. Originally, I planned to use it for declarative configuration and I could still set Stalwart to read the token from the filesystem in the WebUI, but now it is merely there for future reference. As long as Stalwart’s data store is not corrupted, we might just as well store it there directly. In case of corruption, we’ve got a bigger problem anyways.
stalwart:
ldap-bindSecret: xxxxxxxxxxxxxxxxxxxxxxxx
recovery_password: xxxxxxxxxxxxxxxxxxxxxxxxBy default, the newly created stalwart service account won’t have enough privileges to read the mail attribute from the directory, so it has to be added to a group with elavated privileges. There is a group called idm_account_mail_read and one named idm_mail_servers. The latter seemed more appropriate.
kanidm group add-members idm_mail_servers stalwartSuccessfully added ["stalwart"] to group "idm_mail_servers"To be able to login to Stalwart, Kanidm requires a separate UNIX password for LDAP, that is used to bind the account. If the LDAP bind succeeds with that password, Kanidm considers that as successful authentication. First, set the posix attribute on the mail user, in my case, m.
kanidm person posix set mThen in the Kanidm WebUI, a UNIX password can be set. Don’t forget to also hit save on the credentials page. I made that mistake. In my case, where I set my own password as administrator, I can also use:
kanidm person posix set-password mWith that in place, it is time to rebuild and switch to the new configuration. Stalwart will startup unconfigured in normal mode. To enable the Recovery Mode, stop stalwart.service and start stalwart-recover.service:
systemctl stop stalwart && systemctl start stalwart-recoveryThis will launch an http server on the configured recovery port, 8080 in my case. To access it behind the firewall I used a ssh SOCKS proxy:
ssh -D8080 filts.netThe 8080 here is not the port on the server but the local port that the SOCKS proxy listens on. I chose 8080, because that port is set by default in the Firefox proxy settings. With the proxy set, I can then access the WebUI using the recovery credentials on http://filts.net:8080/admin.
WebUI walkthrough
The WebUI is mostly straightforward, but some things need to be set in order. So here is a walkthrough. If you ever see a 🔍 icon with nothing to select, that is a hint, that you first have to complete a side-quest.
The Stalwart WebUI offers three tabs of interest in the bottom left corner: Management, Settings and Account. First, on Management > Domains > Domains create a domain. Set Domain Name. This will be the domain on the right side of the @ in e-mail addresses.
Then, on Settings > Listeners change the port for https, since nginx is already listening on that port. I set it to 9443. Actually, that port will remain unused in my setup. I just left it there for ease of experimentation. One setting on the listener pages that gave me trouble, is Proxy networks > Override proxy networks. When those are set, Stalwart expects the Proxy Protocol to be spoken on connections from those hosts and break the reverse proxy setup above, resulting in 502 Bad Gateway errors.
There is a page in the Stalwart Documentation describing an NGINX reverse proxy setup using the stream module. That setup is likely oriented towards more enterprise-scale deployments and no good for a single host with other services running on port 443. Setting up the proxy protocol in nginx using the stream module significantly complicates the configuration of other virtual hosts. That’s why I let nginx terminate the TLS connection and proxy to plain HTTP port 8080.
On Network > HTTP > General, there is a checkbox to obtain remote IP from Forwarded header. For some reason that setting does nothing for me, even though I confirmed that the X-Forwarded-For and related headers are set correctly, using
tcpdump -i lo -A -s 0 'tcp port 8080' -nContinuing, go to Network > General to set the Default Hostname, matching reverse DNS, madalena.filts.net and Default Domain to the previously configured domain, m.filts.net. I based my settings on this thread on Kanidm Discussions. I am still very unsure about the LDAP settings and will require a lot of investigation and likely background reading to become more confident.
Still on the Settings tab, go to Authentication > Directories and add an LDAP directory. My Server URL is ldaps://id.filts.net:636. The s in ldaps turned out to be important, independent of the Enable TLS setting, which should also be checked.
My Base DN is set to dc=id,dc=filts,dc=net, Bind DN to dn=token, Bind Secret to the API token generated by Kanidm for the stalwart service account. It could also have been set to be read from a file that could have been set up by sops-nix, but I ended up not doing that. Use Bind Authentication set to true. I don’t think there is a way to get the hashed password out of Kanidm.
Set Login Filter to (&(objectClass=person)(mail=?)). On login attempts, that will search for a person with a matching email address, in my case m@m.filts.net. Kanidm should then return among others, a spn (Service Principal Name) attribute, that Stalwart then appears to authenticate with. That spn looks like m@id.filts.net.
Similarly, I set Mailbox Filter to (|(&(objectClass=person)(mail=?))(&(objectClass=group)(mail=?))). That query should be used to find valid accounts for delivery. It looks for a mail attribure on either person or group objects. In my opinion, that should also be enough to search for aliases. Multiple email addresses in Kanidm are set by repeated mail arguments:
kanidm person update m --mail m@m.filts.net --mail alias@m.filts.netThe entries are then returned as a list, according to documentation, which may be jargon for repeated attributes, because that ist what it looks like in the ldapsearch results.
I have not yet figured out, what Account Type Attribure is good for. Currently it is set to objectClass.
The other values fields are set as follows:
Description Attribute
displayname
Primary E-Mail Attribute
mail
E-mail Alias Attribute
mail
Member Of Attribute
memberOf
Password Attribute
userPassword I don’t think this does anything for us.
Password Changed Attribute
pwdChangeTime Likewise, I don’t think Kanidm exposes something like this.
Group Object Class
group
Now, under Authentication > General. The Directory can be set to the just configured LDAP directory. At this point login should work with the e-mail address as account name and the UNIX password set in Kanidm. If not, on the Management tab under Actions, there is a button to reload the settings. If that does not do it, maybe service restart is required. If I remember correctly, the directory should also be consulted in the recovery mode.
An initial login is required to make Stalwart aware of the new user. After that, with the recovery admin credentials, on the Management tab under Directory > Accounts, the new user can be given the Administrator Role. Now everything should be ready to restart into normal mode to continue with the new user.
systemctl stop stalwart-recovery && systemctl start stalwartOn the Settings tab, set up the DNS provider under Network > DNS > DNS Providers. There is a long list of supported providers.
Logging can be set under Telemetry > Tracers. I set mine to Systemd Journal, level Warning, and also to a log file. As far as I remember, I had to create the log directory manually and chose /var/lib/stalwart/log/. Even at the same Logging Level, the Log file method is more informative. Unfortunately, even at Trace level, the log file is not that verbose about LDAP.
Finally, that DNS Provider can then be used to configure Certificate Managemt and DNS Management on the Management tab in the Domains list. For my setup, it later turned out, that I also have to add Additional Hostnames to the TLS section. I now have that set to *.m.filts.net for a wildcard certificate, m.filts.net and madalena.filts.not. It will take some time for the certificates to be created through DNS, but that should be it. Follow the journal or the log file for more information.
That should be it. Stalwart should be ready to exchange mail and provisions for autoconfiguring mail clients should be in place.
Testing it out
To test the groups feature, I created a nested group in Kanidm, one top-level group, stalwart-groups, and a testing group, ldap-demo within it. My plan was to restrict the filters only to the stalwart-groups, but I have not yet figured out how to do that. I added a mail address and two users to the ldap-demo group.
kanidm group create stalwart-groups
kanidm group create ldap-demo
kanidm group add-members stalwart-groups ldap-demo
kanidm group set-mail ldap-demo ldap-demo@m.filts.net
kanidm group add-members ldap-demo m demo_userWhen I sent a mail to the group-address, the group got created and the members were added to the group, according to the WebUI. In the Mailspring client, the test-mail showed up in a newly created shared folder, while thunderbird does not show the shared folders. Weird, but I guess, at least the LDAP settings are not too wrong.
I mentioned that multiple mail aliases did not work for me, so I in addition, I set an Email Alias on my user through the Directory > Accounts Management page. That turned out to be a bug that will be fixed in version 0.16.18. Having postmaster reachable on a mail domain should be good practice.