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

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