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

Cloudflare를 사용한 Mail-Receiver 설정

자체 호스팅 Discourse 포럼에 Cloudflare 프록시 서비스를 사용하는 경우, 수신 메일을 받기 위해 추가 설정이 필요합니다. Cloudflare는 프록시된 도메인의 SMTP 트래픽(포트 25)을 전달하지 않으며, DNS가 올바르게 설정되지 않으면 해당 도메인으로 전송된 메일은 조용히 폐기됩니다. 좋은 소식은 보안 요구 사항과 모든 것을 단일 서버에 유지할지 또는 메일 인프라를 분리할지에 따라 이 문제를 해결하는 여러 가지 방법이 있다는 것입니다.

다음 표는 세 가지 옵션을 고수준으로 요약합니다:

옵션 1 옵션 2 옵션 3
설명 현재 호스트에 mail-receiver, 별도의 메일 서브도메인 옵션 1 + TLS를 위한 DNS 검증 사용 Certbot mail-receiver를 위한 전용 VPS
Cloudflare 프록시와 호환 :white_check_mark: :white_check_mark: :white_check_mark:
DNS에 서버 IP 노출 :white_check_mark: :white_check_mark: :white_check_mark: (메일 VPS만 - Discourse는 숨겨짐)
TLS 암호화 SMTP :cross_mark: :white_check_mark: :white_check_mark:
웹 서버와 메일 분리 :cross_mark: :cross_mark: :white_check_mark:
추가 비용 :cross_mark: :cross_mark: :white_check_mark:
복잡도 낮음 중간 중간

옵션 1은 mail-receiver를 실행하는 가장 빠른 방법입니다. 기존 서버를 가리키는 DNS 전용 레코드로 전용 메일 서브도메인(예: mail.yourdomain.com)을 추가하여 SMTP 트래픽에 대해 프록시를 우회하되, 메인 도메인은 완전히 프록시된 상태로 유지합니다. 단점으로는 기존 서버 IP가 DNS에서 보이며 SMTP 연결에 TLS가 적용되지 않는다는 것입니다.

옵션 2는 옵션 1을 기반으로 Certbot의 Cloudflare DNS 챌린지를 통해 Let’s Encrypt 인증서를 추가하여 TLS 암호화 SMTP를 활성화합니다. DNS 챌린지는 포트 80이나 웹 서버 중단이 필요하지 않으므로, 기존 서버에 중단 없이 추가할 수 있습니다.

옵션 3은 mail-receiver를 별도의 저비용 VPS(디지털오션과 같은 제공업체에서 월 $4–6 정도)로 이동합니다. 이는 메일 인프라를 웹 서버와 완전히 분리하여 메인 서버 IP가 노출되지 않도록 합니다. 또한 포트 충돌이 없는 깨끗한 환경을 제공하며, 보안과 관심사 분리가 중요한 프로덕션 환경에서 권장되는 방법입니다.


옵션 1 — 별도의 메일 서브도메인을 가진 현재 호스트에 mail-receiver 설치

Cloudflare가 프록시된 도메인의 SMTP 트래픽을 전달하지 않으므로, mail-receiver는 Cloudflare 프록시를 완전히 우회하는 자체 서브도메인이 필요합니다. 메인 도메인(예: forums.domain.tld)은 완전히 프록시된 상태로 유지할 수 있으며, 메일 서브도메인만 DNS 전용으로 설정하면 됩니다.

Cloudflare의 DNS 설정

두 개의 새 DNS 레코드를 생성해야 합니다. 둘 다 기존 서버와 동일한 IP 주소를 사용합니다.

1. 메일 서브도메인을 위한 A 레코드 생성:

유형 이름 프록시 상태
A mail YOUR.SERVER.IP :radio_button: DNS 전용 (회색 구름)

이 레코드가 DNS 전용으로 설정되는 것이 중요합니다. 주황색 프록시 구름이 활성화되면 Cloudflare가 트래픽을 가로채고 SMTP가 작동하지 않습니다.

