Run your own Exchange4all deployment¶
Run the only mail and calendaring server that is both fully MAPI compatible and not limited to running only on Windows Server operating systems, and that ships as a single container you deploy yourself.
This page describes docker compose
That is what our setup script automates and what we test against. The image itself is an ordinary
OCI container and runs on other runtimes as well, as long as you reproduce the same configuration:
host networking, the bind mounts, the environment variables and the raised file descriptor limit.
The generated docker-compose.yml is the reference for all
of it. On that path you configure and update the deployment yourself, because setup.sh requires
docker and docker compose.
Runtime Requirements¶
Your host needs to meet the following minimums:
| Requirement | Details |
|---|---|
| CPU and memory | 8 CPU cores and 64GB of RAM. The slim variant needs 4 vCPUs and 16GB. |
| Container runtime | Anything OCI compliant. With docker compose, use 1.29.2 or newer — older versions are not part of our testing pipeline. |
| Tools | docker, curl and jq. The setup script uses the latter two to look up the current container version. |
| Addressing | A dedicated IP, or at least exclusive access to ports 25, 80 and 443. The optional adsredir service needs a second dedicated IP. |
| Firewall | Run the container in host mode for IPv6 access, and restrict access to the publicly required ports yourself. |
Do not put this behind a load balancer or reverse proxy
The container terminates TLS itself, and deploying it behind an SSL-terminating gateway is not supported. If you must forward traffic to it, use a TLS proxy — an HTTP proxy is neither recommended nor supported.
Every externally reachable service also needs to see the real client IP address, for both IPv4 and IPv6, because rate limiting and other checks depend on it. Preserve those addresses in whatever deployment you build. Running the container in host network is the simplest way to get this right.
Quick Start¶
This chapter takes you from an empty host to a running deployment with your first domain and users.
Before you begin¶
Have the following ready. The setup script checks all of it and tells you what is missing, but resolving these first turns the installation into a single uninterrupted run:
- DNS. An
Aand, where available, anAAAArecord for the FQDN of the deployment, pointing at this host. If mail uses a different domain, a record forautodiscover.MAILDOMAINas well. DNS changes take time to propagate, so make them before you start: certificates and licence activation both fail while the names do not resolve. - Ports. 25, 80, 443, 587, 993 and 995 free on the host, and reachable from the internet. Port 25 outbound is also needed to deliver mail.
- A firewall, or a cloud security group, that exposes only those ports. The container uses host networking, see ports that must not be reachable.
- Registry credentials, see the next section, plus
docker,curlandjqinstalled. - Your licence, as the line of text you received. Without it only the webmail client works.
Installation and administration¶
Download the setup script into an empty directory and run it. It is the only file you need — it writes the docker-compose.yml and the .env file for you.
mkdir -p /opt/exchange4all && cd /opt/exchange4all
curl -fsSLO https://manual.myexchange.rocks/on-premise/setup.sh
chmod +x setup.sh
./setup.sh
To verify what you downloaded, compare it against SHA256SUMS.
Once the script has run, you have the following files:
| File or folder | What it is for |
|---|---|
setup.sh |
Configures and starts the deployment. Re-run it whenever you want to change a setting. |
docker-compose.yml |
Configuration of the container. Generated by setup.sh, do not edit it. |
docker-compose.override.yml |
Optional, and created by you. Every change of your own belongs here, see changing the container with an override file. |
exchange4all-config.yml |
Configuration of the application, as a symlink into the data location. Created by setup.sh. |
.env |
Configuration of the container. Created with mode 0600 because it holds secrets. |
/etc/systemd/system/exchange4all.service |
Optional. Lets you run systemctl start\|stop\|restart exchange4all. Offered during setup. |
~/.local/bin/e4a-cmd |
Runs setup.sh of this deployment from any directory, see running the setup script from anywhere. |
/srv/exchange4all |
Where Exchange4all stores its data on the host. Its subfolders are mounted into the container separately, see below. |
The data location holds one folder per mount, Backup describes what each one needs:
| On the host | Inside the container | What it holds |
|---|---|---|
/srv/exchange4all/e4a |
/storage |
Mail stores, configuration, licences, certificates and logs. |
/srv/exchange4all/db |
/var/lib/mysql |
The MariaDB database. |
/srv/exchange4all/php |
/var/lib/php/sessions |
Webmail sessions. |
/storage always means the path inside the container
Wherever this page mentions /storage, it refers to the storage location within the container. On the host that is the e4a folder of the data location, /srv/exchange4all/e4a by default. The data location can be changed during setup.
Back up the data location and .env together
The .env file holds the configuration your deployment was built with. A data directory without it is significantly harder to bring back up. Include both in your backups from day one.
Changing the container with an override file¶
setup.sh owns docker-compose.yml and rewrites it whenever you re-run the script, for example on --update. Anything you edit in that file is lost. Put your own changes in docker-compose.override.yml instead, which the script never touches.
Compose loads that file automatically and merges it into the generated one, so you only write the parts you are changing:
services:
exchange4all:
environment:
- TESTING_LOGGING=true
Apply your changes by recreating the container:
docker compose up -d
A few points that save time later:
- Use one override file. Compose only picks up
docker-compose.override.ymlon its own. Several sections of this page show override snippets — mount your own SSL certificates, enable core dumps, turn on debug logging — and if you need more than one, merge them into that single file. - You only add what you change. Volumes are merged by their path inside the container, and environment variables by name, so the generated mounts and variables stay in place. Use the same path or name when you deliberately want to replace one of them.
- Check the result before restarting anything:
docker compose configprints the merged configuration compose will actually use.
Prefer .env where a variable already exists
The generated compose file passes a number of environment variables straight through from .env, see Optional Environment Variables. Setting one of those needs no override file at all.
Completing the setup by running the setup script¶
The container registry requires a subscription
The images are published to registry.kopano.cloud. Access requires a subscription. You log in
with your email address as the user name and the access token you received as the password. If
you lost your credentials, please contact support.
When no credentials for this registry are present, the setup script asks for them and runs
docker login for you. The token is handed to docker only and never written to .env. For a
non-interactive run, pass --registry-user and --registry-token, or set E4A_REGISTRY_USER and
E4A_REGISTRY_TOKEN. With --registry-token - the token is read from standard input, which
keeps it out of your shell history:
./setup.sh --update --registry-user you@example.com --registry-token - < token.txt
Logging in by hand with docker login registry.kopano.cloud works just as well. The available
images and their tags are listed on the container images page.
The script asks you the questions below. Press Enter to accept the default shown in square brackets.
./setup.sh
What the setup script checks first¶
Before writing anything, the script inspects your host and prints one line per check. Failures stop the run, warnings do not.
These problems abort the setup, because continuing would give you a broken deployment:
| Check | Meaning |
|---|---|
docker |
Docker is not installed, the daemon is not running, or this user may not talk to it. |
compose |
Neither docker compose nor docker-compose 1.29.2 or newer is available. |
curl, jq |
Missing. jq is only needed when the container version still has to be looked up, so --image-version avoids it. |
fqdn |
The given name is not a fully qualified domain name. |
storage |
The data directory is not an absolute path, or its parent is not writable. |
port 25 … |
One of the ports 25, 80, 443, 587, 993 and 995 is already used by another service. Skipped when this deployment is already running. |
kernel |
The host does not run Linux. |
These are warnings. Each one comes with the command that resolves it, and the setup continues:
| Check | Meaning |
|---|---|
cpu, memory |
Fewer cores or less memory than recommended for the selected variant. |
storage |
Less than 50 GB free at the data location. |
docker.service |
Not enabled, so the container will not return after a reboot. |
clock |
The system clock is not synchronised. Certificates, licence activation and logins depend on the correct time. |
pending |
A docker-compose.yml.new or exchange4all.service.new waits next to a file the script did not replace because it was changed locally. |
dns |
A name in E4AFQDN or DOMAINS does not resolve, or resolves to an address that is not this host, including a stale AAAA record. Behind a load balancer or port forwarding this is expected, verify it from the outside. |
mx |
A mail domain has no MX record, or its MX points somewhere else. That is fine behind a mail gateway. |
spf |
A mail domain has no SPF record, more than one, or one that does not allow this host. Mechanisms such as include: are named but not followed. |
autodiscover |
autodiscover.DOMAIN does not resolve, or is missing from DOMAINS and therefore from the certificate. |
rdns |
The reverse DNS of the public IPv4 or IPv6 address is missing or not the FQDN. Set it at your hosting provider. |
smtp-out |
This host cannot reach other mail servers on port 25, which many providers block on new accounts. Ask them to lift the block, or use a mail relay. |
registry |
No credentials for the container registry, see above. |
firewall |
No active firewall, or one that allows an internal port. See required ports. |
variant |
A preview build was selected, which is not supported for production use. |
The mail domains checked are E4AMAILDOMAIN and every domain an autodiscover. name in DOMAINS is requested for. The DNS checks need dig or host, from the dnsutils or bind-utils package. The public addresses of the host are looked up at ip.me.
To run only these checks against an existing deployment, use ./setup.sh --check.
Running the setup non-interactively¶
Answer every question on the command line, or pass the values through the environment under their .env names. With -y the script never asks anything:
./setup.sh -y \
--fqdn mail.example.com \
--mail-domain example.com \
--le-email admin@example.com \
--acme staging \
--registry-user admin@example.com \
--registry-token "3f9a1c07e5b2..." \
--license "eyJhbGciOiJFZERTQSIsImtpZCI6..."
The same run driven entirely by the environment, which suits cloud-init and configuration management:
#!/bin/sh
export E4AFQDN="mail.example.com"
export E4AMAILDOMAIN="example.com"
export LEEMAIL="admin@example.com"
curl -fsSLO https://manual.myexchange.rocks/on-premise/setup.sh
sh setup.sh -y
./setup.sh --help lists every option. The most useful ones are --variant to pick the image, --update to move to a newer version, --check to run only the checks, and --dry-run to see what a run would do without writing anything.
The administrator password is only printed on a terminal
When the output is not a terminal, the summary prints the command to retrieve the password instead of the password itself, so it does not end up in cloud-init or CI logs.
Domains¶
The container creates SSL certificates for the name it is reached at, the FQDN. If you use a different domain for email, the MAILDOMAIN, it also needs a certificate for autodiscover.MAILDOMAIN. Make sure both names resolve to this host before you continue.
FQDN of this deployment [exchange4all.local]: mail.example.com
Domain to use for email [mail.example.com]: example.com
Domains to request certificates for, separated by spaces [autodiscover.example.com mail.example.com]:
The third question is answered for you: when the mail domain differs from the FQDN, autodiscover.MAILDOMAIN is added automatically. Add further names only if you want the deployment to be reachable under them as well. Every name in this list must resolve to this host — if one of them does not, certificate retrieval and licence activation fail for all of them.
Outlook and mobile ActiveSync clients use "autodiscover" to configure an account from nothing but an email address and a password, which is what the autodiscover subdomain is for. You can also point a reverse proxy from https://your-mail-domain.com/Autodiscover/Autodiscover.xml at your deployment, see Reverse Proxy configurations. At a minimum, confirm that this address does not redirect to a conflicting service.
Hosting Static Landing Pages for Unmatched URLs
A static file server answers any URL that does not match a registered client path, so you can serve simple landing pages or redirects without running a second backend.
Put your files in /storage/www inside the container. To send users who land on a subdomain such as autodiscover.exchange4all.local to your main website, create /storage/www/index.html:
<html>
<head>
<script>
window.location.href = "https://exchange4all.local/webapp/";
</script>
</head>
</html>
Every unmatched request now redirects to that address.
Automatic SSL with Let's Encrypt¶
By default the container issues a self-signed certificate from its own CA, whose root certificate you can download from http://your-server.com/.well-known/root-ca.crt. Give the setup script an email address instead and it retrieves a certificate from Let's Encrypt:
Email address for Let's Encrypt, empty for a self-signed certificate []: your@email.com
ACME endpoint, use production once retrieval works [staging]:
By filling in the email address you agree to the Let's Encrypt Terms of Service. The second question only appears once you have given an address.
Verify that certificates can be retrieved from the staging environment first. Once that works, switch over with ./setup.sh --acme production, or set ACMESERVER in the .env file by hand.
A note on using the Let's Encrypt Staging CA
Use the staging CA to test certificate retrieval, not to test Exchange4all itself. Work through it in this order:
- Leave the email field empty and use a self-signed certificate from the container's own CA while you evaluate.
- When you are ready to expose the server publicly, set
LEEMAILto your address, or re-runsetup.sh. - Confirm the container retrieves a certificate.
- Switch to the production CA with
./setup.sh --acme production.
Renewing the certificate right away¶
The container renews its Let's Encrypt certificate on its own. To get a new one immediately, for example after adding names to DOMAINS or to test the rotation, run:
./setup.sh --renew-certificate
It first checks that every name in DOMAINS resolves to this host, because a failed challenge counts against a rate limit of its own. With the production CA it asks before requesting, since Let's Encrypt issues at most five certificates for the same names per week. The new certificate is applied right away, and the script shows how long the certificate now served on port 443 is valid.
Debugging certificate rotation
Inside the container, a renewal is forced with lego-renew add up to version 8.7, and with LEGO_RENEW_FORCE=true lego-renew from version 8.8 onwards. The hook that applies a renewed certificate can also be run by hand, to debug the rotation itself. lego names the certificate after the first name in DOMAINS, and keeps staging certificates in /storage/.lego-tmp instead of /storage/.lego:
CERT_NAME=$(jq -r .DOMAINS /etc/container_environment.json | awk '{ print $1 }')
LEGO_CERT_PATH=/storage/.lego/certificates/$CERT_NAME.crt \
LEGO_CERT_KEY_PATH=/storage/.lego/certificates/$CERT_NAME.key \
/usr/local/bin/lego-hook
Reusing existing SSL certificates¶
To use certificates you already have, leave the Let's Encrypt address empty and mount them into the container yourself. Add the following to your override file, docker-compose.override.yml, next to the generated docker-compose.yml:
services:
exchange4all:
volumes:
- ./private.key:/opt/exchange4all/tls/fullchain.pem.key:ro
- ./certificate.crt:/opt/exchange4all/tls/fullchain.pem:ro
If the certificate comes from an internal CA, mount that root certificate as well by adding one more line to the volumes block above. Recreate the container with docker compose up -d once the file is complete:
- ./rootCA.pem:/opt/exchange4all/tls/root-ca.crt:ro
Data storage¶
Where to store data [/srv/exchange4all]
Most environments can keep the default. The setup script warns you when less than 50GB are free at the location you choose.
Changing this later does not move your data
This value only updates the configuration. If you change it on an existing deployment, move the contents of the old directory yourself before starting the container again.
Container variant¶
Finally the script asks which image to run:
Which container variant should this deployment run?
1) full - all components (8 cores, 64 GB RAM)
2) slim - reduced footprint (4 cores, 16 GB RAM)
3) next - preview build, not for production use
Selection [1]:
Your choice is stored as E4AVARIANT in the .env file and decides which image ./setup.sh --update picks up later, so a slim deployment stays slim. It also adjusts what the checks expect of your CPU and memory. Versions are always pinned by digest, and beta releases are never selected automatically.
Starting at boot¶
If the host uses systemd, the script offers to install a unit:
Install a systemd unit for systemctl start/stop/restart exchange4all? [Y/n]:
Treat the unit as a convenience, not as a supervisor. The container carries restart: unless-stopped, and that is what brings it back after a crash or a reboot, whether or not you install the unit. An existing unit file is never overwritten.
Enable the Docker service as well
The container can only return after a reboot if Docker itself starts at boot. The setup script warns you when it does not; systemctl enable docker fixes it.
First start¶
With the configuration in place, the script pulls the image and starts the container. The first startup runs a set of initialisation tasks and takes a few minutes. The script waits until the container reports itself healthy, then prints a summary:
==> Exchange4all is running
Configuration /opt/exchange4all/.env (mode 0600, keep this in your backups)
Compose file /opt/exchange4all/docker-compose.yml (generated, edit the override instead)
Data /srv/exchange4all (keep this in your backups)
Image registry.kopano.cloud/e4a-container:8.7.4@sha256:f68198...
Health healthy after 252s
Licence Testing <feedback@example.com>
Service systemctl start|stop|restart exchange4all
Manage console https://mail.example.com/manage/
User admin@example.com
Password docker compose exec exchange4all cat /storage/admin.secret
If the container is not healthy within 300 seconds, the script prints the last 50 log lines and exits with an error. Your container keeps starting regardless — on a slow connection the first image pull alone can take longer than that. Follow it with docker compose logs -f, or allow more time on the next run with --timeout 900.
Check on progress at any point with docker compose ps and docker compose logs -f. Once the container has finished starting, continue with your first domain and users.
Installing a licence file¶
Exchange4all needs an installed and activated licence. Your licence is a single long line of text, and the setup script asks you to paste it:
Paste your Exchange4all licence, empty to skip:
The script shows you who the licence belongs to and asks for confirmation before installing it. It lands in /storage/license/exchange4all.license, and because that happens before the first start, the container activates it on its own.
If your licence arrives later, or you renew it, add it at any time:
./setup.sh --license "eyJhbGciOiJFZERTQSIsImtpZCI6..."
On a deployment that was set up before, --license installs and activates only the licence. It asks no setup questions and does not restart the container. A licence previously installed by the script is kept as exchange4all.license.bak-<date>, and other .license files in the directory stay in place. Every run of the script lists the installed licences in its summary.
Activation needs your domains to resolve
Activation performs an HTTP-01 challenge for every name in DOMAINS, so all of them must point at this host. If it fails, fix the DNS entries and run docker compose exec exchange4all /usr/local/bin/lx-renew register.
To install a licence by hand, copy the file into /storage/license/ and run /usr/local/bin/lx-renew register inside the container. The extension matters — only files ending in .license are read.
Without an activated licence, only webmail works
Your users can log in to the Exchange4all webmail client, but connections via Outlook, ActiveSync and IMAP/POP3 are denied. Users with the System Administrator role receive nightly reminders about upcoming and passed licence expiry.
Configuring defaults for MX, SPF and Autodiscover¶
The Manage console tells your administrators which MX and SPF records to create, and how clients find autodiscover. Those instructions are generated from the container's FQDN unless you configure the values in /storage/config.yaml.
To publish your own values, add the following to /storage/config.yaml and adjust it to your environment:
e4a:
# skipping unrelated content
domain_service:
dns:
- type: mx
value: "mail.protection.exchange4all.local"
- type: spf
value: "v=spf1 include:spf.protection.exchange4all.local -all"
- type: autodiscover
value: ""
Match the autodiscover value to the domain in your _autodiscover._tcp SRV record, which is what clients query to locate the service.
Autodiscover recommendations for customer provided domains
If you host domains for your customers, use the "adsredir" service instead of listing every autodiscover name yourself. It requests SSL certificates on demand from Let's Encrypt, or any other service implementing the ACME protocol. See /etc/service/e4a-service-adsredird/run in the container for runtime instructions.
Creating Users, Domains and Organisations¶
With the container running, create your domains and users in the Manage console at https://your-server.com/manage/, or through the manage API behind it. The setup script prints the address and your credentials at the end of its run.
Note
When the container is started for the first time, it automatically creates:
- an organisation named "Default",
- a domain named after the mail domain (
E4AMAILDOMAIN) you gave the setup script, belonging to the above organisation - a user
admin@MAILDOMAINwith "system administrator" privileges belonging to the above domain.
The generated password of that user is stored in /storage/admin.secret and can be read with:
docker compose exec exchange4all cat /storage/admin.secret
As a system administrator you can:
- Create organisations, often called tenants. They are optional and group domains together. Users of domains in the same organisation see each other in the global address list (GAL).
- Create email domains, and within each of them users, groups and aliases.
For step-by-step instructions on the Manage console, see Information for Administrators.
Permissions for using the Administration Interface¶
Permissions decide what each role may change. Every role includes everything the one above it can do:
| Role | Can |
|---|---|
| User | Log in, and view and change their own details, such as their password or profile picture. |
| Global Administrator | Delete domains, and view and create users, groups and aliases — but only within their own organisation. |
| System Administrator | View, create and delete organisations. |
Getting users from an LDAP source¶
From v6.0.8 the container can run ad-connect for you, so users come from a single directory. For how to configure ad-connect itself, see Managing users via Directory Synchronization.
Two things are needed:
- Store the configuration file at
/storage/adc.yaml. A sample sits next to it in/storage/adc.yaml.dist. - Pass the application id and secret token to the container in the
AD_CONNECT_AUTHenvironment variable.
With both in place, an e4a-ad-connect service starts inside the same container and handles password checks, and a cron job imports users every hour. To import immediately, run adc-import inside the container.
Connect clients and change passwords¶
Point your users at the End User Documentation, which covers every supported client.
Changing the Exchange4all configuration¶
Exchange4all reads one central YAML file, /storage/config.yaml, and propagates it to the individual services when the container starts. The defaults cover most deployments beyond the examples on this page. Do not change anything here unless support asks you to. Every available option and its default is listed in the container under /opt/exchange4all/config/conf.d/config-all-v1.yaml.
Changing PHP configuration¶
Requires version 8.0.0 or newer
Older containers ignore these variables.
Set any of these environment variables to change the PHP settings behind the web interface:
| Environment variable | Default | Description |
|---|---|---|
PHP_FPM_POOL_RPC_MAX_CHILDREN |
100 |
Maximum number of PHP-FPM worker processes for the RPC pool |
PHP_FPM_POOL_RPC_MAX_SPARE_SERVERS |
50 |
Maximum number of idle PHP-FPM processes allowed |
PHP_FPM_POOL_RPC_MEMORY_LIMIT |
32M |
PHP memory limit per process |
PHP_FPM_POOL_RPC_MIN_SPARE_SERVERS |
10 |
Minimum number of idle PHP-FPM processes |
PHP_FPM_POOL_RPC_POST_MAX_SIZE |
20M |
Maximum size of POST data (affects file uploads) |
PHP_FPM_POOL_RPC_START_SERVERS |
10 |
Number of PHP-FPM workers started on launch |
PHP_FPM_POOL_RPC_UPLOAD_MAX_FILESIZE |
20M |
Maximum file size for uploads via forms to RPC pool |
PHP_FPM_POOL_WWW_MAX_CHILDREN |
100 |
Maximum number of PHP-FPM worker processes for the frontend |
PHP_FPM_POOL_WWW_MAX_SPARE_SERVERS |
50 |
Maximum number of idle frontend PHP-FPM processes |
PHP_FPM_POOL_WWW_MEMORY_LIMIT |
64M |
Memory limit per frontend PHP process |
PHP_FPM_POOL_WWW_MIN_SPARE_SERVERS |
10 |
Minimum number of idle frontend processes |
PHP_FPM_POOL_WWW_POST_MAX_SIZE |
20M |
Maximum size of POST data for web interface (e.g. large attachments) |
PHP_FPM_POOL_WWW_START_SERVERS |
10 |
Initial number of frontend PHP processes on startup |
PHP_FPM_POOL_WWW_UPLOAD_MAX_FILESIZE |
20M |
Maximum upload size (e.g attachments) allowed in the web app |
The www pool serves clients such as the webmail client and ActiveSync devices; the rpc pool serves internal consumers such as the indexing component. As a rule of thumb, set memory_limit to three times the largest mail you expect: if your users may send 50MB, allow 150MB.
Required ports¶
Open these ports to the network:
| Service | Port(s) |
|---|---|
| Postfix | 25 (SMTP), 587 (submission, explicit TLS) |
| Web server | 80 (HTTP), 443 (HTTPS) |
| POP3 | 995 (TLS) |
| IMAP | 993 (TLS) |
Ports that must not be reachable¶
The container runs in host network mode, so every service inside it binds directly to your host's addresses. Alongside the ports above, these also listen on all interfaces and must not be reachable from the internet:
| Port(s) | Service |
|---|---|
| 110, 143 | POP3 and IMAP without TLS |
| 8025 | SMTP backend |
| 8080 | HTTP backend |
| 8110, 8143 | POP3 and IMAP backends |
A firewall is therefore not optional. The setup script looks for one and lists the exposed ports when it finds none. On a cloud provider, a security group permitting the same set of ports does the same job.
Allow SSH before enabling a firewall
Enable a firewall over SSH without permitting SSH first and you lock yourself out of the host. Allow it first:
ufw allow OpenSSH
ufw allow 25,80,443,587,993,995/tcp
ufw enable
ufw status
The equivalent with firewalld:
firewall-cmd --permanent --add-service=ssh
firewall-cmd --permanent --add-port=25/tcp --add-port=80/tcp --add-port=443/tcp \
--add-port=587/tcp --add-port=993/tcp --add-port=995/tcp
firewall-cmd --reload
Re-check an existing deployment with ./setup.sh --check. Against a running container it compares your firewall with the ports the host is really listening on.
Inspecting the log files¶
/var/log in the container is a link to /storage/log, so the log files survive a restart of the container. On the host they are in the e4a/log directory of your data location, /srv/exchange4all/e4a/log by default.
Every service supervised in the container sends its output to syslog-ng, tagged with the name of the service, for example e4a-imap or libregraph-lico. The startup scripts do the same, tagged with the name of the script. syslog-ng sorts the messages into files by facility and copies the content of /var/log/syslog to standard output, which is what docker compose logs shows. Two kinds of messages are not part of that output: mail and authentication. For those, look at mail.log and auth.log.
| File | What is logged there |
|---|---|
syslog |
All messages except mail, authentication and debug level. The same content as docker compose logs. |
user.log |
The output of the services and startup scripts. Mostly the same as syslog, without the messages of cron and syslog-ng itself. |
mail.log |
Postfix: incoming and outgoing mail, deliveries to e4a, rejects and queue handling. Also read by the Postfix metrics exporter. |
auth.log |
Logins and sessions, in practice the PAM sessions cron opens for its jobs. |
cron.log |
The cron daemon and which jobs it started. The output of the jobs themselves, such as lx-renew, lego-renew and ad-connect-import, goes to syslog. |
messages |
Informational messages from facilities that have no file of their own, mainly syslog-ng itself. |
debug |
Debug-level messages. Only created when something logs at that level. |
daemon.log, kern.log |
Kept from the default configuration. Nothing in the container usually writes there. |
php7.4-fpm.log |
The PHP-FPM master process: start, stop and crashed worker processes. |
A few services write to their own files instead of syslog:
| File | What is logged there |
|---|---|
apisix/apisix-error.log |
Warnings and errors of APISIX, the reverse proxy in front of all services. A failing backend shows up here first. |
apisix/http-access.log |
Every HTTP request reaching the container, with the backend it was routed to. |
apisix/stream-access.log |
Connections to the TCP services passed through by APISIX. |
caddy/webapp-access.log, caddy/webapp-error.log |
Requests to and errors of the webmail client. |
caddy/e4a-access.log |
Requests to the e4a HTTP scripts, such as EWS and the offline address book. |
caddy/manage-webapp-access.log, caddy/manage-api-access.log |
Requests to the administration interface and its API. |
caddy/caddy-access.log |
Requests for static files, the branding and the AD Connect download. |
e4a-servicerpc/servicerpc-grpc.log |
gRPC requests to the e4a service RPC. |
e4a-zcore/ewsrpc-grpc.log |
gRPC requests from the EWS component to zcore. |
The logs of the ActiveSync component are under /storage/push/logs, see Getting (debug) logs from the ActiveSync component.
The files are rotated inside the container. logrotate rotates syslog daily and keeps 7 files, the other syslog files weekly and keeps 4. APISIX starts a new file with a date prefix every day and keeps 56. Caddy rotates its files at 50 MB and keeps 20, for at most 90 days.
Older containers
Before the syslog-ng configuration was cleaned up, the container also wrote /var/log/error, which repeated all errors that were already in the other files, and copied the output of the services into messages as well. You may still find these files in an existing data location. They are no longer written and can be deleted.
Diagnosing a malfunction¶
When the deployment misbehaves, work through the following steps in order. They start at the container as a whole and end at the single service that is failing.
Check the health status of the container¶
Docker runs the container's own healthcheck.sh on a schedule, and docker compose ps shows you nothing but the result of its last run:
docker compose ps
To learn why a container is unhealthy, run that script yourself. It checks every service and prints the result of each one, so you get direct feedback in the terminal instead of a single word:
docker compose exec exchange4all healthcheck.sh --format documentation
Each failing check names the service behind it. That service is the one you continue with below.
Check whether a service was stopped¶
The services inside the container are supervised by runit and live in /etc/service/, which is the same directory as /etc/runit/runsvdir/default/. List their state with:
docker compose exec exchange4all sv status /etc/service/*
A service that crashed repeatedly is taken out of service, and a down file is written into its directory so it stays down across container restarts. Look for those files:
docker compose exec exchange4all ls /etc/service/*/down
A down file means the service did not fail once, it kept failing. Find the cause before you start it again — for e4a-delivery the usual one is a message stuck in the queue, see Inspecting and Managing the Postfix Message Queue. Once the cause is gone, remove the file and start the service:
docker compose exec exchange4all sh -c 'rm /etc/service/e4a-delivery/down && sv up e4a-delivery'
Read the container log for the reason of the crash¶
The services log to standard output, so the container log holds the error that made one of them crash:
docker compose logs --tail 500 exchange4all
Search it for the name of the failing service to get to the relevant lines, and keep in mind that the interesting message is usually the first error, not the last one. The supporting services that write to their own files are listed under Inspecting the log files.
Start the service in the foreground¶
If a service does not start at all, run its run script by hand. Everything it prints goes to your terminal, which is the fastest way to see a broken configuration file or a missing permission:
docker compose exec exchange4all sv down e4a-delivery
docker compose exec exchange4all /etc/service/e4a-delivery/run
Watch docker compose logs -f in a second terminal while you do this. Some startup errors are reported by other components of the container rather than by the service itself, and they only show up there. Stop the foreground process with Ctrl+C and hand the service back to the supervisor:
docker compose exec exchange4all sv up e4a-delivery
Turn on debug logging while you investigate
TESTING_LOGGING=true makes every e4a service log in detail, and LIBSEGFAULT_ENABLED=true gives you more information when a service crashes. See Enabling additional logging for debugging, and turn both off again afterwards.
Adding additional domains to the system¶
Your deployment is already reachable under the FQDN you gave the setup script. To serve a handful of further domains, list them directly so clients can auto-detect their configuration. If you host domains for external customers, use the adsredir service instead — see /etc/runit/runsvdir/default/e4a-service-adsredird/run inside the container.
To add domains:
- Open the
.envfile and extend the line starting withDOMAINS=, separating names with spaces inside the double quotes. - Recreate the container so it picks the new value up:
docker compose up -d. - Request a certificate that covers the new names and add it to the web server:
./setup.sh --renew-certificate.
Every name in DOMAINS must resolve to this host
If one of them does not, Let's Encrypt certificate retrieval and licence activation fail — and they fail for the whole list, not just the missing name.
Adding Additional Postfix Configuration¶
Override both main.cf and master.cf with a mechanism modelled on docker-mailserver:
- For
main.cf, create/storage/postfix-main-v2.cfin the same format asmain.cf. - For
master.cf, create/storage/postfix-master.cf. Every line is passed topostconf -P, so write your parameters as<service_name>/<type>/<parameter>.
Your changes apply the next time the container starts. To apply them immediately, run /etc/my_init.d/43_postfix-config-overrides.sh && sv restart postfix inside the container.
Pass all mails through a mail relay¶
Requires v7.2.0 or newer
To send both inbound and outbound traffic through a relay, the container runs Postfix as two instances. Enable it by adding POSTMULTI_ENABLED=true to the environment of the container.
Configure the relay and a unique hostname for the inbound instance in /storage/postfix-main-v2.cf:
myhostname=inbound.exchange4all.local
relayhost=[relay.example.com]:25
Then configure the outbound instance in /storage/postfix-outbound-main-v2.cf:
myhostname=outbound.exchange4all.local
relayhost=[relay.example.com]:25
inet_protocols=all
virtual_transport=smtp:[relay.example.com]:25
virtual_alias_maps=
mydestination=
alias_maps=
alias_database=
local_recipient_maps=
local_transport=error:5.1.1 Mailbox unavailable
Using a mail relay that requires auth¶
If your relay provider requires basic authentication, add three more lines to /storage/postfix-main-v2.cf, following Adding Additional Postfix Configuration:
smtp_sasl_auth_enable = yes
smtp_sasl_security_options = noanonymous
smtp_sasl_password_maps = hash:/storage/postfix/provider_auth
smtp_sasl_auth_enable turns basic authentication on. smtp_sasl_security_options states which mechanisms your provider accepts:
| Option | Description |
|---|---|
noanonymous |
Disallows anonymous authentication mechanisms (usually the default option) |
noplaintext |
Disallows mechanisms that transmit passwords in plain text |
noactive |
Disallows active authentication mechanisms (e.g. captcha) |
nodicationary |
Disallows mechanisms susceptible to dictionary attacks |
mutual_auth |
Only allows mechanisms that provide mutual authentication |
For the full list, see the Postfix documentation.
smtp_sasl_password_maps tells Postfix where your credentials are. The example keeps them in /storage/postfix/provider_auth, but the name and location are yours to choose — adjust the line if you use a different one.
Check which port your provider expects
Many relays require port 587 rather than 25. Set relayhost accordingly.
Running the setup script from anywhere¶
Whenever the setup script sets up or updates the deployment, it installs e4a-cmd into ~/.local/bin. It changes into the deployment directory and runs setup.sh there, so every option of the script works from any directory:
e4a-cmd --status
e4a-cmd --exec e4a userinfo user1@example.com
e4a-cmd --update
Run without options, e4a-cmd is the same as ./setup.sh --exec: it shows the status and opens a bash shell in the container.
Install it somewhere else with --cmd-dir, for example --cmd-dir /usr/local/bin to make it available to every user. The choice is stored as E4ACMDDIR in .env. When the directory is not in your PATH, the script tells you how to add it. With several deployments on one host, only the first one gets the command, an existing e4a-cmd of another deployment is left alone.
Checking the status and running commands¶
./setup.sh --status shows how the running deployment is doing, without changing anything: the container state with the result of its health checks, the image it runs compared with the version pinned in .env, the installed licences, the space left at the data location, how long the certificate served on port 443 remains valid, the clock, and files waiting to replace a locally changed one. It only looks at this host, the DNS and mail checks are left to --check.
It exits with 0 when everything is fine, 1 on warnings and 2 when the container is not running. For monitoring, --json prints the same report as a single JSON object:
./setup.sh --status --json | jq '.checks[] | select(.status != "ok")'
{"fqdn":"mail.example.com","status":"ok","checked_at":"2026-09-24T20:15:00Z","checks":[
{"name":"container","status":"ok","message":"running and healthy, up 3d 4h, 306 of 306 checks passed","state":"running","health":"healthy","uptime":"3d 4h","checks_total":306,"checks_failed":0},
{"name":"certificate","status":"ok","message":"valid for 75 more days, until 2026-12-09","days_left":75,"not_after":"2026-12-09"}
]}
./setup.sh --logs follows the output of the container. ./setup.sh --logs lnav opens the log files of the services in lnav instead, which merges them into one timeline and lets you filter and search. It picks the current file of each service, among them mail.log, syslog, the web server and APISIX access logs and the RPC services. For everything including rotated files, run lnav on the e4a/log directory of your data location yourself, /srv/exchange4all/e4a/log by default.
--exec runs a command in the container, or opens a bash shell when no command is given. Everything after --exec is the command, and the exit code is the one of the command. The status is printed first, on stderr, so the output of the command can still be piped. Add -q in front of --exec to leave it out:
# Get a bash shell in the container
./setup.sh --exec
# Get the information of the user user1@example.com
./setup.sh --exec e4a userinfo user1@example.com
# List every health check with its result, when the status reports a failed one
./setup.sh -q --exec healthcheck.sh --format documentation
# Show the version of the components inside the container
./setup.sh -q --exec cat /opt/exchange4all/.version
# Dump the database to a file on the host
./setup.sh -q --exec mysqldump email > email.sql
./setup.sh --exec is the same as docker compose exec exchange4all, run from the right directory. Every command of the cheat sheet works with it.
Updating¶
The recommended way to update is to re-run the setup script with --update. It looks up the newest version for the configured variant, pins it by digest, and recreates the container:
./setup.sh --update
An update asks no questions. Every setting is taken from the existing .env file and only the version changes, so it is safe to run unattended. Individual settings can still be changed in the same run by adding the matching option, for example ./setup.sh --update --acme production.
The setup script can update itself as well. The checks warn when a different version is published, and --self-update replaces the script after verifying it against the published checksum. The previous script is kept as setup.sh.bak. Combined with other options, the new script runs with them right away:
./setup.sh --self-update --update
Alternatively the E4AVERSION value in the .env file can be replaced by hand with a version from the release notes, and the container recreated afterwards. A version that is already pinned is never changed without --update.
Beta releases are skipped by updates
Releases with a minor version of 0, such as 8.0.4, are general beta releases. --update never resolves them, so a deployment only moves to x.1.y and later versions. To run a beta deliberately, pin it explicitly:
./setup.sh --image-version 9.0.1@sha256:...
Prometheus Metrics¶
Metrics are served on port 9100, which your firewall blocks by default. Open it for your monitoring host only.
With ufw:
ufw allow from 198.51.100.10 to any port 9100
ufw status
The equivalent with firewalld:
firewall-cmd --permanent --add-rich-rule='rule family="ipv4" \
source address="198.51.100.10" port port="9100" protocol="tcp" accept'
firewall-cmd --reload
firewall-cmd --list-rich-rules
Replace 198.51.100.10 with the address of your monitoring host, then scrape http://your-server.com:9100/metrics.
Enabling/disabling additional plugins on startup¶
Exchange4all uses plugins internally for functionality such as user limit enforcement in hosting setups. Plugins live in the container filesystem, so every change to them is lost when you recreate the container. Set them in the environment instead and they are reapplied on every start:
# list of additional plugins to enable
E4A_FORCE_ENPLUGIN="zcore:svc:oidc delivery:svc:remote_mx_hello"
# List of additional plugins to disable
E4A_FORCE_DISPLUGIN="smtp:pas:status_header delivery:svc:remote_mx_hello"
Only set these when support asks you to
Enabling or disabling the wrong plugin can break mail flow in ways that are hard to diagnose.
Backup¶
Exchange4all supports full system backups. You can extract individual users and mailboxes from such a backup, but restoring a single folder or item is not part of the software. Take file system snapshots — our reference system uses zfs snapshots.
What to back up¶
| On the host | Inside the container | Back it up? |
|---|---|---|
.env and docker-compose.override.yml in the deployment directory |
— | Yes. They hold the configuration your deployment was built with. A data directory without them is significantly harder to bring back up. |
/srv/exchange4all/e4a |
/storage |
Yes. Service configuration, generated passwords, licences, certificates, the Redis database and the user data under /storage/system/var/storage. Redis writes an append-only file, so it can be snapshotted as it is. /storage/log and /storage/coredumps can grow large and may be left out. |
/srv/exchange4all/db |
/var/lib/mysql |
Yes, at the same moment as /storage, see below. The MariaDB database holds the system data: users, organisations and domains. Mails are not stored in it. MariaDB runs on InnoDB. |
/srv/exchange4all/php |
/var/lib/php/sessions |
Optional. Losing the sessions only means your users log in to the webmail client again. |
/srv/exchange4all/branding |
/branding |
If you use your own branding. |
Docker volumes e4a_postfix-spool and e4a_postfix-outbound-spool |
/var/spool/postfix, /var/spool/postfix-outbound |
Optional. Mail that is still queued for delivery. The prefix is COMPOSE_PROJECT_NAME from .env. |
/run/exchange4all/webapp |
/storage/webapp/tmp |
No. Short-lived attachment data of the webmail client, mount it as tmpfs on the host. |
Taking a consistent snapshot¶
The mailboxes in /storage belong to the users, organisations and domains in the database, so both should be captured at the same moment. Restored from different points in time, a user created in between exists in one and not in the other: a mailbox without its user, or a user without a mailbox.
The preferred way is a zfs snapshot. Keep the data location on one dataset, or take a recursive snapshot, which is atomic across all child datasets:
zfs snapshot -r tank/exchange4all@$(date +%F)
Dumping the database¶
A dump can be written straight to the host:
./setup.sh -q --exec mysqldump email | gzip > email-$(date +%F).sql.gz
backup-mariadb.sh does the same inside the data location and keeps the dumps of the last eight days. It writes them to /storage/backup/<date>/mysql/email.sql.gz, so they become part of every snapshot of /storage. Place it in the data location once, then run it through the setup script:
curl -fsSL -o /srv/exchange4all/e4a/backup-mariadb.sh https://manual.myexchange.rocks/on-premise/backup-mariadb.sh
./setup.sh -q --exec bash /storage/backup-mariadb.sh
To run it every night before your snapshot or backup job, add a line like this to the crontab of root. The setup script changes into its own directory, so the path is all it needs:
15 2 * * * /opt/exchange4all/setup.sh -q --exec bash /storage/backup-mariadb.sh
Restoring¶
To restore a whole deployment, on the same or on a new host:
- Stop the container with
docker compose down. Never add-v, it deletes the mail queue volumes. - Restore the data location,
.envanddocker-compose.override.yml, all from the same snapshot. - Start it again with
docker compose up -d, or on a new host run./setup.sh -y, which takes every setting from the restored.env. - Confirm the result with
./setup.sh --status.
To restore only the database from a dump, into a running container:
gunzip -c email-2026-09-24.sql.gz | ./setup.sh -q --exec mysql email
mysqldump drops and recreates every table, so this replaces all users, organisations and domains with the state of the dump. Changes made since then are lost, and mailboxes of users created since then are left without their user.
Deploying for production¶
Work through this list before you put a deployment in front of real users:
./setup.sh --checkreports no failures, and no warnings you have not consciously accepted.- Your firewall exposes only the required ports.
- Certificates come from the Let's Encrypt production CA, not from
stagingor the container's own CA. - Your licence is installed and activated.
- Core dumps are configured, ideally on a separate mount or volume.
- Monitoring and log ingestion are in place. The container has a built-in health check you can watch, see Diagnosing a malfunction. Never let the server run out of disk space — data lost that way cannot be fully recovered.
- Your backups cover the data directory and the
.envfile, see Backup. - A separate mail gateway handles anti-spam, anti-virus and DKIM signing.
Running test/preview versions¶
Preview builds are published as -next images to the same registry as the regular ones, so no
additional access is needed beyond the docker login described above. To switch a deployment to them, re-run the setup script and select the preview variant:
./setup.sh --variant next --update
That resolves the newest -next tag, pins it by digest and recreates the container. Switch back the same way with --variant full or --variant slim.
To debug one specific problem, pin the image support gives you:
./setup.sh --image-version test-image-for-problem@sha256:...
Preview builds are not supported for production use
--update never selects them on its own. Always start a preview container with a specific digest, as shown above.
Security¶
The container defends itself against abuse out of the box. Tune the limits below to your user base rather than removing them.
Rate limiting incoming requests¶
The defaults suit hosting scenarios where few users share an IP address. If a larger group of your users comes from one address — an office behind NAT, for example — either raise the limit for that address or adjust the defaults.
Every service that handles user requests has its own IP filter. The http section covers Outlook, ActiveSync clients, EWS and the webmail client:
e4a:
http:
ip_filter:
audit_interval: 1second
audit_times: 200
imap:
ip_filter:
audit_interval: 1second
audit_times: 200
pop3:
ip_filter:
audit_interval: 1second
audit_times: 200
smtp:
ip_filter:
audit_interval: 1second
audit_times: 200
audit_times is how many requests are allowed within audit_interval. Write the interval as Xsecond, for example 5second or 60second; 0second disables the filter. Each service prints its current rate limiting configuration on startup, so you can confirm your changes took effect.
To give individual addresses their own limits, list them in /storage/system/var/data/ip_filter.txt:
# ip audit_times audit_interval
127.0.0.1 1 0second # rate limiting is disabled for this ip
198.51.100.5 400 1second # allows 400 requests every second
203.0.113.19 1000 5second # allows 1000 requests every five seconds
Rate limiting user sessions¶
After the IP filter, the container limits how many sessions a single user may create in a period. This one restricts the user, not the address they come from.
e4a:
common:
session:
user_session:
rate_limit_period: 10minutes
rate_limit_burst: 100
rate_limit_burst is how many new login sessions a user may create within rate_limit_period.
Disabling connection and session limits
Log-only mode keeps the checks running but stops them rejecting anything, which is useful while you diagnose a limit that fires too early. Do not leave a production system in this state.
E4A_IP_FILTER_LOG_ONLY=1stops the IP filter from enforcing.E4A_SESSION_RATE_LIMIT_LOG_ONLY=1stops the session rate limit from enforcing.
Querying active sessions
Sessions live in Redis. Dump them as JSON with:
e4a service admin dump-sessions
To see the sessions of one user:
e4a service admin dump-sessions | jq 'select(.record[1].v1.username == "username@example.com")'
Rate limiting outgoing emails¶
The e4a-policyd service manages outgoing email quotas as leaky buckets:
- Every
interval, each bucket is refilled to its limit. - Each message sent takes from the bucket.
- When the bucket is empty, no more messages go out until the next refill.
Set the refill interval and the bucket sizes in /storage/config.yaml. The defaults are:
e4a:
policyd:
quota:
smtp:
key_prefix: "pol:quota:"
interval: 12hours
limits:
default: 500
org: 1000
domain: 1000
internal: 1000
| Bucket | Applies to |
|---|---|
default |
Each individual user. |
internal |
Mail sent to recipients on the same server. |
domain |
Everything sent by the sending user's domain. |
org |
Everything sent by the user's organisation, on top of the domain limit. |
Limiting connections at our reverse proxy (Apisix)¶
From version 8.8.0 the container loads the APISIX limit-count and limit-conn plugins, so you can tighten connection limits temporarily on top of the rate limits above:
limit-countcaps how many requests a key, such as an IP address, may make within a time window.limit-conncaps how many connections that key may hold open at once.
Neither is applied to a route by default. Add one to the plugins section of the route you want to protect. This caps concurrent connections to the webmail app per IP address:
routes:
- name: E4A App
...
plugins:
limit-conn:
conn: 200
burst: 100
default_conn_delay: 0.1
key_type: var
key: remote_addr
rejected_code: 503
Testing changes before making them persistent
Edit /run/apisix/apisix-routes.yaml first. Changes there apply immediately without a restart, and are lost on the next one. Once the values do what you want, make the same edit in /storage/apisix-routes.yaml, which is copied over /run/apisix/apisix-routes.yaml on every container start.
Frequently asked questions¶
How does licence activation work?¶
Activation uses the ACME protocol to retrieve a short-lived certificate that unlocks the features of your subscription. The licence exchange tool, lx, performs an HTTP-01 challenge for every domain in the DOMAINS environment variable — which is why all of them have to resolve to your host.
Those certificates are valid for 60 days, and lx renews them automatically once they have less than 40 days left.
How do I reset the admin password?¶
If you lost the main admin password and have no other system administrator who could reset it from the Manage console, send yourself a forgotten-password mail:
docker compose exec exchange4all \
curl -s --unix-socket /var/run/exchange4all-manage-api/rest0.sock "http://manage-api/api/v1/invitations" \
-H 'content-type: application/json' \
--data-raw '{"invitedUserDisplayName":"Administrator","invitedUserEmailAddress":"external-address@example.com","inviteRedeemUrl":"https://exchange4all.local/manage/#/oidc/request-callback?to=set-password","sendInvitationMessage":true,"invitedUserType":"Member","invitedUser":[{"id":"admin@exchange4all.local"}]}'
Replace invitedUserEmailAddress and id with the details of the user you are recovering.
If your container cannot send mail yet, set the password directly instead:
docker compose exec exchange4all \
curl -s --unix-socket /var/run/exchange4all-manage-api/rest0.sock "http://manage-api/api/v1/users/admin@exchange4all.local" \
-X 'PATCH' \
-H 'content-type: application/json' \
--data-raw '{"passwordProfile":{"password":"new-password"}}'
Replace admin@exchange4all.local with the id of the user whose password you are changing.
How does automatic configuration of Outlook and mobile clients work?¶
Microsoft documents the steps at Autodiscover services in Outlook.
Why does Exchange4all need a dedicated IP address?¶
The container terminates SSL itself and does not support an SSL-terminating proxy in front of it. If you use the adsredir service, you need a second dedicated IP.
Why does Exchange4all only configure smtp authentication on the inbound port?¶
Authentication is allowed on the submission port (587) and not on the SMTP port (25). Keeping client-to-server traffic separate from server-to-server traffic lets you rate limit the two independently.
What if Outlook Autodiscover detects IMAP?¶
During Simplified Account Creation, Outlook asks Microsoft's service at prod-autodetect.outlookmobile.com. After a provider change that service can still return cached values for the old provider. When that happens, choose "Configure manually" and then "Exchange", and Outlook detects the remaining settings correctly.
To skip that manual step on your users' machines, set either of these registry values:
HKEY_CURRENT_USER\Software\Microsoft\Office\16.0\Outlook\Setup\DisableAccountSettingsDetectionServiceas aDWORDof1disables the detection service, so Outlook goes straight to the account type dialogue where your user picks "Exchange". This reg file sets it for you.HKEY_CURRENT_USER\Software\Microsoft\Office\16.0\Outlook\Setup\DisableOffice365SimplifiedAccountCreationas aDWORDof1disables simplified account creation altogether, and with it the lookup against Microsoft.
I am getting midb out of memory during a migration, even though I have plenty of ram left¶
Large IMAP migrations can fill the internal buffers of midb, which reports midb out of memory even with free RAM on the host. Raise these limits for the duration of the migration:
e4a:
imap:
context_average_mem: 512K
context_max_mem: 5M
How do disabled users/domains affect usage reporting?¶
Disabling a user or a domain deducts it from your licensed user count immediately. Mail for those addresses is no longer accepted.
Why are my users getting "Sorry, this address is not allowed." when trying to create users?¶
The requested address contains a word from the system-wide "bad words" list, which exists to keep inappropriate addresses out of your deployment.
Either have the user choose a different name, or create the account from an account with system administrator privileges — those bypass the naming policy checks.
Appendix¶
Docker Compose Cheat Sheet¶
Commands you will reach for most often:
# Show the health, image, licences, storage and certificate of the running deployment
./setup.sh --status
# Re-run the host checks against a running deployment, without changing anything
./setup.sh --check
# Move to the newest version of the configured variant and recreate the container
./setup.sh --update
# Update the setup script itself
./setup.sh --self-update
# Get a bash shell in the container, after a short status report
./setup.sh --exec
# Request a new certificate for every name in DOMAINS and apply it
./setup.sh --renew-certificate
# Follow the container output, or open the service log files in lnav
./setup.sh --logs
./setup.sh --logs lnav
# Show the version of the components inside the container
docker compose exec exchange4all cat /opt/exchange4all/.version
# Pull the container tag specified in .env (or the latest if nothing is specified)
docker compose pull
# Start the container in the background, then put the container output into a new command
docker compose up -d && docker compose logs -f
# Get a bash shell in the container
docker compose exec exchange4all bash
# Get a list of environment variables from the container
docker compose exec exchange4all env | sort
# Stop and remove the container. Data in the storage directory is kept.
# Never add -v here, it also deletes the mail queue volumes.
docker compose down
# restart the container (will restart but not rebuild)
docker compose restart
# get verbose healthcheck information from the container
docker compose exec exchange4all healthcheck.sh --format documentation
# get the information of the user user1@exchange4all.local
docker compose exec exchange4all e4a userinfo user1@exchange4all.local
# reset store properties of user1@exchange4all.local. This is helpful after restoring the mailbox to a new user.
docker compose exec exchange4all e4a service admin reset-store-user user1@exchange4all.local
# display the current webapp settings of user1@exchange4all.local as a json object
docker compose exec exchange4all e4a service admin user-settings app dump user1@exchange4all.local
# clear the webapp settings for user1@exchange4all.local
docker compose exec exchange4all e4a service admin user-settings app clear user1@exchange4all.local
# If the systemd unit was installed, this restarts the container
systemctl restart exchange4all
Files written by the setup script¶
setup.sh creates these files for you — they are reproduced here for reference, and you never need to download them separately.
The generated docker-compose.yml
# Generated by setup.sh 6 -- do not edit.
# Local changes belong into docker-compose.override.yml.
# See https://manual.myexchange.rocks/on-premise/
# managed-sha256: fb863dd036dde6a962c60520d1358ae36a107b517d04c0e6797011468eb277b9
services:
exchange4all:
image: ${E4AIMAGE:-registry.kopano.cloud/e4a-container}:${E4AVERSION:-latest}
hostname: ${E4AFQDN:-exchange4all.local}
network_mode: host
restart: unless-stopped
read_only: false
environment:
- E4AFQDN=${E4AFQDN:-exchange4all.local}
- E4AMAILDOMAIN=${E4AMAILDOMAIN:-exchange4all.local}
- LEEMAIL=${LEEMAIL:-}
- ACMESERVER=${ACMESERVER:-staging}
- DOMAINS=${DOMAINS:-} # additional domains for Let's Encrypt certificate
- E4A_ENABLE_EWS_EXPERIMENTAL_OPERATIONS=${E4A_ENABLE_EWS_EXPERIMENTAL_OPERATIONS:-false}
volumes:
- /var/lib/dbus/machine-id:/var/lib/dbus/machine-id:ro
- ${DOCKERBRANDINGDIR:-/srv/exchange4all/branding/}:/branding:ro
- ${DOCKERSTORAGEDIR:-/srv/exchange4all}/db/:/var/lib/mysql
- ${DOCKERSTORAGEDIR:-/srv/exchange4all}/e4a/:/storage
- ${DOCKERSTORAGEDIR:-/srv/exchange4all}/php/:/var/lib/php/sessions
- ${DOCKERTMPDIR:-/run/exchange4all}/webapp:/storage/webapp/tmp
- postfix-spool:/var/spool/postfix
- postfix-outbound-spool:/var/spool/postfix-outbound
tmpfs:
- /run
- /tmp
ulimits:
nofile: 1024000
stop_grace_period: 1m30s
logging:
options:
max-size: ${DOCKERLOGGING_MAXSIZE:-10m}
max-file: ${DOCKERLOGGING_MAXFILE:-3}
volumes:
postfix-spool:
postfix-outbound-spool:
The generated .env
# Configuration of the Exchange4all container.
#
# This file is read by docker compose. It is created and updated by setup.sh, but can also be
# edited by hand. Recreate the container after a change: docker compose up -d
#
# Keep this file in your backups, together with the data directory.
# The name this deployment is reached at. Certificates are requested for it.
E4AFQDN="exchange4all.local"
# First mail domain to create. Only relevant on the very first start.
E4AMAILDOMAIN="exchange4all.local"
# Every name a certificate should cover, separated by spaces. All of them must resolve here.
DOMAINS="exchange4all.local"
# Leave empty to use the container's own CA. Set an address to use Let's Encrypt, which means
# you agree to the Let's Encrypt terms of service.
LEEMAIL=""
# staging while testing certificate retrieval, production once it works.
ACMESERVER="staging"
# Container image and the exact version to run. Update with: ./setup.sh --update
E4AIMAGE="registry.kopano.cloud/e4a-container"
E4AVERSION=""
# full, slim or next. Decides which tag ./setup.sh --update resolves to.
E4AVARIANT="full"
# Where Exchange4all stores its data on this host.
DOCKERSTORAGEDIR="/srv/exchange4all"
# Short lived webmail attachment data. Mounting this as tmpfs on the host is recommended.
DOCKERTMPDIR="/run/exchange4all"
# Names the container and its volumes. Changing this orphans an existing deployment.
COMPOSE_PROJECT_NAME="e4a"
# Where setup.sh installs e4a-cmd, which runs it from anywhere. Empty for $HOME/.local/bin.
E4ACMDDIR=""
# Written by setup.sh so it can migrate older installations.
SETUPVERSION=""
# Optional settings, see https://manual.myexchange.rocks/on-premise/
# Experimental EWS operations. Disabled unless this is set to true.
#E4A_ENABLE_EWS_EXPERIMENTAL_OPERATIONS="true"
#AD_CONNECT_AUTH=""
#POSTMULTI_ENABLED="true"
#PHP_FPM_POOL_WWW_MEMORY_LIMIT="64M"
#TESTING_LOGGING="true"
The optional systemd unit
On installation, WorkingDirectory and the compose command are replaced with the real values of your deployment.
[Unit]
Description=Exchange4all
Requires=docker.service
After=docker.service network-online.target
[Service]
Type=oneshot
RemainAfterExit=yes
WorkingDirectory=/opt/exchange4all
TimeoutStartSec=15min
ExecStart=docker compose up -d --remove-orphans
ExecStop=docker compose down --remove-orphans
[Install]
WantedBy=multi-user.target
Additional DNS Entries for Client Autodiscovery¶
RFC 6186 defines DNS entries that let clients discover your hostnames and ports. Publish them as follows:
# grep SRV zones/my.server/my.server.zone
_imap._tcp IN SRV 0 0 0 .
_imaps._tcp IN SRV 0 0 0 993 your-server.com.
_pop3._tcp IN SRV 0 0 0 .
_pop3s._tcp IN SRV 10 0 995 your-server.com.
_smtp._tcp IN SRV 0 0 0 0 .
_smtps._tcp IN SRV 0 0 0 .
_submission._tcp IN SRV 0 0 0 587 your-server.com.
_autodiscover._tcp IN SRV 0 0 443 your-server.com.
Enabling additional logging for debugging¶
When you are chasing a problem inside the container, these environment variables give you more to work with:
TESTING_LOGGING=trueturns on debug logging for every e4a service in the container.LIBSEGFAULT_ENABLED=trueuses libsegfault, which reports more when a service crashes.
Set them in your override file and recreate the container with docker compose up -d:
services:
exchange4all:
environment:
- LIBSEGFAULT_ENABLED=true
- TESTING_LOGGING=true
Both produce a lot of output. Turn them off again once you have what you need.
Configuring the container/host for core dumps¶
Getting a core dump out of a crash takes changes on both the host and the container.
The kernel core pattern is a host-wide setting, so point it at a path that exists on the host and in the container alike. We recommend /storage/coredumps/. Put a quota on that filesystem — a crash loop writing dumps can otherwise fill the disk, and Exchange4all cannot recover from a full disk.
With ZFS:
mkdir -p /storage
zfs create -o quota=50G -o canmount=on -o mountpoint=/storage/coredumps \
rpool/USERDATA/coredumps
chmod 777 /storage/coredumps # 777 is used because various user ids in the container need write access to this location.
Set the pattern at runtime, which lasts until the next reboot:
echo '/storage/coredumps/core-%e.%h.%t.%p' | sudo tee /proc/sys/kernel/core_pattern
Or make it permanent:
cat <<EOF >>/etc/sysctl.d/99-coredump.conf
kernel.core_pattern=/storage/coredumps/core-%e.%h.%t.%p
EOF
sysctl -p /etc/sysctl.d/99-coredump.conf
To configure the container for core dumps, download the ready made override file next to your docker-compose.yml:
curl -fsSL https://manual.myexchange.rocks/on-premise/coredumps.override.yml -o docker-compose.override.yml
docker compose up -d
Its checksum is listed in SHA256SUMS. If you already have an override file, merge these contents into it rather than overwriting it:
# Example docker-compose.override.yml that enables core dumps.
#
# Copy this file next to your docker-compose.yml, rename it to docker-compose.override.yml
# and recreate the container with: docker compose up -d
#
# The host needs to be prepared as well, otherwise no dump is ever written:
#
# mkdir -p /storage/coredumps
# chmod 777 /storage/coredumps
# echo 'kernel.core_pattern=/storage/coredumps/core-%e.%h.%t.%p' >/etc/sysctl.d/99-coredump.conf
# sysctl -p /etc/sysctl.d/99-coredump.conf
#
# The core pattern is a kernel setting shared by the host and every container on it, so the path
# has to exist on both sides and is mounted below. Put a quota on it, a crash loop can otherwise
# fill the disk and Exchange4all cannot recover from a full disk.
#
# See https://manual.myexchange.rocks/on-premise/#configuring_the_containerhost_for_core_dumps
services:
exchange4all:
volumes:
# left side is the host, right side the container, both must match the core pattern above
- /storage/coredumps/:/storage/coredumps/
ulimits:
# no limit on the size of a written core file
core: -1
environment:
# better stack traces in the log when a service crashes
- LIBSEGFAULT_ENABLED=true
Testing if core dumps are generated¶
Force a core dump of the http process to confirm your configuration works:
This interrupts user connections
Do not run it on a production deployment.
kill -SEGV $(pidof http)
Reporting crashes to the maintainers¶
Before you report a crash, make sure core dumps are configured and one has actually been written.
Include all of the following:
-
The backtrace from the log. You can also extract it from the core file, as long as the version that produced it still matches what you are running:
gdb --cd=/opt/exchange4all/system/var/run /opt/exchange4all/system/libexec/imap /storage/core/filenameThen type
bt. Ask us for the matching debug symbols for your container if you need them. -
The dump file, compressed. Compress it before sending, uncompressed dumps are large:
gzip /storage/coredumps/core-http.exchange4all.local.1641398017.5206 -
The exact versions, both of the container and of the components inside it. Take the container version from your
.env, and the component versions from/opt/exchange4all/.version:docker compose exec exchange4all cat /opt/exchange4all/.version
Send everything to support@nhe4a.tech.
Getting (debug) logs from the ActiveSync component¶
The ActiveSync component is called push and writes these log files:
| File | Contains |
|---|---|
/storage/push/logs/running.log |
The default log: ActiveSync commands, memory usage, timings, device and user information. |
/storage/push/logs/user/userlog.log |
A truncated log of user activity — mail sent, and items changed, moved or created in a folder. |
/storage/push/logs/debug.txt |
Created automatically for matching errors. Define DEBUG_BACKTRACE_IGNORE_ARGS for more detail. |
To log one specific user, set LOG_USER_FILTER in /opt/exchange4all/push/config/config.php:
// debug log switch and filter
// user account should be split by ','
// empty string to log all users
// comment to totally disable log
define("LOG_USER_FILTER", "markus@example.com");
As soon as that user's client makes a request, you get a folder named after them under /storage/push/logs/dump/, holding one log file per connected device.
This configuration file does not survive a restart
/opt/exchange4all/push/config/config.php is part of the container filesystem, so your changes are overwritten whenever the container restarts or is recreated. Use it for a debugging session, not for permanent configuration.
Reverse Proxy configurations¶
When you cannot point the autodiscover domain straight at the container — because of DNS policy, infrastructure constraints or a customer's preference — redirect the traffic from an existing server instead. Requests to https://example.com/Autodiscover/Autodiscover.xml then end up at your deployment. This is a supported fallback, not the recommended deployment.
Add the following to the nginx.conf of that server:
location = /mail/config-v1.1.xml {
return 302 https://autodiscover.example.com$request_uri;
}
location /autodiscover/ {
return 302 https://autodiscover.example.com$request_uri;
}
location /Autodiscover/ {
return 302 https://autodiscover.example.com$request_uri;
}
autodiscover.example.com with your own autodiscover domain. Anything hitting those locations is then redirected to the autodiscover endpoint of your Exchange4all deployment.
Optional Environment Variables¶
Set these at container level, or where applicable for a single service. The generated docker-compose.yml passes some of them through from .env — for those, add the variable there and recreate the container with docker compose up -d. For all others, use an override file.
| Environment variable | Example | Description |
|---|---|---|
ACMESERVER |
staging |
ACME endpoint used for certificate retrieval |
DOMAINS |
mail.example.com example.com |
Domain names through which the system should be reachable. Used for certificate retrieval (if configured) and web server configuration. |
E4A_ENABLE_EWS_EXPERIMENTAL_OPERATIONS |
true |
Enables experimental EWS operations. Defaults to false; only the exact value true enables them. |
E4A_LOG_LEVEL |
4 |
Controls the logging level of the e4a services. |
E4A_LOG_PUT_TIME |
0 |
Prefix for the time and date the log was created. |
E4A_SERVICED_MAILGEN_COMPANY_NAME |
ExampleCompany |
Company name to be used for generated mails. |
E4A_SERVICED_MAILGEN_LOGO_PATH |
/opt/exchange4all/manage/manage-webapp/ |
Logo to be used for generated mails |
E4A_SERVICED_MAILGEN_MAIL_FROM_ADDR |
no-reply@example.com |
Sending information to be used for generated mails |
E4AFQDN |
mail.example.com |
The main domain of the system. |
E4AMAILDOMAIN |
example.com |
First mail domain to be created (only relevant at first start) |
encryption_secret_key |
/storage/lico/lico-encryption-secret.key |
The location of the encryption secret used for auth tokens. |
identifier_registration_conf |
/storage/lico/identifier-registration.yaml |
The location of the OpenID provider registration. |
identifier_scopes_conf |
/storage/lico/scopes.yaml |
Location of the scopes configuration for the OpenID provider. |
LEGO_PATH |
/storage/.lego-tmp |
Location of the Let's Encrypt client data. |
LIBSEGFAULT_ENABLED |
true |
Use libsegfault for better stack traces on crashes (default) |
MARIADB_UNIX_PORT |
/run/mysqld/mysqld.sock |
MariaDB unix socket location |
NOPERMISSIONS |
true |
Don't chown /storage/system and /storage/service (only done on first start) |
signing_private_key |
/storage/lico/signing-private-key.pem |
location of private key used to sign auth tokens |
SUBWORKERS |
1 |
Number of worker processes for the e4a-manage-api |
ULIMIT_FRONTEND |
65536 |
Open file limit for front facing services |
ULIMIT_GENERAL |
8196 |
Open file limit for general services |
ULIMIT_IGNORE |
false |
Ignore container warning about low open file limit |
ULIMIT_NETWORKED |
102400 |
Open file limit for networked services |
validation_keys_path |
/storage/lico/validationkeys |
Location of the keys used to verify auth tokens |
Explanation of privilegeBits¶
When using the Manage API to create or modify users and domains, the following privilege bits can be specified. These are summed and then passed as a single value for privilegeBits.
User Privilege Bits¶
| Value | Name | Description |
|---|---|---|
0x1 |
POP3_IMAP |
if user can retrieve mail via pop3/imap |
0x2 |
SMTP |
if user can send email via SMTP |
0x4 |
CHGPASSWD |
if user can change password |
0x40 |
USRADM |
user administrator |
0x100 |
DOMADM |
domain administrator |
0x400 |
ORGADM |
Organisation Administrator |
0x1000 |
SYSADM |
System Administrator |
Domain Privilege Bits¶
| Value | Name | Description |
|---|---|---|
0x1 |
ARCHIVE |
reserved for historical reasons via the archive_agent plugin, handled by daemon/slugs/sync_from_mysql_1.php |
0x2 |
MONITOR |
unused, reserved for historical reasons |
0x4 |
VERYFYD |
indicates that the domain has passed validation |
0x8 |
SUBSYSTEM |
Reserved for historical reasons via domain_subsystem plugin processed by daemon/slugs/sync_from_mysql_1.php |
0x10 |
NETDISK |
unused, reserved for historical reasons |
0x20 |
EXTPASSWD |
unused, reserved for historical reasons |
0x400 |
SUBROOT |
if domain is available as parent for subdomain creation |
Auto Event Configuration Example¶
New feature in 8.0.5
This is a new feature which is not fully implemented yet. Therefore some of the modifiers do not work as expected. In the final implementation the configuration file will not be modified by admins directly, but instead these options will be exposed through the manage api.
# ============================================================================
# Exchange4all Auto Event Configuration Example
# ============================================================================
# This file configures automatic processing of meeting requests.
#
# Backward Compatibility:
# - File missing: Adds meetings to calendar without auto-response
# - File empty: Checks free/busy status before accepting
# - File with content: Uses configuration below
#
# Location: {mailbox_directory}/config/auto_event.cfg
# ============================================================================
# ----------------------------------------------------------------------------
# Default Action (Required)
# ----------------------------------------------------------------------------
# Specifies the primary action to take for incoming meeting requests
#
# Available options:
# "add_to_calendar" - Add meeting to calendar without sending response
# "decide_based_on_freebusy" - Check availability and accept/reject accordingly
# "accept_and_add" - Always accept and add to calendar
# "reject" - Always reject meeting requests
# "no_action" - Do not process meeting requests
#
# Default: "decide_based_on_freebusy" (same as empty config file)
DEFAULT_ACTION = "decide_based_on_freebusy"
# ----------------------------------------------------------------------------
# Meeting Response Action (Optional)
# ----------------------------------------------------------------------------
# Specifies the action to take for meeting response messages (attendee replies)
#
# Available options:
# "process_reply" - Process meeting responses to update calendar (default)
# "no_action" - Ignore meeting response messages
#
# Default: "process_reply" (same as current behavior)
RESPONSE_ACTION = "process_reply"
# ----------------------------------------------------------------------------
# Meeting Cancellation Action (Optional)
# ----------------------------------------------------------------------------
# Specifies the action to take when a meeting cancellation is received.
# Outlook/M365 sends cancellations as IPM.Schedule.Meeting.Canceled messages.
#
# Available options:
# "no_action" - Ignore cancellations, leave calendar entry in place (default)
# "delete_event" - Automatically delete the calendar entry when a cancellation arrives
#
# For resource accounts (conference rooms, equipment) set to "delete_event" so the
# slot becomes available for re-booking without manual intervention.
#
# Default: "no_action" (backward-compatible)
#CANCELLATION_ACTION = "delete_event"
# ----------------------------------------------------------------------------
# Action Modifiers (Optional)
# ----------------------------------------------------------------------------
# Override the default action for specific meeting types or conditions
# If not specified, the DEFAULT_ACTION is used for all meetings
[MODIFIERS]
# Action for recurring meeting requests
# Set to "reject" to decline all recurring meetings (Kopano --mr-decline-recurring)
# Available: "accept_and_add", "reject", "decide_based_on_freebusy", "add_to_calendar", "no_action"
#RECURRING_MEETINGS = "reject"
# Action for meetings that conflict with existing calendar entries
# Set to "reject" to decline conflicting meetings (Kopano --mr-decline-conflict)
# Only used if the selected action is "decide_based_on_freebusy"
# Available: "accept_and_add", "reject" (default), "add_to_calendar", "no_action"
#CONFLICTING_MEETINGS = "reject"
# Action for meetings scheduled too far in advance
# Helps prevent booking resources too far into the future
# Available: "accept_and_add", "reject", "decide_based_on_freebusy", "add_to_calendar", "no_action"
#TOO_FAR_ADVANCE = "reject"
# Action for too long meetings
# Available: "accept_and_add", "reject", "decide_based_on_freebusy", "add_to_calendar", "no_action"
#TOO_LONG = "reject"
# Action for meetings scheduled outside configured working hours
# Useful for resource booking systems with restricted availability
# Available: "accept_and_add", "reject", "decide_based_on_freebusy", "add_to_calendar", "no_action"
#OUTSIDE_WORKING_HOURS = "reject"
# ----------------------------------------------------------------------------
# Modifier Conditions (Optional)
# ----------------------------------------------------------------------------
# Configure the conditions that trigger the modifiers above
[CONDITIONS]
# Maximum number of days in advance to accept meeting requests
# Meetings beyond this limit will trigger TOO_FAR_ADVANCE modifier
# Range: 1-36500 (100 years), Default: 365
MAX_DAYS_ADVANCE = 365
# Maximum duration of meetings in minutes to accept meeting request
# Meetings longer than this limit will trigger TOO_LONG modifier
# Range: 1-525600 (1 year), Default: 1440
MAX_DURATION = 1440
# Working hours configuration (24-hour format)
# Used by OUTSIDE_WORKING_HOURS modifier
# Format: "HH:MM", Default: "09:00" and "17:00"
WORKING_HOURS_START = "09:00"
WORKING_HOURS_END = "17:00"
# Working days of the week
# Used by OUTSIDE_WORKING_HOURS modifier for weekend/holiday handling
# Available: ["mon", "tue", "wed", "thu", "fri", "sat", "sun"]
# Default: Monday through Friday
WORKING_DAYS = ["mon", "tue", "wed", "thu", "fri"]
# Overbooking capacity
# New meetings will not be considered a conflict if the number of conflicting
# meetings is not above the configured capacity.
# Used by CONFLICTING_MEETINGS modifier
# Range: 1-1000, Default: 1
OVERBOOKING_CAPACITY = 1
Configuring aliases in Postfix¶
In Exchange4all e-mail aliases and groups can easily be created through the Manage console and RestAPI. In addition to this it is also possible to configure aliases directly in the containers Postfix instance.
The aliases file is stored at /storage/postfix-virtual-alias and will automatically be created (along with an alias for the root mailbox) if it does not yet exist.
# remember to run postmap /storage/postfix-virtual-alias after making changes
root@exchange4all.local admin@exchange4all.local
user1@exchange4all.local user1@exchange4all.local user2@exchange4all.local
user3@exchange4all.local external@example.com
To apply changes run postmap /storage/postfix-virtual-alias after editing the file.
Inspecting and Managing the Postfix Message Queue¶
If the delivery service (e4a-delivery) is crashing, the cause is often a problematic message stuck in the local Postfix queue.
List all queued messages:
mailq
Inspect a specific message:
postqueue -p <queue-id>
Remove a message from the queue:
postsuper -d <queue-id>
Removing the offending message is usually sufficient to stop the crash. Once resolved, restart the delivery service with:
rm /etc/runit/runsvdir/default/e4a-delivery/down && sv up e4a-delivery
Send us the message before you delete it
Share its content with support first, so we can fix what made it crash the service.