DEV Community

Cover image for From Zero to Production: Building Postfix + AWS SES in 2 Hours
Cyril Sebastian
Cyril Sebastian

Posted on • Originally published at tech.cyrilsebastian.com

From Zero to Production: Building Postfix + AWS SES in 2 Hours

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
Enter fullscreen mode Exit fullscreen mode

Add the TXT record SES provides to your DNS:

Type: TXT
Name: _amazonses.yourdomain.com
Value: [provided by SES]
Enter fullscreen mode Exit fullscreen mode

Verify it worked:

aws ses get-identity-verification-attributes \
  --identities yourdomain.com \
  --region ap-south-1
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

Add DMARC record:

Type: TXT  
Name: _dmarc.yourdomain.com
Value: "v=DMARC1; p=quarantine; rua=mailto:dmarc@yourdomain.com"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Configure SES Credentials

sudo vim /etc/postfix/sasl_passwd
Enter fullscreen mode Exit fullscreen mode

Add this line (use YOUR credentials):

[email-smtp.ap-south-1.amazonaws.com]:587 YOUR_USERNAME:YOUR_PASSWORD
Enter fullscreen mode Exit fullscreen mode

Secure it:

sudo chmod 600 /etc/postfix/sasl_passwd
sudo postmap /etc/postfix/sasl_passwd
Enter fullscreen mode Exit fullscreen mode

Configure Postfix Main Settings

sudo vim /etc/postfix/main.cf
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Create Sender Whitelist

sudo vim /etc/postfix/allowed_senders
Enter fullscreen mode Exit fullscreen mode

Add approved senders:

info@yourdomain.com         OK
noreply@yourdomain.com      OK
@yourdomain.com             REJECT Not authorized
Enter fullscreen mode Exit fullscreen mode

Compile and setup:

sudo postmap /etc/postfix/allowed_senders
sudo mkdir -p /var/log/postfix
sudo chown postfix:postfix /var/log/postfix
Enter fullscreen mode Exit fullscreen mode

Start Postfix

sudo postfix check  # Should output nothing
sudo systemctl start postfix
sudo systemctl status postfix
Enter fullscreen mode Exit fullscreen mode

Test it:

echo "Test" | mail -s "Test Email" -r info@yourdomain.com your-email@example.com
sudo tail -f /var/log/postfix/postfix.log
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Wait 10 seconds, then verify:

aws sts get-caller-identity  # Should show the role
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Phase 4: Logger Deployment (30 mins)

Install Dependencies

sudo yum install -y python3-boto3
python3 -c "import boto3; print('✓ boto3 installed')"
Enter fullscreen mode Exit fullscreen mode

Create Logger Script

sudo nano /usr/local/bin/ses_logger.py
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

Make executable:

sudo chmod +x /usr/local/bin/ses_logger.py
Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

Start Logger

sudo systemctl daemon-reload
sudo systemctl enable ses-logger
sudo systemctl start ses-logger
sudo systemctl status ses-logger
Enter fullscreen mode Exit fullscreen mode

Should show Active: active (running)

View logs:

sudo tail -f /var/log/postfix/ses-logger.log
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

Expected:

# Postfix log (immediate):
status=sent (250 Ok 0109019c...)

# Mail log (after 10-30 seconds):
status=delivered, delay=3558ms
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

View logs:

sudo tail -f /var/log/postfix/postfix.log  # Sent
sudo tail -f /var/log/postfix/mail.log     # Delivered
Enter fullscreen mode Exit fullscreen mode

Check queue:

mailq
Enter fullscreen mode Exit fullscreen mode

Search for email:

grep "user@example.com" /var/log/postfix/*.log
Enter fullscreen mode Exit fullscreen mode

Common Issues

Email stuck in queue?

# Check why
sudo tail -50 /var/log/postfix/postfix.log | grep deferred
# Flush after fixing
sudo postqueue -f
Enter fullscreen mode Exit fullscreen mode

Logger not running?

# Check errors
sudo journalctl -u ses-logger -n 50
# Restart
sudo systemctl restart ses-logger
Enter fullscreen mode Exit fullscreen mode

No delivery events?

# Check SQS has messages
aws sqs get-queue-attributes \
  --queue-url "YOUR_QUEUE_URL" \
  --attribute-names ApproximateNumberOfMessages
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)