2. 새 서브도메인을 가리키는 MX 레코드 생성:

유형 이름 우선순위
MX @ mail.domain.tld 10

설치

메인 mail-receiver 설치 지침을 따르되, 설정에서 MAIL_DOMAIN을 메인 포럼 도메인이 아닌 새 메일 서브도메인으로 설정하는 한 가지 변경 사항이 있습니다:

MAIL_DOMAIN: mail.domain.tld

트레이드오프

이 옵션은 최소한의 노력과 추가 비용 없이 mail-receiver를 실행할 수 있게 합니다. 주의해야 할 두 가지 사항은 메일 서브도메인을 통해 서버 IP 주소가 DNS에서 공개적으로 보인다는 점과 SMTP 연결이 TLS로 암호화되지 않는다는 점입니다. TLS가 필수적이라면 이 설정을 기반으로 구축된 옵션 2를 참조하세요.


옵션 2 — TLS 암호화 SMTP를 가진 옵션 1

옵션 2는 TLS 암호화 SMTP를 활성화하기 위해 Let’s Encrypt 인증서를 추가하여 옵션 1을 기반으로 직접 구축됩니다. 모든 인증서 작업은 Discourse 또는 mail-receiver 컨테이너 내부가 아닌 호스트 서버에서 수행됩니다.

Discourse가 이미 포트 80을 점유하고 있으므로, 표준 Certbot HTTP 검증 방법은 사용할 수 없습니다. 대신, Cloudflare에 임시 TXT 레코드를 생성하여 도메인 소유권을 검증하는 DNS 챌린지 방법을 사용합니다. 이는 포트 80 접근, 웹 서버 중단이 필요하지 않으며 Cloudflare API 토큰을 사용하여 완전히 자동화할 수 있습니다.

DNS 챌린지 작동 방식

Certbot → Cloudflare에 _acme-challenge.mail.domain.tld TXT 레코드 생성
Let's Encrypt → 해당 TXT 레코드 조회 → 검증 → 인증서 발급
Certbot → TXT 레코드 자동 삭제

인증서가 발급되면, 인증서 파일은 컨테이너가 사용할 수 있도록 mail-receiver 공유 폴더로 복사됩니다. Certbot은 systemd 타이머를 통해 백그라운드에서 갱신을 자동으로 처리합니다 — 유일한 추가 단계는 각 갱신 후 갱신된 인증서 파일을 복사하고 컨테이너를 재시작하는 배포 훅입니다.

선행 요구 사항

시작하기 전에 옵션 1의 모든 단계를 완료해야 합니다. 옵션 1의 DNS 레코드와 mail-receiver 설정은 변경되지 않으며 — 이 옵션은 단순히 그 위에 TLS 인증서 계층만 추가합니다.

트레이드오프

이 옵션은 추가 비용 없이 TLS 암호화 SMTP를 제공하면서 모든 것을 기존 서버에 유지합니다. 주요 고려 사항은 옵션 1과 마찬가지로 서버 IP가 DNS에서 계속 노출된다는 점입니다. 완전한 인프라 분리가 요구된다면 옵션 3을 참조하세요.

설정

1 — certbot과 Cloudflare certbot 플러그인 설치:

bash

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

2 — Cloudflare API 토큰 생성:

  1. Cloudflare → 내 프로필 → API 토큰 → 토큰 생성으로 이동
  2. “Edit zone DNS” 템플릿 사용
  3. 권한: Zone → DNS → Edit
  4. Zone 리소스: Include → Specific zone → lotuselan.net
  5. IP 제한: 서버 IP 주소에서만 허용하도록 설정
  6. 토큰 복사

3 — 토큰을 자격 증명 파일에 저장:

bash

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

붙여넣기:

dns_cloudflare_api_token = YOUR_CLOUDFLARE_API_TOKEN

파일 잠금:

