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_maps plus reject_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 25 should 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 +short returns 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 spf1 prints one line.
  • TLS on 587 is valid. openssl s_client -starttls smtp -connect mail.example.com:587 shows a proper certificate chain.
  • Renewal is automatic. sudo certbot renew --dry-run passes, 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:

  1. Delete Resend's DKIM record (such as resend._domainkey).
  2. Delete the MX and SPF records on Resend's sending subdomain (such as send).
  3. Remove any Resend-related include from the root SPF record, if you added one.
  4. If you used click tracking, check whether links in recent emails depend on Resend before removing any tracking-domain records.
  5. Revoke the API key, remove the resend package, 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.