Adds CrowdSec threat detection and IP banning to a Mailcow Dockerized installation via docker-compose.override.yml — no changes to Mailcow's own files, fully upgrade-safe.
- Detects brute-force attacks on SMTP (Postfix), IMAP/POP3 (Dovecot), and the web UI (nginx) by reading their Docker logs
- Bans attacking IPs at the iptables level (INPUT, FORWARD, DOCKER-USER chains) using ipsets — blocks before traffic reaches any service
- Shares threat intelligence with the CrowdSec community network, pulling a live blocklist of known bad actors
- Geo-aware thresholds — your home country gets a lenient threshold (ban after 3 failed logins); the rest of the world is banned immediately on the first attempt
- Escalating bans — persistent returners across SMTP, IMAP/POP3, and HTTP receive a 7-day ban after repeated offences
- Zero Mailcow modifications — everything is in
docker-compose.override.yml, which Docker Compose merges automatically
Mailcow containers (nginx, postfix, dovecot)
│ logs via Docker socket
▼
┌─────────────────────────────┐
│ crowdsec container │ ← LAPI + agent, port 127.0.0.1:8080
│ reads logs, detects attacks│
│ maintains ban decisions │
└────────────┬────────────────┘
│ decisions via API
▼
┌─────────────────────────────┐
│ cs-firewall-bouncer │ ← network_mode: host
│ enforces bans in iptables │
│ INPUT / FORWARD / DOCKER-USER chains
└─────────────────────────────┘
- Mailcow Dockerized installed and running
- Docker Compose v2
- Root or sudo access on the host
cp docker-compose.override.yml /opt/mailcow-dockerized/
cp -r crowdsec/ /opt/mailcow-dockerized/If you already have a
docker-compose.override.yml(e.g. for the Prometheus exporter), merge theservices:andvolumes:blocks manually instead of overwriting.
Mailcow container names follow the pattern <project>-<service>-<index>. The default project name is mailcowdockerized, giving names like mailcowdockerized-nginx-mailcow-1.
Verify your actual container names:
docker ps --format '{{.Names}}' | grep mailcowIf your names differ, edit /opt/mailcow-dockerized/crowdsec/acquis.yaml accordingly.
Edit /opt/mailcow-dockerized/crowdsec/parsers/s02-enrich/whitelists.yaml and add any IPs or CIDRs that should never be banned — your home IP, monitoring server, VPN range, etc.
The included SMTP brute-force scenarios apply different ban thresholds by country:
| Group | Threshold | Scenario file |
|---|---|---|
| Lenient (home country) | Ban after 3 failed logins within 1 hour | crowdsec/scenarios/postfix-sasl-brute-de.yaml |
| Strict (rest of world) | Ban on first failed login | crowdsec/scenarios/postfix-sasl-brute-world.yaml |
The files ship configured for Germany (DE). To use a different country, edit both scenario files and replace DE with your country's ISO 3166-1 alpha-2 code (e.g. FR for France, NL for Netherlands, US for United States):
# Example: switch from Germany to Austria
sed -i "s/IsoCode == 'DE'/IsoCode == 'AT'/g" \
/opt/mailcow-dockerized/crowdsec/scenarios/postfix-sasl-brute-de.yaml
sed -i "s/IsoCode != 'DE'/IsoCode != 'AT'/g" \
/opt/mailcow-dockerized/crowdsec/scenarios/postfix-sasl-brute-world.yamlTo grant the lenient threshold to multiple countries (e.g. DE, AT, CH), edit the filter lines manually:
# postfix-sasl-brute-de.yaml
filter: "evt.Meta.log_type_enh == 'spam-attempt' && evt.Enriched.IsoCode in ['DE', 'AT', 'CH']"
# postfix-sasl-brute-world.yaml
filter: "evt.Meta.log_type_enh == 'spam-attempt' && !(evt.Enriched.IsoCode in ['DE', 'AT', 'CH'])"To disable geo-awareness entirely and apply a single threshold for all IPs, delete postfix-sasl-brute-de.yaml, keep only postfix-sasl-brute-world.yaml, and set whatever capacity you prefer. Remove the -de bind mount from docker-compose.override.yml.
After any scenario change, restart CrowdSec:
cd /opt/mailcow-dockerized && docker compose restart crowdseccd /opt/mailcow-dockerized
docker compose pull crowdsec
docker compose up -d crowdsecWait ~10 seconds for startup, then verify log acquisition:
docker exec mailcowdockerized-crowdsec-1 cscli metrics show acquisitionAll three sources (nginx, postfix, dovecot) should show non-zero Lines read.
BOUNCER_KEY=$(docker exec mailcowdockerized-crowdsec-1 cscli bouncers add firewall-bouncer -o raw)
echo "Key: $BOUNCER_KEY"cp /opt/mailcow-dockerized/crowdsec/cs-firewall-bouncer.yaml.example \
/opt/mailcow-dockerized/crowdsec/cs-firewall-bouncer.yaml
# Replace the placeholder with the generated key:
sed -i "s/REPLACE_WITH_YOUR_BOUNCER_API_KEY/${BOUNCER_KEY}/" \
/opt/mailcow-dockerized/crowdsec/cs-firewall-bouncer.yamlThe bouncer is built from crowdsec/Dockerfile.bouncer. It automatically fetches the latest release from GitHub at build time — no version to maintain manually.
cd /opt/mailcow-dockerized
docker compose build --no-cache cs-firewall-bouncer
docker compose up -d cs-firewall-bouncer# Bouncer is registered and valid
docker exec mailcowdockerized-crowdsec-1 cscli bouncers list
# iptables chains exist
iptables -L INPUT | grep CROWDSEC
iptables -L FORWARD | grep CROWDSEC
# Active bans (may be empty immediately, populates within minutes)
docker exec mailcowdockerized-crowdsec-1 cscli decisions listThe CrowdSec Console gives you a web UI to view alerts, bans, and threat intelligence across all your enrolled machines.
docker exec mailcowdockerized-crowdsec-1 cscli console enroll <your-enrollment-id>Accept the enrollment at https://app.crowdsec.net, then restart CrowdSec:
cd /opt/mailcow-dockerized && docker compose restart crowdsecdocker exec mailcowdockerized-crowdsec-1 cscli decisions listdocker exec mailcowdockerized-crowdsec-1 cscli alerts listdocker exec mailcowdockerized-crowdsec-1 cscli decisions delete --ip <ip>docker exec mailcowdockerized-crowdsec-1 cscli decisions add --ip <ip> --reason "manual ban" --duration 24hdocker exec mailcowdockerized-crowdsec-1 cscli hub update
docker exec mailcowdockerized-crowdsec-1 cscli hub upgrade
docker compose restart crowdsecEdit docker-compose.override.yml, update the COLLECTIONS environment variable:
COLLECTIONS: "crowdsecurity/nginx crowdsecurity/postfix crowdsecurity/dovecot crowdsecurity/sshd"Then recreate the container:
cd /opt/mailcow-dockerized && docker compose up -d crowdsecThe Dockerfile resolves the latest release automatically via the GitHub API. To upgrade:
cd /opt/mailcow-dockerized
docker compose build --no-cache cs-firewall-bouncer
docker compose up -d cs-firewall-bouncer--no-cache is required to bypass Docker's layer cache and force a fresh GitHub API lookup. To pin a specific version instead:
docker compose build --no-cache --build-arg VERSION=v0.0.34 cs-firewall-bouncer
docker compose up -d cs-firewall-bouncerMailcow runs post_update_hook.sh from the install root at the end of every
./update.sh. Copy the example so the firewall bouncer is rebuilt (and re-fetches
its latest release) automatically after each Mailcow update:
cp post_update_hook.sh.example /opt/mailcow-dockerized/post_update_hook.sh
chmod +x /opt/mailcow-dockerized/post_update_hook.shThe hook also pulls and restarts FederLoom if it is installed (see below); it is a no-op otherwise.
FederLoom is a decentralized, federated IP-reputation sidecar. Where CrowdSec shares intel through its central community network, FederLoom shares trust-weighted reputation peer-to-peer between self-hosted servers. It runs alongside this CrowdSec integration — CrowdSec for local detection and enforcement, FederLoom for federated intel — and consumes the same local CrowdSec LAPI.
A helper script installs and auto-configures it:
cd /opt/mailcow-dockerized # or wherever this repo's files were copied
./federloom/setup-federloom.sh /opt/mailcow-dockerizedThe script auto-detects your public IP, Tailscale IP, Docker networks, and
Mailcow container names and asks you to confirm before applying. It registers a
CrowdSec bouncer named federloom, writes federloom/config.local.yaml (which is
gitignored — it holds the API key), fetches rules.yaml from upstream, merges a
federloom service into your existing docker-compose.override.yml (after a
timestamped backup), and starts the container. Re-running it is safe.
After it starts, open inbound tcp/7700 in your firewall for full peer-to-peer federation.
The script offers (opt-in) to join the federloom.jru.me honeypot federation
using the bundled trust invite. Verify this fingerprint out-of-band first:
79bb d13a 114b 88fe
The setup script saves the invite to federloom/federation.invite. To join later by hand:
cd /opt/mailcow-dockerized
docker compose cp federloom/federation.invite federloom:/tmp/federation.invite
docker compose exec federloom federloomctl federation join /tmp/federation.invite \
--config /etc/federloom/config.yamlMailcow's ./update.sh uses docker compose from the installation directory, which automatically merges docker-compose.override.yml. CrowdSec services are unaffected by Mailcow updates.
docker-compose.override.yml ← drop into /opt/mailcow-dockerized/
crowdsec/
acquis.yaml ← log sources (adjust container names)
Dockerfile.bouncer ← builds the firewall bouncer image
cs-firewall-bouncer.yaml.example ← copy to .yaml and fill in API key
parsers/
s01-parse/
postfix-auth-failures.yaml ← catches disconnect auth=0/N lines
s02-enrich/
whitelists.yaml ← trusted IPs/CIDRs (adjust to your setup)
scenarios/
postfix-sasl-brute-world.yaml ← strict: ban on 1st failed login (all non-home-country IPs)
postfix-sasl-brute-de.yaml ← lenient: ban after 3 failed logins (home country, default: DE)
postfix-sasl-recidive.yaml ← escalation: 7-day ban after 3 SMTP returns within 24h
dovecot-recidive.yaml ← escalation: 7-day ban after 2 IMAP/POP3 returns within 24h
http-recidive.yaml ← escalation: 7-day ban after 3 HTTP scan returns within 24h
Persistent attackers that return after their initial ban receive progressively longer bans via dedicated recidive scenarios. Each scenario watches the same events as its base counterpart but uses a 24-hour leaky bucket — events drain slowly, so only IPs that return within 24h of their previous attempt accumulate toward the escalated ban.
| Protocol | Base scenario | Escalation trigger | Escalated ban |
|---|---|---|---|
| SMTP AUTH | 1st failed connection → 4h (postfix-sasl-brute-world) |
3 returns within 24h | 7 days |
| IMAP/POP3 | 4 auth failures in 6 min → 4h (dovecot-spam) |
2 returns within 24h | 7 days |
| HTTP scan | 10 unique 404/403/400 paths → 4h (http-probing) |
3 return visits within 24h | 7 days |
IPs that space their attacks more than 24h apart continue receiving short bans from the base scenarios — the recidive bucket drains before they return. Only persistent same-day returners escalate.
cs-firewall-bouncer.yamlcontains the LAPI API key — it is listed in.gitignoreand must not be committed- The bouncer runs with
network_mode: hostandCAP_NET_ADMIN/CAP_NET_RAW— required to manipulate iptables on the host - The Docker socket (
/var/run/docker.sock) is mounted read-only into the CrowdSec container for log reading — standard practice for log collectors
- CrowdSec Documentation
- CrowdSec Hub — parsers, scenarios, collections
- Mailcow Documentation
- mailcow/mailcow-dockerized#4433 — original integration discussion