Configure direct-delivery incoming email for self-hosted sites with Mail-Receiver

How exactly does one disable the DMARC support?

I.e., adding INCLUDE_DMARC: false to the env section of mail-receiver.yml does not seem to do it. This does appear to cause the opendkim and opendmarc daemons to not run (leading to a warning in the logs), but SPF checking is still being performed.

Edited to add:
I think I managed to disable the SPF checks by also adding the following POSTCONF_ line to the env section:

env:
  ...
  INCLUDE_DMARC: false
  POSTCONF_smtpd_recipient_restrictions: check_policy_service unix:private/policy
  ...

I got this by looking at the commit which introduced the DMARC checks, and seeing what should happen when INCLUDE_DMARC is false.

I know next-to-nothing about how docker images are built, but I am getting the impression that the INCLUDE_DMARC flag is something meant to be set by someone else, somewhere else, at some other time — not something that can be done in mail-receiver.yml.

2 Likes

I’ve found the need to open port 443 on ufw — I got API Request Preparation Failed in the logs otherwise. I thought this is best mentioned because the standard installation instructions mention enabling ufw.

Port 25 is mentioned in the mail-receiver.yml and seems to bypass ufw.

1 Like

Should the GitHub repo be in the OP?

3 Likes

Users of mail-receiver, please see Remove smtp_should_reject & discourse-smtp-fast-rejection

We’re going to remove fast-rejection entirely as the original feature was broken and causing problems for users, specifically this kind of thing:

and it also affects forwarded mail as the pre-delivery test was checking the envelope-from and envelope-to, whereas Discourse uses only the values in headers.

1 Like

I just submitted this PR to remove the unnecessary quotes around DISCOURSE_BASE_URL value in the mail-receiver.yml sample file. The quotes were breaking my setup. Getting rid of the quotes allows for successful completion of this document.

Can you explain how? The presence/absence of quotes around this value yields no difference:

[2] pry(main)> YAML::load("env:\n  DISCOURSE_BASE_URL: 'https://discourse.example.com'")
=> {"env"=>{"DISCOURSE_BASE_URL"=>"https://discourse.example.com"}}

[3] pry(main)> YAML::load("env:\n  DISCOURSE_BASE_URL: https://discourse.example.com")
=> {"env"=>{"DISCOURSE_BASE_URL"=>"https://discourse.example.com"}}

When tailing the logs from that container and sending messages to it, I was seeing a bunch of errors mentioning something like discourse.example.com is not part of MX records or such. I removed the quotes, rebuilt the container and it started working :person_shrugging:

The sequence of events may matter too:

  1. I configured and launched the mail-receiver container
  2. Some days later I got the MX DNS records going
  3. I validated that MX records were set correctly and then started testing. It wasn’t working - postfix was seeing the messages, but not delivering to discourse, complaining about MX
  4. Removed quotes, rebuilt container, started working

So I’m not sure if the resolution was related to the removal of quotes, or the rebuild of container after MX records were created.

Worst case the PR makes the yml look consistent :slight_smile:

1 Like

It appears there is an assumption that the mail-receiver will always be the same domain as the base forum. When that is not the case, how do we setup TLS?

For example:
forum => forum.domain.tld
mail-receiver => mail.domain.tld

In mail-receiver.yml, the TLS points to the base forum certs. Is there a way to have mail-receiver to obtain thier own certs?

I don’t know the direct answer, though I suspect it would require additional options in the yml for making modifications in the container during build.

More on that to follow but I wonder what your reasoning is for wanting to run it on a different domain. The mail-receiver is heavily tailored for and, without modifications, works exclusively to receive emails for a paired Discourse instance, so it is typically reasonable to have it operate on the same domain as that instance.


If you have a look at some of the templates for including in your Discourse yml, some of which will already be used, you should be able to get some hints for how you can run commands and modify files via the yml (during container build).

web.onion.template.yml has some examples of how to replace strings within files and web.letsencrypt.ssl.template.yml is the one which adds Let’s Encrypt to the main Discourse container.

I don’t know how much of that relies on things in the base image so potentially it might be simpler to make the main Discourse container obtain a second certificate, then just change the cert/key paths in mail-receiver.yml to match.

Be careful with these kinds of changes if you take this approach, making sure you know exactly what effect the change will have. An erroneous change in the Let’s Encrypt stuff could lead to silently failing to renew certificates, for example, which you might not notice until ~3 months later when visitors start getting expired certificate errors.

Configuring Mail-Receiver with Cloudflare

If you use Cloudflare’s proxy service with your self-hosted Discourse forum, additional configuration is required to receive incoming email. Cloudflare does not forward SMTP traffic (port 25) for proxied domains — mail sent to your domain will be silently dropped unless your DNS is configured correctly. The good news is there are several ways to solve this, depending on your security requirements and whether you want to keep everything on a single server or isolate your mail infrastructure.

The following table summarizes your three options at a high level:

