From Zero to Production: Building Postfix + AWS SES in 2 Hours
You're about to build a production email system that costs $30/month instead of $90+, tracks every email from send to delivery, and works in private subnets. No fluff, just the exact commands you need. Series Navigation Part 1: Architecture & Design Part 2: Implementation Guide ← You are here Part 3: Operations & Troubleshooting

I’m Cyril Sebastian, a DevOps and Cloud Infrastructure architect with 10+ years of experience building, scaling, and securing cloud-native and hybrid systems. I specialize in automation, cost optimization, observability, and platform engineering across AWS, GCP, and Oracle Cloud. My passion lies in solving complex infrastructure challenges—from cloud migrations to Infrastructure as Code (IaC), and from deployment automation to scalable monitoring strategies. I blog here about:
Cloud strategy and migration playbooks Real-world DevOps and automation with Terraform, Jenkins, and Ansible DevSecOps practices and security-first thinking in production Monitoring, cost optimization, and incident response at scale
If you're building in the cloud, optimizing infra, or exploring DevOps culture—let’s connect and share ideas! 🔗 linkedin.com/in/sebastiancyril
What You Need Before Starting
AWS account with SES access
EC2 instance (t3a.medium, Amazon Linux 2023)
Your domain's DNS access
2 hours of focused time
That's it. Everything else, we'll build together.
Phase 1: AWS SES Setup (15 mins)
Step 1: Verify Your Domain
AWS Console → SES → Verified Identities → Create Identity
Identity type: Domain
Domain: yourdomain.com
Add the TXT record SES provides to your DNS:
Type: TXT
Name: _amazonses.yourdomain.com
Value: [provided by SES]
Verify it worked:
aws ses get-identity-verification-attributes \
--identities yourdomain.com \
--region ap-south-1
Look for "VerificationStatus": "Success"
Step 2: Configure DKIM (5 mins)
In SES Console:
Click your domain → DKIM tab → Edit
Enable Easy DKIM → Save
Add the 3 CNAME records SES provides to your DNS.
Verify:
aws ses get-identity-dkim-attributes \
--identities yourdomain.com \
--region ap-south-1
Should show "DkimEnabled": true
Step 3: SPF + DMARC (3 mins)
Add SPF record:
Type: TXT
Name: yourdomain.com
Value: "v=spf1 include:amazonses.com ~all"
Add DMARC record:
Type: TXT
Name: _dmarc.yourdomain.com
Value: "v=DMARC1; p=quarantine; rua=mailto:dmarc@yourdomain.com"
Step 4: Get SMTP Credentials (2 mins)
SES Console → SMTP Settings → Create SMTP Credentials
Save these immediately (you won't see them again):
Username: AKAWSSAMPLEEXAMPLE
Password: wJalrXUtnuTde/EXAMPLE
Phase 2: Postfix Setup (20 mins)
SSH into your server and let's configure Postfix.
Install Postfix
sudo yum update -y
sudo yum install -y postfix cyrus-sasl-plain mailx
sudo systemctl enable postfix
Configure SES Credentials
sudo vim /etc/postfix/sasl_passwd
Add this line (use YOUR credentials):
[email-smtp.ap-south-1.amazonaws.com]:587 YOUR_USERNAME:YOUR_PASSWORD
Secure it:
sudo chmod 600 /etc/postfix/sasl_passwd
sudo postmap /etc/postfix/sasl_passwd
Configure Postfix Main Settings
sudo vim /etc/postfix/main.cf
Replace entire contents with:
# Basic Settings
myhostname = mail.yourdomain.com
mydomain = yourdomain.com
myorigin = $mydomain
inet_interfaces = all
mynetworks = 127.0.0.0/8, 10.0.0.0/16
mydestination =
# AWS SES Relay
relayhost = [email-smtp.ap-south-1.amazonaws.com]:587
smtp_sasl_auth_enable = yes
smtp_sasl_security_options = noanonymous
smtp_sasl_password_maps = hash:/etc/postfix/sasl_passwd
# TLS Security
smtp_use_tls = yes
smtp_tls_security_level = encrypt
smtp_tls_CAfile = /etc/ssl/certs/ca-bundle.crt
# Sender Validation
smtpd_sender_restrictions =
check_sender_access hash:/etc/postfix/allowed_senders,
reject
smtpd_recipient_restrictions =
permit_mynetworks,
reject_unauth_destination
# Logging
maillog_file = /var/log/postfix/postfix.log
Create Sender Whitelist
sudo vim /etc/postfix/allowed_senders
Add approved senders:
info@yourdomain.com OK
noreply@yourdomain.com OK
@yourdomain.com REJECT Not authorized
Compile and setup:
sudo postmap /etc/postfix/allowed_senders
sudo mkdir -p /var/log/postfix
sudo chown postfix:postfix /var/log/postfix
Start Postfix
sudo postfix check # Should output nothing
sudo systemctl start postfix
sudo systemctl status postfix
Test it:
echo "Test" | mail -s "Test Email" -r info@yourdomain.com your-email@example.com
sudo tail -f /var/log/postfix/postfix.log
Look for status=sent (250 Ok...) ✅
Phase 3: Event Pipeline (25 mins)
This is where we set up bounce/delivery tracking.
Create IAM Role
# Trust policy
cat trust-policy.json
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": {"Service": "ec2.amazonaws.com"},
"Action": "sts:AssumeRole"
}]
}
# Create role
aws iam create-role \
--role-name PostfixSESLogger \
--assume-role-policy-document file://trust-policy.json
# Permissions policy
cat policy.json
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Action": [
"sqs:ReceiveMessage",
"sqs:DeleteMessage",
"sqs:GetQueueUrl"
],
"Resource": "arn:aws:sqs:ap-south-1:*:ses-events-queue"
}]
}
# Attach policy
aws iam put-role-policy \
--role-name PostfixSESLogger \
--policy-name SESLogging \
--policy-document file:///policy.json
# Create instance profile
aws iam create-instance-profile --instance-profile-name PostfixSESLogger
aws iam add-role-to-instance-profile \
--instance-profile-name PostfixSESLogger \
--role-name PostfixSESLogger
# Attach to instance
INSTANCE_ID=$(curl -s http://169.254.169.254/latest/meta-data/instance-id)
aws ec2 associate-iam-instance-profile \
--instance-id $INSTANCE_ID \
--iam-instance-profile Name=PostfixSESLogger
Wait 10 seconds, then verify:
aws sts get-caller-identity # Should show the role
Create SQS Queue
QUEUE_URL=$(aws sqs create-queue \
--queue-name ses-events-queue \
--region ap-south-1 \
--query 'QueueUrl' \
--output text)
QUEUE_ARN=$(aws sqs get-queue-attributes \
--queue-url "$QUEUE_URL" \
--attribute-names QueueArn \
--region ap-south-1 \
--query 'Attributes.QueueArn' \
--output text)
echo "Queue URL: $QUEUE_URL"
echo "Queue ARN: $QUEUE_ARN"
Create SNS Topic and Subscribe SQS
SNS_ARN=$(aws sns create-topic \
--name ses-events-topic \
--region ap-south-1 \
--query 'TopicArn' \
--output text)
# Allow SNS to send to SQS
cat sqs-policy.json
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": {"Service": "sns.amazonaws.com"},
"Action": "sqs:SendMessage",
"Resource": "$QUEUE_ARN",
"Condition": {"ArnEquals": {"aws:SourceArn": "$SNS_ARN"}}
}]
}
aws sqs set-queue-attributes \
--queue-url "$QUEUE_URL" \
--attributes Policy="$(cat /tmp/sqs-policy.json)" \
--region ap-south-1
# Subscribe SQS to SNS
aws sns subscribe \
--topic-arn "$SNS_ARN" \
--protocol sqs \
--notification-endpoint "$QUEUE_ARN" \
--region ap-south-1
Configure SES to Publish Events
for EVENT in Delivery Bounce Complaint; do
aws ses set-identity-notification-topic \
--identity yourdomain.com \
--notification-type $EVENT \
--sns-topic "$SNS_ARN" \
--region ap-south-1
done
# Disable email forwarding
aws ses set-identity-feedback-forwarding-enabled \
--identity yourdomain.com \
--no-forwarding-enabled \
--region ap-south-1
Phase 4: Logger Deployment (30 mins)
Install Dependencies
sudo yum install -y python3-boto3
python3 -c "import boto3; print('✓ boto3 installed')"
Create Logger Script
sudo nano /usr/local/bin/ses_logger.py
Paste this complete script:
#!/usr/bin/env python3
import boto3, json, syslog, os, sys
from datetime import datetime
REGION = 'ap-south-1'
syslog.openlog('postfix/ses-events', logoption=syslog.LOG_PID, facility=syslog.LOG_MAIL)
def log_event(msg_id, event_type, recipient, details):
log = f"{msg_id}: to=<{recipient}>, relay=amazonses.com, {details}"
level = syslog.LOG_WARNING if event_type == "Bounce" else syslog.LOG_INFO
syslog.syslog(level, log)
print(f"[{datetime.now():%Y-%m-%d %H:%M:%S}] {event_type}: {log}")
def process_event(message):
try:
event = json.loads(message)
event_type = event.get('notificationType')
mail = event.get('mail', {})
msg_id = mail.get('messageId', 'UNKNOWN')
if event_type == 'Delivery':
delivery = event.get('delivery', {})
for recipient in delivery.get('recipients', []):
delay = delivery.get('processingTimeMillis', 0)
details = f"dsn=2.0.0, status=delivered, delay={delay}ms"
log_event(msg_id, event_type, recipient, details)
elif event_type == 'Bounce':
bounce = event.get('bounce', {})
for r in bounce.get('bouncedRecipients', []):
details = f"dsn=5.0.0, status=bounced, type={bounce.get('bounceType')}"
log_event(msg_id, event_type, r.get('emailAddress'), details)
return True
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
return False
def main():
queue_url = os.environ.get('SQS_QUEUE_URL')
if not queue_url:
sys.exit("ERROR: SQS_QUEUE_URL not set")
print(f"SES Logger Started\nQueue: {queue_url}\n")
sqs = boto3.client('sqs', region_name=REGION)
while True:
try:
response = sqs.receive_message(
QueueUrl=queue_url,
MaxNumberOfMessages=10,
WaitTimeSeconds=20
)
for message in response.get('Messages', []):
body = json.loads(message['Body'])
if process_event(body.get('Message')):
sqs.delete_message(
QueueUrl=queue_url,
ReceiptHandle=message['ReceiptHandle']
)
except KeyboardInterrupt:
break
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
if __name__ == '__main__':
main()
Make executable:
sudo chmod +x /usr/local/bin/ses_logger.py
Create Systemd Service
QUEUE_URL=$(aws sqs get-queue-url --queue-name ses-events-queue --region ap-south-1 --query 'QueueUrl' --output text)
sudo vim /etc/systemd/system/ses-logger.service
[Unit]
Description=SES Event Logger
After=network.target
[Service]
Type=simple
User=root
Environment="SQS_QUEUE_URL=$QUEUE_URL"
Environment="AWS_DEFAULT_REGION=ap-south-1"
ExecStart=/usr/bin/python3 /usr/local/bin/ses_logger.py
Restart=always
RestartSec=10
StandardOutput=append:/var/log/postfix/ses-logger.log
StandardError=append:/var/log/postfix/ses-logger-error.log
[Install]
WantedBy=multi-user.target
Start Logger
sudo systemctl daemon-reload
sudo systemctl enable ses-logger
sudo systemctl start ses-logger
sudo systemctl status ses-logger
Should show Active: active (running) ✅
View logs:
sudo tail -f /var/log/postfix/ses-logger.log
Phase 5: Testing (20 mins)
Test 1: Complete Flow
Send test email:
echo "Test from infrastructure" | \
mail -s "Test Email" -r info@yourdomain.com your-email@example.com
Watch both logs:
# Terminal 1 - Sent status (immediate)
sudo tail -f /var/log/postfix/postfix.log | grep "status=sent"
# Terminal 2 - Delivered status (10-30 sec delay)
sudo tail -f /var/log/postfix/mail.log | grep "status=delivered"
Expected:
# Postfix log (immediate):
status=sent (250 Ok 0109019c...)
# Mail log (after 10-30 seconds):
status=delivered, delay=3558ms
✅ Both statuses visible? Success!
Test 2: Bounce Detection
# Use SES bounce simulator
echo "Bounce test" | \
mail -s "Bounce Test" -r info@yourdomain.com bounce@simulator.amazonses.com
# Watch for bounce (1-2 mins)
sudo tail -f /var/log/postfix/mail.log | grep "bounced"
Expected: status=bounced, type=Permanent
Test 3: Sender Validation
# Try unauthorized sender
echo "Should fail" | \
mail -s "Test" -r unauthorized@yourdomain.com test@example.com
# Check rejection
sudo tail /var/log/postfix/postfix.log | grep reject
Expected: Sender address rejected: Access denied
What You Built
In 2 hours, you created:
✅ Postfix SMTP relay with sender validation
✅ AWS SES integration with DKIM/SPF/DMARC
✅ Real-time tracking for delivery and bounces
✅ Unified logging - both "sent" and "delivered" in one place
✅ Cost-effective - ~\(30/month vs \)90+ for SaaS
Quick Reference
Restart services:
sudo systemctl restart postfix
sudo systemctl restart ses-logger
View logs:
sudo tail -f /var/log/postfix/postfix.log # Sent
sudo tail -f /var/log/postfix/mail.log # Delivered
Check queue:
mailq
Search for email:
grep "user@example.com" /var/log/postfix/*.log
Common Issues
Email stuck in queue?
# Check why
sudo tail -50 /var/log/postfix/postfix.log | grep deferred
# Flush after fixing
sudo postqueue -f
Logger not running?
# Check errors
sudo journalctl -u ses-logger -n 50
# Restart
sudo systemctl restart ses-logger
No delivery events?
# Check SQS has messages
aws sqs get-queue-attributes \
--queue-url "YOUR_QUEUE_URL" \
--attribute-names ApproximateNumberOfMessages
What's Next?
Read Part 3: Operations & Troubleshooting
🔗 If this helped or resonated with you, connect with me on LinkedIn. Let’s learn and grow together.
👉 Stay tuned for more behind-the-scenes write-ups and system design breakdowns.



