Deploy
A production daemon runs as its own system user, so your user and your agents cannot read its vault or its state. The quick start runs a dev daemon as yourself, which is for development only. The security model explains why each piece below matters.
Every production daemon needs the same things:
- A system user for the daemon, such as
chainmail. - The client group,
chainmail-clientsby default (daemon.client_group). The daemon refuses to start when the group does not exist. The daemon's user is in it too, so it can give its socket that group. Each user who may use the daemon is a member. - The state directory,
/var/lib/chainmailby default, owned by the daemon's user with mode0700. It holdsvault.age,state.dbandcache.db, which is not encrypted. - The socket's directory,
/run/chainmailby default, owned by the daemon's user with mode0755. The socket itself gets mode0660and the client group. - The config at
/etc/chainmail/config.toml. It holds no secrets. - The vault key, an age identity. The daemon refuses a key file that others can read.
chainmail daemonrun as that user, without--dev, with umask077.
The NixOS module sets all of this up. On other Linux hosts and on macOS you set it up yourself.
NixOS
The flake exports a NixOS module as nixosModules.default. It runs the daemon as the chainmail system user, with its state in /var/lib/chainmail and its vault key sealed with systemd-creds, and puts the chainmail CLI on PATH. Since it adds the package to environment.systemPackages, the shell completion scripts and the man page are installed system-wide with it. The flake builds the package for x86_64-linux and aarch64-linux.
Add the flake input
In your host's flake, add chainmail as an input and the module to the host. mailhost stands for your host's configuration name, and ./configuration.nix for the rest of its configuration.
{
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
inputs.chainmail.url = "github:yaklabco/chainmail";
outputs = { nixpkgs, chainmail, ... }: {
nixosConfigurations.mailhost = nixpkgs.lib.nixosSystem {
modules = [
./configuration.nix
chainmail.nixosModules.default
{
services.chainmail = {
enable = true;
clientUsers = [ "alice" ];
vaultKeyCredential = ./chainmail-vault-key.cred;
settings = {
policy.send_allow = [ "*@example.org" ];
accounts.site = {
provider = "hostinger";
};
};
};
}
];
};
};
}
settings is the daemon's config as Nix values. The configuration reference lists every key, and Accounts and providers shows each provider's account table.
This page covers deploying with nixos-rebuild. A host built by a GitOps tool from a repository uses the same flake input and module. Setting up such a tool is out of scope here.
Seal the vault key
Make an age key and seal it to the host's TPM2, on the host itself. Then commit the .cred file with the host's config. Only that host's TPM can decrypt it, so the file is safe in the repository and in the Nix store.
nix shell nixpkgs#age -c age-keygen -o '<key-file>'
sudo systemd-creds encrypt --with-key=tpm2 --name=vault-key '<key-file>' chainmail-vault-key.cred
'<key-file>' is a new file in a private directory. age-keygen -o writes it with mode 0600. Keep that plain key offline as well, such as in a password manager, and then remove it from the host. It is the only way to open the vault on another machine or after the TPM is reset (see The vault key).
To keep a dev daemon's accounts and history, seal the dev key instead, ~/.config/chainmail-dev/vault-key in the quick start, and follow Move a dev daemon's state to production after the first deploy.
Options
| Option | Type | Default | Meaning |
|---|---|---|---|
services.chainmail.enable | bool | false | Runs the daemon. |
services.chainmail.package | package | this flake's package | Provides the daemon and the CLI. |
services.chainmail.clientUsers | list of strings | [] | Users added to the chainmail-clients group, who may use the socket. Membership applies from the user's next login, so a running agent session needs a new login or sg chainmail-clients. |
services.chainmail.vaultKeyCredential | path, or an absolute path string | required | A systemd-creds encrypted file holding the vault's age key, encrypted with the name vault-key. A Nix path is copied to the store, which is safe because it is encrypted. An absolute path string points at a credential made on the host. |
services.chainmail.settings | TOML attribute set | {} | The daemon config, written to /etc/chainmail/config.toml with mode 0444. It must hold no secrets. |
The module sets settings.daemon.socket to /run/chainmail/chainmail.sock and settings.daemon.client_group to chainmail-clients by default. It refuses another client group, and a socket outside /run/chainmail/. It also refuses a vaultKeyCredential that is neither a Nix path nor an absolute path. Each refusal fails the build with an assertion message (see The daemon refuses to start). The daemon has no log level flag, so there is no log level option.
What the module sets up
It creates the chainmail user and group, the chainmail-clients group, and a hardened chainmail.service (description chainmail policy daemon). The unit:
- runs
chainmail daemon --config /etc/chainmail/config.tomlaschainmail, withchainmail-clientsas a supplementary group so it can give the socket that group. - starts after
network-online.target, and is wanted bymulti-user.target. - has
StateDirectory=chainmail(mode0700) andRuntimeDirectory=chainmail(mode0755, since the socket's own0660mode and group are the gate), andUMask=0077. - loads the key with
LoadCredentialEncrypted=vault-key:<vaultKeyCredential>. With no--vault-key-file, the daemon reads$CREDENTIALS_DIRECTORY/vault-key. - restarts on failure after 5 seconds, and whenever the rendered config changes.
- runs with
ProtectSystem=strict,ProtectHome,PrivateTmp,PrivateDevices,NoNewPrivileges, no capabilities, and only Unix, IPv4 and IPv6 sockets, among other hardening.
ProtectSystem=strict leaves only the state and runtime directories writable. The cache needs no settings: cache.db is created in /var/lib/chainmail, beside state.db. Leave cache.dir unset under the module, since the unit cannot write anywhere else. To tune the cache, set services.chainmail.settings.cache, for example cache.prefetch_bodies = "30d";, or per account settings.accounts.<name>.cache.
The health checks page has the whole layout in one table.
Deploy and add secrets
sudo nixos-rebuild switch --flake .#mailhost
systemctl status chainmail --no-pager
The first start creates /var/lib/chainmail with an empty vault. Then, as a user in clientUsers, after a new login, store each account's secrets and log in to OAuth2 accounts, as in Accounts and providers:
chainmail secret set accounts/site
chainmail accounts list --output table
Client users reach the daemon through the default socket, /run/chainmail/chainmail.sock, with no settings.
The module's own tests
On x86_64-linux, nix flake check runs a NixOS VM test of the module. It starts the service and checks its user, directories, socket permissions, the policy table in the config, and that only client users can connect. A second check evaluates the module with another client group and with a socket outside /run/chainmail/, and requires both to fail evaluation.
Other Linux hosts
Set up the user, group and directories
Run these as a user with sudo. '<chainmail-binary>' is the chainmail binary from a release archive (see Install), or bin/chainmail after go tool stave build in a checkout. '<vault-key-file>' is the age key the daemon will use, and '<user>' is a user who may use the daemon.
sudo install -m 0755 '<chainmail-binary>' /usr/local/bin/chainmail
sudo groupadd --system chainmail-clients
sudo useradd --system --user-group --groups chainmail-clients --home-dir /var/lib/chainmail --shell /usr/sbin/nologin chainmail
sudo install -d -o chainmail -g chainmail -m 0700 /var/lib/chainmail
sudo install -d -o chainmail -g chainmail -m 0755 /run/chainmail
sudo install -d -m 0755 /etc/chainmail
sudo install -o chainmail -g chainmail -m 0400 '<vault-key-file>' /etc/chainmail/vault-key
sudo usermod -aG chainmail-clients '<user>'
A new key comes from age-keygen -o '<vault-key-file>'. Keep a copy offline. Group membership applies from the user's next login. On Linux the daemon reads users and memberships from /etc/passwd and /etc/group, so a membership that exists only in LDAP or SSSD is refused.
Write the config to /etc/chainmail/config.toml. A minimal one:
[policy]
send_allow = ["*@example.org"]
[accounts.site]
provider = "hostinger"
Run the daemon once in the foreground to check the setup. It logs daemon listening when it is ready, and Ctrl-C stops it.
sudo -u chainmail sh -c 'umask 077 && exec /usr/local/bin/chainmail daemon --config /etc/chainmail/config.toml --state-dir /var/lib/chainmail --vault-key-file /etc/chainmail/vault-key'
A systemd unit
For real use, run the daemon from a unit modelled on the one the NixOS module writes. Save this as /etc/systemd/system/chainmail.service:
[Unit]
Description=chainmail policy daemon
Wants=network-online.target
After=network-online.target
[Service]
ExecStart=/usr/local/bin/chainmail daemon --config /etc/chainmail/config.toml --vault-key-file /etc/chainmail/vault-key
User=chainmail
Group=chainmail
SupplementaryGroups=chainmail-clients
StateDirectory=chainmail
StateDirectoryMode=0700
RuntimeDirectory=chainmail
RuntimeDirectoryMode=0755
UMask=0077
Restart=on-failure
RestartSec=5s
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
PrivateDevices=true
NoNewPrivileges=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
RestrictNamespaces=true
LockPersonality=true
MemoryDenyWriteExecute=true
SystemCallArchitectures=native
CapabilityBoundingSet=
AmbientCapabilities=
[Install]
WantedBy=multi-user.target
Then enable and start it:
sudo systemctl daemon-reload
sudo systemctl enable --now chainmail
systemctl status chainmail --no-pager
RuntimeDirectory= creates /run/chainmail at each start, since /run is emptied at boot. The state directory is the default, so the unit leaves out --state-dir. A config edit needs sudo systemctl restart chainmail, since the daemon reads its config only at start.
The daemon also reads CHAINMAIL_CONFIG, CHAINMAIL_STATE_DIR, CHAINMAIL_VAULT_KEY_FILE and CHAINMAIL_DEV when the matching flag is not given. A systemd unit starts with a clean environment, so none is set unless the unit sets it.
To keep the key out of a plain file, seal it with systemd-creds encrypt as on NixOS and save the result as /etc/chainmail/chainmail-vault-key.cred. Then remove --vault-key-file /etc/chainmail/vault-key from ExecStart, and add LoadCredentialEncrypted=vault-key:/etc/chainmail/chainmail-vault-key.cred to [Service]. The daemon then reads $CREDENTIALS_DIRECTORY/vault-key, which systemd delivers as a root:root file the service can read. The daemon accepts that form.
macOS
A Homebrew Cask cannot install a launchd daemon, so on macOS you make the user, the group, the directories and the launchd job yourself. Install the binary with brew install yaklabco/tap/chainmail. It lands in $(brew --prefix)/bin, which is /opt/homebrew/bin on Apple silicon and /usr/local/bin on Intel Macs.
macOS has no /run, so the config must set daemon.socket to a path in a directory the daemon owns, and clients must be told where it is. This page uses /usr/local/var/run/chainmail/chainmail.sock.
Make the user and group
Pick an unused group ID and user ID below 500, so the user stays out of the login window. dscl . -list /Groups PrimaryGroupID and dscl . -list /Users UniqueID show the ones in use.
sudo dseditgroup -o create -i '<gid>' chainmail-clients
sudo dscl . -create /Users/_chainmail
sudo dscl . -create /Users/_chainmail UniqueID '<uid>'
sudo dscl . -create /Users/_chainmail PrimaryGroupID '<gid>'
sudo dscl . -create /Users/_chainmail UserShell /usr/bin/false
sudo dscl . -create /Users/_chainmail NFSHomeDirectory /var/empty
sudo dscl . -create /Users/_chainmail IsHidden 1
sudo dseditgroup -o edit -a '<user>' -t user chainmail-clients
The daemon's user, _chainmail, has chainmail-clients as its primary group, so it can give its socket that group. '<user>' is a user who may use the daemon. Membership applies from that user's next login. On macOS the daemon looks users and groups up through the system's directory services, so a group made with dseditgroup works.
Make the directories and the config
sudo install -d -o _chainmail -g chainmail-clients -m 0700 /var/lib/chainmail
sudo install -d -o _chainmail -g chainmail-clients -m 0755 /usr/local/var/run/chainmail
sudo install -d -o _chainmail -g chainmail-clients -m 0700 /usr/local/var/log/chainmail
sudo install -d -m 0755 /etc/chainmail
sudo install -o _chainmail -g chainmail-clients -m 0400 '<vault-key-file>' /etc/chainmail/vault-key
The config at /etc/chainmail/config.toml names the socket:
[daemon]
socket = "/usr/local/var/run/chainmail/chainmail.sock"
[policy]
send_allow = ["*@example.org"]
[accounts.site]
provider = "hostinger"
A launchd job
Save this as /Library/LaunchDaemons/com.example.chainmail.plist, with your own label in place of com.example.chainmail. Use /usr/local/bin/chainmail as the first argument on an Intel Mac.
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.example.chainmail</string>
<key>ProgramArguments</key>
<array>
<string>/opt/homebrew/bin/chainmail</string>
<string>daemon</string>
<string>--config</string>
<string>/etc/chainmail/config.toml</string>
<string>--state-dir</string>
<string>/var/lib/chainmail</string>
<string>--vault-key-file</string>
<string>/etc/chainmail/vault-key</string>
</array>
<key>UserName</key>
<string>_chainmail</string>
<key>GroupName</key>
<string>chainmail-clients</string>
<key>Umask</key>
<integer>63</integer>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<dict>
<key>SuccessfulExit</key>
<false/>
</dict>
<key>ThrottleInterval</key>
<integer>5</integer>
<key>StandardErrorPath</key>
<string>/usr/local/var/log/chainmail/daemon.log</string>
</dict>
</plist>
Umask is decimal, so 63 is the octal 077 the daemon needs. KeepAlive with SuccessfulExit false restarts the daemon when it fails, as Restart=on-failure does under systemd. The daemon's log goes to StandardErrorPath.
Load and start it, then check it:
sudo chown root:wheel /Library/LaunchDaemons/com.example.chainmail.plist
sudo chmod 0644 /Library/LaunchDaemons/com.example.chainmail.plist
sudo launchctl bootstrap system /Library/LaunchDaemons/com.example.chainmail.plist
sudo launchctl print system/com.example.chainmail
sudo tail -n 20 /usr/local/var/log/chainmail/daemon.log
Start, stop and restart has the other launchctl commands.
Point clients at the socket
Clients look for /run/chainmail/chainmail.sock unless told otherwise. The simplest way to tell every shell and every agent is socket in cli.toml. This writes ~/.config/chainmail/cli.toml, replacing any you have. When XDG_CONFIG_HOME is set to an absolute path, the file belongs in $XDG_CONFIG_HOME/chainmail/ instead.
mkdir -p ~/.config/chainmail
printf 'socket = "%s"\n' /usr/local/var/run/chainmail/chainmail.sock > ~/.config/chainmail/cli.toml
chainmail admin status
Or set CHAINMAIL_SOCKET in your shell's startup file:
- bash
- fish
export CHAINMAIL_SOCKET=/usr/local/var/run/chainmail/chainmail.sock
set -gx CHAINMAIL_SOCKET /usr/local/var/run/chainmail/chainmail.sock
An agent started from somewhere other than your shell does not see the variable, so cli.toml is the safer choice for chainmail mcp. chainmail admin status should show dev mode false.
Check the deployment
Run the health checks. They cover the unit, the log, the socket and your groups, the daemon's status, the accounts, watching and the cache. To move a dev daemon's accounts and history over, follow Move a dev daemon's state to production.