Skip to main content

Command Palette

Search for a command to run...

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

Published
8 min read
From Zero to Production: Building Postfix + AWS SES in 2 Hours
C

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:

  1. Click your domain → DKIM tab → Edit

  2. 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.