Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
9afbd8a
Add installation via docker compose (MVP 1)
Aug 9, 2025
c5f0136
rename dockerfile
Aug 23, 2025
5102ffc
add port 80 to docker-compose-default
Aug 23, 2025
22fc0e4
add 465 port
Aug 23, 2025
7a5725d
add RECREATE_VENV var
Aug 23, 2025
9dc3aae
change "restart nginx" to "reload nginx"
Aug 23, 2025
dd21a83
pass values to `MAIL_DOMAIN` and `ACME_EMAIL` from vars for docker-co…
Aug 23, 2025
9bb1d56
Fix bug with attaching certs
Aug 23, 2025
fedfab2
fix docs - nginx "restart" to "reload"
Aug 23, 2025
a0b13c1
Delete ssh connection from docker installation
Aug 23, 2025
d701858
Fix issue with acmetool
Aug 24, 2025
293bb4b
fix unlink if default nginx conf is not exist
Aug 25, 2025
9bf2755
docker: enable DNS checks before cmdeploy run again
missytake Aug 26, 2025
a41f21b
Suggestions from @Keonik1
missytake Nov 13, 2025
fb7b76a
docker: disable port check if docker is running. fix #694
missytake Nov 13, 2025
48b0029
doc: fix linebreak
missytake Nov 14, 2025
cd79402
docker: move all configuration to example.env
missytake Nov 14, 2025
15f7740
docker: open ports for TURN + STUN
missytake Nov 14, 2025
1f03093
docker: use --network=host so chatmail-turn can use any port
missytake Nov 14, 2025
0c10e63
cmdeploy: add config (, )
missytake Nov 18, 2025
c7a92ee
cmdeploy: Add config parameters `change_kernel_settings` and `fs_inot…
Nov 18, 2025
f3eadbd
docker: remove echobot parts that were lingering in the feature branch
j4n Feb 13, 2026
e57bdd2
feat(cmdeploy): guard against non-running systemd
j4n Feb 13, 2026
ab918ca
docker: widen build context to repo root for build-time install stage
j4n Feb 13, 2026
91f8f3f
docker: run install stage at build time, configure+activate at startup
j4n Feb 13, 2026
477635a
docker: don't overwrite existing DKIM keys on container start
j4n Feb 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
.git
data/
venv/
__pycache__
*.pyc
*.orig
.pytest_cache
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -164,3 +164,8 @@ cython_debug/
#.idea/

chatmail.zone

# docker
/data/
/custom/
.env
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,13 @@
Provide an "fsreport" CLI for more fine grained analysis of message files.
([#637](https://github.com/chatmail/relay/pull/637))

- Add installation via docker compose (MVP 1). The instructions, known issues and limitations are located in `/docs`
([#614](https://github.com/chatmail/relay/pull/614))

- Add configuration parameters
([#614](https://github.com/chatmail/relay/pull/614)):
- `change_kernel_settings` - Whether to change kernel parameters during installation (default: `True`)
- `fs_inotify_max_user_instances_and_watchers` - Value for kernel parameters `fs.inotify.max_user_instances` and `fs.inotify.max_user_watches` (default: `65535`)

## 1.7.0 2025-09-11

Expand Down
6 changes: 6 additions & 0 deletions chatmaild/src/chatmaild/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,12 @@ def __init__(self, inipath, params):
self.addr_v4 = os.environ.get("CHATMAIL_ADDR_V4", "")
self.addr_v6 = os.environ.get("CHATMAIL_ADDR_V6", "")
self.acme_email = params.get("acme_email", "")
self.change_kernel_settings = (
params.get("change_kernel_settings", "true").lower() == "true"
)
self.fs_inotify_max_user_instances_and_watchers = int(
params["fs_inotify_max_user_instances_and_watchers"]
)
self.imap_rawlog = params.get("imap_rawlog", "false").lower() == "true"
self.imap_compress = params.get("imap_compress", "false").lower() == "true"
if "iroh_relay" not in params:
Expand Down
10 changes: 10 additions & 0 deletions chatmaild/src/chatmaild/ini/chatmail.ini.f
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,16 @@
# Your email adress, which will be used in acmetool to manage Let's Encrypt SSL certificates
acme_email =

#
# Kernel settings
#

# if you set "True", the kernel settings will be configured according to the values below
change_kernel_settings = True

# change fs.inotify.max_user_instances and fs.inotify.max_user_watches kernel settings
fs_inotify_max_user_instances_and_watchers = 65535

# Defaults to https://iroh.{{mail_domain}} and running `iroh-relay` on the chatmail
# service.
# If you set it to anything else, the service will be disabled
Expand Down
5 changes: 5 additions & 0 deletions cmdeploy/src/cmdeploy/basedeploy.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@
from pyinfra.operations import files, server, systemd


def has_systemd():
"""Returns False during Docker image builds or any other non-systemd environment."""
return os.path.isdir("/run/systemd/system")


def get_resource(arg, pkg=__package__):
return importlib.resources.files(pkg).joinpath(arg)

Expand Down
2 changes: 2 additions & 0 deletions cmdeploy/src/cmdeploy/cmdeploy.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,8 @@ def run_cmd(args, out):

cmd = f"{pyinf} --ssh-user root {ssh_host} {deploy_path} -y"
if ssh_host in ["localhost", "@docker"]:
if ssh_host == "@docker":
env["CHATMAIL_DOCKER"] = "True"
cmd = f"{pyinf} @local {deploy_path} -y"

if version.parse(pyinfra.__version__) < version.parse("3"):
Expand Down
65 changes: 35 additions & 30 deletions cmdeploy/src/cmdeploy/deployers.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
activate_remote_units,
configure_remote_units,
get_resource,
has_systemd,
)
from .dovecot.deployer import DovecotDeployer
from .filtermail.deployer import FiltermailDeployer
Expand Down Expand Up @@ -65,6 +66,8 @@ def _build_chatmaild(dist_dir) -> None:


def remove_legacy_artifacts():
if not has_systemd():
return
# disable legacy doveauth-dictproxy.service
if host.get_fact(SystemdEnabled).get("doveauth-dictproxy.service"):
systemd.service(
Expand Down Expand Up @@ -299,7 +302,7 @@ def install(self):
present=False,
)
# remove echobot if it is still running
if host.get_fact(SystemdEnabled).get("echobot.service"):
if has_systemd() and host.get_fact(SystemdEnabled).get("echobot.service"):
systemd.service(
name="Disable echobot.service",
service="echobot.service",
Expand Down Expand Up @@ -535,12 +538,13 @@ def activate(self):
)


def deploy_chatmail(config_path: Path, disable_mail: bool, website_only: bool) -> None:
def deploy_chatmail(config_path: Path, disable_mail: bool, website_only: bool, docker: bool) -> None:
"""Deploy a chat-mail instance.

:param config_path: path to chatmail.ini
:param disable_mail: whether to disable postfix & dovecot
:param website_only: if True, only deploy the website
:param docker: whether it is running in a docker container
"""
config = read_config(config_path)
check_config(config)
Expand All @@ -566,34 +570,35 @@ def deploy_chatmail(config_path: Path, disable_mail: bool, website_only: bool) -
Out().red(f"Deploy failed: mtail_address {config.mtail_address} is not available (VPN up?).\n")
exit(1)

port_services = [
(["master", "smtpd"], 25),
("unbound", 53),
("acmetool", 80),
(["imap-login", "dovecot"], 143),
("nginx", 443),
(["master", "smtpd"], 465),
(["master", "smtpd"], 587),
(["imap-login", "dovecot"], 993),
("iroh-relay", 3340),
("mtail", 3903),
("stats", 3904),
("nginx", 8443),
(["master", "smtpd"], config.postfix_reinject_port),
(["master", "smtpd"], config.postfix_reinject_port_incoming),
("filtermail", config.filtermail_smtp_port),
("filtermail", config.filtermail_smtp_port_incoming),
]
for service, port in port_services:
print(f"Checking if port {port} is available for {service}...")
running_service = host.get_fact(Port, port=port)
services = [service] if isinstance(service, str) else service
if running_service:
if running_service not in services:
Out().red(
f"Deploy failed: port {port} is occupied by: {running_service}"
)
exit(1)
if not docker:
port_services = [
(["master", "smtpd"], 25),
("unbound", 53),
("acmetool", 80),
(["imap-login", "dovecot"], 143),
("nginx", 443),
(["master", "smtpd"], 465),
(["master", "smtpd"], 587),
(["imap-login", "dovecot"], 993),
("iroh-relay", 3340),
("mtail", 3903),
("stats", 3904),
("nginx", 8443),
(["master", "smtpd"], config.postfix_reinject_port),
(["master", "smtpd"], config.postfix_reinject_port_incoming),
("filtermail", config.filtermail_smtp_port),
("filtermail", config.filtermail_smtp_port_incoming),
]
for service, port in port_services:
print(f"Checking if port {port} is available for {service}...")
running_service = host.get_fact(Port, port=port)
services = [service] if isinstance(service, str) else service
if running_service:
if running_service not in services:
Out().red(
f"Deploy failed: port {port} is occupied by: {running_service}"
)
exit(1)

tls_domains = [mail_domain, f"mta-sts.{mail_domain}", f"www.{mail_domain}"]

Expand Down
35 changes: 19 additions & 16 deletions cmdeploy/src/cmdeploy/dovecot/deployer.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
activate_remote_units,
configure_remote_units,
get_resource,
has_systemd,
)


Expand All @@ -22,10 +23,11 @@ def __init__(self, config, disable_mail):

def install(self):
arch = host.get_fact(Arch)
if not "dovecot.service" in host.get_fact(SystemdEnabled):
_install_dovecot_package("core", arch)
_install_dovecot_package("imapd", arch)
_install_dovecot_package("lmtpd", arch)
if has_systemd() and "dovecot.service" in host.get_fact(SystemdEnabled):
return # already installed and running
_install_dovecot_package("core", arch)
_install_dovecot_package("imapd", arch)
_install_dovecot_package("lmtpd", arch)

def configure(self):
configure_remote_units(self.config.mail_domain, self.units)
Expand Down Expand Up @@ -116,18 +118,19 @@ def _configure_dovecot(config: Config, debug: bool = False) -> (bool, bool):

# as per https://doc.dovecot.org/2.3/configuration_manual/os/
# it is recommended to set the following inotify limits
for name in ("max_user_instances", "max_user_watches"):
key = f"fs.inotify.{name}"
if host.get_fact(Sysctl)[key] > 65535:
# Skip updating limits if already sufficient
# (enables running in incus containers where sysctl readonly)
continue
server.sysctl(
name=f"Change {key}",
key=key,
value=65535,
persist=True,
)
if config.change_kernel_settings:
for name in ("max_user_instances", "max_user_watches"):
key = f"fs.inotify.{name}"
if host.get_fact(Sysctl)[key] > 65535:
# Skip updating limits if already sufficient
# (enables running in incus containers where sysctl readonly)
continue
server.sysctl(
name=f"Change {key}",
key=key,
value=65535,
persist=True,
)

timezone_env = files.line(
name="Set TZ environment variable",
Expand Down
3 changes: 2 additions & 1 deletion cmdeploy/src/cmdeploy/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,9 @@ def main():
)
disable_mail = bool(os.environ.get("CHATMAIL_DISABLE_MAIL"))
website_only = bool(os.environ.get("CHATMAIL_WEBSITE_ONLY"))
docker = bool(os.environ.get("CHATMAIL_DOCKER"))

deploy_chatmail(config_path, disable_mail, website_only)
deploy_chatmail(config_path, disable_mail, website_only, docker)


if pyinfra.is_cli:
Expand Down
7 changes: 7 additions & 0 deletions doc/source/getting_started.rst
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,13 @@ steps. Please substitute it with your own domain.
configure at your DNS provider (it can take some time until they are
public).

Docker installation
-------------------

We have experimental support for `docker compose <https://github.com/chatmail/relay/blob/docker-rebase/docs/DOCKER_INSTALLATION_EN.md>`_,
but it is not covered by automated tests yet,
so don't expect everything to work.

