Real-Time AWS Resource Notifications to Discord Using Terraform
Your AWS account changes silently — instances spin up, VPCs get deleted, RDS clusters get created — and nobody knows until something breaks. Here's how I built a fully event-driven notification pipeline with CloudTrail, EventBridge, SNS, and Lambda that fires a rich Discord message the moment anything changes in your infrastructure.
The Problem with Flying Blind in AWS
It was a Tuesday morning. I came into stand-up and someone casually mentioned a new RDS instance had been spun up in production over the weekend. Nobody knew who did it. Nobody knew why. It had just been sitting there — billable, untagged, unreachable.
That was the moment I realized we were flying blind. We had CloudTrail enabled — great for auditing after the fact — but zero real-time visibility. No alerts. No notifications. If someone accidentally deleted a VPC at 3 AM, we'd find out when users called in.
The ask was simple: whenever something meaningful happens in our AWS account — EC2 launched, RDS created, ECS cluster deleted — ping our team's Discord channel immediately. Not hours later via a CloudTrail query.
Architecture — The Full Event-Driven Pipeline
The solution is fully serverless and event-driven — five AWS services chained together, all provisioned with Terraform. Here's how an API call in your account becomes a Discord notification:
include_global_service_events = true records all management events across EC2, RDS, and ECS and ships them to S3 and CloudWatch Logs.eventName values we care about — RunInstances, CreateDBInstance, DeleteCluster, etc. Everything else is dropped here before Lambda is ever invoked.match/case to extract the right fields for each event — instance IDs, VPC IDs, DB class, cluster names — and builds a rich Discord embed.Prerequisites & Initial Setup
Before running terraform apply, make sure these are in place:
discord-webhook==1.1.0 and Python 3.10's structural pattern matching. Basic familiarity with match/case helps when extending the handler.Step 0 — Store the Discord webhook in SSM
Do this before any Terraform commands. The Lambda fetches the URL at runtime so it never appears in your codebase or state file as plaintext.
# Store the Discord webhook URL as an encrypted SecureString
aws ssm put-parameter \
--name "discord-notifcation-webhook" \
--value "https://discord.com/api/webhooks/YOUR_ID/YOUR_TOKEN" \
--type "SecureString" \
--overwrite \
--region "us-east-1"
data.aws_ssm_parameter.discord_notifcation_webhook in data.tf. The typo in "notifcation" is intentional to match the existing codebase.
Code Walkthrough — Module by Module
The entire infrastructure is five Terraform modules wired together. Let's walk through each one and why it's built the way it is.
1. S3 Bucket + CloudTrail
CloudTrail requires a bucket policy with two statements: GetBucketAcl so it can validate ownership, and PutObject to ship logs. The PutObject statement has a critical condition on s3:x-amz-acl — skip it and CloudTrail will silently create the trail but never deliver a single log.
# S3 bucket with versioning + CloudTrail-specific bucket policy
module "s3_logs" {
source = "terraform-aws-s3-module"
bucket = local.logs_bucket
acl = var.acl
attach_policy = true
policy = data.aws_iam_policy_document.default.json
force_destroy = true
object_ownership = "BucketOwnerPreferred"
versioning = { enabled = true }
}
data "aws_iam_policy_document" "default" {
statement {
sid = "cloudtrail-logs-get-bucket-acl"
effect = "Allow"
principals { type = "Service"; identifiers = ["cloudtrail.amazonaws.com"] }
actions = ["s3:GetBucketAcl"]
resources = ["arn:aws:s3:::${local.logs_bucket}"]
}
statement {
sid = "cloudtrail-logs-put-object"
effect = "Allow"
principals { type = "Service"; identifiers = ["cloudtrail.amazonaws.com"] }
actions = ["s3:PutObject"]
resources = ["arn:aws:s3:::${local.logs_bucket}/AWSLogs/*"]
condition {
test = "StringEquals"
variable = "s3:x-amz-acl" # ← critical — don't skip this
values = ["bucket-owner-full-control"]
}
}
}
# Multi-region trail — catches events in every AWS region
module "cloudtrail" {
source = "terraform-aws-cloudtrail-module"
trail_name = local.trail_name
is_multi_region_trail = true
include_global_service_events = true
s3_bucket_name = local.logs_bucket
cloudwatch_log_group_name = local.cloudwatch_log_group_name
}
2. EventBridge Rule — Filter Everything at the Source
This is the most important architectural decision in the whole system. The JSON event pattern matches only the 10 API calls we actually care about — filtering happens here before Lambda is ever invoked. Initially I did the filtering inside Lambda which meant paying for invocations on thousands of irrelevant events.
module "eventbridge" {
source = "terraform-aws-modules/eventbridge/aws"
version = "2.2.0"
create_bus = false # use the default event bus
rules = {
"${local.rules}" = {
description = "Capture AWS resource lifecycle events via CloudTrail"
event_pattern = jsonencode({
"source" : ["aws.ec2", "aws.rds", "aws.ecs"],
"detail-type" : ["AWS API Call via CloudTrail"],
"detail" = {
"eventSource" = ["ec2.amazonaws.com", "rds.amazonaws.com", "ecs.amazonaws.com"],
"eventName" = [
"RunInstances", "TerminateInstances",
"CreateVpc", "DeleteVpc",
"CreateDBInstance", "DeleteDBInstance",
"AllocateAddress", "ReleaseAddress",
"CreateCluster", "DeleteCluster"
]
}
})
}
}
targets = {
"${local.rules}" = [{
name = "send-logs-to-sns"
arn = module.sns.aws_sns_topic_arn
}]
}
}
3. SNS with Dual Subscriptions
One SNS topic, two subscribers simultaneously: email for a raw JSON audit trail, and Lambda for the Discord notification. Adding more channels — Slack, PagerDuty, Opsgenie — later is just a new subscription entry in this module.
module "sns" {
source = "terraform-aws-sns-module"
name = local.sns_name
create_topic_policy = true
subscriptions = {
email = { protocol = "email", endpoint = "your-email@example.com" },
lambda = { protocol = "lambda", endpoint = module.lambda.lambda_function_arn }
}
# Allow EventBridge to publish to this topic
topic_policy_statements = {
TrustCWEToPublishEvents = {
actions = ["sns:Publish"]
principals = [{ type = "Service"; identifiers = ["events.amazonaws.com"] }]
resources = [module.sns.aws_sns_topic_arn]
effect = "Allow"
}
}
}
4. Lambda — Python 3.10 Discord Formatter
The Lambda handler uses Python 3.10's structural match/case to handle different CloudTrail event schemas cleanly. Each AWS service has a different response shape in CloudTrail — RunInstances nests the instance ID under responseElements.instancesSet.items[0].instanceId while CreateVpc puts the VPC ID under responseElements.vpc.vpcId. Pattern matching keeps each case self-contained and easy to extend.
import os, json
from discord_webhook import DiscordWebhook, DiscordEmbed
def send_message(title, description, webhook_url):
webhook = DiscordWebhook(url=webhook_url)
embed = DiscordEmbed(title, description, color='03b2f8')
webhook.add_embed(embed)
return webhook.execute()
def lambda_handler(event, context):
WEBHOOK_URL = os.environ['DISCORD_WEBHOOK']
sns = event['Records'][0]['Sns']
message_data = json.loads(sns['Message'])
source = message_data['source']
time = message_data['time']
region = message_data['region']
principal_id = message_data['detail']['userIdentity']['principalId']
event_name = message_data['detail']['eventName']
# Python 3.10 structural pattern matching
# Each case handles the specific CloudTrail schema for that event type
match event_name:
case 'RunInstances':
instance_id = message_data['detail']['responseElements'] \
['instancesSet']['items'][0]['instanceId']
instance_type = message_data['detail']['requestParameters']['instanceType']
title = f'AWS Resources - {event_name}'
description = (
f"Source: {source}\nRegion: {region}\nTime: {time}\n"
f"Principal: {principal_id}\nInstance: {instance_id}\nType: {instance_type}"
)
case 'TerminateInstances':
instance_id = message_data['detail']['responseElements'] \
['instancesSet']['items'][0]['instanceId']
title = f'AWS Resources - {event_name}'
description = f"Source: {source}\nRegion: {region}\nTime: {time}\nInstance: {instance_id}"
case 'CreateVpc':
vpc_id = message_data['detail']['responseElements']['vpc']['vpcId']
title = f'AWS Resources - {event_name}'
description = f"Source: {source}\nRegion: {region}\nTime: {time}\nVPC: {vpc_id}"
case 'CreateDBInstance':
db_id = message_data['detail']['requestParameters']['dBInstanceIdentifier']
db_cls = message_data['detail']['requestParameters']['dBInstanceClass']
title = f'AWS Resources - {event_name}'
description = f"Source: {source}\nRegion: {region}\nTime: {time}\nDB: {db_id} ({db_cls})"
case _:
title = f'AWS Resources - {event_name}'
description = f"Source: {source}\nRegion: {region}\nTime: {time}\nPrincipal: {principal_id}"
if send_message(title, description, WEBHOOK_URL):
return {'statusCode': 200, 'body': 'Notification Sent'}
return {'statusCode': 400, 'body': 'Notification Failed'}
5. Lambda Terraform Module — Packaging + Permissions
The community terraform-aws-modules/lambda module handles packaging the Python source with its pip dependencies, building the zip, and wiring the SNS trigger — all automatically inferred from source_path.
module "lambda" {
source = "terraform-aws-modules/lambda/aws"
version = "5.0.0"
function_name = local.function_name
handler = local.handler_name # "lambda_function.lambda_handler"
runtime = local.runtime # "python3.10"
timeout = local.timeout # 30 seconds
create_current_version_allowed_triggers = false
# Allow SNS to invoke the function
allowed_triggers = {
AllowExecutionFromSNS = {
service = "sns"
source_arn = module.sns.aws_sns_topic_arn
}
}
# Least-privilege read-only policy for related services
attach_policy_json = true
policy_json = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = ["s3:Get*", "s3:List*", "sns:GetTopicAttributes",
"cloudtrail:Get*", "cloudtrail:LookupEvents",
"events:Describe*", "events:List*"]
Resource = ["*"]
}]
})
# Discord webhook URL injected from SSM at deploy time
environment_variables = {
DISCORD_WEBHOOK = data.aws_ssm_parameter.discord_notifcation_webhook.value
}
# Auto-packages lambda_function.py + pip installs requirements.txt
source_path = [{
path = "${path.module}/src/lambda_function.py"
pip_requirements = "${path.module}/src/requirements.txt"
}]
}
Deploying the Stack
Standard three-step Terraform workflow. Terraform resolves the dependency graph automatically — S3 bucket before CloudTrail, IAM role before Lambda, Lambda before SNS subscription, SNS before EventBridge target.
# Initialise providers and download modules
terraform init
# Preview — review all resources before applying
terraform plan
# Apply — dependency ordering is handled automatically
terraform apply
To verify the pipeline is working after deployment, trigger a test event and watch the Lambda logs:
# Manually trigger a test invocation
aws lambda invoke \
--function-name <your-function-name> \
--payload '{"test": true}' \
response.json
# Tail CloudWatch logs in real time
aws logs tail "/aws/lambda/<your-function-name>" --follow
Lessons Learned & Challenges Faced
The project went smoothly overall but a few rough edges are worth calling out for anyone building something similar.
event_pattern to EventBridge cut Lambda invocations by ~95% and removed all noise-handling logic from Python entirely. Always filter at the source.RunInstances puts instance ID in responseElements.instancesSet, but CreateVpc puts it in responseElements.vpc. No consistent structure. Read the CloudTrail docs per service, not a generic reference.s3:x-amz-acl condition on the PutObject statement is easy to miss. Skipping it = silent failure — trail creates successfully but zero logs are ever shipped to S3.eventName entries in EventBridge and new case blocks in the Lambda handler — no architectural changes needed.Wrapping Up
What started as a frustrating Tuesday morning stand-up became one of the most useful internal tools our team runs. The entire pipeline costs essentially nothing — EventBridge rule evaluation is free, SNS is sub-cent per month at our volume, and Lambda invocations at this frequency fall well within the permanent free tier.
More importantly, the architecture is completely modular. The EventBridge rule is the only thing that changes when adding new services. The Lambda pattern matching is trivially extendable. SSM-backed secrets are rotatable with a single CLI command. This is what infrastructure as code should feel like: one change, one redeploy, done.