bash

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

4 — 인증서 요청:

다음 명령을 관리자 이메일과 도메인 이름으로 업데이트하세요.

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

결과에 다음과 같은 문구가 포함되어야 합니다:

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

Certbot은 하루 두 번 인증서 만료 여부를 확인하는 cron을 설정합니다. 만료까지 30일 이내가 되면 인증서를 갱신합니다. 다음으로 이를 검증할 수 있습니다:

# systemd 타이머가 활성인지 확인 (대부분의 최신 Ubuntu 시스템)
systemctl status certbot.timer

# 또는 cron 작업이 추가되었는지 확인
cat /etc/cron.d/certbot

이제 새 mail-receiver 도메인 이름을 위한 TLS 인증서가 서버에 있습니다. 아직 사용 가능한 위치에 있지 않습니다.

5 — 파일 이동을 위한 배포 스크립트 설정
certbot이 자동으로 갱신하므로, 스크립트는 Discourse 특정 부분만 처리하면 됩니다 — 갱신된 인증서를 복사하고 mail-receiver를 재빌드하는 것입니다. certbot의 내장 배포 훅을 사용하면 성공적인 갱신 후 자동으로 실행되므로 스크립트를 상당히 단순화할 수 있습니다.

배포 훅 파일 생성:

bash

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

이 내용을 파일에 붙여넣으세요. 상단 섹션의 주요 변수(도메인 이름과 이메일 주소)를 자신의 데이터로 업데이트하세요:

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} ==="

# 인증서 복사 (심볼릭 링크를 실제 파일로 해석하기 위해 -L 사용)
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

# 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 ==="

수동 cron 작업이 전혀 필요하지 않습니다 — certbot이 전체 프로세스를 조정합니다. 배포 훅은 실제로 갱신이 발생했을 때만 트리거되므로, certbot이 확인하지만 갱신하지 않는 날에는 mail-receiver가 불필요하게 재빌드되지 않습니다.

갱신 훅을 테스트하려면 다음을 실행하세요:

bash

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

모든 것이 올바르게 설정되었다면, 이는
→ 인증서를 Discourse 디렉토리로 복사
→ mail-receiver 재빌드
→ 모든 것을 로깅

6 — mail-receiver.yml에서 TLS 설정
이제 mail.receiver.yml 파일을 업데이트할 수 있습니다. 디렉터리와 볼륨 위치가 다르다는 점에 유의하세요.

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

다음 매개변수를 사용하세요. 주석을 해제하고 특정 데이터를 입력하세요.

   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

mail-receiver를 재빌드하고 모든 것이 작동하는지 검증할 수 있습니다.

./launcher rebuild mail-receiver

로그를 검증하려면

./launcher logs mail-receiver

로그가 깨끗하게 나와야 합니다. 서버로 직접 답변을 받는 것을 테스트하기 시작하세요.

추가 보너스로, 이는 mail-receiver를 약 60일마다 재빌드합니다. 메인 Discourse 앱을 재빌드할 때 최신 소프트웨어를 가져옵니다. 이렇게 하면 mail-receiver가 정기적으로 최신 상태를 유지합니다.


옵션 3 — mail-receiver를 위한 전용 VPS

옵션 3은 mail-receiver를 메인 Discourse 서버에서 완전히 분리하여 전용 VPS로 이동합니다. 이는 보안과 관심사 분리가 중요한 프로덕션 환경에서 권장되는 방법입니다. Hetzner, Vultr, DigitalOcean과 같은 제공업체에서 월 약 $4–6의 비용으로, 추가 비용이 드는 유일한 옵션이지만 세 가지 중 가장 깨끗하고 격리된 설정을 제공합니다.

이것은 다른 서비스가 실행되지 않는 독립 서버이므로 Discourse와의 포트 충돌이나 포트 80에 대한 제약이 없습니다. 즉, 옵션 2에서 필요했던 DNS 챌린지 방법이 아닌 표준 Certbot standalone 모드를 인증서 발급에 사용할 수 있어 인증서 설정을 상당히 단순화할 수 있습니다.

