Reading time ~3 minutes
Serverless Emails with Cloudflare Email Routing
This is going to be a short blog post detailing another migration I undertook to simplify my current stack.
This time, I replaced SES with Cloudflare Email Routing for incoming emails across my domains.
Before: AWS SES
Since my original migration to AWS in ~2018, I’ve used SES to handle incoming and outbound emails for my domains. (Those interested can read more at My Blogging Stack and Building a Serverless Mailing List in AWS ).
SES allows you to configure rule sets for receiving emails, each of which can then contain one or more receipt rules, as shown below.


In my case, I only needed one rule, composed of two actions:
- First, each new incoming email gets delivered (as a text file) in a dedicated S3 bucket.
- Second, the event of creating a new file in the bucket triggered, in turn, a Lambda function which takes care of parsing the file and forwarding it as a “proper” email to a catch-all address I specified.


As you might have assumed by now, the Lambda function (taken from the (AWS Lambda SES Email Forwarder) repo) contains the core logic of the processing workflow. It is a Node.js script that uses SES’s inbound/outbound capabilities to run a “serverless” email forwarding service.
Although this solution worked fine for nearly five years, it had some management overhead. For example:
- The need to monitor the Lambda function to ensure it didn’t silently fail/drop messages.
- Whenever Lambda did fail, it was a matter of manually logging in to the console to check the logs, find the related message in S3, download it, and only then read the content.
I wanted to get rid of this overhead.
After: Cloudflare Email Routing
Cloudflare announced Email Routing in September 2021, as a more straightforward way to manage email delivery. You can also read “Migrating to Cloudflare Email Routing” for instructions on migrating.
Here is my current setup for inbound emails (obviously in Terraform 😅):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
locals {
domain = "example.com"
email_gmail = "<redacted>"
email_custom = {
info = "info@${local.domain}"
marco = "marco@${local.domain}"
}
}
#
# Global Config
#
resource "cloudflare_email_routing_settings" "example" {
zone_id = cloudflare_zone.example.id
enabled = "true"
}
#
# Addresses
#
resource "cloudflare_email_routing_rule" "example" {
zone_id = cloudflare_zone.example.id
for_each = local.email_custom
name = each.value
enabled = true
matcher {
type = "literal"
field = "to"
value = each.value
}
action {
type = "forward"
value = [local.email_gmail]
}
}
#
# Catch all
#
resource "cloudflare_email_routing_catch_all" "example" {
zone_id = cloudflare_zone.example.id
name = "${local.domain} catch all"
enabled = true
matcher {
type = "all"
}
action {
type = "forward"
value = [local.email_gmail]
}
}
- Lines
14
-17
:cloudflare_email_routing_settings
is the primary resource for managing Email Routing settings. Here we are linking it to the relevant Cloudflare Zone and setting it as enabled. - Lines
22
-40
: we iterate over the set of alias we want (defined in lines5
-8
), to create an Email Routing rule for each of them. As per theaction
, it is set toforward
emails to a catch-all address in Gmail. - Lines
45
-58
: we define a catch-all rule to handle email received to any handle not covered by its own Email Routing rule.
And here is what it looks like in the Cloudflare dashboard:


As you can see, with this setup, there are significantly fewer moving parts on my side of the shared responsibility model.
What about outgoing emails?
Note that this setup handles inbound only.
SES still handles outbound via an SMTP user.
Conclusions
I hope you found this post valuable and interesting, and I’m keen to get feedback on it! If you find the information shared helpful, if something is missing, or if you have ideas on improving it, please let me know on 🐣 Twitter or at 📢 feedback.marcolancini.it.
Thank you! 🙇♂️