Running it
Deployment
Deploying this on a VPS and expecting Gmail to accept your mail is not a matter of installing Postfix. Deliverability is earned through correct DNS, a clean IP, consistent volume, low bounce and complaint rates, and time. Read § Before you commit first — the wrong provider makes everything after it impossible.
Before you commit to a provider
Port 25 is the whole question
Outbound SMTP runs on port 25. Most cloud providers block it by default, and several never unblock it. Check before you spend a day on setup:
| Provider | Outbound 25 |
|---|---|
| AWS EC2 | Blocked. Request removal via the support form; also request the rDNS record. |
| Google Cloud | Permanently blocked. No exceptions. Do not plan to send from GCE. |
| Azure | Blocked on pay-as-you-go. Enterprise agreements can request an exemption. |
| DigitalOcean | Blocked on new accounts. Support ticket, often declined. |
| Hetzner | Blocked by default; usually unblocked on request after account age. |
| OVH / Scaleway | Generally open. |
| Vultr / Linode | Blocked by default; support ticket. |
Test it before anything else:
# Outbound — must connect and return a 220 banner
nc -vz gmail-smtp-in.l.google.com 25
# Inbound — bounce reports arrive over 25 too. Run from another machine.
nc -vz mail.yourservice.com 25
If outbound 25 is blocked you have two options: get it unblocked, or relay
through a provider that accepts submission on 587 (in which case configure
SMTP_HOST/SMTP_PORT to point at them, and their IP reputation replaces
yours).
The IP matters more than the server
You are buying an IP address's history, not just a machine. Before you commit:
# Check the IP against major blocklists
dig +short $YOUR_IP.zen.spamhaus.org # any answer = listed
dig +short $YOUR_IP.bl.spamcop.net
Reverse the octets for those queries (203.0.113.10 → 10.113.0.203). If the
IP is listed, ask for a different one before you configure anything.
Reverse DNS is not optional
You must be able to set a PTR record for the sending IP. Only your hosting
provider can do this. Many receivers reject mail outright from an IP with no PTR
or a generic one like 203-0-113-10.static.example-isp.net.
The PTR, the A record, and Postfix's myhostname must all agree:
mail.yourservice.com → A 203.0.113.10
203.0.113.10 → PTR mail.yourservice.com
SMTP_HOSTNAME = mail.yourservice.com
Server preparation
Ubuntu 22.04/24.04 or Debian 12. 2 vCPU / 4 GB RAM handles a healthy amount of transactional mail.
sudo apt-get update && sudo apt-get upgrade -y
# Docker
curl -fsSL https://get.docker.com | sudo sh
sudo usermod -aG docker "$USER" # log out and back in
# Debian/Ubuntu ship a local MTA that will fight Postfix for port 25.
sudo systemctl disable --now exim4 sendmail postfix 2>/dev/null || true
sudo apt-get purge -y exim4-base exim4-daemon-light 2>/dev/null || true
Firewall
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow 22/tcp # SSH — restrict to your address if you can
sudo ufw allow 80/tcp # HTTP + ACME challenge
sudo ufw allow 443/tcp # HTTPS
sudo ufw allow 25/tcp # inbound bounce reports
sudo ufw enable
Postgres (5432) and Redis (6379) are not published by the compose file and must never be exposed.
DNS for the platform
These are the platform's own records, published once. Customers never touch them.
; A records — all pointing at the VPS
mail.yourservice.com. 300 IN A 203.0.113.10
bounce.yourservice.com. 300 IN A 203.0.113.10
api.yourservice.com. 300 IN A 203.0.113.10
dashboard.yourservice.com. 300 IN A 203.0.113.10
; The record every customer's `include:` resolves to. Adding a sending IP here
; propagates to every customer without any of them changing their DNS.
mail.yourservice.com. 3600 IN TXT "v=spf1 ip4:203.0.113.10 -all"
; Bounce reports are mailed back to bounce+<id>@bounce.yourservice.com
bounce.yourservice.com. 3600 IN MX 10 mail.yourservice.com.
; Your own DMARC and a contact address
_dmarc.yourservice.com. 3600 IN TXT "v=DMARC1; p=none; rua=mailto:dmarc@yourservice.com"
Then ask your provider to set the PTR record for 203.0.113.10 to
mail.yourservice.com.
Verify everything resolves before starting the stack:
dig +short A mail.yourservice.com
dig +short TXT mail.yourservice.com
dig +short MX bounce.yourservice.com
dig -x 203.0.113.10 +short # must return mail.yourservice.com.
Configuration
git clone <your-repo> /opt/mail-platform
cd /opt/mail-platform
cp .env.example .env
Generate the four secrets — never reuse them across environments:
for name in DKIM_PRIVATE_KEY_ENCRYPTION_KEY JWT_SECRET WEBHOOK_SIGNING_SECRET INTERNAL_API_TOKEN; do
printf '%s=%s\n' "$name" "$(openssl rand -hex 32)"
done
Paste those into .env, then set the identity and database values:
NODE_ENV=production
MAIL_DOMAIN=yourservice.com
SMTP_HOSTNAME=mail.yourservice.com # must match the PTR record
SPF_INCLUDE_HOST=mail.yourservice.com
BOUNCE_HOST=bounce.yourservice.com
API_BASE_URL=https://api.yourservice.com
DASHBOARD_URL=https://dashboard.yourservice.com
NEXT_PUBLIC_API_URL=https://api.yourservice.com # baked into the browser bundle
API_DOMAIN=api.yourservice.com
DASHBOARD_DOMAIN=dashboard.yourservice.com
POSTGRES_PASSWORD=<a long random string>
DATABASE_URL=postgresql://mail:<same password>@postgres:5432/mail?schema=public
# Close registration once your own account exists.
ALLOW_SIGNUP=true
chmod 600 .env. It is git-ignored; keep it that way.
Start
docker compose up -d --build
docker compose ps # every service should be healthy
Boot order is enforced by health checks: Postgres and Redis first, then migrations run to completion, then the API and worker start.
curl -fsS http://localhost:4000/health
curl -fsS http://localhost:4000/ready | jq
/ready must report database, redis and mail all ok.
TLS
docker run --rm \
-v mail-platform_certbot_certs:/etc/letsencrypt \
-v mail-platform_certbot_www:/var/www/certbot \
certbot/certbot certonly --webroot -w /var/www/certbot \
-d api.yourservice.com -d dashboard.yourservice.com \
--email you@yourservice.com --agree-tos --no-eff-email
Then enable the HTTPS server blocks:
cp infrastructure/nginx/production-tls.conf.example \
infrastructure/nginx/templates/tls.conf.template
# edit the two domain names, then remove the plain-HTTP `location /` blocks
# from default.conf.template so everything redirects
docker compose restart nginx
docker compose exec nginx nginx -t
Renewal, via cron:
0 3 * * * cd /opt/mail-platform && docker run --rm -v mail-platform_certbot_certs:/etc/letsencrypt -v mail-platform_certbot_www:/var/www/certbot certbot/certbot renew --quiet && docker compose exec -T nginx nginx -s reload
TLS for SMTP
The Postfix container generates a self-signed certificate for its inbound
listener so STARTTLS is offered. Outbound delivery uses the system CA bundle
and is unaffected by this. To present a real certificate on port 25, mount the
Let's Encrypt files over /etc/postfix/certs:
postfix:
volumes:
- certbot_certs:/etc/letsencrypt:ro
- ./infrastructure/postfix/certs:/etc/postfix/certs
and copy fullchain.pem → server.crt, privkey.pem → server.key.
First account
curl -X POST https://api.yourservice.com/v1/auth/register \
-H "Content-Type: application/json" \
-d '{"email":"you@yourservice.com","password":"<a long password>"}'
Then close the door:
sed -i 's/^ALLOW_SIGNUP=true/ALLOW_SIGNUP=false/' .env
docker compose up -d api
To grant yourself admin (raising other accounts' limits, suspending abusers):
docker compose exec postgres psql -U mail -d mail \
-c "UPDATE users SET role = 'ADMIN' WHERE email = 'you@yourservice.com';"
Warming up
A brand-new IP has no reputation, and reputation is the thing that decides whether your mail lands. Sending 10,000 messages on day one from a cold IP is the fastest way to get filtered.
Ramp over two to four weeks:
| Days | Daily volume |
|---|---|
| 1–3 | 50 |
| 4–7 | 200 |
| 8–14 | 1,000 |
| 15–21 | 5,000 |
| 22+ | Increase ~50%/day while metrics stay clean |
Use the platform's own limits to enforce this:
RATE_LIMIT_EMAILS_PER_DAY=50 # then raise as you ramp
Send to engaged recipients first. Watch GET /v1/stats/overview daily and stop
ramping the moment the bounce rate climbs.
The numbers to watch
| Metric | Keep it | Trouble at |
|---|---|---|
| Bounce rate | < 2% | > 5% |
| Complaint rate | < 0.1% | > 0.3% |
| Delivery rate | > 95% | < 90% |
Gmail's Postmaster Tools (free) shows your domain reputation and spam rate
directly. Register yourservice.com there on day one.
Backups
The DKIM private keys live in Postgres. Lose that database and every customer domain must republish its DNS.
# Nightly dump
docker compose exec -T postgres pg_dump -U mail mail | gzip > /backups/mail-$(date +%F).sql.gz
DKIM_PRIVATE_KEY_ENCRYPTION_KEY is not in the dump. A backup is useless
without it — store it separately, and store it somewhere you will still have it
after the server is gone.
Restore:
gunzip -c /backups/mail-2026-08-16.sql.gz | docker compose exec -T postgres psql -U mail -d mail
Redis holds queued jobs and rate-limit counters. Losing it drops in-flight messages; it is not a source of truth and does not need backing up.
Upgrading
git pull
docker compose build
docker compose up -d # migrations run before api/worker restart
docker compose ps
The worker finishes in-flight jobs before exiting (SIGTERM is forwarded by tini), so a deploy does not abandon a message mid-send.
Scaling
More throughput — run more workers. They compete for the same queue safely:
docker compose up -d --scale worker=4
Note that only one worker should tail the Postfix log; if you scale beyond one,
run the extra workers with POSTFIX_LOG_PATH pointing at a non-existent path so
they process jobs without duplicating delivery events.
More volume — add sending IPs, add them to the platform SPF record, and configure Postfix to rotate. Reputation is per-IP, so each new one needs its own warm-up.
Separating concerns — Postgres and Redis can move to managed services by
changing DATABASE_URL and REDIS_URL. Nothing else changes.
When mail is not landing
docker compose logs postfix | grep 'status='— what did the remote server actually say? A550with a URL in it usually links to the exact policy you tripped.- Send to a Gmail account and use Show original. You need
SPF: PASS,DKIM: PASS with domain <customer domain>,DMARC: PASS. - Check the IP on Spamhaus. If listed, use their delisting form — and find out what you sent that got you there before requesting removal.
- Check
GET /v1/stats/health-signalsfor elevated bounce or complaint rates. - Confirm the PTR record still matches
SMTP_HOSTNAME.
Do not attempt to work around a filter. If a provider is rejecting your mail, the reject is information: fix the underlying signal — list hygiene, content, volume, authentication — rather than trying to evade the check.