Back to Blog

How to Send Email From Your Server in 2026: AWS SES + Postfix Relay

April 3, 202614 min read

Why Send Email From Your Own Server

Most modern web apps end up needing to send email at some point — password resets, contact form submissions, transactional notifications, alerts. The default solution is to plug into Mailgun, SendGrid, Postmark, or one of the other transactional email services and pay them per message.

That works. It is also expensive at scale, locks you into a vendor, and means a third party sees every email your app sends.

The alternative is to send through your own server using AWS SES (Simple Email Service) as the underlying SMTP relay. SES is dirt cheap (10 cents per 1,000 emails — about a tenth of what the big-name services charge), gives you full control, and is essentially the same infrastructure those services use under the hood. The downside is that the setup is more involved and the documentation is scattered across a dozen AWS pages.

This article walks through the entire process end-to-end. It is based on real production setups across multiple servers and multiple sender domains, and it covers the gotchas that take everyone hours to figure out — including the credential trap that wasted a full day for us recently.

What We Are Building

The architecture is simple: your app talks to a local Postfix server on localhost:25. Postfix relays the message to AWS SES at email-smtp.us-east-2.amazonaws.com:587 using STARTTLS and SMTP auth. SES delivers it to the recipient.

Your App  →  localhost:25 (Postfix)  →  AWS SES SMTP  →  Recipient

Why this architecture instead of having your app talk to SES directly?

  • Decoupling. Your app code does not need AWS SDK or SES credentials. It just sends to localhost:25 like any other Unix mail client.
  • Multiple senders. One Postfix instance can relay mail from many apps and many sender addresses, all through one SES configuration.
  • Standard tools. The local mail command, sendmail, nodemailer, PHPMailer, and every other mail library work out of the box.
  • Queue and retry. Postfix handles transient failures automatically. If SES is briefly unreachable, mail queues locally and retries.

Step 1: Set Up AWS SES

If you do not already have an AWS account, create one at aws.amazon.com. Once you are signed in, navigate to the SES console.

Pick a Region — Carefully

SES is region-specific, and the SMTP endpoint changes per region. Pick a region close to your server for lower latency. Common choices:

  • us-east-1 (N. Virginia) — endpoint: email-smtp.us-east-1.amazonaws.com
  • us-east-2 (Ohio) — endpoint: email-smtp.us-east-2.amazonaws.com
  • us-west-2 (Oregon) — endpoint: email-smtp.us-west-2.amazonaws.com
  • eu-west-1 (Ireland) — endpoint: email-smtp.eu-west-1.amazonaws.com

Important: once you verify a domain in a region, you cannot move it. If you start in us-east-1 and later want to move to us-east-2, you have to re-verify everything. Pick once, pick right.

Verify Your Sender Domain

SES requires you to prove ownership of any domain you send from. This prevents people from sending mail as [email protected]. The verification is done by adding DNS records.

  1. In the SES console, go to Verified identitiesCreate identity
  2. Choose Domain (not email address — domain verification is more flexible)
  3. Enter your domain (e.g., example.com)
  4. Enable DKIM signing and choose Easy DKIM with RSA 2048-bit
  5. Click Create

SES will give you several CNAME records to add to your DNS. They look like this:

NAME: abc123xyz._domainkey.example.com
TYPE: CNAME
VALUE: abc123xyz.dkim.amazonses.com

Add all three CNAME records to your DNS provider (Cloudflare, Route 53, GoDaddy, etc.). Wait 5-15 minutes for DNS propagation, then SES will mark the domain as verified.

Add SPF and DMARC

DKIM authenticates the message. SPF tells receivers which servers are allowed to send mail from your domain. DMARC tells them what to do if both fail. You want all three.

SPF — add a TXT record at the root of your domain:

NAME: @ (or example.com)
TYPE: TXT
VALUE: v=spf1 include:amazonses.com -all

If you also send mail through Google Workspace or another provider, combine them:

v=spf1 include:_spf.google.com include:amazonses.com -all

DMARC — add another TXT record at _dmarc.example.com:

NAME: _dmarc.example.com
TYPE: TXT
VALUE: v=DMARC1; p=quarantine; rua=mailto:[email protected]

Start with p=quarantine (suspicious mail goes to spam) and graduate to p=reject (suspicious mail bounces) once you are confident your setup is correct.

Get Out of the Sandbox

By default, every new SES account is in a "sandbox." You can only send to verified email addresses (yours), and the sending limit is 200 emails per 24 hours. This is fine for testing but useless for production.

