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:25like 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
mailcommand,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.comus-east-2(Ohio) — endpoint:email-smtp.us-east-2.amazonaws.comus-west-2(Oregon) — endpoint:email-smtp.us-west-2.amazonaws.comeu-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.
- In the SES console, go to Verified identities → Create identity
- Choose Domain (not email address — domain verification is more flexible)
- Enter your domain (e.g.,
example.com) - Enable DKIM signing and choose Easy DKIM with RSA 2048-bit
- 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:
- SES console → Account dashboard → Request production access
- Fill out the form: explain what you are sending (transactional? marketing?), expected volume, how you handle bounces and complaints, and your unsubscribe process
- 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.
- 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
- SES console → SMTP settings
- Click Create SMTP credentials
- AWS will generate an IAM user behind the scenes with the right policy attached
- 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_mapsto 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:
- SES console → Configuration sets → Create configuration set
- Add an event destination for Bounces and Complaints
- 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:
- Create AWS account, navigate to SES, pick a region
- Verify your sender domain — add the DKIM CNAME records to DNS
- Add SPF and DMARC TXT records
- Request production access (out of sandbox)
- Generate SMTP credentials via SES → SMTP settings (NOT IAM access keys)
sudo apt install postfix mailutils libsasl2-modules -y- Edit
/etc/postfix/main.cfwithrelayhost,smtp_sasl_auth_enable,smtp_tls_security_level - Add credentials to
/etc/postfix/sasl_passwd sudo postmap /etc/postfix/sasl_passwdsudo chmod 600 /etc/postfix/sasl_passwd*sudo systemctl restart postfix- Test with
echo test | mail -s test [email protected] - Check
/var/log/mail.logforstatus=sent - 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.