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:

ProviderOutbound 25
AWS EC2Blocked. Request removal via the support form; also request the rDNS record.
Google CloudPermanently blocked. No exceptions. Do not plan to send from GCE.
AzureBlocked on pay-as-you-go. Enterprise agreements can request an exemption.
DigitalOceanBlocked on new accounts. Support ticket, often declined.
HetznerBlocked by default; usually unblocked on request after account age.
OVH / ScalewayGenerally open.
Vultr / LinodeBlocked 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.1010.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.pemserver.crt, privkey.pemserver.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:

DaysDaily volume
1–350
4–7200
8–141,000
15–215,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

MetricKeep itTrouble 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

  1. docker compose logs postfix | grep 'status=' — what did the remote server actually say? A 550 with a URL in it usually links to the exact policy you tripped.
  2. Send to a Gmail account and use Show original. You need SPF: PASS, DKIM: PASS with domain <customer domain>, DMARC: PASS.
  3. Check the IP on Spamhaus. If listed, use their delisting form — and find out what you sent that got you there before requesting removal.
  4. Check GET /v1/stats/health-signals for elevated bounce or complaint rates.
  5. 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.