Self-Hosted Mail Server with Mailcow and Claude Code

11 min read
  • mailcow
  • self-hosted
  • mail-server
  • devops
  • claude-code

Today I set up a fully self-hosted mail server using Mailcow — and I did not do it alone. I used Claude Code as an installation agent, guiding it through each step with structured prompts. The result: a production-ready mail server with valid Let’s Encrypt certificates on all mail ports and complete DNS authentication for four domains.

This post documents exactly how it was done, including the prompts I used, the problems that came up, and how they were solved.

Table of Contents


Why Self-Host Email?

Most developers rent their email. Google Workspace, Fastmail, Proton — all good products, but they come with trade-offs: monthly costs per domain, dependency on a third-party provider, and no real ownership of the infrastructure.

Self-hosting a mail server has a reputation for being difficult. SPF, DKIM, DMARC, PTR records, TLS on multiple ports, spam filtering — there are a lot of moving parts. Most tutorials either gloss over the hard parts or assume you already know what you are doing.

Mailcow solves most of this by bundling everything into a Docker Compose stack: Postfix, Dovecot, Rspamd, ClamAV, Unbound, SOGo webmail, and an admin UI — all configured to work together out of the box.

What remained was the setup, the DNS, and debugging whatever the hosting environment threw at me.


The Agentic Approach — Claude Code as Installation Agent

Instead of following documentation step by step, I used Claude Code as an installation agent. Each major step was a structured prompt with:

  • clear context about what was already done
  • explicit constraints (read-only, no restarts, etc.)
  • expected output format

This works remarkably well for infrastructure tasks. Claude Code can read config files, run commands, interpret output, and adjust — all within a single session. When something breaks, it diagnoses the problem from logs and proposes a fix rather than requiring you to search through documentation.

One important principle I used throughout: separate read-only audit prompts from action prompts. Never mix “check everything” with “fix everything” in the same prompt.


Prerequisites

  • A VPS with at least 4 GB RAM (Mailcow’s minimum; 6 GB recommended)
  • A dedicated hostname for the mail server — I used mail.deviss.de
  • At least one domain you control
  • An existing Nginx installation on the host (for reverse proxy mode)
  • Claude Code CLI

Step 1 — Mailcow Installation

Mailcow is installed in reverse proxy mode, which means the web UI binds to 127.0.0.1 only and Nginx on the host proxies it. Mail ports (25, 465, 587, 993) bind publicly as usual.

Install Mailcow on this server in reverse proxy mode.

## Requirements
- Clone to /opt/mailcow-dockerized
- Reverse proxy config:
  HTTP_PORT=8080
  HTTPS_PORT=8443
  HTTP_BIND=127.0.0.1
  HTTPS_BIND=127.0.0.1
- MAILCOW_HOSTNAME=mail.deviss.de
- Generate a strong API key and enable API access
- Do NOT start containers yet
- Do NOT touch any existing Nginx configs

## Steps
1. Install dependencies (git, docker, docker-compose)
2. Clone mailcow-dockerized
3. Run ./generate_config.sh
4. Apply reverse proxy settings to mailcow.conf
5. Show the final relevant lines of mailcow.conf

After confirming the config, start the stack:

Start all Mailcow containers and verify they are running.

sudo docker compose -f /opt/mailcow-dockerized/docker-compose.yml up -d

Then run:
sudo docker compose -f /opt/mailcow-dockerized/docker-compose.yml ps

Expected: all 18+ containers in Up or healthy state.
Report any container that is not running.
Do NOT restart anything automatically — report only.

Step 2 — The UDP Port 53 Problem

This is where most self-hosted mail setups on Netcup (and some other providers) silently fail. Mailcow includes its own DNS resolver (Unbound) for DNSSEC validation and DANE verification. Unbound uses UDP port 53 for outbound queries — but Netcup blocks outbound UDP on port 53.

The symptom: mail delivery fails because Postfix cannot resolve remote mail server hostnames.

The fix is to configure Unbound to forward queries over TCP instead:

Mailcow's Unbound container cannot resolve DNS because the
hosting provider blocks outbound UDP port 53.

## Fix
Configure Unbound to forward all queries over TCP to
1.1.1.1 and 8.8.8.8.

Edit:
/opt/mailcow-dockerized/data/conf/unbound/unbound.conf

Add to the server section:
  tcp-upstream: yes

The existing do-tcp: yes and do-udp: yes in the default config
keep UDP and TCP listening enabled for internal container queries.
The tcp-upstream: yes directive forces all outbound queries to
upstream resolvers over TCP.

Add a forward zone:
  forward-zone:
    name: "."
    forward-addr: 1.1.1.1
    forward-addr: 8.8.8.8
    forward-addr: 1.0.0.1
    forward-addr: 8.8.4.4

Then restart only the Unbound container:
sudo docker restart mailcowdockerized-unbound-mailcow-1