Option 1 Option 2 Option 3
Description mail-receiver on current host, separate mail subdomain Option 1 + Certbot with DNS validation for TLS Dedicated VPS for mail-receiver
Works with Cloudflare proxy :white_check_mark: :white_check_mark: :white_check_mark:
Server IP exposed in DNS :white_check_mark: :white_check_mark: :white_check_mark: (mail VPS only - Discourse is hidden)
TLS encrypted SMTP :cross_mark: :white_check_mark: :white_check_mark:
Isolates mail from web server :cross_mark: :cross_mark: :white_check_mark:
Additional cost :cross_mark: :cross_mark: :white_check_mark:
Complexity Low Medium Medium

Option 1 is the quickest path to get mail-receiver running. You add a dedicated mail subdomain (e.g. mail.yourdomain.com) as a DNS-only record in Cloudflare pointing to your existing server, bypassing the proxy for SMTP traffic while leaving your main domain fully proxied. The trade-off is that your existing server IP becomes visible in DNS and there is no TLS on the SMTP connection.

Option 2 builds on Option 1 by adding a Let’s Encrypt certificate via Certbot’s Cloudflare DNS challenge, enabling TLS encrypted SMTP. Since the DNS challenge requires no port 80 and no web server downtime, this can be added to an existing server without disruption.

Option 3 moves mail-receiver onto a separate low-cost VPS (typically $4–6/month on providers like DigitalOcean). This fully isolates your mail infrastructure from your web server, meaning your main server IP is never exposed. It also gives you a clean environment with no port conflicts and is the recommended approach for production setups where security and separation of concerns matter.


Option 1 — mail-receiver on Current Host with a Separate Mail Subdomain

Since Cloudflare does not forward SMTP traffic for proxied domains, mail-receiver requires its own subdomain that bypasses the Cloudflare proxy entirely. Your main domain (e.g. forums.domain.tld) can remain fully proxied — only the mail subdomain needs to be DNS only.

DNS Configuration in Cloudflare

You will need to create two new DNS records. Both use the same IP address as your existing server.

1. Create an A record for the mail subdomain:

Type Name Value Proxy Status
A mail YOUR.SERVER.IP :radio_button: DNS Only (grey cloud)

It is critical that this record is set to DNS Only. If the orange proxy cloud is enabled, Cloudflare will intercept the traffic and SMTP will not work.

2. Create an MX record pointing to the new subdomain:

Type Name Value Priority
MX @ mail.domain.tld 10

Installation

Follow the main mail-receiver installation instructions with one change — set MAIL_DOMAIN in your configuration to the new mail subdomain rather than your main forum domain:

MAIL_DOMAIN: mail.domain.tld

Trade-offs

This option gets mail-receiver running with minimal effort and no additional cost. The two things to be aware of are that your server’s IP address will be publicly visible in DNS through the mail subdomain, and SMTP connections will not be TLS encrypted. If TLS is a requirement, see Option 2 which builds directly on this setup.


Option 2 — Option 1 with TLS Encrypted SMTP

Option 2 builds directly on Option 1 by adding a Let’s Encrypt certificate to enable TLS encrypted SMTP. All certificate work is done on the host server, not inside the Discourse or mail-receiver containers.

Because Discourse already occupies port 80, the standard Certbot HTTP validation method is not available. Instead, we use the DNS challenge method, which validates domain ownership by creating a temporary TXT record in Cloudflare. This requires no port 80 access, no web server downtime, and can be fully automated using a Cloudflare API token.

How the DNS Challenge Works

Certbot → Creates _acme-challenge.mail.domain.tld TXT record in Cloudflare
Let's Encrypt → Looks up that TXT record → Validates → Issues cert
Certbot → Deletes the TXT record automatically

Once the certificate is issued, the cert files are copied into the mail-receiver shared folder so the container can use them. Certbot handles renewals automatically in the background via a systemd timer — the only additional step is a deploy hook that copies the renewed cert files and restarts the container after each renewal.

Prerequisites

Before starting, complete all steps in Option 1. The DNS records and mail-receiver configuration from Option 1 remain unchanged — this option only adds the TLS certificate layer on top.

Trade-offs

This option gives you TLS encrypted SMTP at no additional cost while keeping everything on your existing server. The main consideration is that your server IP remains visible in DNS, the same as Option 1. If full infrastructure isolation is a requirement, see Option 3.

Setup

1 — Install the cerbot and the Cloudflare certbot plugin:

bash

apt install certbot python3-certbot-dns-cloudflare -y

2 — Create a Cloudflare API token:

  1. Go to Cloudflare → My Profile → API Tokens → Create Token
  2. Use the “Edit zone DNS” template
  3. Permissions: Zone → DNS → Edit
  4. Zone Resources: Include → Specific zone → lotuselan.net
  5. IP Restrictions: Set up only to allow from your servers ip address
  6. Copy the token

3 — Save the token to a credentials file:

bash

mkdir -p /etc/letsencrypt/cloudflare
nano /etc/letsencrypt/cloudflare/credentials.ini

Paste:

dns_cloudflare_api_token = YOUR_CLOUDFLARE_API_TOKEN

Lock down the file:

bash

chmod 600 /etc/letsencrypt/cloudflare/credentials.ini

4 — Request the cert:

Update the following command with your admin email and domain name.

bash