작동 방식

mail-receiver는 새 VPS에서 독립 Docker 컨테이너로 배포됩니다. 포트 25에서 리스닝하며 인터넷에서 수신하는 SMTP를 받아 처리된 이메일을 Discourse API를 통해 HTTPS로 Discourse 인스턴스로 전달합니다 — 옵션 1과 2와 정확히 동일하지만 별도의 하드웨어에서 실행됩니다.

옵션 1과 2 대비 주요 장점

메인 서버 IP 주소는 DNS에 노출되지 않습니다. 메일 서브도메인 A 레코드는 Discourse 서버가 아닌 새 VPS IP를 가리키므로, 메일 서브도메인이 조회되더라도 전용 메일 서버만 노출됩니다. Discourse 서버는 Cloudflare 뒤에 완전히 숨겨집니다.

또한 메일 서버의 문제(무거운 스팸 부하, 잘못된 설정, 또는 필요한 재시작)는 Discourse 포럼에 전혀 영향을 미치지 않으며, 그 반대도 마찬가지입니다.

트레이드오프

이것은 두 번째 서버의 프로비저닝과 유지보수가 필요하므로 세 가지 옵션 중 가장 복잡한 설정입니다. 그러나 설정이 완료되면 대부분 자체 관리됩니다 — 컨테이너는 실패 시 자동으로 재시작되고, Certbot은 인증서 갱신을 처리하며, 분기별 업데이트 스크립트는 이미지를 최신 상태로 유지합니다. 지속적인 관리 오버헤드는 최소입니다.

일반 대중을 위해 작성된 옵션 3의 완전한 지침 세트는 다음과 같습니다:


옵션 3 — 지침

필요한 것

  • 실행 중인 Discourse 포럼
  • Cloudflare에서 관리되는 도메인
  • Discourse API 키 (아래 지침 참조)
  • Ubuntu 24.04를 실행하는 새 VPS 서버 — Hetzner, Vultr, DigitalOcean에서 월 약 $4–6
  • 새 VPS에 대한 root 권한 SSH 접근

단계 1 — VPS 선택 및 프로비저닝

원하는 VPS 제공업체에 가입하세요. 최소 권장 사양은 1 vCPU와 1GB RAM입니다 — mail-receiver는 경량이며 많은 리소스가 필요하지 않습니다.

권장 제공업체 및 대략적인 월 비용:

제공업체 플랜 비용
Hetzner CX22 (2 vCPU, 4GB RAM) ~$4/월
Vultr Cloud Compute 1GB ~$6/월
DigitalOcean Basic Droplet 1GB ~$6/월

서버 프로비저닝 시:

  • 운영 체제로 Ubuntu 24.04 LTS 선택
  • 설정 중에 SSH 공개 키 추가
  • 서버에 할당된 공개 IPv4 주소 기록 — DNS에 필요합니다

단계 2 — Cloudflare에서 DNS 구성

서버에 손대기 전에 DNS 레코드를 설정하세요. 나머지 단계를 진행하는 동안 DNS가 전파될 시간을 줍니다.

Cloudflare에 로그인하고 도메인에 다음 두 레코드를 추가하세요:

A 레코드 — 메일 서브도메인을 새 VPS로 가리킵니다:

유형 이름 프록시 상태
A mail YOUR.VPS.IP :radio_button: DNS 전용 (회색 구름)

MX 레코드 — 인터넷에 이메일을 어디로 전달할지 알려줍니다:

유형 이름 우선순위
MX @ mail.yourdomain.tld 10

A 레코드는 반드시 DNS 전용(회색 구름)으로 설정해야 합니다. 이 레코드에 Cloudflare 프록시가 활성화되면 SMTP 트래픽이 차단되고 메일이 전달되지 않습니다.