Verify with:
sudo docker exec mailcowdockerized-unbound-mailcow-1 \
  dig google.com +short

Expected: a valid IP address.

Step 3 — Nginx Reverse Proxy

With Mailcow running on localhost, Nginx needs to proxy mail.deviss.de to it. The HTTP block also handles ACME challenge requests so Let’s Encrypt can issue a certificate.

Note: If you are running Nginx >= 1.25.1, you can use listen 443 ssl; with a separate http2 on; directive instead. The syntax below works on all versions.

server {
    listen 80;
    listen [::]:80;
    server_name mail.deviss.de;

    location /.well-known/acme-challenge/ {
        proxy_pass http://127.0.0.1:8080/.well-known/acme-challenge/;
        proxy_set_header Host $host;
    }

    location / {
        return 301 https://$host$request_uri;
    }
}

server {
    listen 443 ssl http2;
    listen [::]:443 ssl http2;
    server_name mail.deviss.de;

    ssl_certificate /opt/mailcow-dockerized/data/assets/ssl/cert.pem;
    ssl_certificate_key /opt/mailcow-dockerized/data/assets/ssl/key.pem;

    location / {
        proxy_pass https://127.0.0.1:8443;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_read_timeout 3600;
        proxy_connect_timeout 3600;
        proxy_send_timeout 3600;
        proxy_buffer_size 128k;
        proxy_buffers 64 512k;
        proxy_busy_buffers_size 512k;
        client_max_body_size 0;
    }
}

Step 4 — SSL Certificate

Mailcow’s built-in ACME container handles Let’s Encrypt automatically once Nginx is proxying the challenge path. After enabling the Nginx config and reloading:

# Trigger ACME renewal
sudo docker restart mailcowdockerized-acme-mailcow-1

# Watch until cert is issued
sudo docker logs -f mailcowdockerized-acme-mailcow-1 2>&1

The ACME container checks every 5 minutes. Once successful, the certificate is stored at /opt/mailcow-dockerized/data/assets/ssl/cert.pem. If your Nginx config initially used a self-signed placeholder, update the ssl_certificate and ssl_certificate_key paths to point to the Mailcow cert directory, then reload Nginx with sudo systemctl reload nginx.


Step 5 — SPF, DKIM, and DMARC DNS Setup

Your mail server hostname needs an A record (and optionally AAAA) pointing to the server IP. The PTR record (reverse DNS) must match that hostname — set it in your hosting provider’s control panel. Many providers reject mail when PTR does not match the HELO hostname.

For each domain, four DNS records are required for mail delivery. Replace the placeholder values with your actual domain before applying:

TypeHostValue
MX@mail.deviss.de (priority 10)
TXT@v=spf1 mx a:mail.deviss.de -all
TXT_dmarcv=DMARC1; p=reject; rua=mailto:postmaster@YOUR-DOMAIN; adkim=s; aspf=s
TXTdkim._domainkey(value from Mailcow admin UI)

Optionally, add an autodiscover SRV record so mail clients (Outlook, Thunderbird, iOS Mail) can find your IMAP/SMTP settings automatically without manual configuration:

TypeHostValue
SRV_autodiscover._tcp0 1 443 mail.deviss.de

The DKIM key is generated automatically by Mailcow when you add a domain. Retrieve it from Mail Setup → Domains → DKIM in the admin UI.

To verify all records at once after DNS propagation (replace the placeholder domains and IP):

for domain in yourdomain1.com yourdomain2.com; do
  echo "════ $domain ════"
  echo "MX:    $(dig MX $domain +short)"
  echo "SPF:   $(dig TXT $domain +short | grep spf)"
  echo "DMARC: $(dig TXT _dmarc.$domain +short)"
  echo "DKIM:  $(dig TXT dkim._domainkey.$domain +short | cut -c1-60)..."
  echo "SRV:   $(dig SRV _autodiscover._tcp.$domain +short)"
done
echo "PTR: $(dig -x YOUR.SERVER.IP +short @1.1.1.1 +tcp)"

Step 6 — Security Audit

Before going live, I ran a full read-only audit with this prompt:

Perform a complete security audit of the Mailcow installation.
READ-ONLY — report findings only, no changes.

Checks:
1. Default admin credentials (test if moohoo still works)
2. API key strength and access restriction
3. Port exposure — 8080/8443 must be 127.0.0.1 only
4. All Docker containers healthy
5. Firewall — UFW + MAILCOW iptables chain
6. Unbound DNS — resolves correctly, TCP forwarding active
7. SSL/TLS — valid cert on ports 25, 587, 465, 993
8. File permissions — mailcow.conf must be 600 root:root
9. Postfix open relay check — mynetworks restrictive

Output: PASS / WARNING / FAIL per check.
Prioritized action list at the end.
Do NOT fix anything.

