June 3rd - Blog Subscription Service - Part 3: Infrastructure and Terraform Deep Dive
With the CI pipeline handling authentication and execution, the Terraform itself can stay focused on what it actually needs to build. This post walks through every resource - what it is, how it connects to the rest of the system, and a few design decisions worth explaining.
Overview
A note on process: the architecture and design decisions here are my own, but I used Claude to help scaffold the initial Terraform and Lambda code. The example files linked throughout this post reflect that collaboration.
The infrastructure lives entirely in infra/terraform/. There's no module abstraction - everything is flat. A future post will go through refactoring the code into modules; I wanted to be able to show the progression from flat terraform to using modules because I know this was tricky to wrap my brain around when I started. For now, the flat structure makes it easier to see exactly what Terraform is doing before introducing that layer of organization.
Resources deployed:
- API Gateway (HTTP API)
- Two Lambda functions (
subscribe,poller) - DynamoDB table with a GSI
- SES domain identity, DKIM, and custom MAIL FROM
- EventBridge scheduled rule
- ACM certificate and Route53 records for the custom API domain
- IAM execution roles (one per Lambda)
API Gateway
The API uses the HTTP API type (v2), not REST API. HTTP APIs are simpler to configure and cheaper - REST APIs cost roughly $3.50/million requests, HTTP APIs cost $1/million. For a personal blog, the savings are negligible, but there's no reason to use the more expensive one.
Three routes, all pointing to the same Lambda:
POST /subscribe- new subscriber signupsGET /confirm- email confirmation link targetGET /unsubscribe- unsubscribe link target
The API sits behind a custom domain at api.mydomain.com, with an ACM certificate and a Route53 A record (alias) pointing at the API Gateway domain name. TLS is handled by ACM - no certificate management needed.
Throttling is configured on the $default stage via default_route_settings with low sustained and burst limits. This stops scripted abuse without needing WAF, which runs at minimum ~$5-6/month.
Example code: api_gateway.tf
Lambda
Both Lambda functions are Python 3.12. Terraform zips the source at plan time using the archive_file data source, so the zip is built locally before any AWS API calls are made. The resulting zip paths are referenced by the Lambda resources.
Each function gets its configuration via environment variables - table name, SES sender address, domain - so nothing is hardcoded in the Python source.
Each function gets its own IAM execution role. Claude initially scaffolded a single shared role, but I split them in the spirit of least privilege - the poller has no business touching the Turnstile SSM parameter, and a shared role would give it that access for free. The subscribe role needs: DynamoDB read/write, SES SendEmail, and SSM GetParameter (for the Turnstile secret). The poller role needs: DynamoDB read/write and SES SendEmail - nothing else.
The Lambda zip artifacts from tf:plan are uploaded as CI artifacts and downloaded by tf:apply in the same pipeline. This is why tf:apply uses dependencies on tf:plan - artifacts don't persist across pipeline boundaries.
Example code: lambda.tf | iam.tf
DynamoDB
Single table (blog-subscribers) with email as the partition key. Each item stores:
email- partition keyconfirmed- boolean flagtoken- UUID used to validate confirmation and unsubscribe linkslast_seen- timestamp used by the poller (stored in a dedicated item, not on subscriber records)
The poller needs to query only confirmed subscribers. A full table scan would work at small scale, but a GSI on the confirmed attribute is the right pattern - it lets the poller use a Query instead of a Scan, which is both cheaper and faster as the table grows.
Two sanity saving settings worth enabling via Terraform:
deletion_protection_enabled = true- prevents the table from being accidentally deleted via the console or a Terraform mistake- Point-in-time recovery (
point_in_time_recovery { enabled = true }) - allows restoring to any second in the last 35 days
One gotcha: token is a DynamoDB reserved word. Any ConditionExpression that references token directly throws a ValidationException. The fix is to use ExpressionAttributeNames={"#tok": "token"} and reference #tok in the expression.
Example code: dynamodb.tf
SES
SES handles all outbound email from contact@mydomain.com. Three things are configured via Terraform:
- Domain identity - verifies that the domain is owned and authorizes sending from it
- DKIM - DNS records published to Route53 that let receiving servers verify email signatures
- Custom MAIL FROM domain - sets
mail.mydomain.comas the envelope sender domain, which improves deliverability
SES starts in sandbox mode, where it can only send to verified addresses. In order to send to addresses that are not pre-verified, production access is required. Approval from AWS came through the same day within a few hours.
Example code: ses.tf
EventBridge
A single scheduled rule runs the poller Lambda daily. The rule uses a cron expression targeting a fixed UTC time. EventBridge needs permission to invoke the Lambda - this is granted via a aws_lambda_permission resource with principal = "events.amazonaws.com" and the rule ARN as the source.
Example code: eventbridge.tf
Remote State
Terraform state lives in S3 (mydomain-terraform-state) with versioning enabled. Versioning is worth the negligible storage cost - it lets you roll back a corrupted state file without losing everything.
State locking uses S3 native locking (use_lockfile = true) rather than DynamoDB. DynamoDB-based locking was deprecated in Terraform 1.10.
Example code: backend.tf
What's Next
Part 4 covers both Lambdas - the subscribe, confirm, and unsubscribe flows, the security decisions behind them, and how the daily poller fetches the RSS feed and emails confirmed subscribers.
- Part 1: The Why and the What
- Part 2: OIDC Authentication and the Terraform CI Pipeline
- Part 4: The Lambda Code
This is part of the Blog Subscription Service series.

Keep the coffee flowing