To get production access:

  1. SES console → Account dashboardRequest production access
  2. Fill out the form: explain what you are sending (transactional? marketing?), expected volume, how you handle bounces and complaints, and your unsubscribe process
  3. Be specific. "Password reset and order confirmation emails for our SaaS, 5,000 messages per month, with bounce/complaint webhooks and a one-click unsubscribe link" gets approved fast. Vague descriptions get rejected.
  4. Approval usually comes within 24 hours

Step 2: The Credential Trap (Read This Carefully)

Here is where almost everyone — including us, recently — wastes hours. AWS SES has two completely different sets of credentials, and they look almost identical, and the wrong one will silently fail with confusing error messages.

IAM Access Keys vs SMTP Credentials

IAM access keys are what you use for the AWS API and SDK. They look like this:

Access Key ID:     AKIAIOSFODNN7EXAMPLE
Secret Access Key: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY

SMTP credentials are what you use for the SES SMTP endpoint. They look like this:

SMTP Username: AKIAIOSFODNN7EXAMPLE
SMTP Password: BCJMcQLHEqcqq+fIdVTXMC9LFYXVpe//GjbI7NEntZEu

Notice anything? The username portion of an SMTP credential looks identical to an IAM access key. They both start with "AKIA" and have the same length and format. But they are not interchangeable. If you use IAM credentials with the SMTP endpoint, you will get a SignatureDoesNotMatch error and spend hours wondering why.

How to Generate SMTP Credentials Correctly

  1. SES console → SMTP settings
  2. Click Create SMTP credentials
  3. AWS will generate an IAM user behind the scenes with the right policy attached
  4. Download the credentials immediately — they are shown once and never again

The downloaded file or screen will say "SMTP Username" and "SMTP Password" explicitly. Do not try to use IAM access keys you generated elsewhere. Do not reuse keys from other AWS services. Generate fresh SMTP credentials specifically through the SES SMTP settings page.

If you see SignatureDoesNotMatch in your logs, this is almost always the cause. The fix is to delete the credentials and generate fresh ones via SES → SMTP settings.

Step 3: Install and Configure Postfix

Now we install Postfix on the server and configure it as an SES relay.

Install

sudo apt update
sudo apt install postfix mailutils libsasl2-modules -y

During installation, the Debian configurator will ask what kind of mail server. Choose Internet Site. For "system mail name," enter your domain (this only affects the default From header for system mail).

Configure as a Relay

Edit the main config file:

sudo nano /etc/postfix/main.cf

Find or add these lines (replace us-east-2 with your SES region):

# Listen only on localhost — never expose Postfix to the internet
inet_interfaces = loopback-only

# Relay everything through SES
relayhost = [email-smtp.us-east-2.amazonaws.com]:587

# SASL authentication
smtp_sasl_auth_enable = yes
smtp_sasl_password_maps = hash:/etc/postfix/sasl_passwd
smtp_sasl_security_options = noanonymous

# TLS
smtp_tls_security_level = encrypt
smtp_use_tls = yes
smtp_tls_note_starttls_offer = yes

# Use modern TLS versions only
smtp_tls_protocols = !SSLv2, !SSLv3, !TLSv1, !TLSv1.1
smtp_tls_mandatory_protocols = !SSLv2, !SSLv3, !TLSv1, !TLSv1.1

# Where to find CA certs (Ubuntu default location)
smtp_tls_CAfile = /etc/ssl/certs/ca-certificates.crt

Add Your SMTP Credentials

Create the password map file:

sudo nano /etc/postfix/sasl_passwd

Add one line — the format is [server]:port username:password:

[email-smtp.us-east-2.amazonaws.com]:587 AKIAEXAMPLE:BCJMcQLHEqcqq+fIdVTXMC9LFYXVpe//GjbI7NEntZEu

Convert the file to Postfix's hash format and lock down permissions:

sudo postmap /etc/postfix/sasl_passwd
sudo chmod 600 /etc/postfix/sasl_passwd /etc/postfix/sasl_passwd.db
sudo chown root:root /etc/postfix/sasl_passwd /etc/postfix/sasl_passwd.db

Critical: the credentials file contains your SMTP password in plaintext. Always set permissions to 600 (owner read/write only) and owned by root. If the file is world-readable, anyone with shell access can send mail as you.

Restart and Test

sudo systemctl restart postfix
sudo systemctl status postfix

Send a test email from the command line:

echo "Test from server" | mail -s "Test email" -r [email protected] [email protected]