Other helpful commands
----------------------

Expand Down
52 changes: 52 additions & 0 deletions docker-compose.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
services:
chatmail:
build:
context: ./
dockerfile: docker/chatmail_relay.dockerfile
tags:
- chatmail-relay:latest
image: chatmail-relay:latest
restart: unless-stopped
container_name: chatmail
cgroup: host # required for systemd
tty: true # required for logs
tmpfs: # required for systemd
- /tmp
- /run
- /run/lock
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
environment:
CHANGE_KERNEL_SETTINGS: "False"
MAIL_DOMAIN: $MAIL_DOMAIN
ACME_EMAIL: $ACME_EMAIL
RECREATE_VENV: $RECREATE_VENV
MAX_MESSAGE_SIZE: $MAX_MESSAGE_SIZE
DEBUG_COMMANDS_ENABLED: $DEBUG_COMMANDS_ENABLED
FORCE_REINIT_INI_FILE: $FORCE_REINIT_INI_FILE
USE_FOREIGN_CERT_MANAGER: $USE_FOREIGN_CERT_MANAGER
ENABLE_CERTS_MONITORING: $ENABLE_CERTS_MONITORING
CERTS_MONITORING_TIMEOUT: $CERTS_MONITORING_TIMEOUT
IS_DEVELOPMENT_INSTANCE: $IS_DEVELOPMENT_INSTANCE
CMDEPLOY_STAGES: ${CMDEPLOY_STAGES:-}
network_mode: "host"
volumes:
## system
- /sys/fs/cgroup:/sys/fs/cgroup:rw # required for systemd
- ./:/opt/chatmail

## data
- ./data/chatmail:/home
- ./data/chatmail-dkimkeys:/etc/dkimkeys
- ./data/chatmail-acme:/var/lib/acme

## custom resources
# - ./custom/www/src/index.md:/opt/chatmail/www/src/index.md

## debug
# - ./docker/files/setup_chatmail_docker.sh:/setup_chatmail_docker.sh
# - ./docker/files/entrypoint.sh:/entrypoint.sh
# - ./docker/files/update_ini.sh:/update_ini.sh
Loading