# 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**

```plaintext
Identity type: Domain
Domain: yourdomain.com
```

Add the TXT record SES provides to your DNS:

```plaintext
Type: TXT
Name: _amazonses.yourdomain.com
Value: [provided by SES]
```

**Verify it worked:**

```bash
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:**

```bash
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:**

```plaintext
Type: TXT
Name: yourdomain.com
Value: "v=spf1 include:amazonses.com ~all"
```

**Add DMARC record:**

```plaintext
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):

```plaintext
Username: AKAWSSAMPLEEXAMPLE
Password: wJalrXUtnuTde/EXAMPLE
```

* * *

## Phase 2: Postfix Setup (20 mins)

SSH into your server and let's configure Postfix.

### Install Postfix

```bash
sudo yum update -y
sudo yum install -y postfix cyrus-sasl-plain mailx
sudo systemctl enable postfix
```

* * *

### Configure SES Credentials

```bash
sudo vim /etc/postfix/sasl_passwd
```

Add this line (use YOUR credentials):

```plaintext
[email-smtp.ap-south-1.amazonaws.com]:587 YOUR_USERNAME:YOUR_PASSWORD
```

Secure it:

```bash
sudo chmod 600 /etc/postfix/sasl_passwd
sudo postmap /etc/postfix/sasl_passwd
```

* * *

### Configure Postfix Main Settings

```bash
sudo vim /etc/postfix/main.cf
```

**Replace entire contents with:**

```ini
# 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

```bash
sudo vim /etc/postfix/allowed_senders
```

Add approved senders:

```plaintext
info@yourdomain.com         OK
noreply@yourdomain.com      OK
@yourdomain.com             REJECT Not authorized
```

Compile and setup:

```bash
sudo postmap /etc/postfix/allowed_senders
sudo mkdir -p /var/log/postfix
sudo chown postfix:postfix /var/log/postfix
```

* * *

### Start Postfix

```bash
sudo postfix check  # Should output nothing
sudo systemctl start postfix
sudo systemctl status postfix
```

**Test it:**

```bash
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

```bash
# 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:

```bash
aws sts get-caller-identity  # Should show the role
```

* * *

### Create SQS Queue

```bash
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

```bash
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

```bash
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

```bash
sudo yum install -y python3-boto3
python3 -c "import boto3; print('✓ boto3 installed')"
```

* * *

### Create Logger Script

```bash
sudo nano /usr/local/bin/ses_logger.py
```

**Paste this complete script:**

```python
#!/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:

```bash
sudo chmod +x /usr/local/bin/ses_logger.py
```

* * *

### Create Systemd Service

```bash
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

```bash
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:**

```bash
sudo tail -f /var/log/postfix/ses-logger.log
```

* * *

## Phase 5: Testing (20 mins)

### Test 1: Complete Flow

**Send test email:**

```bash
echo "Test from infrastructure" | \
  mail -s "Test Email" -r info@yourdomain.com your-email@example.com
```

**Watch both logs:**

```bash
# 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:**

```plaintext
# 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

```bash
# 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

```bash
# 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:**

```bash
sudo systemctl restart postfix
sudo systemctl restart ses-logger
```

**View logs:**

```bash
sudo tail -f /var/log/postfix/postfix.log  # Sent
sudo tail -f /var/log/postfix/mail.log     # Delivered
```

**Check queue:**

```bash
mailq
```

**Search for email:**

```bash
grep "user@example.com" /var/log/postfix/*.log
```

* * *

## Common Issues

**Email stuck in queue?**

```bash
# Check why
sudo tail -50 /var/log/postfix/postfix.log | grep deferred
# Flush after fixing
sudo postqueue -f
```

**Logger not running?**

```bash
# Check errors
sudo journalctl -u ses-logger -n 50
# Restart
sudo systemctl restart ses-logger
```

**No delivery events?**

```bash
# 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**](https://www.linkedin.com/in/sebastiancyril/). Let’s learn and grow together.

👉 Stay tuned for more behind-the-scenes write-ups and system design breakdowns.
