Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
985e98c
Add installation via docker compose (MVP 1)
Aug 9, 2025
fb798bb
rename dockerfile
Aug 23, 2025
31fc856
add port 80 to docker-compose-default
Aug 23, 2025
0d5e544
add 465 port
Aug 23, 2025
3896071
add RECREATE_VENV var
Aug 23, 2025
e1be8a2
change "restart nginx" to "reload nginx"
Aug 23, 2025
72ae869
pass values to `MAIL_DOMAIN` and `ACME_EMAIL` from vars for docker-co…
Aug 23, 2025
c605d1a
Fix bug with attaching certs
Aug 23, 2025
959afe6
fix docs - nginx "restart" to "reload"
Aug 23, 2025
1e61704
Delete ssh connection from docker installation
Aug 23, 2025
dbc386b
Fix issue with acmetool
Aug 24, 2025
38fb191
fix unlink if default nginx conf is not exist
Aug 25, 2025
c5a8d00
docker: enable DNS checks before cmdeploy run again
missytake Aug 26, 2025
615613b
Suggestions from @Keonik1
missytake Nov 13, 2025
8bba78e
docker: disable port check if docker is running. fix #694
missytake Nov 13, 2025
92b6825
doc: fix linebreak
missytake Nov 14, 2025
6e5004d
docker: move all configuration to example.env
missytake Nov 14, 2025
8be7082
docker: open ports for TURN + STUN
missytake Nov 14, 2025
f9fad1f
docker: use --network=host so chatmail-turn can use any port
missytake Nov 14, 2025
60ff982
cmdeploy: add config (, )
missytake Nov 18, 2025
f26cb08
cmdeploy: Add config parameters `change_kernel_settings` and `fs_inot…
Nov 18, 2025
1889f55
docker: remove echobot parts that were lingering in the feature branch
j4n Feb 13, 2026
e20256c
feat(cmdeploy): guard against non-running systemd
j4n Feb 13, 2026
e5ba9f9
docker: widen build context to repo root for build-time install stage
j4n Feb 13, 2026
ae0b234
docker: run install stage at build time, configure+activate at startup
j4n Feb 13, 2026
f939c30
docker: don't overwrite existing DKIM keys on container start
j4n Feb 13, 2026
645b60d
docker: make compose work with cgroups (v2), conversion scripts/docs
j4n Feb 16, 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
image: chatmail-relay:latest
restart: unless-stopped
container_name: chatmail
# Required for systemd — use only one of the following:
cgroup: host # compose v2 only
# privileged: true # compose v1 (not tested)
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