certbot certonly \
  --dns-cloudflare \
  --dns-cloudflare-credentials /etc/letsencrypt/cloudflare/credentials.ini \
  --non-interactive \
  --agree-tos \
  --email youremailadress@domain.tld \
  -d mail.domain.tld

In your results, should be a statement that says:

Certbot has set up a scheduled task to automatically renew this certificate in the background.

Certbot will setup a cron to check the certs expiration twice a day. It will renew the certs when they are within 30 days of expiring. You can validate this by:

# Check if systemd timer is active (most modern Ubuntu systems)
systemctl status certbot.timer

# Or check if a cron job was added
cat /etc/cron.d/certbot

You now have the TLS certs on your server for the new mail-receiver domain name. They are not in a place that can be used.

5 — Setup a deployment script for moving files
Since certbot auto-renews, you only need your script to handle the Discourse-specific parts — copying the renewed certs and rebuilding the mail-receiver. You can simplify the script significantly by using certbot’s built-in deploy hook, which runs automatically after a successful renewal.

Create a deploy hook file:

bash

nano /etc/letsencrypt/renewal-hooks/deploy/mail-receiver-deploy.sh
chmod +x /etc/letsencrypt/renewal-hooks/deploy/mail-receiver-deploy.sh

Paste this into the file. Update the critical variables in the top section with your data (domain name and email address):

bash

#!/bin/bash
DOMAIN="mail.domain.tld"
DISCOURSE_DIR="/var/discourse"
CERT_SRC="/etc/letsencrypt/live/${DOMAIN}"
CERT_DEST_1="${DISCOURSE_DIR}/shared/mail-receiver/letsencrypt/${DOMAIN}"
CERT_DEST_2="${DISCOURSE_DIR}/shared/mail-receiver/letsencrypt/${DOMAIN}_ecc"
ADMIN_EMAIL="admin email address"
LOG_FILE="/var/log/mail-cert-renewal.log"

log() {
    echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" | tee -a "$LOG_FILE"
}

log "=== Certbot deploy hook triggered for ${DOMAIN} ==="

# Copy certs (use -L to resolve symlinks)
for DEST in "$CERT_DEST_1" "$CERT_DEST_2"; do
    mkdir -p "$DEST"
    cp -L "${CERT_SRC}/fullchain.pem" "${DEST}/fullchain.pem"
    cp -L "${CERT_SRC}/privkey.pem"   "${DEST}/privkey.pem"
    cp -L "${CERT_SRC}/cert.pem"      "${DEST}/cert.pem"
    cp -L "${CERT_SRC}/chain.pem"     "${DEST}/chain.pem"
    chmod 644 "${DEST}/fullchain.pem" "${DEST}/cert.pem" "${DEST}/chain.pem"
    chmod 600 "${DEST}/privkey.pem"
    log "Certs copied to ${DEST}"
done

# Rebuild mail-receiver
cd "$DISCOURSE_DIR" || { echo "Cannot cd to ${DISCOURSE_DIR}" | mail -s "[FAILURE] Mail cert deploy hook failed" "$ADMIN_EMAIL"; exit 1; }
log "Rebuilding mail-receiver..."
if ./launcher rebuild mail-receiver >> "$LOG_FILE" 2>&1; then
    log "mail-receiver rebuilt successfully"
else
    log "ERROR: rebuild failed"
    echo "mail-receiver rebuild failed after cert renewal. Check ${LOG_FILE}" | \
        mail -s "[FAILURE] Mail cert deploy hook failed" "$ADMIN_EMAIL"
    exit 1
fi

log "=== Deploy hook completed successfully ==="

No manual cron job needed at all — certbot orchestrates the entire process. The deploy hook only fires when a renewal actually happens, so your mail-receiver won’t get unnecessary rebuilds on days certbot checks but doesn’t renew.

To test the renewal hook, run the following:

bash

bash /etc/letsencrypt/renewal-hooks/deploy/mail-receiver-deploy.sh

If everything was set up correctly, this will
→ copies certs to Discourse dirs
→ rebuilds mail-receiver
→ logs everything

6 — Setup TLS in mail-receiver.yml
You can now update the mail.receiver.yml file. Notice that the directory and volume locations are different

nano /var/discourse/containers/mail-receiver.yml

With the following parameters. Uncomment and enter your specific data.

   POSTCONF_smtpd_tls_key_file:  /letsencrypt/mail.domain.tld/privkey.pem
   POSTCONF_smtpd_tls_cert_file: /letsencrypt/mail.domain.tld/fullchain.pem
   POSTCONF_smtpd_tls_security_level: may

  - volume:
      host: /var/discourse/shared/mail-receiver/letsencrypt
      guest: /letsencrypt

You can rebuild the mail-receiver and validate everything works.

./launcher rebuild mail-receiver

To validate the logs

./launcher logs mail-receiver

The logs should come out clean. Start testing the receiving of replies directly to your server.

As an extra bonus, this will rebuild the mail-receiver ~ every 60 days. When you rebuild your main Discourse app, it pulls down the latest software. This way your mail-receiver stays current on a regular basis.


Option 3 — Dedicated VPS for mail-receiver