계속하기 전에 레코드가 올바르게 해석되는지 확인하세요:

# 모든 기계에서 실행 — VPS IP를 반환해야 하며, Cloudflare IP가 아니어야 합니다
nslookup mail.yourdomain.tld

# mail.yourdomain.tld가 메일 서버로 표시되어야 합니다
nslookup -type=MX yourdomain.tld

Cloudflare IP는 항상 104., 172.64–68., 또는 162.158.로 시작합니다 — 이를 보았다면 A 레코드가 아직 프록시된 상태입니다.


단계 3 — 초기 서버 설정

새 VPS에 SSH로 로그인:

ssh root@YOUR.VPS.IP

시스템 업데이트:

apt update && apt upgrade -y

서버 호스트 이름을 메일 서브도메인과 일치하도록 설정:

hostnamectl set-hostname mail.yourdomain.tld
hostname

단계 4 — 방화벽 구성

apt install ufw -y

# 먼저 SSH 허용 — UFW를 활성화하기 전에 이 작업을 수행하지 않으면 스스로를 잠금 상태에 빠뜨릴 수 있습니다
ufw allow 22/tcp

# 수신 SMTP 허용
ufw allow 25/tcp

# 방화벽 활성화
ufw enable

# 규칙 확인
ufw status

단계 5 — 시스템 Postfix 비활성화

Ubuntu 24.04는 기본적으로 Postfix를 설치하는 경우가 있습니다. mail-receiver 컨테이너는 Docker 내부에서 자체 Postfix 인스턴스를 실행하므로, 포트 25를 해제하기 위해 시스템의 Postfix를 비활성화해야 합니다.

# 포트 25를 사용하는 것이 있는지 확인
ss -tlnp | grep :25

# Postfix가 목록에 표시되면 비활성화
systemctl stop postfix
systemctl disable postfix

# 포트 25가 이제 비어 있는지 확인
ss -tlnp | grep :25

단계 6 — Docker 설치

Ubuntu의 기본 저장소에서 온 오래된 Docker 패키지가 있으면 제거:

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

공식 Docker 저장소에서 Docker 설치:

# 사전 요구 사항 설치
apt install ca-certificates curl gnupg -y

# Docker의 공식 GPG 키 추가
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

# Docker apt 저장소 추가
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

# apt 업데이트 및 Compose 플러그인과 함께 Docker Engine 설치
apt update
apt install -y docker-ce docker-ce-cli containerd.io \
  docker-buildx-plugin docker-compose-plugin

# 부팅 시 Docker가 자동으로 시작되도록 활성화
systemctl enable docker
systemctl start docker

# Docker와 Compose가 모두 작동하는지 확인
docker --version
docker compose version

단계 7 — Certbot 설치

apt install certbot -y

# 확인
certbot --version

단계 8 — 포트 80 열기 및 TLS 인증서 획득

이 서버에는 웹 서버가 실행되지 않으므로, Certbot은 standalone 모드를 사용할 수 있습니다 — Let’s Encrypt 챌린지를 완료하기 위해 포트 80에서 자체 웹 서버를 임시로 실행한 후 종료합니다.

# 포트 80 임시로 열기
ufw allow 80/tcp

# 인증서 요청
certbot certonly \
  --standalone \
  --non-interactive \
  --agree-tos \
  --email admin@yourdomain.tld \
  -d mail.yourdomain.tld

# 포트 80 닫기 — 더 이상 필요하지 않음
ufw delete allow 80/tcp

# 인증서가 발급되었는지 확인
certbot certificates

# Certbot 자동 갱신 설정 확인
systemctl status certbot.timer

인증서 경로를 확인하는 출력을 보아야 합니다:

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

자동 갱신을 위한 포트 80 구성

Certbot은 백그라운드 systemd 타이머를 통해 인증서를 자동으로 갱신합니다. 이 서버에서는 포트 80이 일반적으로 닫혀 있으므로, Certbot이 각 갱신 중에 자동으로 열고 닫도록 지시해야 합니다. 갱신 구성을 편집하세요:

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

