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 | |||
| Server IP exposed in DNS | |||
| TLS encrypted SMTP | |||
| Isolates mail from web server | |||
| Additional cost | |||
| 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 |
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:
- Go to Cloudflare → My Profile → API Tokens → Create Token
- Use the “Edit zone DNS” template
- Permissions:
Zone → DNS → Edit - Zone Resources:
Include → Specific zone → lotuselan.net - IP Restrictions: Set up only to allow from your servers ip address
- 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 |
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:
- Log in as an admin
- Go to Admin → API → New API Key
- Set the following:
- Description: mail-receiver
- User Level: All Users
- Scope: Global
- 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:
- Go to Admin → Settings → Email
- Enable reply by email
- Set reply by email address to:
reply+%{reply_key}@mail.yourdomain.tld - 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