Check the mail log to see what happened:

sudo tail -20 /var/log/mail.log

You want to see something like:

postfix/smtp[12345]: ABC123: to=<[email protected]>, relay=email-smtp.us-east-2.amazonaws.com[...], delay=1.2, status=sent (250 Ok ...)

If you see status=sent, you are done. The email is in flight.

Step 4: Sending From Multiple Domains

Most production servers need to send mail from several different sender addresses — [email protected], [email protected], [email protected]. Here is how to handle that.

Verify Each Sender Domain in SES

Each domain needs its own DKIM records. Repeat the verification process from Step 1 for every domain you want to send from. SES does not require any per-domain configuration in Postfix — it accepts mail from any verified sender as long as the SMTP credentials are valid.

Sender Rewriting (Optional)

By default, system-generated mail (from cron jobs, root, etc.) goes out with whatever From address the script uses. If you want to rewrite system mail to always go from a specific verified address, use Postfix's sender canonical maps:

sudo nano /etc/postfix/sender_canonical
root        [email protected]
@localhost  [email protected]

Add to main.cf:

sender_canonical_maps = hash:/etc/postfix/sender_canonical

Apply:

sudo postmap /etc/postfix/sender_canonical
sudo systemctl restart postfix

Sending From Apps With Different From Addresses

From your application, set the From header explicitly. With nodemailer:

const transporter = nodemailer.createTransport({
  host: '127.0.0.1',
  port: 25,
  secure: false,
  ignoreTLS: true,
});

await transporter.sendMail({
  from: 'Brand Name <[email protected]>',
  to: '[email protected]',
  subject: 'Welcome',
  text: 'Hello!',
});

Postfix forwards the message to SES with that From address. SES checks that the domain is verified and accepts it. No special config needed.

Step 5: Common Errors and How to Fix Them

Error: SignatureDoesNotMatch

Symptom: SignatureDoesNotMatch: The request signature we calculated does not match the signature you provided

Cause: You are using IAM access keys instead of SMTP credentials. They look similar, they are not the same.

Fix: Go to SES → SMTP settings → Create SMTP credentials. Use those instead. Update /etc/postfix/sasl_passwd, run postmap on it, restart Postfix.

Error: 554 Message rejected: Email address is not verified

Symptom: SES rejects the email with a verification error.

Cause: Your account is still in the sandbox, OR you are sending from a sender address whose domain is not verified.

Fix: Check both — request production access from SES, and verify the domain you are sending from.

Error: SASL authentication failed

Symptom: Postfix log says SASL authentication failed; cannot authenticate to server.

Cause: Wrong credentials, wrong format in sasl_passwd, or you forgot to run postmap after editing the file.

Fix: Triple-check the file format (one line, brackets around the host, colon before port, space then username colon password). Run sudo postmap /etc/postfix/sasl_passwd after any edit. Restart Postfix.

Error: TLS library problem / certificate verify failed

Symptom: TLS handshake fails when connecting to SES.

Cause: Missing or stale CA certificate bundle.

Fix:

sudo apt install ca-certificates -y
sudo update-ca-certificates

Make sure smtp_tls_CAfile in main.cf points to /etc/ssl/certs/ca-certificates.crt.

Mail Sent But Goes to Spam

Symptom: SES accepts the email, but recipients (especially Gmail) put it in spam.

Causes and fixes:

  • Missing SPF record — add the SPF TXT record from Step 1
  • Missing DMARC record — add the DMARC TXT record
  • Sender domain DKIM not verified — re-check in SES
  • From and Return-Path domains do not match — set smtp_generic_maps to align them
  • Message has spammy content — try a different test message, then check with mail-tester.com
  • Your IP has bad reputation — SES uses shared IPs by default; for high-volume senders, request a dedicated IP

Postfix Mail Queue Building Up

Symptom: mailq shows lots of deferred messages.

Diagnose:

mailq                              # see queued messages
sudo postcat -q [QUEUE_ID]         # inspect a specific message
sudo tail -100 /var/log/mail.log   # see why they failed

Common reasons: SES sandbox, unverified domain, network issue. Fix the underlying problem and Postfix will retry automatically. To force immediate retry: sudo postqueue -f.

Step 6: Monitor Bounces and Complaints

SES tracks bounces (messages that could not be delivered) and complaints (recipients marking your mail as spam). If your bounce or complaint rate gets too high, SES will pause your sending privileges.