[renewalparams] 섹션 아래에 다음 두 줄을 추가하세요:

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

dry run으로 자동 갱신 구성을 테스트하세요:

certbot renew --dry-run

pre-hook이 포트 80을 열고, 챌린지가 성공하고, post-hook이 포트 80을 닫는 것을 보아야 합니다. 배포 훅은 건너뛴 것으로 표시될 것입니다 — 이는 실제 인증서가 발급되지 않는 dry run에서는 정상적인 것입니다.


단계 9 — 작업 디렉터리 생성 및 인증서 복사

# 작업 디렉터리 구조 생성
mkdir -p /opt/mail-receiver/certs/mail.yourdomain.tld
mkdir -p /opt/mail-receiver/postfix-spool

# 인증서 복사 — -L 플래그는 Let's Encrypt 심볼릭 링크를 실제 파일로 해석합니다
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/

# 개인 키 잠금
chmod 600 /opt/mail-receiver/certs/mail.yourdomain.tld/privkey.pem

# 네 개의 파일이 모두 존재하는지 확인
ls -la /opt/mail-receiver/certs/mail.yourdomain.tld/

단계 10 — Discourse API 키 가져오기

Discourse 포럼에서:

  1. 관리자로 로그인
  2. Admin → API → New API Key로 이동
  3. 다음을 설정:
    • Description: mail-receiver
    • User Level: All Users
    • Scope: Global
  4. Save를 클릭하고 생성된 키를 복사 — 다음 단계에서 필요합니다

단계 11 — docker-compose.yml 생성

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

다음 내용을 붙여넣고, 대문자로 표시된 모든 값을 자신의 값으로 교체하세요:

services:
  mail-receiver:
    image: discourse/mail-receiver:release
    restart: always
    ports:
      - "25:25"
    volumes:
      # Postfix 메일 스풀 — 컨테이너 재시작 시 메일 유지
      - ./postfix-spool:/var/spool/postfix
      # 컨테이너에 마운트된 TLS 인증서
      - ./certs:/letsencrypt
    environment:
      LC_ALL: en_US.UTF-8
      LANG: en_US.UTF-8
      LANGUAGE: en_US.UTF-8

      # 메일을 받을 도메인
      MAIL_DOMAIN: mail.yourdomain.tld

      # Discourse 인스턴스의 handle_mail 엔드포인트
      DISCOURSE_MAIL_ENDPOINT: 'https://forums.yourdomain.tld/admin/email/handle_mail'
      DISCOURSE_BASE_URL: ''https://forums.yourdomain.tld'

      # 단계 10의 API 키
      DISCOURSE_API_KEY: YOUR_API_KEY_HERE

      # Discourse에서 해당 사용자를 이름 변경하지 않았다면 system으로 유지
      DISCOURSE_API_USERNAME: system

      # TLS 인증서 경로 — 이는 컨테이너 내부의 경로입니다
      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 호스트 이름 공지
      POSTCONF_myhostname: mail.yourdomain.tld

단계 12 — 컨테이너 시작

cd /opt/mail-receiver

# 최신 이미지 가져오기
docker compose pull

# 디텍티드 모드로 컨테이너 시작
docker compose up -d

# 실행 중인지 확인
docker compose ps

# 시작 로그 관찰 — TLS 오류 없이 'daemon started'를 찾으세요
docker compose logs -f

정상적인 시작은 다음과 같습니다:

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

로그 추적을 중지하려면 Ctrl+C를 누르세요.


단계 13 — 수신 메일 수신을 위한 Discourse 구성

Discourse 포럼에서:

  1. Admin → Settings → Email로 이동
  2. reply by email 활성화
  3. reply by email address를 다음으로 설정: reply+%{reply_key}@mail.yourdomain.tld
  4. 도착하는 메일을 모니터링하려면 Admin → Email → Incoming으로 이동

