← back to blog
cloud Featured · Oct 6, 2025 · 14 min read

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.

SJ
Sabin Joshi
DevOps Engineer
#aws #terraform #lambda #cloudtrail #eventbridge #sns #discord #devops

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.

⚠️ CloudTrail logs everything, but logging and alerting are two completely different things. Having a record of what happened is not the same as knowing when it happens.
10
AWS event types monitored
3
AWS services covered (EC2, RDS, ECS)
~$0
monthly cost to run
0
servers to manage

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:

CloudTrail → EventBridge → SNS → Lambda → Discord
🔍 CloudTrail Multi-Region Trail ⚡ EventBridge Pattern Rule 📣 SNS Topic 2 subscribers λ Lambda Python 3.10 💬 Discord Webhook embed API events matched invoke webhook
1
CloudTrail captures every API call
A multi-region trail with include_global_service_events = true records all management events across EC2, RDS, and ECS and ships them to S3 and CloudWatch Logs.
2
EventBridge filters only what matters
A JSON event pattern matches only the 10 specific eventName values we care about — RunInstances, CreateDBInstance, DeleteCluster, etc. Everything else is dropped here before Lambda is ever invoked.
3
SNS fans out to two subscribers
An SNS topic receives the matched event and simultaneously triggers Lambda and sends a raw JSON email — two subscription protocols, one publish call.
4
Lambda parses and formats per event type
Python 3.10 uses structural match/case to extract the right fields for each event — instance IDs, VPC IDs, DB class, cluster names — and builds a rich Discord embed.
5
Discord webhook delivers the alert
The webhook URL is fetched at runtime from SSM Parameter Store (SecureString) — never hardcoded in the codebase or present in Terraform state as plaintext.

Prerequisites & Initial Setup

Before running terraform apply, make sure these are in place:

AWS CLI configured with sufficient permissions
Needs access to create IAM roles, S3 buckets, CloudTrail trails, EventBridge rules, SNS topics, Lambda functions, and SSM parameters.
Terraform ≥ 1.0 + AWS provider ~> 4.0
All five infrastructure components are provisioned via Terraform — no console interaction required after initial setup.
Discord server with webhook access
Channel Settings → Integrations → Webhooks → New Webhook → Copy URL. You'll need to store this in SSM before running Terraform.
Python 3.10 runtime familiarity
The Lambda uses 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"
💡 Note the parameter name exactly — it must match 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
    }]
  }
}
ℹ️ EventBridge pattern matching is free — you only pay when a rule matches and triggers a target. Filtering at this layer instead of Lambda cut our invocations by ~95%.

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.

Challenge
CloudTrail event delivery delay
Events can take 5–15 minutes to arrive. For sub-minute alerting on critical events, EventBridge Pipes directly on the API calls would be the right choice instead.
Lesson
Filter at EventBridge, not Lambda
Moving the event_pattern to EventBridge cut Lambda invocations by ~95% and removed all noise-handling logic from Python entirely. Always filter at the source.
Challenge
Inconsistent CloudTrail schemas per service
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.
Lesson
SSM SecureString over hardcoded env vars
Fetching from SSM at deploy time means the webhook URL is encrypted at rest, rotatable without redeploying Lambda, and absent from Terraform state in plaintext.
Challenge
S3 bucket policy took 3 attempts
The 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.

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.

🚀 The complete Terraform modules and Lambda source are available on GitHub. If this saved you from a mystery 3 AM incident, drop a star.