A Postfix log dashboard for mailcow
mailcow already shows Postfix’s log. Its UI reads recent lines from Redis, which is enough to see whether something just went out. It is a list of lines, though. A message that was deferred three times and then delivered is spread over a dozen of them, among everyone else’s, and older lines drop off the end.
Mailinois keeps each message as one record (its recipients, every attempt and the remote server’s answers) for 30 days by default, groups deferrals by provider and reason, and alerts on bounce rates, blocklistings and failing DNS. If mailcow’s log view already answers your questions, you don’t need it.
How does mailcow log Postfix?
Section titled “How does mailcow log Postfix?”The postfix-mailcow container has no rsyslog. It runs syslog-ng, and its syslog-ng.conf does two things with each line: it writes it to the container’s output (what docker compose logs postfix-mailcow shows), and it pushes mail lines to Redis for mailcow’s UI and its fail2ban-style blocking.
So the rsyslog file the other setups use does nothing here. Two things make another way in easy:
- The container’s entrypoint runs every executable file in
data/hooks/postfix/before the daemons start. - syslog-ng can send to more than one place. A hook can add a destination for Mailinois to the container’s syslog-ng.conf, next to mailcow’s own, without changing the image.
Add Mailinois to the stack
Section titled “Add Mailinois to the stack”Run Mailinois as one more service on mailcow-network, in the docker-compose.override.yml beside mailcow’s docker-compose.yml:
services: mailinois-mailcow: image: ghcr.io/samjuk/mailinois:v0.1.0 restart: always command: ["serve", "-config", "/etc/mailinois.yaml"] environment: - MAILINOIS_PASSWORD=${MAILINOIS_PASSWORD} volumes: - ./data/conf/mailinois/mailinois.yaml:/etc/mailinois.yaml:ro - mailinois-vol-1:/data ports: - "127.0.0.1:7480:7480" # the dashboard, on this host only networks: mailcow-network: aliases: - mailinois
volumes: mailinois-vol-1:Add MAILINOIS_PASSWORD=… to mailcow.conf, and you sign in as admin. mailcow’s .env is a link to mailcow.conf, so Compose fills in ${MAILINOIS_PASSWORD} from it. Then the config:
listen: http: ":7480" syslog: ":5514" # on mailcow-network only: nothing publishes it milter: ":8892"capture: mode: headerschecks: sending_ips: [203.0.113.10] # your mailcow's public address domains: [example.com] # the domains you send asdocker compose up -d mailinois-mailcow starts it. The dashboard is on the host’s loopback. Reach it by SSH tunnel, or put your own TLS proxy in front (Authentication covers sign-in limits behind a proxy).
The hook
Section titled “The hook”#!/bin/sh# Copies Postfix's lines to Mailinois, on top of mailcow's own log (the container's output and Redis).# mailcow runs this before syslog-ng starts, on every container start: the grep stops a restart adding it twice.conf=/etc/syslog-ng/syslog-ng.confgrep -q d_mailinois "$conf" && exit 0cat >> "$conf" <<'EOF'rewrite r_mailinois_host { set("mailcow" value("HOST")); };destination d_mailinois { network("mailinois" port(5514) transport("tcp") flags(syslog-protocol)); };log { source(s_src); filter(f_mail); rewrite(r_mailinois_host); destination(d_mailinois); };EOFchmod +x data/hooks/postfix/mailinois.shdocker compose restart postfix-mailcowWhat each line does:
- The
grep. A restart reuses the container, and the hook has already added its lines. Without the check, the second start would defined_mailinoistwice, syslog-ng would refuse its config, and mailcow would lose its log too. - The
rewrite. syslog-ng names each line after the container’s hostname, which is its ID and changes when the container is recreated. This gives Mailinois a fixed server name,mailcow, in Mailinois’s copy only. - The
destination. RFC 5424 over TCP to themailinoisalias onmailcow-network, with full timestamps and time zones. - The
logpath. mailcow’s own source (s_src) and its mail filter (f_mail), so Mailinois gets what mailcow’s UI gets.
What we ran: this hook inside ghcr.io/mailcow/postfix:3.10.12-1, the image mailcow’s docker-compose.yml uses at the time of writing (syslog-ng 4.8.1). We pointed it at a local Mailinois, without the rest of mailcow:
- Run twice, it added the destination once, and
syslog-ng --syntax-onlypassed. - A Postfix-shaped message logged through
/dev/log(client, Message-ID, sender, a delivery, removal) arrived in Mailinois as one sent message, under servermailcow. - mailcow’s own copy on the container’s output still carried the container’s hostname, so the rewrite stayed in Mailinois’s path.
A whole mailcow stack has not been run against Mailinois.
Gotchas
Section titled “Gotchas”REDIS_SLAVEOF_IP. When it is set, the entrypoint copiessyslog-ng-redis_slave.confover syslog-ng.conf after the hooks have run, and the hook’s lines are lost. Append the same block to that file too, if you use it.- An outage. The new log path doesn’t ask syslog-ng for flow control, so a Mailinois outage doesn’t slow Postfix’s logging. We tested that on 25th October 2026: the hook in
ghcr.io/mailcow/postfix:3.10.12-1(syslog-ng 4.8.1) beside Mailinois, without Redis or the rest of mailcow. With Mailinois stopped, syslog-ng loggedSyslog connection closed … time_reopen='60'and carried on. 200 lines through/dev/logtook 84 to 93 ms, against 92 to 113 ms with Mailinois up, and the container’s output had all 600 lines of three runs. - What an outage keeps. syslog-ng holds the lines in memory and reconnects a minute after Mailinois is back. In that test all 120 messages logged during the outage arrived. A restart of
postfix-mailcowwhile Mailinois is down loses them (40 of 40 in the same test), and Old lines brings them back from the container’s output.
Capturing headers
Section titled “Capturing headers”mailcow’s main.cf runs Rspamd as a milter, with milter_default_action = tempfail. mailcow’s documented place for changes is data/conf/postfix/extra.cf. Add Mailinois after Rspamd, with its own default:
smtpd_milters = inet:rspamd:9900, { inet:mailinois:8892, default_action=accept }non_smtpd_milters = inet:rspamd:9900, { inet:mailinois:8892, default_action=accept }docker compose restart postfix-mailcowDon’t leave out default_action=accept. With mailcow’s global tempfail, a plain inet:mailinois:8892 makes Postfix refuse mail whenever Mailinois is down. We reproduced exactly that in docker-mailserver with the global setting at tempfail (milter-reject: … 4.7.1 Service unavailable - try again later). With the braces form, the same messages were delivered while Mailinois was stopped. That ran on Postfix 3.10.13; mailcow’s image runs 3.10.12. The details are on Postfix in Docker.
Rspamd stays first in the list, so Postfix asks it before Mailinois’s milter. Capturing headers and bodies explains what is stored, and why bodies are off unless you ask.
Old lines
Section titled “Old lines”docker compose logs holds whatever Docker has kept since the container was created. Load it once:
docker compose logs --no-log-prefix postfix-mailcow \ | docker compose exec -T mailinois-mailcow /mailinois import -server mailcow ---no-log-prefix matters: without it each line starts with the container’s name, and import skips them. The lines themselves are syslog-ng’s classic format (Oct 19 10:45:09 6a4cda985b5c postfix/qmgr[200]: …). In our test, import read all five of five. -server mailcow matches the name the hook gives live lines, and import skips lines it already has.
What you see then
Section titled “What you see then”The Servers page lists mailcow, and every message it handles appears under Messages with its recipients and attempts. Use cases goes through deferrals, bounces, blocklists and the rest, with the searches that answer each.