The thresholds:

  • Bounce rate above 5 percent → warning
  • Bounce rate above 10 percent → suspension
  • Complaint rate above 0.1 percent → warning
  • Complaint rate above 0.5 percent → suspension

Monitor these in the SES console under Reputation. For automated alerting, set up an SNS topic and subscribe via email to get notified the instant a bounce or complaint comes in:

  1. SES console → Configuration sets → Create configuration set
  2. Add an event destination for Bounces and Complaints
  3. Point it at an SNS topic with your email subscribed

Then in your application, set the configuration set in the email headers:

X-SES-CONFIGURATION-SET: my-config-set

You will get an email every time someone bounces or complains. Hard-bounce addresses should be removed from your list immediately — sending to them again is the fastest way to tank your reputation.

Step 7: Cost and Scale

SES pricing (as of 2026):

  • From an EC2 instance: 62,000 messages per month free, then 0.10 USD per 1,000 messages
  • From outside EC2: 0.10 USD per 1,000 messages from message one
  • Receiving: 0.10 USD per 1,000 received messages
  • Attachments: 0.12 USD per GB outgoing

For comparison:

  • SendGrid: ~1.00 USD per 1,000 (10x more)
  • Mailgun: ~0.80 USD per 1,000 (8x more)
  • Postmark: ~1.25 USD per 1,000 (12x more)

If you send any meaningful volume of email, SES is dramatically cheaper than the alternatives. The trade-off is the setup work (which you have just done) and the lack of fancy dashboards. For 90 percent of use cases, that trade-off is worth it.

Step 8: Optional Polish

Send From Apps Without Postfix

If you only need to send from one app and want to skip Postfix entirely, you can use nodemailer (or any SMTP library) to talk directly to SES:

const transporter = nodemailer.createTransport({
  host: 'email-smtp.us-east-2.amazonaws.com',
  port: 587,
  secure: false,
  auth: {
    user: 'YOUR_SMTP_USERNAME',
    pass: 'YOUR_SMTP_PASSWORD',
  },
});

This is simpler if you have one app. But if you have multiple apps on the same server, the Postfix relay approach is better — credentials in one place, one set of logs, one queue.

Use a CLI Email Client for System Notifications

For alerts and admin notifications from cron jobs, the built-in mail command is fine. For more advanced needs, install himalaya — a modern Rust-based CLI email client with TOML config and per-account profiles.

curl -sSL https://raw.githubusercontent.com/pimalaya/himalaya/master/install.sh | sh

Configure himalaya to talk directly to SES SMTP, with one profile per sender domain. Then you can do:

himalaya template send -a brand1 < message.txt

And it sends from [email protected] automatically.

Set Up a Bounce Webhook

For high-volume senders, write a small webhook receiver that SNS calls when a bounce or complaint comes in. Have it remove the address from your mailing list automatically. This keeps your reputation clean without manual intervention.

The Quick Reference

Setting up AWS SES + Postfix on a new server, condensed:

  1. Create AWS account, navigate to SES, pick a region
  2. Verify your sender domain — add the DKIM CNAME records to DNS
  3. Add SPF and DMARC TXT records
  4. Request production access (out of sandbox)
  5. Generate SMTP credentials via SES → SMTP settings (NOT IAM access keys)
  6. sudo apt install postfix mailutils libsasl2-modules -y
  7. Edit /etc/postfix/main.cf with relayhost, smtp_sasl_auth_enable, smtp_tls_security_level
  8. Add credentials to /etc/postfix/sasl_passwd
  9. sudo postmap /etc/postfix/sasl_passwd
  10. sudo chmod 600 /etc/postfix/sasl_passwd*
  11. sudo systemctl restart postfix
  12. Test with echo test | mail -s test [email protected]
  13. Check /var/log/mail.log for status=sent
  14. Set up bounce/complaint monitoring via SNS

The Bottom Line

The first time you set up SES + Postfix, it takes 1-2 hours. The first time you hit the IAM-vs-SMTP-credentials trap, you waste another 1-2 hours figuring out why it is silently failing. After that, every subsequent server takes 15 minutes.

The payoff: sending email from your own infrastructure, at SES prices, with the same deliverability as SendGrid and the rest, and zero per-month subscription fees.

If you are running multiple sites or apps and any of them send mail, the math is overwhelmingly in favor of doing this once and not paying SaaS email prices forever. Bookmark this guide. Use it next time you spin up a new server.

Resources

Need help building this?

We turn ideas like these into production-ready software. Let's talk about your project.

Get a Free Quote