NodeMailServer
Deployment

Docker Compose

Run the mail server, KumoMTA, IMAP and Redis together with one compose file.

deploy/docker-compose.yml runs the whole stack. Run every command from the repository root.

Start it

# 1. Configuration: copy each example and fill it in
cp server/.env.example server/.env
cp outbound/.env.example outbound/.env
cp outbound/policy/users.example.lua outbound/policy/users.lua
cp relay/.env.example relay/.env
cp relay/policy/users.example.lua relay/policy/users.lua
 
# 2. Start inbound, outbound, relay and Redis
docker compose -f deploy/docker-compose.yml up -d
docker compose -f deploy/docker-compose.yml ps
 
# Optional: also start IMAP
cp server/imap/conf/imap.example.xml server/imap/conf/imap.xml
cp server/imap/conf/users.example.xml server/imap/conf/users.xml
docker compose -f deploy/docker-compose.yml --profile imap up -d

Services

ServiceImagePublished portRole
inboundBuilt from server/Dockerfile25The Node mail server. Receives mail on port 25.
outboundghcr.io/kumocorp/kumomta:latest587KumoMTA. Accepts authenticated mail and delivers it.
relayghcr.io/kumocorp/kumomta:latest2587KumoMTA. Public relay with a login and a webhook per account. See Relay.
imapBuilt from server/imap/Dockerfile143 (127.0.0.1 only), 993Gluon IMAP server with mail in Pebble. 993 answers once TLS is set. Only with --profile imap.
redisredis:7-alpinenoneUsed by KumoMTA for DKIM keys. Not published.

The inbound image runs bun src/main.ts, because Bun resolves the @/ path alias that plain Node cannot.

Volumes

VolumeMounted atHolds
kumo-spool/var/spool/kumomtaKumoMTA's queued messages.
kumo-logs/var/log/kumomtaKumoMTA's logs.
relay-spool/var/spool/kumomtaThe relay's queued messages and pending webhook events.
relay-logs/var/log/kumomtaThe relay's logs.
imap-data/data (read-write)Stored mail (Pebble, in /data/mail) and Gluon's cache (/data/gluon). Back this volume up.
redis-data/dataRedis data.

outbound/policy/ is mounted read-only at /opt/kumomta/etc/policy. To use file-based DKIM keys, uncomment the outbound/dkim mount in the compose file.

The relay service mounts relay/policy/ the same way, and has its own commented-out mounts for relay/dkim and relay/tls.

The imap service mounts two folders:

FolderMounted atModeWhy
server/imap/conf/etc/imapread-writeimapctl user add/update/delete saves users.xml there, with a users.xml.bak of the previous version.
server/imap/tls/etc/ssl/imapread-onlyThe certificate and private key. The server never writes them.

The container runs as uid 1000. On a Linux host, make the config folder writable for it: sudo chown -R 1000:1000 server/imap/conf.

Who owns port 587

Only one program can publish a host port. In this compose file:

  • The Node app publishes 25 and KumoMTA publishes 587.
  • The Node app still opens 587 inside its own container, but it is not published, so nothing outside can reach it.

To send through the Node app instead, publish 587 on inbound and remove it from outbound. To use KumoMTA as a relay behind the Node app, keep KumoMTA's port private and point OUTBOUND_RELAY_HOST at the outbound service. The two services share a Docker network, so outbound resolves by name.

IMAP is opt-in

IMAP is behind a profile. Port 143 is published on 127.0.0.1 only. Port 993 is public, but answers only after you put a certificate in server/imap/tls/ and enable <tls>. To store mail received on port 25, set IMAP_LMTP_HOST=imap in server/.env. The LMTP port (24) stays on the Docker network and is never published. See IMAP.

Everyday commands

docker compose -f deploy/docker-compose.yml logs -f outbound   # follow KumoMTA's logs
docker compose -f deploy/docker-compose.yml restart outbound   # reload policy changes
docker compose -f deploy/docker-compose.yml down               # stop everything, keep volumes
 
# IMAP: apply edits to server/imap/conf/*.xml without a restart
docker compose -f deploy/docker-compose.yml exec imap imapctl reload

compose needs every env file, even to run one command

docker compose reads the env_file of every service before it runs any command, including exec. If relay/.env or outbound/.env is missing, exec imap ... fails with "env file not found". Copy the examples as shown above, or run the command in the container directly: docker exec node-mail-server-imap-1 imapctl reload.

On this page