Option 3 moves mail-receiver off your main Discourse server entirely and onto a dedicated VPS. This is the recommended approach for production setups where security and separation of concerns matter. At approximately $4–6 per month on providers such as Hetzner, Vultr, or DigitalOcean, it is the only option with an additional cost but offers the cleanest and most isolated setup of the three.

Because this is a standalone server with no other services running, there are no port conflicts with Discourse and no constraints around port 80. This means you can use standard Certbot standalone mode for certificate issuance rather than the DNS challenge method required in Option 2, simplifying the certificate setup considerably.

How it Works

mail-receiver is deployed as a standalone Docker container on the new VPS. It listens on port 25, accepts inbound SMTP from the internet, and forwards processed email to your Discourse instance over HTTPS using the Discourse API — exactly the same as Options 1 and 2, just running on separate hardware.

Key Advantages Over Options 1 and 2

Your main server IP address is never exposed in DNS. The mail subdomain A record points to the new VPS IP rather than your Discourse server, meaning even if the mail subdomain is looked up, it reveals only the dedicated mail server. Your Discourse server remains fully hidden behind Cloudflare.

Additionally, any issues with the mail server — heavy spam load, a misconfiguration, or a required restart — have zero impact on your Discourse forum, and vice versa.

Trade-offs

This is the most involved setup of the three options as it requires provisioning and maintaining a second server. However, once configured it is largely self-managing — the container restarts automatically on failure, Certbot handles certificate renewals, and the quarterly update script keeps the image current. The ongoing administrative overhead is minimal.

Here’s the complete Option 3 instruction set written for a general audience:


Option 3 — Instructions

What You Will Need

  • A Discourse forum that is up and running
  • A domain managed in Cloudflare
  • A Discourse API key (instructions below)
  • A new VPS server running Ubuntu 24.04 — approximately $4–6/month from Hetzner, Vultr, or DigitalOcean
  • SSH access to the new VPS as root

Step 1 — Choose and Provision Your VPS

Sign up with a VPS provider of your choice. The minimum recommended specs are 1 vCPU and 1GB RAM — mail-receiver is lightweight and does not need much resources.

Recommended providers and approximate monthly cost:

Provider Plan Cost
Hetzner CX22 (2 vCPU, 4GB RAM) ~$4/month
Vultr Cloud Compute 1GB ~$6/month
DigitalOcean Basic Droplet 1GB ~$6/month

When provisioning the server:

  • Select Ubuntu 24.04 LTS as the operating system
  • Add your SSH public key during setup
  • Note the public IPv4 address assigned to the server — you will need it for DNS

Step 2 — Configure DNS in Cloudflare

Before touching the server, set up your DNS records. This gives DNS time to propagate while you work through the rest of the steps.

Log into Cloudflare and add the following two records for your domain:

A Record — points your mail subdomain to the new VPS:

Type Name Value Proxy Status
A mail YOUR.VPS.IP :radio_button: DNS Only (grey cloud)

MX Record — tells the internet where to deliver your email:

Type Name Value Priority
MX @ mail.yourdomain.tld 10

The A record must be set to DNS Only (grey cloud). If the Cloudflare proxy is enabled on this record, SMTP traffic will be blocked and mail will not be delivered.

Verify the records are resolving correctly before continuing:

# Run from any machine — should return your VPS IP, not a Cloudflare IP
nslookup mail.yourdomain.tld

# Should show mail.yourdomain.tld as the mail server
nslookup -type=MX yourdomain.tld

Cloudflare IPs always start with 104., 172.64–68., or 162.158. — if you see those, the A record is still proxied.


Step 3 — Initial Server Setup

SSH into your new VPS:

ssh root@YOUR.VPS.IP

Update the system:

apt update && apt upgrade -y

Set the server hostname to match your mail subdomain:

hostnamectl set-hostname mail.yourdomain.tld
hostname

Step 4 — Configure the Firewall

apt install ufw -y

# Allow SSH first — do this before enabling UFW or you will lock yourself out
ufw allow 22/tcp

# Allow inbound SMTP
ufw allow 25/tcp

# Enable the firewall
ufw enable

# Confirm the rules
ufw status

Step 5 — Disable the System Postfix

Ubuntu 24.04 sometimes installs Postfix by default. The mail-receiver container runs its own Postfix instance inside Docker, so the system one must be disabled to free up port 25.

# Check if anything is using port 25
ss -tlnp | grep :25

# If Postfix is listed, disable it
systemctl stop postfix
systemctl disable postfix

# Confirm port 25 is now free
ss -tlnp | grep :25

Step 6 — Install Docker

Remove any old Docker packages that may have come from Ubuntu’s default repository:

apt remove docker docker.io docker-compose docker-doc podman-docker -y

Install Docker from the official Docker repository:

# Install prerequisites
apt install ca-certificates curl gnupg -y

# Add Docker's official GPG key
install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg \
  -o /etc/apt/keyrings/docker.asc
chmod a+r /etc/apt/keyrings/docker.asc

# Add the Docker apt repository
echo \
  "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] \
  https://download.docker.com/linux/ubuntu \
  $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \
  tee /etc/apt/sources.list.d/docker.list > /dev/null

