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
Services
| Service | Image | Published port | Role |
|---|---|---|---|
inbound | Built from server/Dockerfile | 25 | The Node mail server. Receives mail on port 25. |
outbound | ghcr.io/kumocorp/kumomta:latest | 587 | KumoMTA. Accepts authenticated mail and delivers it. |
relay | ghcr.io/kumocorp/kumomta:latest | 2587 | KumoMTA. Public relay with a login and a webhook per account. See Relay. |
imap | Built from server/imap/Dockerfile | 143 (127.0.0.1 only), 993 | Gluon IMAP server with mail in Pebble. 993 answers once TLS is set. Only with --profile imap. |
redis | redis:7-alpine | none | Used 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
| Volume | Mounted at | Holds |
|---|---|---|
kumo-spool | /var/spool/kumomta | KumoMTA's queued messages. |
kumo-logs | /var/log/kumomta | KumoMTA's logs. |
relay-spool | /var/spool/kumomta | The relay's queued messages and pending webhook events. |
relay-logs | /var/log/kumomta | The 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 | /data | Redis 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:
| Folder | Mounted at | Mode | Why |
|---|---|---|---|
server/imap/conf | /etc/imap | read-write | imapctl user add/update/delete saves users.xml there, with a users.xml.bak of the previous version. |
server/imap/tls | /etc/ssl/imap | read-only | The 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
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.