Local SMTP Relay with SendGrid in Docker

You have a self-hosted app running in Docker. It needs to send emails — password resets, notifications, alerts. You could integrate SendGrid's API directly into every app, but that means adding SDK dependencies, API keys, and email logic to each service.
What if your apps could just send email to localhost:25 like any traditional SMTP client, and a relay handles the rest?
That's exactly what we'll build: a local SMTP relay server that accepts mail on port 25 and forwards it through SendGrid's SMTP API. Docker containers on the same host connect to it via an external fixed network — no internet exposure, no complex configuration.
The current version of the project extends this idea one step further: besides SendGrid, the relay can now forward through Hostinger or Resend just by changing environment variables in .env. In other words, this article still keeps the SendGrid walkthrough as the main example, but the architecture behind it is now more flexible.
What You'll Learn
✅ Why a local SMTP relay is useful for self-hosted setups
✅ How to configure Postfix as a SendGrid SMTP relay
✅ How to use Docker external networks for container-to-container communication
✅ How to connect any Dockerized app to the relay on port 25
✅ How to extend the relay to switch providers through .env without changing every app
✅ Security best practices for local SMTP relays
Why a Local SMTP Relay?
The Problem
Every app that sends email needs:
- An email provider SDK or SMTP configuration
- API keys or credentials stored per service
- Retry logic, error handling, rate limiting
When you have multiple services — a blog, a monitoring tool, a CI runner, a home automation system — managing email credentials in each one becomes tedious.
The Solution
A single SMTP relay on the host that:
- Listens on port 25 (standard SMTP)
- Accepts mail from trusted Docker containers only
- Forwards everything through SendGrid's SMTP servers
- Handles retries, queuing, and delivery
Your apps just point their SMTP settings to the relay container — no SDK, no API keys in each app.
The biggest advantage of this model is that your apps only ever talk to smtp-relay:25. So if you later switch from SendGrid to another provider, most of your applications do not need to change at all.
Prerequisites
- A Linux server (Ubuntu/Debian) with Docker installed
- A SendGrid account with an API key that has Mail Send permissions
- A verified sender identity or domain in SendGrid
- Basic familiarity with Docker and Docker Compose
If you are not using SendGrid, the overall architecture stays the same. Only the credentials and upstream relay host change based on the provider.
Step 1: Create the External Docker Network
First, create a fixed external Docker network. This network persists independently of any docker compose up/down cycle, so all containers that join it can always reach each other.
docker network create --driver bridge smtp-networkWhy external? Because:
- It survives container restarts and redeployments
- Multiple independent Compose projects can join the same network
- You control the network name — no random prefixes
Verify it exists:
docker network ls | grep smtp-networkStep 2: Build the SMTP Relay Container
We'll use Postfix as the SMTP relay — it's battle-tested, lightweight, and perfect for relaying mail.
Project Structure
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
This script configures Postfix at container startup using environment variables:
#!/bin/bash
set -e
# Configure Postfix as a 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"
# Accept mail from Docker network only
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 the hostname
postconf -e "myhostname = ${MAIL_HOSTNAME:-smtp-relay.local}"
postconf -e "mydomain = ${MAIL_HOSTNAME:-smtp-relay.local}"
# Disable local delivery — relay everything
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 in foreground
postfix start-fgKey configuration explained:
| Setting | Purpose |
|---|---|
relayhost | Forward all mail to SendGrid's SMTP server on port 587 |
smtp_sasl_auth_enable | Enable authentication with SendGrid |
smtp_sasl_password_maps | SendGrid uses apikey as username and your API key as password |
smtp_tls_security_level | Enforce TLS encryption to SendGrid |
mynetworks | Only accept mail from private IP ranges (Docker networks) |
mydestination | Empty — don't deliver locally, relay everything |
inet_interfaces = all | Listen on all interfaces inside the container |
.env
SENDGRID_API_KEY=SG.xxxxxxxxxxxxxxxxxxxx
MAIL_HOSTNAME=smtp-relay.localNever commit your
.envfile to version control. Add it to.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: trueNotice:
- No port mapping to the host (
portsis intentionally omitted) — the relay is only accessible from other containers on thesmtp-network, not from the internet - The network is declared as
external: true— it references the network we created in Step 1
Build and Start
cd smtp-relay
docker compose up -d --buildVerify it's running:
docker compose logs smtp-relayYou should see:
Starting Postfix SMTP relay...
Relay host: smtp.sendgrid.net:587
Hostname: smtp-relay.local
postfix/master[1]: daemon started -- version 3.8.xExtension: Support Multiple Providers via .env
This is the main addition in the current project README. Instead of keeping the relay hard-wired to SendGrid, you can use MAIL_PROVIDER to switch between supported upstream providers without changing how your apps connect to the relay.
| Variable | Description |
|---|---|
MAIL_PROVIDER | sendgrid (default), hostinger, or resend |
MAIL_HOSTNAME | Postfix hostname (default: smtp-relay.local) |
SENDGRID_API_KEY | SendGrid API key when using SendGrid |
HOSTINGER_EMAIL | Email account used with Hostinger |
HOSTINGER_PASSWORD | Hostinger email password |
RESEND_API_KEY | Resend API key |
Example configurations:
MAIL_PROVIDER=sendgrid
MAIL_HOSTNAME=smtp-relay.local
SENDGRID_API_KEY=SG.xxxxxxxxxxxxxxxxxxxxMAIL_PROVIDER=hostinger
MAIL_HOSTNAME=smtp-relay.local
HOSTINGER_EMAIL=no-reply@yourdomain.com
HOSTINGER_PASSWORD=your-email-passwordMAIL_PROVIDER=resend
MAIL_HOSTNAME=smtp-relay.local
RESEND_API_KEY=re_xxxxxxxxxxxxxxxxxxxxAfter changing .env, rebuild the container:
docker compose up -d --buildWhat is nice here is that the app side stays unchanged:
- it still sends to
smtp-relay - it still uses port
25 - it still does not need SMTP auth or TLS at the application level
All provider-specific differences stay inside the relay layer.
Step 3: Connect Your App Containers
Any Docker Compose project can now send email through the relay by joining the smtp-network.
Example: A Web App That Sends Email
# In your app's docker-compose.yml
services:
web-app:
image: your-app:latest
environment:
SMTP_HOST: smtp-relay
SMTP_PORT: 25
SMTP_TLS: "false" # TLS is handled by the relay -> upstream provider
SMTP_AUTH: "false" # No auth needed for local relay
networks:
- default
- smtp-network
networks:
smtp-network:
external: trueYour app connects to smtp-relay:25 — the container name resolves via Docker's built-in DNS because both containers are on the same smtp-network.
This is also why the pattern ages well: you can move from SendGrid to Hostinger or Resend without touching SMTP settings in each app.
Example: 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 = 'noreply@yourdomain.com'Example: Node.js with Nodemailer
const nodemailer = require('nodemailer');
const transporter = nodemailer.createTransport({
host: 'smtp-relay',
port: 25,
secure: false,
tls: {
rejectUnauthorized: false
}
});
await transporter.sendMail({
from: 'noreply@yourdomain.com',
to: 'user@example.com',
subject: 'Hello from Docker',
text: 'This email was sent through the local SMTP relay!'
});Example: Spring Boot (application.yml)
spring:
mail:
host: smtp-relay
port: 25
properties:
mail.smtp.auth: false
mail.smtp.starttls.enable: falseExample: Using sendmail or curl for Testing
From inside any container on the smtp-network:
# Quick test with curl (if available)
curl --url 'smtp://smtp-relay:25' \
--mail-from 'noreply@yourdomain.com' \
--mail-rcpt 'test@example.com' \
--upload-file - <<EOF
From: noreply@yourdomain.com
To: test@example.com
Subject: SMTP Relay Test
This is a test email sent through the local SMTP relay.
EOFStep 4: Verify Email Delivery
Check Postfix Logs
docker logs smtp-relay 2>&1 | tail -20A successful delivery looks like:
postfix/smtp[123]: ABC123DEF: to=<test@example.com>,
relay=smtp.sendgrid.net[xxx.xxx.xxx.xxx]:587,
status=sent (250 Ok queued as xxxxxxxxx)If you use another provider, the hostname in the relay= field will change, but the overall debugging pattern stays the same.
Check Provider Dashboard
For SendGrid, go to SendGrid → Activity to see sent emails, delivery status, opens, and clicks.
For Hostinger or Resend, check the corresponding provider dashboard. The key idea is that the relay is still the middle layer, so final delivery status often appears both in Postfix logs and in the provider UI.
Check the Mail Queue
If emails are stuck:
# View the mail queue
docker exec smtp-relay postqueue -p
# Flush the queue (retry delivery)
docker exec smtp-relay postqueue -f
# View deferred mail reasons
docker exec smtp-relay postcat -q <QUEUE_ID>Architecture Overview
Here's the complete picture of how everything connects:
Key points:
- All containers share the
smtp-networkbridge - The relay listens on port 25 inside the network only — not exposed to the host or internet
- The relay authenticates to the upstream provider over TLS
- Apps do not need SendGrid, Hostinger, or Resend credentials — they just send to
smtp-relay:25
Security Best Practices
1. Don't Expose Port 25 to the Host
Never add ports: "25:25" to the relay's Compose file. The relay should only be reachable from the Docker network.
# ❌ BAD — exposes SMTP to the entire host network
services:
smtp-relay:
ports:
- "25:25"
# ✅ GOOD — only accessible from smtp-network
services:
smtp-relay:
networks:
- smtp-network2. Restrict mynetworks
The Postfix mynetworks setting controls who can send mail through the relay. Our config limits it to private IP ranges:
mynetworks = 127.0.0.0/8 172.16.0.0/12 10.0.0.0/8 192.168.0.0/16For tighter security, use only the specific Docker network subnet:
# Find your smtp-network subnet
docker network inspect smtp-network --format '{{range .IPAM.Config}}{{.Subnet}}{{end}}'
# Then use that specific subnet in entrypoint.sh
postconf -e "mynetworks = 127.0.0.0/8 172.20.0.0/16"3. Rotate Credentials Regularly
Store keys or passwords in .env and rotate them periodically. If compromised, revoke or reset them immediately in the dashboard of the provider you are using.
With SendGrid specifically, it is best to create a dedicated API key with only Mail Send permissions.
4. Monitor the Mail Queue
Set up alerts if the mail queue grows unexpectedly — it could indicate SendGrid credential issues, Hostinger auth failures, an invalid Resend API key, rate limiting, or a compromised container spamming through the relay.
# Simple queue size check
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 has $QUEUE_SIZE messages"
fi5. Keep Credentials Only in the Relay Layer
The fewer containers that know real provider credentials, the smaller your attack surface. That is one of the most practical security benefits of an internal SMTP relay.
Troubleshooting
"Connection refused" from App Container
Cause: The app container isn't on the smtp-network.
Fix: Add the network to your app's docker-compose.yml:
networks:
smtp-network:
external: trueAnd attach your service to it.
"Relay access denied" (Postfix Error 554)
Cause: The sending container's IP isn't in mynetworks.
Fix: Check the container's IP and ensure it falls within the allowed mynetworks range:
docker inspect <container_name> --format '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}'Emails Stuck in Queue
Cause: Authentication failure, an unverified sender identity, or provider-side rate limiting.
Fix:
# Check the logs for specific errors
docker logs smtp-relay 2>&1 | grep "status=deferred"
# Common issues:
# - Invalid API key → regenerate it in the provider dashboard
# - Unverified sender → verify the domain/email
# - Rate limit → check your quota or plan limits"TLS required but not available"
Cause: Missing CA certificates in the container.
Fix: Ensure ca-certificates is installed in the Dockerfile (already included in our setup).
Bonus: Health Check
Add a health check to your relay container so Docker restarts it if Postfix crashes:
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: 10sSummary
Setting up a local SMTP relay with SendGrid is still the easiest and most practical example:
- Centralized email delivery — one place to manage credentials and monitor delivery
- Simple app configuration — just point to
smtp-relay:25, no SDK needed - Network isolation — the relay is only accessible from Docker containers, not the internet
- Reliable delivery — Postfix handles queuing and retries
The important extension in the current version is that the relay is no longer locked to SendGrid. You can keep the same pattern and switch the upstream to Hostinger or Resend simply by updating .env and rebuilding the container.
This pattern works great for self-hosted setups where multiple services need to send email without each one managing its own provider integration.
Related Posts
📬 Subscribe to Newsletter
Get the latest blog posts delivered to your inbox every week. No spam, unsubscribe anytime.
We respect your privacy. Unsubscribe at any time.
💬 Comments
Sign in to leave a comment
We'll never post without your permission.