# Update apt and install Docker Engine with the Compose plugin
apt update
apt install -y docker-ce docker-ce-cli containerd.io \
  docker-buildx-plugin docker-compose-plugin

# Enable Docker to start automatically on boot
systemctl enable docker
systemctl start docker

# Verify both Docker and Compose are working
docker --version
docker compose version

Step 7 — Install Certbot

apt install certbot -y

# Verify
certbot --version

Step 8 — Open Port 80 and Obtain the TLS Certificate

This server has no web server running, so Certbot can use standalone mode — it temporarily spins up its own web server on port 80 just long enough to complete the Let’s Encrypt challenge, then shuts it down.

# Open port 80 temporarily
ufw allow 80/tcp

# Request the certificate
certbot certonly \
  --standalone \
  --non-interactive \
  --agree-tos \
  --email admin@yourdomain.tld \
  -d mail.yourdomain.tld

# Close port 80 — no longer needed
ufw delete allow 80/tcp

# Confirm the cert was issued
certbot certificates

# Confirm Certbot auto renewal setup
systemctl status certbot.timer

You should see output confirming the certificate path:

Certificate Name: mail.yourdomain.tld
Expiry Date: YYYY-MM-DD
Certificate Path: /etc/letsencrypt/live/mail.yourdomain.tld/fullchain.pem
Private Key Path: /etc/letsencrypt/live/mail.yourdomain.tld/privkey.pem

Configure Port 80 for Auto-Renewals

Certbot renews certificates automatically via a background systemd timer. Since port 80 is normally closed on this server, you need to tell Certbot to open and close it automatically during each renewal. Edit the renewal configuration:

nano /etc/letsencrypt/renewal/mail.yourdomain.tld.conf

Add these two lines under the [renewalparams] section:

pre_hook = ufw allow 80/tcp
post_hook = ufw delete allow 80/tcp

Test the auto-renewal configuration with a dry run:

certbot renew --dry-run

You should see the pre-hook open port 80, the challenge succeed, and the post-hook close port 80. The deploy hook will be shown as skipped — this is normal for dry runs, as no actual certificate is issued.


Step 9 — Create the Working Directory and Copy Certs

# Create the working directory structure
mkdir -p /opt/mail-receiver/certs/mail.yourdomain.tld
mkdir -p /opt/mail-receiver/postfix-spool

# Copy certs — the -L flag resolves Let's Encrypt symlinks to real files
cp -L /etc/letsencrypt/live/mail.yourdomain.tld/fullchain.pem \
       /opt/mail-receiver/certs/mail.yourdomain.tld/
cp -L /etc/letsencrypt/live/mail.yourdomain.tld/privkey.pem \
       /opt/mail-receiver/certs/mail.yourdomain.tld/
cp -L /etc/letsencrypt/live/mail.yourdomain.tld/cert.pem \
       /opt/mail-receiver/certs/mail.yourdomain.tld/
cp -L /etc/letsencrypt/live/mail.yourdomain.tld/chain.pem \
       /opt/mail-receiver/certs/mail.yourdomain.tld/

# Lock down the private key
chmod 600 /opt/mail-receiver/certs/mail.yourdomain.tld/privkey.pem

# Verify all four files are present
ls -la /opt/mail-receiver/certs/mail.yourdomain.tld/

Step 10 — Get Your Discourse API Key

On your Discourse forum:

  1. Log in as an admin
  2. Go to Admin → API → New API Key
  3. Set the following:
    • Description: mail-receiver
    • User Level: All Users
    • Scope: Global
  4. Click Save and copy the generated key — you will need it in the next step

Step 11 — Create the docker-compose.yml

nano /opt/mail-receiver/docker-compose.yml

Paste the following, replacing all values shown in capitals with your own:

services:
  mail-receiver:
    image: discourse/mail-receiver:release
    restart: always
    ports:
      - "25:25"
    volumes:
      # Postfix mail spool — persists mail across container restarts
      - ./postfix-spool:/var/spool/postfix
      # TLS certificates mounted into the container
      - ./certs:/letsencrypt
    environment:
      LC_ALL: en_US.UTF-8
      LANG: en_US.UTF-8
      LANGUAGE: en_US.UTF-8

      # The domain to accept mail for
      MAIL_DOMAIN: mail.yourdomain.tld

      # Your Discourse instance handle_mail endpoint
      DISCOURSE_MAIL_ENDPOINT: 'https://forums.yourdomain.tld/admin/email/handle_mail'
      DISCOURSE_BASE_URL: ''https://forums.yourdomain.tld'

      # API key from Step 10
      DISCOURSE_API_KEY: YOUR_API_KEY_HERE

      # Leave as system unless you have renamed that user in Discourse
      DISCOURSE_API_USERNAME: system

      # TLS certificate paths — these are paths inside the container
      POSTCONF_smtpd_tls_key_file:  /letsencrypt/mail.yourdomain.tld/privkey.pem
      POSTCONF_smtpd_tls_cert_file: /letsencrypt/mail.yourdomain.tld/fullchain.pem
      POSTCONF_smtpd_tls_security_level: may

      # Postfix hostname announcement
      POSTCONF_myhostname: mail.yourdomain.tld

