Wenn du mehrere Domains mit Mailcow betreibst, wiederholt sich immer derselbe Nervkram: MX setzen, SPF schreiben, DKIM eintragen, DMARC hinterher, Autoconfig und Co. nicht vergessen.
Weil ich darauf keine Lust mehr hatte, erledigt das bei mir jetzt ein Bash-Script, das:
- die DNS-Zone bei Cloudflare findet
- den DKIM-Key automatisch aus der Mailcow-API zieht
- alle relevanten DNS-Records für Mailcow setzt bzw. aktualisiert
In diesem Beitrag zeige ich dir:
- welche DNS-Records angelegt werden und warum
- wie das Script aufgebaut ist
- wie du die
.env-Datei für deine Secrets nutzt - wie du das Ganze in der Praxis einsetzt
Am Ende findest du das komplette Script und die passende .env-Datei als kopierbare Code-Blöcke.
Was das Script macht – einmal in Kurzform
Für eine Domain wie example.org macht das Script:
- MX: zeigt auf deinen Mailcow-Host (z. B.
mail.example.org) - SPF (TXT): Standardmäßig
v=spf1 mx -all - DKIM (TXT): Holt den Key aus der Mailcow-API und legt
dkim._domainkey.example.org(oder deinen Selector) an - DMARC (TXT): einfacher Default
v=DMARC1; p=quarantine; rua=mailto:dmarc-reports@example.org - Autoconfig/Autodiscover (CNAME):
autoconfig.example.orgundautodiscover.example.org→mail.example.org - MTA-STS (CNAME + TXT):
mta-sts.example.org+_mta-sts.example.org - TLS-Reporting (TXT):
_smtp._tls.example.orgfür TLS-Reports
Wichtig: Existierende DKIM-Records mit anderen Selektoren (z. B. vom alten Mailserver) bleiben absichtlich unberührt. Solange du noch über den alten Server versendest oder alte Mails nachsigniert werden, ist das hilfreich. Aufräumen kannst du später manuell, wenn der alte Server wirklich komplett raus ist.
Voraussetzungen
Damit das Script sauber läuft, brauchst du:
- macOS oder Linux mit:
bashcurljq- Cloudflare-API-Token mit:
- Zone → DNS → Edit
- Zone → Zone → Read
- Mailcow-API-Key mit Leserechten für DKIM (
/api/v1/get/dkim/{domain}) - Eine Mailcow-Instanz, z. B.
https://mailcow.example.org - Deine Domains müssen bereits als Zonen bei Cloudflare existieren
Wie das Script funktioniert
1. .env-Datei laden
Das Script liest – falls vorhanden – eine .env im selben Verzeichnis.
Darin stehen die sensitiven Daten wie API-Tokens und URLs. So bleiben die Werte getrennt vom Script selbst.
# .env automatisch laden, falls vorhanden
if [ -f .env ]; then
echo "Lade .env..."
set -o allexport
source .env
set +o allexport
fi
Die Kombination aus set -o allexport und source .env sorgt dafür, dass jede Variable aus der .env automatisch als Umgebungsvariable exportiert wird, ohne dass du überall export schreiben musst.
2. API-Variablen & Standardwerte
Das Script nutzt:
- Cloudflare API Token (
CF_API_TOKEN) - Mailcow API Key (
MAILCOW_API_KEY) - Mailcow Base-URL (
MAILCOW_BASEURL) - Mail-Host / MX-Ziel (
MAIL_HOST)
Dazu kommen ein paar Default-Policies:
CF_API_TOKEN="${CF_API_TOKEN:-}"
CF_API_BASE="https://api.cloudflare.com/client/v4"
MAILCOW_BASEURL="${MAILCOW_BASEURL:-https://mail.net73.de}"
MAILCOW_API_KEY="${MAILCOW_API_KEY:-}"
DOMAIN="${1:-}" # Domain als CLI-Argument
MAIL_HOST="${MAIL_HOST:-mail.net73.de}"
SPF_VALUE="${SPF_VALUE:-v=spf1 mx -all}"
DMARC_VALUE="${DMARC_VALUE:-v=DMARC1; p=quarantine; rua=mailto:dmarc-reports@${DOMAIN}}"
MTA_STS_ID="${MTA_STS_ID:-$(date +%Y%m%d)}"
TTL=3600
Ein paar Punkte dazu:
DOMAINkommt als erstes Argument, z. B../setup_mail_dns.sh example.orgSPF_VALUE,DMARC_VALUE,MTA_STS_IDkannst du bei Bedarf über die.envüberschreibenMTA_STS_IDnutzt einfach das aktuelle Datum als ID – reicht völlig
3. Fehler-Handling & Pflicht-Variablen
Damit du nicht mit halbgaren Umgebungen arbeitest, prüft das Script die nötigsten Variablen:
err() { echo "ERROR: $*" >&2; exit 1; }
require_env() {
local name="$1"
[ -n "${!name:-}" ] || err "Umgebungsvariable $name ist nicht gesetzt."
}
[ -n "$DOMAIN" ] || err "Usage: $0 <domain.tld> (z.B. example.org)"
require_env CF_API_TOKEN
require_env MAILCOW_API_KEY
require_env MAILCOW_BASEURL
Wenn z. B. CF_API_TOKEN fehlt, bricht das Script mit einer sauberen Fehlermeldung ab.
4. Cloudflare-Zone finden
Cloudflare arbeitet intern mit Zone-IDs. Die holt sich das Script über die API:
echo "Suche Cloudflare Zone-ID für ${DOMAIN}..."
ZONE_ID=$(
curl -s -X GET "${CF_API_BASE}/zones?name=${DOMAIN}" \
-H "Authorization: Bearer ${CF_API_TOKEN}" \
-H "Content-Type: application/json" \
| jq -r '.result[0].id // empty'
)
[ -n "$ZONE_ID" ] || err "Konnte Zone-ID für ${DOMAIN} nicht finden. Stimmt die Domain in Cloudflare?"
Damit bist du unabhängig von irgendwelchen manuellen IDs.
5. DKIM-Key aus der Mailcow-API ziehen
Statt den DKIM-Key per Copy & Paste ins DNS zu kippen, zieht das Script den aktuellen Key direkt aus Mailcow:
echo "Hole DKIM-Key von Mailcow für ${DOMAIN}..."
DKIM_JSON=$(
curl -s -X GET "${MAILCOW_BASEURL}/api/v1/get/dkim/${DOMAIN}" \
-H "X-API-Key: ${MAILCOW_API_KEY}"
)
DKIM_SELECTOR=$(echo "$DKIM_JSON" | jq -r '.dkim_selector // "dkim"')
DKIM_TXT=$(echo "$DKIM_JSON" | jq -r '.dkim_txt // empty')
[ -n "$DKIM_TXT" ] || err "Kein DKIM-TXT von Mailcow erhalten. Prüfe API-Key, Domain oder ob ein DKIM-Key angelegt wurde."
DKIM_NAME="${DKIM_SELECTOR}._domainkey.${DOMAIN}"
echo "DKIM-Selector: ${DKIM_SELECTOR}"
echo "DKIM-Record: ${DKIM_NAME}"
dkim_selector: z. B.dkimdkim_txt: der komplette DKIM-Record, wie er ins DNS gehört (inkl.v=DKIM1; k=rsa; p=…)
Das Script macht daraus später einen TXT-Record bei Cloudflare.
6. DNS-Record-Handling mit cf_upsert_record
Der eigentliche Trick steckt in einer kleinen Hilfsfunktion, die Records entweder aktualisiert oder neu anlegt:
cf_upsert_record() {
local TYPE="$1" # A, CNAME, MX, TXT, ...
local NAME="$2"
local CONTENT="$3"
local TTL_VAL="$4"
local PROXIED="${5:-false}" # true/false (nur für A/AAAA/CNAME)
local PRIORITY="${6:-0}" # nur für MX relevant
local DATA
if [[ "$TYPE" == "MX" ]]; then
DATA=$(jq -n --arg type "$TYPE" --arg name "$NAME" --arg content "$CONTENT" \
--argjson ttl "$TTL_VAL" --argjson priority "$PRIORITY" \
'{type:$type,name:$name,content:$content,ttl:$ttl,priority:$priority}')
elif [[ "$TYPE" == "TXT" ]]; then
DATA=$(jq -n --arg type "$TYPE" --arg name "$NAME" --arg content "$CONTENT" \
--argjson ttl "$TTL_VAL" \
'{type:$type,name:$name,content:$content,ttl:$ttl}')
else
DATA=$(jq -n --arg type "$TYPE" --arg name "$NAME" --arg content "$CONTENT" \
--argjson ttl "$TTL_VAL" --argjson proxied "$PROXIED" \
'{type:$type,name:$name,content:$content,ttl:$ttl,proxied:$proxied}')
fi
local EXISTING_ID
EXISTING_ID=$(
curl -s -X GET "${CF_API_BASE}/zones/${ZONE_ID}/dns_records?type=${TYPE}&name=${NAME}" \
-H "Authorization: Bearer ${CF_API_TOKEN}" \
-H "Content-Type: application/json" \
| jq -r '.result[0].id // empty'
)
if [[ -n "$EXISTING_ID" ]]; then
curl -s -X PUT "${CF_API_BASE}/zones/${ZONE_ID}/dns_records/${EXISTING_ID}" \
-H "Authorization: Bearer ${CF_API_TOKEN}" \
-H "Content-Type: application/json" \
--data "${DATA}" >/dev/null
echo "Updated ${TYPE} ${NAME}"
else
curl -s -X POST "${CF_API_BASE}/zones/${ZONE_ID}/dns_records" \
-H "Authorization: Bearer ${CF_API_TOKEN}" \
-H "Content-Type: application/json" \
--data "${DATA}" >/dev/null
echo "Created ${TYPE} ${NAME}"
fi
}
Damit musst du dich im Hauptteil des Scripts nicht mehr darum kümmern, ob ein Record schon existiert.
7. Welche DNS-Records das Script setzt
Im letzten Block legt das Script alle relevanten Records für die Domain an bzw. aktualisiert sie:
echo "Lege Standard-Mailcow-DNS-Einträge für ${DOMAIN} an / aktualisiere sie..."
# MX
cf_upsert_record "MX" "${DOMAIN}" "${MAIL_HOST}" "${TTL}" "false" 10
# SPF
cf_upsert_record "TXT" "${DOMAIN}" "${SPF_VALUE}" "${TTL}"
# DKIM
cf_upsert_record "TXT" "${DKIM_NAME}" "${DKIM_TXT}" "${TTL}"
# DMARC
cf_upsert_record "TXT" "_dmarc.${DOMAIN}" "${DMARC_VALUE}" "${TTL}"
# Autoconfig / Autodiscover
cf_upsert_record "CNAME" "autoconfig.${DOMAIN}" "${MAIL_HOST}" "${TTL}" "false"
cf_upsert_record "CNAME" "autodiscover.${DOMAIN}" "${MAIL_HOST}" "${TTL}" "false"
# Optional: MTA-STS & TLS-RPT
cf_upsert_record "CNAME" "mta-sts.${DOMAIN}" "${MAIL_HOST}" "${TTL}" "false"
cf_upsert_record "TXT" "_mta-sts.${DOMAIN}" "v=STSv1; id=${MTA_STS_ID}" "${TTL}"
cf_upsert_record "TXT" "_smtp._tls.${DOMAIN}" "v=TLSRPTv1; rua=mailto:tls-reports@${DOMAIN}" "${TTL}"
echo "Fertig. Bitte DNS-Änderungen und Mailcow-Check-Tools (z.B. Mailcow DNS-Check, mail-tester.com) prüfen."
Kurz dazu:
- MX: zeigt auf deinen Mailcow-Host, Proxy ist ausgeschaltet (Cloudflare darf kein SMTP sprechen)
- SPF: minimalistisch, erlaubt nur den MX-Server
- DKIM: nutzt den aktuellen Key aus Mailcow
- DMARC: schickt Reports an
dmarc-reports@deine-domain - Autoconfig/Autodiscover: hilft Mail-Clients bei der Auto-Konfiguration
- MTA-STS & TLSRPT: härten Transportverschlüsselung und sammeln TLS-Reports
8. Was ist mit alten DKIM-Records?
Viele migrieren von einem alten Mailserver (anderer Provider, eigene Kiste, …) zu Mailcow. Oft existieren dort bereits DKIM-Records mit einem anderen Selector, z. B.:
google._domainkey.example.orgmailjet._domainkey.example.orgoldserver1._domainkey.example.org
Das Script fasst diese bewusst nicht an.
Das ist kein Bug, sondern Feature: Solange der alte Mailserver noch Mails mit seinem Key signiert (oder alte Nachrichten Relevanz haben), ist es sinnvoll, die alten DKIM-Keys im DNS zu lassen. Erst wenn du sicher bist, dass der alte Server endgültig außer Betrieb ist, kannst du seine Selektoren gezielt löschen – das ist eine Entscheidung, die ich lieber manuell treffen möchte.
Praxis: Setup, .env und Aufruf
1. Script speichern
Speichere das Script z. B. als setup_mail_dns.sh in ein Verzeichnis deiner Wahl.
2. .env anlegen
Im selben Verzeichnis legst du eine Datei .env an, z. B.:
export CF_API_TOKEN="xxxxxxx"
export MAILCOW_API_KEY="xxxxxxx"
export MAILCOW_BASEURL="https://mailcow.example.org"
export MAIL_HOST="mail.example.org"
Für weitere Domains kannst du MAIL_HOST gleich lassen, wenn alle denselben Mailcow-Host nutzen.
3. Script ausführbar machen und aufrufen
chmod +x setup_mail_dns.sh
./setup_mail_dns.sh example.org
Für jede weitere Domain:
./setup_mail_dns.sh andere-domain.tld
Komplettes Script (kopierfertig)
#!/usr/bin/env bash
# .env automatisch laden, falls vorhanden
if [ -f .env ]; then
echo "Lade .env..."
set -o allexport
source .env
set +o allexport
fi
set -euo pipefail
# 1) Cloudflare
CF_API_TOKEN="${CF_API_TOKEN:-}" # besser als Umgebungsvariable setzen
CF_API_BASE="https://api.cloudflare.com/client/v4"
# 2) Mailcow
MAILCOW_BASEURL="${MAILCOW_BASEURL:-https://mail.net73.de}" # ohne trailing slash
MAILCOW_API_KEY="${MAILCOW_API_KEY:-}"
# 3) Domain & Mailserver
DOMAIN="${1:-}" # z.B. example.org (als Argument)
MAIL_HOST="${MAIL_HOST:-mail.net73.de}" # MX-Ziel
# 4) Mail-Policy Defaults
SPF_VALUE="${SPF_VALUE:-v=spf1 mx -all}"
DMARC_VALUE="${DMARC_VALUE:-v=DMARC1; p=quarantine; rua=mailto:dmarc-reports@${DOMAIN}}"
MTA_STS_ID="${MTA_STS_ID:-$(date +%Y%m%d)}" # z.B. 20251115
TTL=3600
# ============================
# HILFSFUNKTIONEN
# ============================
err() { echo "ERROR: $*" >&2; exit 1; }
require_env() {
local name="$1"
[ -n "${!name:-}" ] || err "Umgebungsvariable $name ist nicht gesetzt."
}
# Upsert-Funktion für DNS-Records
cf_upsert_record() {
local TYPE="$1" # A, CNAME, MX, TXT, ...
local NAME="$2"
local CONTENT="$3"
local TTL_VAL="$4"
local PROXIED="${5:-false}" # true/false (nur für A/AAAA/CNAME)
local PRIORITY="${6:-0}" # nur für MX relevant
# JSON-Body je nach Typ bauen
local DATA
if [[ "$TYPE" == "MX" ]]; then
DATA=$(jq -n --arg type "$TYPE" --arg name "$NAME" --arg content "$CONTENT" \
--argjson ttl "$TTL_VAL" --argjson priority "$PRIORITY" \
'{type:$type,name:$name,content:$content,ttl:$ttl,priority:$priority}')
elif [[ "$TYPE" == "TXT" ]]; then
DATA=$(jq -n --arg type "$TYPE" --arg name "$NAME" --arg content "$CONTENT" \
--argjson ttl "$TTL_VAL" \
'{type:$type,name:$name,content:$content,ttl:$ttl}')
else
# A/AAAA/CNAME mit proxied
DATA=$(jq -n --arg type "$TYPE" --arg name "$NAME" --arg content "$CONTENT" \
--argjson ttl "$TTL_VAL" --argjson proxied "$PROXIED" \
'{type:$type,name:$name,content:$content,ttl:$ttl,proxied:$proxied}')
fi
# Existing Record abfragen
local EXISTING_ID
EXISTING_ID=$(
curl -s -X GET "${CF_API_BASE}/zones/${ZONE_ID}/dns_records?type=${TYPE}&name=${NAME}" \
-H "Authorization: Bearer ${CF_API_TOKEN}" \
-H "Content-Type: application/json" \
| jq -r '.result[0].id // empty'
)
if [[ -n "$EXISTING_ID" ]]; then
curl -s -X PUT "${CF_API_BASE}/zones/${ZONE_ID}/dns_records/${EXISTING_ID}" \
-H "Authorization: Bearer ${CF_API_TOKEN}" \
-H "Content-Type: application/json" \
--data "${DATA}" >/dev/null
echo "Updated ${TYPE} ${NAME}"
else
curl -s -X POST "${CF_API_BASE}/zones/${ZONE_ID}/dns_records" \
-H "Authorization: Bearer ${CF_API_TOKEN}" \
-H "Content-Type: application/json" \
--data "${DATA}" >/dev/null
echo "Created ${TYPE} ${NAME}"
fi
}
# ============================
# CHECKS
# ============================
[ -n "$DOMAIN" ] || err "Usage: $0 <domain.tld> (z.B. example.org)"
require_env CF_API_TOKEN
require_env MAILCOW_API_KEY
require_env MAILCOW_BASEURL
# ============================
# ZONE ID HOLEN
# ============================
echo "Suche Cloudflare Zone-ID für ${DOMAIN}..."
ZONE_ID=$(
curl -s -X GET "${CF_API_BASE}/zones?name=${DOMAIN}" \
-H "Authorization: Bearer ${CF_API_TOKEN}" \
-H "Content-Type: application/json" \
| jq -r '.result[0].id // empty'
)
[ -n "$ZONE_ID" ] || err "Konnte Zone-ID für ${DOMAIN} nicht finden. Stimmt die Domain in Cloudflare?"
echo "Gefundene Zone-ID: ${ZONE_ID}"
# ============================
# DKIM PER MAILCOW-API HOLEN
# ============================
echo "Hole DKIM-Key von Mailcow für ${DOMAIN}..."
DKIM_JSON=$(
curl -s -X GET "${MAILCOW_BASEURL}/api/v1/get/dkim/${DOMAIN}" \
-H "X-API-Key: ${MAILCOW_API_KEY}"
)
DKIM_SELECTOR=$(echo "$DKIM_JSON" | jq -r '.dkim_selector // "dkim"')
DKIM_TXT=$(echo "$DKIM_JSON" | jq -r '.dkim_txt // empty')
[ -n "$DKIM_TXT" ] || err "Kein DKIM-TXT von Mailcow erhalten. Prüfe API-Key, Domain oder ob ein DKIM-Key angelegt wurde."
DKIM_NAME="${DKIM_SELECTOR}._domainkey.${DOMAIN}"
echo "DKIM-Selector: ${DKIM_SELECTOR}"
echo "DKIM-Record: ${DKIM_NAME}"
# ============================
# DNS-RECORDS ANLEGEN / AKTUALISIEREN
# ============================
echo "Lege Standard-Mailcow-DNS-Einträge für ${DOMAIN} an / aktualisiere sie..."
# MX
cf_upsert_record "MX" "${DOMAIN}" "${MAIL_HOST}" "${TTL}" "false" 10
# SPF
cf_upsert_record "TXT" "${DOMAIN}" "${SPF_VALUE}" "${TTL}"
# DKIM
cf_upsert_record "TXT" "${DKIM_NAME}" "${DKIM_TXT}" "${TTL}"
# DMARC
cf_upsert_record "TXT" "_dmarc.${DOMAIN}" "${DMARC_VALUE}" "${TTL}"
# Autoconfig / Autodiscover
cf_upsert_record "CNAME" "autoconfig.${DOMAIN}" "${MAIL_HOST}" "${TTL}" "false"
cf_upsert_record "CNAME" "autodiscover.${DOMAIN}" "${MAIL_HOST}" "${TTL}" "false"
# Optional: MTA-STS & TLS-RPT
cf_upsert_record "CNAME" "mta-sts.${DOMAIN}" "${MAIL_HOST}" "${TTL}" "false"
cf_upsert_record "TXT" "_mta-sts.${DOMAIN}" "v=STSv1; id=${MTA_STS_ID}" "${TTL}"
cf_upsert_record "TXT" "_smtp._tls.${DOMAIN}" "v=TLSRPTv1; rua=mailto:tls-reports@${DOMAIN}" "${TTL}"
echo "Fertig. Bitte DNS-Änderungen und Mailcow-Check-Tools (z.B. Mailcow DNS-Check, mail-tester.com) prüfen."
Beispiel-.env zum direkten Einsatz
export CF_API_TOKEN="xxxxxxx"
export MAILCOW_API_KEY="xxxxxxx"
export MAILCOW_BASEURL="https://mailcow.example.org"
export MAIL_HOST="mail.example.org"

