To migrate from Resend to your own mail server, run the two side by side: build a Postfix server, publish its DNS records next to Resend's, point your app at the new server over authenticated SMTP, and shift traffic across gradually while Resend stays ready as a fallback. Your React Email templates come with you; you just render them to HTML and send over SMTP.
We did this ourselves in August 2026, moving the outbound mail for three of our own production domains from Resend to one self-hosted Postfix server. The steps below are the ones we followed. We wrote about the experience in moving off Resend and Mailgun.
Throughout, example.com is your domain and 203.0.113.10 is your server's IP.
Step 1: List everything you send through Resend
Start with an inventory, because the email nobody remembers is the one that breaks.
For each stream, note:
- The From address and domain (
noreply@,receipts@,alerts@). - What sends it: the main app, a background worker, a cron job, a separate service.
- How it connects: the Resend SDK, the HTTP API directly, or Resend's SMTP.
- Daily volume, from Resend's dashboard.
- Resend features you rely on: webhooks, scheduled sends, the dashboard's logs, open/click tracking, marketing email.
Find the integration points in code:
grep -rniE "resend|re_[A-Za-z0-9_]{10,}|RESEND_API_KEY" \
--exclude-dir=node_modules --exclude-dir=.git .
If you send newsletters through Resend's marketing product, treat that separately. A plain Postfix server sends mail; it doesn't manage contacts, campaigns or unsubscribe pages. 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, so check first), running Postfix with OpenDKIM, TLS, SASL authentication on 587, and a matching PTR record. Our Postfix on Ubuntu guide walks through the whole build.
Our own server runs Ubuntu 26.04 LTS, Postfix 3.10, OpenDKIM with a 2048-bit key per domain on selector mail, Dovecot as the SASL backend only, fail2ban and Let's Encrypt. For a migration, the important parts are:
- Authenticated submission on 587 with STARTTLS, for your app.
smtpd_sender_login_mapsplusreject_sender_login_mismatch, so each app login can only send as its own address.- Forward-confirmed reverse DNS: PTR, A record and HELO name all agree. See reverse DNS for email.
Step 3: DNS changes while both are live
Record what you have now:
dig +short TXT example.com
dig +short TXT _dmarc.example.com
dig +short TXT resend._domainkey.example.com
dig +short MX send.example.com
dig +short TXT send.example.com
Resend's setup typically puts its DKIM key on its own selector (often resend._domainkey) and its bounce-handling MX and SPF on a subdomain (often send). Check your DNS for the exact names. Because those live apart from your root records, they coexist neatly with your new server.
SPF: one record, both senders
Your server sends with your root domain in the envelope, so add its IP to the root SPF record. If there's already one (for example, for your office mail), merge into it; never publish two.
example.com. TXT "v=spf1 ip4:203.0.113.10 -all"
mail.example.com. TXT "v=spf1 a -all"
Leave the SPF record on Resend's subdomain alone. If you previously added a Resend-related include to the root record, keep it until the end. Mind the 10-lookup limit; see SPF too many DNS lookups.
DKIM: add your own selector
Publish your server's key under its own selector, next to Resend's:
mail._domainkey.example.com. TXT "v=DKIM1; k=rsa; p=MIIBIjANBgkq..."
Paste it once, as one record, with only the key as the value. A duplicated DKIM TXT record, or one with the host name pasted into the value, causes "multiple DNS replies" and DKIM fails. That one caught us. Test it:
sudo opendkim-testkey -d example.com -s mail -vvv
How to set up DKIM has more detail.
DMARC: don't touch it
DMARC doesn't list senders, so it stays as it is. Both Resend and your server pass when their DKIM signature uses d=example.com. We publish p=quarantine with strict alignment (adkim=s; aspf=s); if you do too, confirm both senders align before moving real traffic. If you're still on p=none, tighten it after the migration with DMARC none to reject.
Step 4: Swap the Resend SDK for SMTP
If you already send through Resend's SMTP, you only need new values for host, username and password. If you use the SDK, the change is usually one module.
Set your environment:
SMTP_HOST=mail.example.com
SMTP_PORT=587
SMTP_USER=app-example
SMTP_PASS=change-me
Node.js: nodemailer with React Email and a Resend fallback
// mailer.tsx
import nodemailer from "nodemailer";
import { Resend } from "resend";
import { render } from "@react-email/render";
import WelcomeEmail from "./emails/WelcomeEmail";
const smtp = nodemailer.createTransport({
host: process.env.SMTP_HOST,
port: 587,
secure: false, // 587 = plain connection upgraded with STARTTLS
requireTLS: true, // fail rather than send without TLS
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS,
},
});
const resend = new Resend(process.env.RESEND_API_KEY);
export async function sendWelcome(to: string, name: string) {
const html = await render(<WelcomeEmail name={name} />);
const message = {
from: "Example App <noreply@example.com>",
to,
subject: "Welcome to Example App",
html,
text: `Hi ${name}, welcome to Example App.`,
};
// Blank SMTP_HOST = roll back to Resend.
if (!process.env.SMTP_HOST) {
const { error } = await resend.emails.send(message);
if (error) throw new Error(error.message);
return;
}
await smtp.sendMail(message);
}
Your React Email components don't change. The only difference is that you render them to HTML yourself with await render(<Email />) instead of handing the component to Resend.
Python: 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()
with smtplib.SMTP(os.environ["SMTP_HOST"], 587, timeout=30) as smtp:
smtp.starttls(context=context) # verify the server's certificate
smtp.login(os.environ["SMTP_USER"], os.environ["SMTP_PASS"])
smtp.send_message(msg)
Python's starttls() only verifies certificates if you give it a context, so always pass one.
Before any real user gets mail from the new server, send a test to port25's check-auth verifier. When we did, the reply showed SPF pass, DKIM pass and iprev pass, which is what you want. Our email checker is a quick second opinion.
Check the server before it carries real mail
Run through these before switching any production traffic. Each one is a problem we've either hit ourselves or see regularly:
- Outbound port 25 works. From the server,
nc -vz gmail-smtp-in.l.google.com 25should connect. A timeout usually means your provider blocks it; OVH didn't in our case, AWS does by default. - Reverse DNS matches.
dig -x 203.0.113.10 +shortreturns your mail hostname, that hostname resolves to the same IP, and Postfix announces it as its HELO name. - Exactly one SPF record at the root:
dig +short TXT example.com | grep spf1prints one line. - TLS on 587 is valid.
openssl s_client -starttls smtp -connect mail.example.com:587shows a proper certificate chain. - Renewal is automatic.
sudo certbot renew --dry-runpasses, and a deploy hook reloads Postfix so it picks up new certificates. - Blocklist results are real. Spamhaus returns 127.255.255.x to queries from public resolvers, which some checkers misread as "listed". UCEPROTECT level 3 lists entire provider networks (all of OVH, for example), which says nothing about your IP.
Step 5: Carry over bounces and complaints
Your new IP should never mail an address that hard-bounced or complained. Gather that history from Resend before you switch:
- If your app has been receiving Resend's webhooks for bounce and complaint events, your own stored events are the most complete list.
- Otherwise, check Resend's dashboard for bounced and complained messages and any suppression data you can export.
Store the result in your app, and check it before every send:
CREATE TABLE email_suppressions (
email TEXT PRIMARY KEY,
reason TEXT NOT NULL, -- bounce, complaint, unsubscribe
source TEXT NOT NULL DEFAULT 'resend',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
Postfix doesn't send webhooks, so after the move you'll pick up new bounces from its log:
sudo grep "status=bounced" /var/log/mail.log | grep -oP "to=<\K[^>]+" | sort -u
Run that on a schedule and insert new addresses into the table. (On systems without rsyslog, read from sudo journalctl -t postfix/smtp instead.)
Step 6: Warm up gradually
Passing every authentication check doesn't give a new IP a reputation. On our first day, two near-identical test emails to Gmail, from two of our domains on the same new server, landed in different places: one in Inbox, one in Spam as "similar to messages identified as spam in the past". Nothing was misconfigured. New and dormant domains simply have no history yet.
So shift traffic in steps. Add a share variable alongside the fallback:
// 0.0 = everything via Resend, 1.0 = everything via your server
const share = Number(process.env.SMTP_SHARE ?? "0");
const useSmtp = Boolean(process.env.SMTP_HOST) && Math.random() < share;
| Period | Via your server | Send first |
|---|---|---|
| Days 1–3 | Tens per day | Your own addresses, engaged users |
| Week 1 | ~10% | Password resets, receipts to active users |
| Weeks 2–3 | 25–50% | All transactional mail |
| Weeks 4–6 | 75–100% | Everything, if bounces and spam placement stay low |
We kept volume low for about two weeks and also rate-limited the newer domain inside Postfix, with a dedicated transport using _destination_concurrency_limit = 1 and _destination_rate_delay = 30s. The IP warm-up guide explains how to set that up and what to watch: bounces, DMARC aggregate reports and Gmail's Postmaster Tools.
Step 7: Keep Resend as your fallback
Leave your Resend account, API key and DNS records in place throughout warm-up. With the code above, rolling back means clearing SMTP_HOST and restarting. That's exactly what we did: a blank SMTP host variable sent mail back through the old provider. It meant we never had to rush.
Note the rollback in your runbook, along with who can do it.
Step 8: Remove Resend's records
When your server has handled all traffic for a few weeks without problems and you're done with the fallback:
- Delete Resend's DKIM record (such as
resend._domainkey). - Delete the MX and SPF records on Resend's sending subdomain (such as
send). - Remove any Resend-related include from the root SPF record, if you added one.
- If you used click tracking, check whether links in recent emails depend on Resend before removing any tracking-domain records.
- Revoke the API key, remove the
resendpackage, and cancel your plan.
Then check DNS again with dig, re-run the verifier, and watch your DMARC reports to confirm only your server is sending.
Want it done for you?
For our own domains, the result was simple: the per-email bills went away, and the VPS was one we already ran. Whether that's worth it for you depends on volume; self-hosted email vs Resend lays out the real costs.
If you'd like us to build the server and run the migration, see Server Setup and Managed Server on our pricing page, or read more about email migration.