Step 12 — Start the Container

cd /opt/mail-receiver

# Pull the latest image
docker compose pull

# Start the container in detached mode
docker compose up -d

# Confirm it is running
docker compose ps

# Watch the startup logs — look for 'daemon started' with no TLS errors
docker compose logs -f

A healthy startup looks like this:

postfix/master[1]: daemon started -- version 3.x.x, configuration /etc/postfix

Press Ctrl+C to stop following the logs.


Step 13 — Configure Discourse to Accept Incoming Mail

On your Discourse forum:

  1. Go to Admin → Settings → Email
  2. Enable reply by email
  3. Set reply by email address to: reply+%{reply_key}@mail.yourdomain.tld
  4. Go to Admin → Email → Incoming to monitor arriving mail

Step 14 — Test the Setup

Run these tests from your local machine, not the VPS:

# Test that port 25 is reachable and Postfix responds
telnet mail.yourdomain.tld 25
# Expected response: 220 mail.yourdomain.tld ESMTP Postfix

# Test the TLS handshake
openssl s_client -connect mail.yourdomain.tld:25 -starttls smtp
# Should display your Let's Encrypt certificate details with no errors

Then send a test email to any address at your mail domain (e.g. test@mail.yourdomain.tld) and watch the container logs:

docker compose -f /opt/mail-receiver/docker-compose.yml logs -f

A successful delivery looks like:

postfix/smtpd[x]: connect from mail-server.example.com
postfix/smtpd[x]: starttls=1
postfix/pipe[x]: status=sent (delivered via discourse service)

Step 15 — Set Up the Certbot Deploy Hook

Create a deploy hook so that after each automatic certificate renewal, the new cert files are copied into the container mount path and the container is restarted to load them:

nano /etc/letsencrypt/renewal-hooks/deploy/mail-receiver-deploy.sh
chmod +x /etc/letsencrypt/renewal-hooks/deploy/mail-receiver-deploy.sh

Paste the following, replacing the domain and email with your own:

#!/bin/bash
export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"

DOMAIN="mail.yourdomain.tld"
CERT_SRC="/etc/letsencrypt/live/${DOMAIN}"
CERT_DEST="/opt/mail-receiver/certs/${DOMAIN}"
LOG="/var/log/mail-cert-renewal.log"
ADMIN_EMAIL="admin@yourdomain.tld"

echo "[$(date '+%Y-%m-%d %H:%M:%S')] Cert renewal deploy hook triggered" >> "$LOG"

# Copy renewed certs
cp -L "${CERT_SRC}/fullchain.pem" "${CERT_DEST}/" && \
cp -L "${CERT_SRC}/privkey.pem"   "${CERT_DEST}/" && \
cp -L "${CERT_SRC}/cert.pem"      "${CERT_DEST}/" && \
cp -L "${CERT_SRC}/chain.pem"     "${CERT_DEST}/" || {
    echo "[$(date '+%Y-%m-%d %H:%M:%S')] ERROR: cert copy failed" >> "$LOG"
    echo "mail-receiver cert copy failed" | \
      mail -s "[FAILURE] Cert renewal" "$ADMIN_EMAIL"
    exit 1
}

chmod 600 "${CERT_DEST}/privkey.pem"
echo "[$(date '+%Y-%m-%d %H:%M:%S')] Certs copied, restarting container..." >> "$LOG"

cd /opt/mail-receiver && docker compose restart mail-receiver >> "$LOG" 2>&1 || {
    echo "[$(date '+%Y-%m-%d %H:%M:%S')] ERROR: container restart failed" >> "$LOG"
    echo "mail-receiver restart failed after cert renewal" | \
      mail -s "[FAILURE] Cert renewal restart" "$ADMIN_EMAIL"
    exit 1
}

echo "[$(date '+%Y-%m-%d %H:%M:%S')] Deploy hook completed successfully" >> "$LOG"

Step 16 — Set Up the Quarterly Update Script

Create the update script directly on the server:

nano /usr/local/bin/update-mail-receiver.sh
chmod +x /usr/local/bin/update-mail-receiver.sh

Paste the following, replacing the admin email with your own:

#!/bin/bash
# =============================================================================
# update-mail-receiver.sh
# Pulls the latest discourse/mail-receiver image and restarts the container
# Intended to be run quarterly via cron
# =============================================================================

# --- Configuration -----------------------------------------------------------
COMPOSE_DIR="/opt/mail-receiver"
COMPOSE_FILE="${COMPOSE_DIR}/docker-compose.yml"
ADMIN_EMAIL="admin@yourdomain.tld"        # <-- Change to your admin email
LOG_FILE="/var/log/mail-receiver-update.log"
IMAGE="discourse/mail-receiver:release"

# --- Helpers -----------------------------------------------------------------
SCRIPT_START=$(date '+%Y-%m-%d %H:%M:%S')
ERRORS=()

export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"

log() {
    echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" | tee -a "$LOG_FILE"
}

log_error() {
    log "ERROR: $1"
    ERRORS+=("$1")
}

