To migrate from SendGrid to your own mail server, you run both in parallel: build a Postfix server, publish its DNS records alongside SendGrid's, point your app at the new server over authenticated SMTP, and move traffic across gradually while SendGrid stays available as a fallback. Done this way, there's no cutover day and no lost mail.
This guide follows the process we used to move our own production domains off hosted providers in August 2026. Commands assume Ubuntu and Postfix; adjust names and addresses for your setup. Throughout, example.com is your domain and 203.0.113.10 is your server's IP.
Step 1: Inventory what you send through SendGrid
Before touching anything, write down everything that sends through SendGrid. Migrations fail on the email nobody remembered.
For each stream, record:
- From addresses:
noreply@,billing@,alerts@and so on, per domain. - What triggers it: app code, a cron job, a CMS plugin, a CRM, a monitoring tool.
- How it connects: the SendGrid API (via an SDK) or SendGrid's SMTP relay.
- Rough daily volume: from SendGrid's activity or stats pages.
- Features you use: dynamic templates, scheduled sends, open/click tracking, Inbound Parse, Marketing Campaigns.
Search your code and config for the giveaways:
grep -rniE "sendgrid|SG\.[A-Za-z0-9_-]{10,}|smtp\.sendgrid\.net" \
--exclude-dir=node_modules --exclude-dir=.git .
Two things need special thought. Dynamic templates live in SendGrid, so you'll need to move them into your codebase as HTML (or a template engine) before switching. Marketing Campaigns isn't something a plain Postfix server replaces; keep that on a marketing tool and move transactional mail first.
Step 2: Build your mail server
You need a VPS that allows outbound port 25 (AWS and some other clouds block it by default; check your provider), with Postfix, OpenDKIM, TLS, SASL authentication on port 587 and a PTR record. Our self-hosted Postfix on Ubuntu guide covers this end to end.
The parts that matter most for this migration:
- Submission on 587 with STARTTLS and SASL, so your app can authenticate.
- One login per sending address, enforced with
smtpd_sender_login_mapsandreject_sender_login_mismatch, so a leaked app password can only send as its own address. - Reverse DNS that matches the server's hostname. See PTR records for email.
A sender login map looks like this. Note that Postfix lookup tables only allow comments on lines of their own, never at the end of an entry:
# /etc/postfix/sender_login
noreply@example.com app-example
alerts@example.com app-alerts
sudo postmap /etc/postfix/sender_login
sudo systemctl reload postfix
Step 3: Change DNS while both are live
You'll publish records for the new server without removing SendGrid's. Make a note of your current records first:
dig +short TXT example.com
dig +short TXT _dmarc.example.com
dig +short CNAME s1._domainkey.example.com
dig +short CNAME s2._domainkey.example.com
Merge SPF into one record
A domain can have only one SPF record; two is a permanent error. Add your server's IP to the existing record rather than creating a new one.
If your root SPF record already includes SendGrid, keep it during the migration:
example.com. TXT "v=spf1 ip4:203.0.113.10 include:sendgrid.net -all"
If you set up SendGrid's domain authentication with its own return-path subdomain (a CNAME such as em1234.example.com), SendGrid's SPF check happens on that subdomain, and your root record may not include SendGrid at all. In that case you only add your IP to the root record. Either way, watch the 10-lookup limit; see SPF too many DNS lookups.
Also publish SPF for the mail host itself, which is what your server's HELO name is checked against:
mail.example.com. TXT "v=spf1 a -all"
Add a second DKIM selector
SendGrid signs with its own selectors (typically s1 and s2, published as CNAMEs). Your server uses a different selector, such as mail, so the two never clash:
mail._domainkey.example.com. TXT "v=DKIM1; k=rsa; p=MIIBIjANBgkq..."
Publish the key exactly once, as a single record. A DKIM TXT record pasted twice, or with the host name pasted in as the value, causes "multiple DNS replies" and breaks signing. We hit exactly this. Check with:
dig +short TXT mail._domainkey.example.com
sudo opendkim-testkey -d example.com -s mail -vvv
More detail is in how to set up DKIM.
Leave DMARC alone
Your DMARC record doesn't name senders, so it doesn't need to change. Both SendGrid and your server will pass DMARC as long as their DKIM signatures use your domain (d=example.com). If you use strict alignment (adkim=s; aspf=s), double-check that both senders align before moving real traffic. If you're still on p=none, this isn't the moment to tighten it; do that after the migration with DMARC none to reject.
Step 4: Switch your app from the SendGrid API to SMTP
SMTP is the portable choice: every language has a client, and switching providers later means changing environment variables, not code. Create the credentials in your server's SASL backend and set:
SMTP_HOST=mail.example.com
SMTP_PORT=587
SMTP_USER=app-example
SMTP_PASS=change-me
Node.js with nodemailer
// mailer.js
const nodemailer = require("nodemailer");
const sgMail = require("@sendgrid/mail");
const smtp = nodemailer.createTransport({
host: process.env.SMTP_HOST,
port: 587,
secure: false, // port 587 starts plain and upgrades with STARTTLS
requireTLS: true, // refuse to send if STARTTLS isn't available
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS,
},
});
sgMail.setApiKey(process.env.SENDGRID_API_KEY || "");
async function sendEmail({ to, subject, text, html }) {
const message = {
from: "Example App <noreply@example.com>",
to,
subject,
text,
html,
};
// Blank SMTP_HOST = roll back to SendGrid.
if (!process.env.SMTP_HOST) {
return sgMail.send(message);
}
return smtp.sendMail(message);
}
module.exports = { sendEmail };
Python with smtplib
import os
import smtplib
import ssl
from email.message import EmailMessage
def send_email(to: str, subject: str, text: str, html: str | None = None) -> None:
msg = EmailMessage()
msg["From"] = "Example App <noreply@example.com>"
msg["To"] = to
msg["Subject"] = subject
msg.set_content(text)
if html:
msg.add_alternative(html, subtype="html")
context = ssl.create_default_context() # verifies the server certificate
with smtplib.SMTP(os.environ["SMTP_HOST"], 587, timeout=30) as smtp:
smtp.starttls(context=context)
smtp.login(os.environ["SMTP_USER"], os.environ["SMTP_PASS"])
smtp.send_message(msg)
Pass an explicit SSL context to starttls(): without one, Python doesn't verify the server's certificate.
Send a test to a verifier before real users. We use port25's check-auth service; send a message to it and the reply shows SPF, DKIM and reverse DNS results. You want pass on all three. Our email checker is another quick check.
Pre-flight checklist before real traffic
Before the first real user gets mail from the new server, check each of these. They're the issues we see most often:
- Port 25 is open outbound. Try
nc -vz gmail-smtp-in.l.google.com 25from the server. If it times out, your provider blocks it. - Forward-confirmed reverse DNS.
dig -x 203.0.113.10 +shortshould return your mail hostname, and that hostname should resolve back to the same IP and match Postfix'smyhostname. - One SPF record.
dig +short TXT example.com | grep spf1should print exactly one line. - DKIM resolves to a single key, and a test message shows
dkim=passwithd=example.com. - TLS works on 587.
openssl s_client -starttls smtp -connect mail.example.com:587should show a valid certificate chain. - Blocklists, checked properly. Query them from the server's own resolver, not a public one. Spamhaus returns 127.255.255.x to public resolvers, which careless tools report as a listing. UCEPROTECT level 3 lists whole provider networks and isn't a listing for your IP.
- Certificate renewal.
sudo certbot renew --dry-runsucceeds, and the deploy hook reloads Postfix.
Step 5: Export and import your suppression list
Never start a new IP by mailing addresses that bounced or complained. In SendGrid's dashboard, export each suppression group as CSV: bounces, blocks, spam reports, invalid emails and unsubscribes (global and per group). SendGrid's API can do the same if you prefer scripting it.
Postfix has no built-in suppression list, so the right home is your application: a table checked before every send.
CREATE TABLE email_suppressions (
email TEXT PRIMARY KEY,
reason TEXT NOT NULL, -- bounce, spam_report, unsubscribe, invalid
source TEXT NOT NULL DEFAULT 'sendgrid',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
Load the CSVs into that table (lower-case the addresses), and make sendEmail skip any recipient found there. Be careful with unsubscribes: they apply to marketing mail, while bounces and spam reports should block everything.
As a backstop, you can also have Postfix refuse suppressed recipients at submission. Build a map:
# /etc/postfix/suppressed
bounced@example.org REJECT suppressed
complained@example.net REJECT suppressed
Then reference it from your existing submission entry in master.cf (add or extend its recipient restrictions; keep your other -o lines) and run postmap:
submission inet n - y - - smtpd
-o smtpd_recipient_restrictions=check_recipient_access,hash:/etc/postfix/suppressed,permit_sasl_authenticated,reject
sudo postmap /etc/postfix/suppressed
sudo systemctl reload postfix
Going forward, new bounces show up in Postfix's log as status=bounced. Add them to your table regularly:
sudo grep "status=bounced" /var/log/mail.log | grep -oP "to=<\K[^>]+" | sort -u
If your system logs to the journal only (no rsyslog), pipe sudo journalctl -t postfix/smtp into the same filters instead.
Step 6: Warm up by shifting traffic gradually
Your new IP has no reputation, and authentication alone won't give it one. On our first day, two near-identical test emails to Gmail from two domains on the same server landed differently: one in Inbox, one in Spam, with every check passing. That's normal for a cold start.
So move traffic in stages. A simple percentage switch works well:
// Route a share of mail to the new server; the rest stays on SendGrid.
const share = Number(process.env.SMTP_SHARE || "0"); // 0.0 to 1.0
const useSmtp = Boolean(process.env.SMTP_HOST) && Math.random() < share;
A typical schedule (adjust to your volume and results):
| Period | Share via your server | What to send |
|---|---|---|
| Days 1–3 | Tens of emails a day | Internal addresses, engaged users, password resets |
| Week 1 | ~10% | Transactional mail to active users |
| Weeks 2–3 | 25–50% | All transactional mail |
| Weeks 4–6 | 75–100% | Everything, if metrics stay healthy |
You can also slow delivery in Postfix itself for a newer stream, using a dedicated transport. This is how we rate-limit our own newer domain:
# master.cf: a second smtp client transport named "slow"
slow unix - - y - - smtp
# main.cf
sender_dependent_default_transport_maps = hash:/etc/postfix/sender_transport
slow_destination_concurrency_limit = 1
slow_destination_rate_delay = 30s
# /etc/postfix/sender_transport
@example.com slow:
Run sudo postmap /etc/postfix/sender_transport and reload Postfix. Remove the mapping once the domain is warm.
Watch bounces, DMARC aggregate reports, and Gmail's Postmaster Tools. If spam placement rises, hold the share where it is for a few days before increasing. Our IP warm-up guide goes deeper.
Step 7: Keep SendGrid as a fallback
Keep your SendGrid plan, API key, SPF include and DKIM records in place for the whole warm-up. With the code above, rolling back is one change: clear SMTP_HOST and restart. We used exactly this pattern for our own migration, and it's what makes the process low-risk.
Write the rollback down in your runbook so whoever is on call knows it exists.
Step 8: Remove SendGrid's records
Once your server has carried 100% of traffic for a few weeks with healthy results, and you no longer want the fallback:
- Remove
include:sendgrid.netfrom SPF, if it's there. - Delete the
s1._domainkeyands2._domainkeyCNAMEs. - Delete SendGrid's return-path CNAME (such as
em1234). - Check for link-branding CNAMEs. If old emails contain tracked links routed through them, those links stop working when you delete the records, so wait until that no longer matters.
- Revoke the SendGrid API key, remove the SDK from your code, and cancel the plan.
Then re-run your checks with dig, send a test to the verifier again, and confirm DMARC reports show only your server.
Want help?
If you'd rather have this done for you, we handle email migrations like this one, and the server build is our Server Setup plan on the pricing page. Moving from Resend instead? See how to migrate from Resend.