Local SMTP Relay với SendGrid trong Docker

Bạn có một self-hosted app chạy trong Docker. Nó cần gửi email — password reset, notification, alert. Bạn có thể integrate trực tiếp SendGrid API vào mỗi app, nhưng vậy là phải thêm SDK dependency, API key, và email logic cho từng service.
Nếu app chỉ cần gửi email tới localhost:25 như một SMTP client truyền thống, rồi có thằng relay lo phần còn lại thì sao?
Đó chính xác là thứ chúng ta sẽ build: một local SMTP relay server nhận mail trên port 25 và forward qua SendGrid SMTP API. Các Docker container trên cùng host connect tới nó qua external fixed network — không expose ra internet, không config phức tạp.
Phiên bản mới của project cũng đã mở rộng ý tưởng này thêm một bước: ngoài SendGrid, relay còn có thể forward qua Hostinger hoặc Resend chỉ bằng cách đổi biến môi trường trong .env. Nói cách khác, bài viết này vẫn giữ walkthrough SendGrid làm ví dụ chính, nhưng kiến trúc đằng sau giờ đã linh hoạt hơn trước.
Bạn Sẽ Học Được Gì
✅ Tại sao local SMTP relay hữu ích cho self-hosted setup
✅ Cách configure Postfix làm SendGrid SMTP relay
✅ Cách dùng Docker external network cho container-to-container communication
✅ Cách connect bất kỳ Dockerized app nào tới relay trên port 25
✅ Cách mở rộng relay để đổi provider qua .env mà không sửa từng app
✅ Security best practice cho local SMTP relay
Tại Sao Cần Local SMTP Relay?
Vấn Đề
Mỗi app gửi email đều cần:
- Email provider SDK hoặc SMTP configuration
- API key hoặc credential lưu riêng cho từng service
- Retry logic, error handling, rate limiting
Khi bạn có nhiều service — blog, monitoring tool, CI runner, home automation — việc manage email credential trong từng cái trở nên cực kỳ mệt mỏi.
Giải Pháp
Một SMTP relay duy nhất trên host mà:
- Listen trên port 25 (standard SMTP)
- Chỉ accept mail từ trusted Docker container
- Forward tất cả qua SendGrid SMTP server
- Handle retry, queuing, và delivery
App chỉ cần trỏ SMTP setting tới relay container — không SDK, không API key trong từng app.
Điểm đáng giá nhất của mô hình này là các app chỉ nói chuyện với smtp-relay:25. Vì vậy nếu sau này bạn muốn đổi từ SendGrid sang provider khác, phần lớn ứng dụng không cần thay đổi gì cả.
Yêu Cầu
- Linux server (Ubuntu/Debian) đã cài Docker
- Tài khoản SendGrid với API key có quyền Mail Send
- Verified sender identity hoặc domain trong SendGrid
- Kiến thức cơ bản về Docker và Docker Compose
Nếu bạn không dùng SendGrid, phần kiến trúc vẫn giữ nguyên. Chỉ phần credential và upstream relay host sẽ thay đổi theo provider.
Bước 1: Tạo External Docker Network
Đầu tiên, tạo một fixed external Docker network. Network này tồn tại độc lập với mọi chu kỳ docker compose up/down, nên tất cả container join vào đều luôn reach được nhau.
docker network create --driver bridge smtp-networkTại sao external? Vì:
- Nó survive khi container restart và redeploy
- Nhiều Compose project độc lập có thể join cùng network
- Bạn control tên network — không có random prefix
Kiểm tra network đã tạo:
docker network ls | grep smtp-networkBước 2: Build SMTP Relay Container
Chúng ta sẽ dùng Postfix làm SMTP relay — battle-tested, nhẹ, và hoàn hảo cho việc relay mail.
Cấu Trúc Project
smtp-relay/
├── Dockerfile
├── docker-compose.yml
├── .env
└── entrypoint.shDockerfile
FROM ubuntu:24.04
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && \
apt-get install -y postfix libsasl2-modules ca-certificates && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
EXPOSE 25
CMD ["/entrypoint.sh"]entrypoint.sh
Trong phiên bản ban đầu, script này configure Postfix để relay qua SendGrid lúc container startup bằng environment variable:
#!/bin/bash
set -e
# Configure Postfix làm SendGrid relay
postconf -e "relayhost = [smtp.sendgrid.net]:587"
postconf -e "smtp_sasl_auth_enable = yes"
postconf -e "smtp_sasl_password_maps = static:apikey:${SENDGRID_API_KEY}"
postconf -e "smtp_sasl_security_options = noanonymous"
postconf -e "smtp_tls_security_level = encrypt"
postconf -e "smtp_tls_CAfile = /etc/ssl/certs/ca-certificates.crt"
postconf -e "header_size_limit = 4096000"
# Chỉ accept mail từ Docker network
postconf -e "mynetworks = 127.0.0.0/8 172.16.0.0/12 10.0.0.0/8 192.168.0.0/16"
postconf -e "inet_interfaces = all"
postconf -e "inet_protocols = ipv4"
# Set hostname
postconf -e "myhostname = ${MAIL_HOSTNAME:-smtp-relay.local}"
postconf -e "mydomain = ${MAIL_HOSTNAME:-smtp-relay.local}"
# Tắt local delivery — relay tất cả
postconf -e "mydestination = "
postconf -e "local_transport = error:no local delivery"
echo "Starting Postfix SMTP relay..."
echo "Relay host: smtp.sendgrid.net:587"
echo "Hostname: ${MAIL_HOSTNAME:-smtp-relay.local}"
# Start Postfix ở foreground
postfix start-fgGiải thích các setting quan trọng:
| Setting | Mục đích |
|---|---|
relayhost | Forward tất cả mail tới SendGrid SMTP server trên port 587 |
smtp_sasl_auth_enable | Bật authentication với SendGrid |
smtp_sasl_password_maps | SendGrid dùng apikey làm username và API key làm password |
smtp_tls_security_level | Bắt buộc TLS encryption tới SendGrid |
mynetworks | Chỉ accept mail từ private IP range (Docker network) |
mydestination | Để trống — không deliver local, relay tất cả |
inet_interfaces = all | Listen trên tất cả interface trong container |
.env
SENDGRID_API_KEY=SG.xxxxxxxxxxxxxxxxxxxx
MAIL_HOSTNAME=smtp-relay.localKhông bao giờ commit file
.envlên version control. Thêm nó vào.gitignore.
docker-compose.yml
services:
smtp-relay:
build: .
container_name: smtp-relay
restart: unless-stopped
env_file:
- .env
networks:
- smtp-network
networks:
smtp-network:
external: trueLưu ý:
- Không map port ra host (
portscố tình bỏ qua) — relay chỉ accessible từ container khác trênsmtp-network, không từ internet - Network khai báo
external: true— tham chiếu tới network đã tạo ở Bước 1
Build và Chạy
cd smtp-relay
docker compose up -d --buildKiểm tra relay đang chạy:
docker compose logs smtp-relayBạn sẽ thấy:
Starting Postfix SMTP relay...
Relay host: smtp.sendgrid.net:587
Hostname: smtp-relay.local
postfix/master[1]: daemon started -- version 3.8.xMở Rộng: Hỗ Trợ Nhiều Provider Qua .env
Đây là điểm mới trong README hiện tại của project. Thay vì relay cố định qua SendGrid, bạn có thể dùng biến MAIL_PROVIDER để chuyển giữa nhiều upstream provider mà không phải thay đổi cách app connect tới relay.
| Variable | Description |
|---|---|
MAIL_PROVIDER | sendgrid (default), hostinger, hoặc resend |
MAIL_HOSTNAME | Postfix hostname (default: smtp-relay.local) |
SENDGRID_API_KEY | SendGrid API key khi dùng SendGrid |
HOSTINGER_EMAIL | Email account dùng với Hostinger |
HOSTINGER_PASSWORD | Password email Hostinger |
RESEND_API_KEY | Resend API key |
Ví dụ cấu hình:
MAIL_PROVIDER=sendgrid
MAIL_HOSTNAME=smtp-relay.local
SENDGRID_API_KEY=SG.xxxxxxxxxxxxxxxxxxxxMAIL_PROVIDER=hostinger
MAIL_HOSTNAME=smtp-relay.local
HOSTINGER_EMAIL=[email protected]
HOSTINGER_PASSWORD=your-email-passwordMAIL_PROVIDER=resend
MAIL_HOSTNAME=smtp-relay.local
RESEND_API_KEY=re_xxxxxxxxxxxxxxxxxxxxSau khi đổi .env, rebuild lại container:
docker compose up -d --buildĐiều hay ở đây là các app phía sau vẫn giữ nguyên:
- vẫn gửi tới
smtp-relay - vẫn dùng port
25 - vẫn không cần auth hoặc TLS ở phía application container
Toàn bộ khác biệt nằm ở relay layer.
Bước 3: Connect App Container
Bất kỳ Docker Compose project nào giờ đều có thể gửi email qua relay bằng cách join smtp-network.
Ví dụ: Web App Gửi Email
# Trong docker-compose.yml của app
services:
web-app:
image: your-app:latest
environment:
SMTP_HOST: smtp-relay
SMTP_PORT: 25
SMTP_TLS: "false" # TLS do relay handle -> provider upstream
SMTP_AUTH: "false" # Không cần auth cho local relay
networks:
- default
- smtp-network
networks:
smtp-network:
external: trueApp connect tới smtp-relay:25 — container name được resolve qua Docker DNS vì cả hai container cùng nằm trên smtp-network.
Đây cũng là lý do mô hình này khá bền theo thời gian: bạn có thể chuyển SendGrid sang Hostinger hoặc Resend mà không cần sửa cấu hình SMTP trong từng app.
Ví dụ: Django Settings
# settings.py
EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend'
EMAIL_HOST = 'smtp-relay'
EMAIL_PORT = 25
EMAIL_USE_TLS = False
EMAIL_HOST_USER = ''
EMAIL_HOST_PASSWORD = ''
DEFAULT_FROM_EMAIL = '[email protected]'Ví dụ: Node.js với Nodemailer
const nodemailer = require('nodemailer');
const transporter = nodemailer.createTransport({
host: 'smtp-relay',
port: 25,
secure: false,
tls: {
rejectUnauthorized: false
}
});
await transporter.sendMail({
from: '[email protected]',
to: '[email protected]',
subject: 'Hello from Docker',
text: 'Email này được gửi qua local SMTP relay!'
});Ví dụ: Spring Boot (application.yml)
spring:
mail:
host: smtp-relay
port: 25
properties:
mail.smtp.auth: false
mail.smtp.starttls.enable: falseVí dụ: Test bằng curl
Từ bên trong bất kỳ container nào trên smtp-network:
# Test nhanh với curl
curl --url 'smtp://smtp-relay:25' \
--mail-from '[email protected]' \
--mail-rcpt '[email protected]' \
--upload-file - <<EOF
From: [email protected]
To: [email protected]
Subject: SMTP Relay Test
Đây là email test gửi qua local SMTP relay.
EOFBước 4: Kiểm Tra Email Delivery
Check Postfix Log
docker logs smtp-relay 2>&1 | tail -20Delivery thành công trông như này:
postfix/smtp[123]: ABC123DEF: to=<[email protected]>,
relay=smtp.sendgrid.net[xxx.xxx.xxx.xxx]:587,
status=sent (250 Ok queued as xxxxxxxxx)Nếu bạn dùng provider khác, hostname ở dòng relay= sẽ thay đổi, nhưng pattern debug nhìn chung vẫn giống nhau.
Check Provider Dashboard
Với SendGrid, bạn có thể vào SendGrid → Activity để xem email đã gửi, delivery status, open, và click.
Với Hostinger hoặc Resend, hãy kiểm tra dashboard tương ứng của provider bạn đang dùng. Ý chính ở đây là relay vẫn là một lớp trung gian; trạng thái delivery cuối cùng thường sẽ hiện cả ở log Postfix lẫn dashboard của provider.
Check Mail Queue
Nếu email bị stuck:
# Xem mail queue
docker exec smtp-relay postqueue -p
# Flush queue (retry delivery)
docker exec smtp-relay postqueue -f
# Xem lý do deferred mail
docker exec smtp-relay postcat -q <QUEUE_ID>Tổng Quan Kiến Trúc
Đây là bức tranh toàn cảnh cách mọi thứ connect với nhau:
Điểm quan trọng:
- Tất cả container share
smtp-networkbridge - Relay listen trên port 25 chỉ trong network — không expose ra host hay internet
- Relay authenticate tới provider upstream qua TLS
- App không cần giữ credential của SendGrid, Hostinger, hay Resend — chỉ gửi tới
smtp-relay:25
Security Best Practice
1. Không Expose Port 25 Ra Host
Không bao giờ thêm ports: "25:25" vào Compose file của relay. Relay chỉ nên reachable từ Docker network.
# ❌ SAI — expose SMTP ra toàn bộ host network
services:
smtp-relay:
ports:
- "25:25"
# ✅ ĐÚNG — chỉ accessible từ smtp-network
services:
smtp-relay:
networks:
- smtp-network2. Giới Hạn mynetworks
Setting mynetworks của Postfix kiểm soát ai được gửi mail qua relay. Config của chúng ta giới hạn ở private IP range:
mynetworks = 127.0.0.0/8 172.16.0.0/12 10.0.0.0/8 192.168.0.0/16Để security chặt hơn, chỉ dùng subnet cụ thể của Docker network:
# Tìm subnet của smtp-network
docker network inspect smtp-network --format '{{range .IPAM.Config}}{{.Subnet}}{{end}}'
# Rồi dùng subnet cụ thể đó trong entrypoint.sh
postconf -e "mynetworks = 127.0.0.0/8 172.20.0.0/16"3. Rotate Credential Định Kỳ
Lưu key hoặc password trong .env và rotate định kỳ. Nếu bị lộ, revoke hoặc reset ngay trên dashboard của provider đang dùng.
Với SendGrid, tốt nhất là tạo API key riêng chỉ với quyền Mail Send. Đừng dùng lại key có full account access.
4. Monitor Mail Queue
Setup alert nếu mail queue tăng bất thường — có thể là SendGrid credential issue, Hostinger auth fail, Resend API key sai, rate limiting, hoặc container bị compromise đang spam qua relay.
# Check đơn giản queue size
QUEUE_SIZE=$(docker exec smtp-relay postqueue -p | tail -1 | grep -oP '\d+(?= Request)')
if [ "$QUEUE_SIZE" -gt 100 ]; then
echo "Warning: SMTP relay queue có $QUEUE_SIZE message"
fi5. Giữ Credential Chỉ Ở Relay Layer
Càng ít container biết credential thật sự, bề mặt tấn công càng nhỏ. Đây là một lợi ích bảo mật khá thực tế của SMTP relay nội bộ.
Xử Lý Sự Cố
"Connection refused" từ App Container
Nguyên nhân: App container không nằm trên smtp-network.
Cách fix: Thêm network vào docker-compose.yml của app:
networks:
smtp-network:
external: trueVà attach service vào network đó.
"Relay access denied" (Postfix Error 554)
Nguyên nhân: IP của container gửi không nằm trong mynetworks.
Cách fix: Check IP của container và đảm bảo nó nằm trong mynetworks range cho phép:
docker inspect <container_name> --format '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}'Email Bị Stuck Trong Queue
Nguyên nhân: Authentication fail, sender chưa verify, hoặc provider đang rate limit.
Cách fix:
# Check log để tìm error cụ thể
docker logs smtp-relay 2>&1 | grep "status=deferred"
# Các vấn đề thường gặp:
# - Invalid API key → tạo lại trong dashboard provider
# - Unverified sender → verify domain/email
# - Rate limit → check quota hoặc plan limit"TLS required but not available"
Nguyên nhân: Thiếu CA certificate trong container.
Cách fix: Đảm bảo ca-certificates đã được install trong Dockerfile (đã có sẵn trong setup của chúng ta).
Bonus: Health Check
Thêm health check cho relay container để Docker tự restart nếu Postfix crash:
services:
smtp-relay:
build: .
container_name: smtp-relay
restart: unless-stopped
env_file:
- .env
networks:
- smtp-network
healthcheck:
test: ["CMD", "postfix", "status"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10sTổng Kết
Setup local SMTP relay với SendGrid vẫn là ví dụ dễ hiểu và thực dụng nhất:
- Centralized email delivery — một nơi duy nhất manage credential và monitor delivery
- Simple app configuration — chỉ cần trỏ tới
smtp-relay:25, không cần SDK - Network isolation — relay chỉ accessible từ Docker container, không từ internet
- Reliable delivery — Postfix handle queuing và retry
Điểm mở rộng quan trọng trong phiên bản mới là relay không còn bị khóa cứng vào SendGrid. Bạn có thể giữ nguyên mô hình này nhưng đổi upstream sang Hostinger hoặc Resend chỉ bằng cách cập nhật .env và rebuild container.
Pattern này rất phù hợp cho self-hosted setup nơi nhiều service cần gửi email mà không cần mỗi cái tự manage provider integration riêng.
Bài Viết Liên Quan
📬 Đăng ký nhận bản tin
Nhận những bài viết mới nhất qua email mỗi tuần. Không spam, hủy đăng ký bất cứ lúc nào.
Chúng tôi tôn trọng quyền riêng tư của bạn. Hủy đăng ký bất cứ lúc nào.
💬 Comments
Đăng nhập để bình luận
Chúng tôi sẽ không đăng bất cứ điều gì mà không có sự cho phép của bạn.