send_notification() {
    local subject="$1"
    local body="$2"
    if command -v mail &>/dev/null; then
        echo -e "$body" | mail -s "$subject" "$ADMIN_EMAIL"
        log "Notification sent to ${ADMIN_EMAIL}"
    else
        log "WARNING: mail command not available — install mailutils to enable notifications"
    fi
}

check_root() {
    if [[ $EUID -ne 0 ]]; then
        log_error "Script must be run as root"
        exit 1
    fi
}

check_dependencies() {
    log "--- Checking dependencies ---"
    for cmd in docker ufw; do
        if ! command -v "$cmd" &>/dev/null; then
            log_error "Required command not found: ${cmd}"
            return 1
        fi
    done
    if [ ! -f "$COMPOSE_FILE" ]; then
        log_error "docker-compose.yml not found at ${COMPOSE_FILE}"
        return 1
    fi
    log "All dependencies found"
}

# --- Step functions ----------------------------------------------------------

step_get_current_image_id() {
    log "--- Getting current image digest ---"
    CURRENT_IMAGE_ID=$(docker inspect --format='{{.Id}}' "$IMAGE" 2>/dev/null || echo "none")
    log "Current image ID: ${CURRENT_IMAGE_ID}"
}

step_pull_latest_image() {
    log "--- Pulling latest image: ${IMAGE} ---"
    if docker pull "$IMAGE" >> "$LOG_FILE" 2>&1; then
        log "Image pull completed"
    else
        log_error "Failed to pull latest image"
        return 1
    fi
}

step_check_if_updated() {
    log "--- Checking if image was updated ---"
    NEW_IMAGE_ID=$(docker inspect --format='{{.Id}}' "$IMAGE" 2>/dev/null || echo "none")
    log "New image ID:     ${NEW_IMAGE_ID}"

    if [ "$CURRENT_IMAGE_ID" = "$NEW_IMAGE_ID" ]; then
        log "Image is already up to date — no restart needed"
        IMAGE_UPDATED=false
    else
        log "New image detected — container will be restarted"
        IMAGE_UPDATED=true
    fi
}

step_restart_container() {
    if [ "$IMAGE_UPDATED" = false ]; then
        log "--- Skipping restart (image unchanged) ---"
        return 0
    fi

    log "--- Restarting container with new image ---"
    cd "$COMPOSE_DIR" || { log_error "Cannot cd to ${COMPOSE_DIR}"; return 1; }

    log "Stopping current container..."
    if docker compose down >> "$LOG_FILE" 2>&1; then
        log "Container stopped"
    else
        log_error "Failed to stop container"
        return 1
    fi

    log "Starting container with new image..."
    if docker compose up -d >> "$LOG_FILE" 2>&1; then
        log "Container started successfully"
    else
        log_error "Failed to start container"
        return 1
    fi

    # Brief pause to let postfix initialise
    sleep 5

    # Verify container is actually running
    if docker compose ps | grep -q "running\|Up"; then
        log "Container is confirmed running"
    else
        log_error "Container does not appear to be running after restart"
        return 1
    fi
}

step_cleanup_old_images() {
    log "--- Cleaning up unused Docker images ---"
    if docker image prune -f >> "$LOG_FILE" 2>&1; then
        log "Unused images cleaned up"
    else
        log "WARNING: Image cleanup failed — non-critical, continuing"
    fi
}

step_verify_port_25() {
    log "--- Verifying port 25 is listening ---"
    sleep 3
    if ss -tlnp | grep -q ':25'; then
        log "Port 25 is listening — Postfix is up"
    else
        log_error "Port 25 is NOT listening — Postfix may have failed to start"
        log "Run: docker compose -f ${COMPOSE_FILE} logs --tail=50"
        return 1
    fi
}

# --- Main --------------------------------------------------------------------
main() {
    log "============================================================"
    log "  mail-receiver update started: ${SCRIPT_START}"
    log "  Image: ${IMAGE}"
    log "============================================================"

    check_root
    check_dependencies  || { send_notification "[FAILURE] mail-receiver update failed" \
                              "Dependency check failed.\n\nCheck log: ${LOG_FILE}"; exit 1; }

    step_get_current_image_id
    step_pull_latest_image    || { send_notification "[FAILURE] mail-receiver update failed" \
                                   "Image pull failed.\n\nCheck log: ${LOG_FILE}"; exit 1; }
    step_check_if_updated
    step_restart_container    || { send_notification "[FAILURE] mail-receiver update failed" \
                                   "Container restart failed.\n\nCheck log: ${LOG_FILE}"; exit 1; }
    step_cleanup_old_images
    step_verify_port_25       || { send_notification "[FAILURE] mail-receiver update failed" \
                                   "Port 25 not listening after update.\n\nCheck log: ${LOG_FILE}"; exit 1; }

    # Final summary
    log "============================================================"
    if [ "$IMAGE_UPDATED" = true ]; then
        log "  UPDATE COMPLETED SUCCESSFULLY"
        log "  Old image: ${CURRENT_IMAGE_ID:0:20}..."
        log "  New image: ${NEW_IMAGE_ID:0:20}..."
        send_notification "[SUCCESS] mail-receiver updated" \
            "mail-receiver was successfully updated to a new image on ${SCRIPT_START}.\n\nCheck log: ${LOG_FILE}"
    else
        log "  CHECK COMPLETED — IMAGE ALREADY UP TO DATE"
    fi
    log "============================================================"
}