단계 14 — 설정 테스트

VPS가 아닌 로컬 머신에서 이 테스트를 실행하세요:

# 포트 25가 도달 가능하고 Postfix가 응답하는지 테스트
telnet mail.yourdomain.tld 25
# 예상 응답: 220 mail.yourdomain.tld ESMTP Postfix

# TLS 핸드셰이크 테스트
openssl s_client -connect mail.yourdomain.tld:25 -starttls smtp
# 오류 없이 Let's Encrypt 인증서 세부 정보가 표시되어야 합니다

그런 후 메일 도메인의 아무 주소(예: test@mail.yourdomain.tld)로 테스트 이메일을 보내고 컨테이너 로그를 관찰하세요:

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

성공적인 전달은 다음과 같습니다:

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

단계 15 — Certbot 배포 훅 설정

각 자동 인증서 갱신 후 새 인증서 파일이 컨테이너 마운트 경로로 복사되고 컨테이너가 재시작되어 이를 로드하도록 배포 훅을 생성하세요:

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

다음 내용을 붙여넣고, 도메인과 이메일을 자신의 것으로 교체하세요:

#!/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"

# 갱신된 인증서 복사
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"

단계 16 — 분기별 업데이트 스크립트 설정

서버에 업데이트 스크립트를 직접 생성하세요:

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

다음 내용을 붙여넣고, 관리자 이메일을 자신의 것으로 교체하세요:

#!/bin/bash
# =============================================================================
# update-mail-receiver.sh
# 최신 discourse/mail-receiver 이미지를 가져오고 컨테이너를 재시작합니다
# cron을 통해 분기별로 실행되도록 의도됨
# =============================================================================

# --- 구성 -----------------------------------------------------------
COMPOSE_DIR="/opt/mail-receiver"
COMPOSE_FILE="${COMPOSE_DIR}/docker-compose.yml"
ADMIN_EMAIL="admin@yourdomain.tld"        # <-- 관리자 이메일로 변경
LOG_FILE="/var/log/mail-receiver-update.log"
IMAGE="discourse/mail-receiver:release"

# --- 헬퍼 -----------------------------------------------------------------
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_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

    # postfix가 초기화되도록 잠시 대기
    sleep 5

    # 컨테이너가 실제로 실행 중인지 확인
    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() {
    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; }

    # 최종 요약
    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 "$@"

cron에 의존하기 전에 수동으로 테스트하세요:

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

# 로그 관찰
tail -f /var/log/mail-receiver-update.log

그런 후 1월, 4월, 7월, 10월의 1일 오전 2시에 실행되도록 cron 작업을 추가하세요:

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

스크립트는 실제로 더 새로운 이미지가 사용할 수 있을 때만 이메일을 보내고 컨테이너를 재시작합니다. 이미지가 이미 최신 상태라면 메일 전달에 아무런 차질 없이 조용하게 완료됩니다.


유용한 명령어 참고

# 실시간 로그 보기
docker compose -f /opt/mail-receiver/docker-compose.yml logs -f

# 컨테이너 재시작
docker compose -f /opt/mail-receiver/docker-compose.yml restart

# 중지 및 시작
docker compose -f /opt/mail-receiver/docker-compose.yml stop
docker compose -f /opt/mail-receiver/docker-compose.yml up -d

# 수동으로 최신 이미지로 업데이트
docker compose -f /opt/mail-receiver/docker-compose.yml pull
docker compose -f /opt/mail-receiver/docker-compose.yml up -d

# 디버깅을 위해 컨테이너에 진입
docker exec -it mail-receiver-mail-receiver-1 bash

# certbot 타이머가 활성 상태인지 확인
systemctl status certbot.timer

# 인증서 갱신 로그 보기
tail -f /var/log/mail-cert-renewal.log

# 업데이트 로그 보기
tail -f /var/log/mail-receiver-update.log