The audit caught three items:

  • ❌ SSL not yet issued (ACME challenge failing — fixed by adding the Nginx config from Step 3)
  • ⚠️ Default admin password not changed
  • ⚠️ TFA not enabled on admin account

After fixing those, all remaining checks passed. Everything else — port exposure, firewall, API restrictions, file permissions, open relay protection — was correctly configured out of the box.


Step 7 — Mailboxes and Aliases

RFC 5321 requires postmaster@ to exist on every mail domain. Rather than creating separate mailboxes for every required address, I created one info@ per domain and pointed postmaster@ and abuse@ there as aliases.

Via the Mailcow API (replace yourdomain.com and YOUR_PASSWORD with your actual values). Note that the API is accessed via 127.0.0.1:8443 because API_ALLOW_FROM in mailcow.conf restricts access to localhost and the Docker network — requests through the public domain will be rejected:

API_KEY=$(sudo grep ^API_KEY /opt/mailcow-dockerized/mailcow.conf | cut -d= -f2)

# Create mailbox
curl -s -k -X POST https://127.0.0.1:8443/api/v1/add/mailbox \
  -H "X-API-Key: $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "local_part": "info",
    "domain": "yourdomain.com",
    "name": "Info",
    "password": "YOUR_PASSWORD",
    "password2": "YOUR_PASSWORD",
    "quota": "1024",
    "active": "1"
  }'

# Create alias
curl -s -k -X POST https://127.0.0.1:8443/api/v1/add/alias \
  -H "X-API-Key: $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "address": "postmaster@yourdomain.com",
    "goto": "info@yourdomain.com",
    "active": "1"
  }'

Final Verification

After completing the setup I ran one final verification pass covering all eight blocks — Nginx, TLS on mail ports, port exposure, Docker health, DNS, mailboxes, firewall, and security hardening. All checks passed.

Then I sent a test mail to mail-tester.com — SPF, DKIM, DMARC, PTR all green.

mail-tester.com score


Lessons Learned

UDP port 53 is silently blocked on some VPS providers. There is no error message. Mail just stops working. If DNS resolution fails inside Mailcow’s containers, check whether your provider blocks outbound UDP 53 before spending hours debugging Postfix.

Read-only audit prompts are invaluable. Running a structured audit before and after setup caught issues that would have been invisible until a mail bounced from a major provider.

PTR records matter more than most tutorials mention. Gmail, Outlook, and most enterprise filters do a reverse DNS lookup on the sending IP. If the PTR does not match your HELO hostname, expect rejections or spam folder placement.

Aliases over mailboxes for system addresses. postmaster@ and abuse@ are required, but nobody wants eight separate inboxes. One real mailbox per domain with aliases keeps things clean.

The agentic workflow genuinely works for infrastructure. Not because the AI knows more than documentation, but because it maintains context across the entire session. When the UDP issue appeared mid-install, it diagnosed the problem from Unbound logs, proposed a fix, applied it, and verified — without starting over.

Keep maintenance in mind. Run sudo ./update.sh in /opt/mailcow-dockerized periodically to stay on top of Mailcow updates — this handles migrations that a raw docker compose pull would miss. Back up mailcow.conf and the entire data/ directory (it contains your mails, DKIM keys, and the database). Set WATCHDOG_NOTIFY_EMAIL in mailcow.conf so the watchdog container alerts you when services go down. Certificate renewal is handled automatically by the ACME container — no cron job needed. Monitor disk usage and check Rspamd logs for anomalies.


Self-hosting email is not for everyone, but if you want full control over your mail infrastructure, Mailcow makes it manageable — and with an agentic workflow the entire setup takes a single session, plus some waiting time for DNS propagation.

Frequently Asked Questions

Q: Do I need technical experience to set up Mailcow?

A: You need basic Linux and DNS knowledge. Claude Code handles most of the complexity, but you need to understand what you are doing when setting DNS records and firewall rules. This is not a one-click install.

Q: Why use Claude Code instead of just following the Mailcow docs?

A: Claude Code handles the entire sequence — installation, configuration, debugging, and verification — in one session. When something unexpected happens (like a blocked UDP port), it diagnoses and fixes the issue immediately rather than requiring you to search through forum threads.

Q: Is a self-hosted mail server reliable?

A: Yes, if configured correctly. The critical pieces are SPF, DKIM, DMARC, and a proper PTR record. Get those right and major providers like Gmail and Outlook will accept your mail without issues.

Q: What is the UDP port 53 workaround?

A: Some hosting providers block outbound UDP traffic on port 53, which breaks DNS resolution. The fix is to configure Mailcow's internal Unbound resolver to forward queries over TCP instead of UDP, using trusted resolvers like 1.1.1.1 and 8.8.8.8.

Q: Can I use this setup for multiple domains?

A: Yes. Mailcow supports multiple domains natively. In this setup I configured four domains — codn.dev, poiu.dev, hoffmann.gr, and romanhoffmann.de — all served by a single Mailcow instance.