main "$@"

Test it manually before relying on cron:

/usr/local/bin/update-mail-receiver.sh

# Watch the log
tail -f /var/log/mail-receiver-update.log

Then add the cron job to run on the 1st of January, April, July and October at 2am:

crontab -e
0 2 1 1,4,7,10 * /usr/local/bin/update-mail-receiver.sh

The script will only email you and restart the container if a newer image is actually available. If the image is already up to date it completes silently with no disruption to mail delivery.


Useful Commands Reference

# View live logs
docker compose -f /opt/mail-receiver/docker-compose.yml logs -f

# Restart the container
docker compose -f /opt/mail-receiver/docker-compose.yml restart

# Stop and start
docker compose -f /opt/mail-receiver/docker-compose.yml stop
docker compose -f /opt/mail-receiver/docker-compose.yml up -d

# Update to the latest image manually
docker compose -f /opt/mail-receiver/docker-compose.yml pull
docker compose -f /opt/mail-receiver/docker-compose.yml up -d

# Enter the container for debugging
docker exec -it mail-receiver-mail-receiver-1 bash

# Check certbot timer is active
systemctl status certbot.timer

# View the cert renewal log
tail -f /var/log/mail-cert-renewal.log

# View the update log
tail -f /var/log/mail-receiver-update.log

The main issue here seems to be that the A record for forum.domain.tld is masked by the proxy, as opposed to explicitly wanting the mail server on a separate domain.

When negotiating TLS, the certificate’s common name is compared with the hostname of the MX record, i.e. the hostname the client (which might be another mail server) is trying to connect to, rather than the A record it references. This means you can create your mail.domain.tld A record set to DNS Mode Only, then create an MX record for forum.domain.tld referencing mail.domain.tld and no further special steps are necessary in that arrangement.

Yes, you can use DNS mode only for the A record for your main forum. Using this approach means you loose the reverse global proxy capabilities of CloudFlare. (This was not an option for my Discourse install.)

This is why the first line defined this solution is for sites using the CloudFlare Proxy.

I was referring to the mail.domain.tld A record being set to DNS Mode Only rather than the forum.domain.tld A record, however I realised that I have been misinterpreting how SMTP clients authenticate TLS certificates.

The behaviour I was seeing was an artefact of the default opportunistic method which does not validate the hostname, so my assertion that it validates the hostname of the MX record rather than the target of the MX record was incorrect. It would work in most cases but not if DANE or MTA-STS are used for enforcing TLS identity authentication.

Having the A record proxied and the MX record DNS Only does not work. CloudFlare docs state that any domain that has the A record proxied has all SMTP traffic is blocked.

I validate this with several rounds of testing. The minute you unproxy the A record, SMTP data would flow. Turn on the Proxy, SMTP data would never flow. (The tests were performed by using TELNET to port 25.)

So If you want:

  • Your Discourse forum to use CloudFlare Proxy services
  • Your SMTP Mail-Receiver to accept mail

You have to have different domains for your inbound mail.

If you want TLS for your SMTP Mail-receiver:

  • You have to setup a LetsEncrypt via DNS Checking

The instructions look daunting, but the instructions took longer to write up than to actually implement the solution.

That’s not what I meant, specifically what I was suggesting was three DNS records:
A: forum.domain.tld → host IP address (proxy enabled)
A: mail.domain.tld → host IP address (DNS Mode Only)
MX: forum.domain.tldmail.domain.tld

However as mentioned, I later realised that would only work in the default opportunistic TLS mode, it will not work if you (someone) also want to enable DANE or MTA-STS to enforce identity authentication (ensure the correct server is being connected to rather than only encrypt traffic).

They look very good, easy to follow and does everything outside the container so there’s no risk of it potentially breaking with Discourse updates. I particularly like the use of a certbot renewal hook which I wasn’t familiar with before.

1 Like

Note that this does expose the IP address of your forum and makes it possible for people to bypass Cloudflare protection mechanisms like DDoS protection and WAF. It’s better to run mail-receiver on a separate server.

My first intent was to run mail-receiver on a different server for this reason. Every time I tried to run the launcher app to start the mail-receiver, it wanted to install the complete discourse system. Is there a simple way to only launch and run mail-receiver on a standalone docker server?

I’m not really familiar with the launcher app since we roll our own infra, but my idea would be to not use the launcher app then and start it as any other container. The env vars you need are in the readme.

@Simon_Manning & @RGJ - The Cloudflare write-up has been updated to provide the 3 main options and their trade-offs. Hopefully, this addresses the various issues you raised with the first options presented.

@kelv You might consider placing a footnote in the main description to the CloudFlare write-up. It will save people time that it applies to. Configure direct-delivery incoming email for self-hosted sites with Mail-Receiver - #541 by LotusJeff

2 Likes