Automatisches DNS-Setup mit Cloudflare- und Mailcow-API

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.org und autodiscover.example.org → mail.example.org
  • MTA-STS (CNAME + TXT): mta-sts.example.org + _mta-sts.example.org
  • TLS-Reporting (TXT): _smtp._tls.example.org fü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:
  • bash
  • curl
  • jq
  • 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:

  • DOMAIN kommt als erstes Argument, z. B. ./setup_mail_dns.sh example.org
  • SPF_VALUE, DMARC_VALUE, MTA_STS_ID kannst du bei Bedarf über die .env überschreiben
  • MTA_STS_ID nutzt 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. dkim
  • dkim_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.org
  • mailjet._domainkey.example.org
  • oldserver1._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"

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert