Skip to content

20+ AWS Services in One terraform apply — Built for the New Free Plan

AWS’s new Free Plan has landmines: every ElastiCache node burns credits now, DynamoDB on-demand bypasses Always Free. One module, validated constraints, 20+ services.

AWS’s new free plan comes with $200 in credits ($100 base + $100 bonus) — but only if you activate each activity individually. Most people don’t. I almost didn’t.

The first time I spun up an ElastiCache cluster for a personal project, I picked cache.t4g.micro — the Graviton node type, which seemed like the obvious modern choice. The legacy free tier only ever covered cache.t3.micro, and under the new Free Plan neither node type gets a free allowance — both burn credits. By the time I caught it, I’d burned a few days of charges on a cache I wasn’t even actively using. That’s the kind of mistake this module is designed to prevent.

I built a Terraform module that satisfies four of the five credit-earning activities automatically, provisions 20+ services, and makes it structurally difficult to misconfigure yourself into charges. It’s published on the Terraform Registry as cloudplz/free-tier/aws.

Aurora provisioning can fail on the Free Plan

The module defaults to aurora = true. Aurora is officially available on both Free and Paid plans, but some account/API paths on Free Plan accounts fail cluster creation with a FreeTierRestrictionError. If apply fails that way, either switch the account to a Paid Plan (your credits carry over — it doesn’t cost anything by itself) or run with features = { aurora = false }.

The Problem with “Just Use Free Tier”

Navigating AWS free tier without Terraform is a game of reading footnotes. A few examples of what trips people up:

  • ElastiCache node types all burn credits now. Under the legacy 12-month free tier, cache.t3.micro had a 750-hour/month allowance and cache.t4g.micro didn’t. Under the new Free Plan, neither has an allowance — both consume credits. The legacy rule is still all over older blog posts, and it got me: I picked the Graviton t4g assuming the legacy table applied.
  • DynamoDB on-demand billing eats the free tier. The 25 RCU/WCU Always Free allocation only applies to provisioned capacity mode. Switch to on-demand and you’re paying per request.
  • Public IPv4 addresses cost $0.005/hr (~$3.65/month) since the 2024 pricing change. Per IP, per resource. The first 750 hours/month are free for the first 12 months — which covers exactly one always-on instance — but every additional IP (a second instance, a NAT gateway, an ALB) bills immediately.
  • KMS customer-managed keys aren’t free. SSE-S3 is, and AWS-managed keys have no storage charge. But default to customer-managed KMS keys on your S3 buckets (which many security guides recommend) and you pay $1/month per key plus per-call charges past the 20K free monthly requests.

The module encodes all of these as validated constraints. You can’t accidentally configure a non-free ElastiCache node type — the validation block rejects it at plan time before anything gets created.

What the Module Provisions

The module splits resources into two categories: core services that always deploy, and optional services you toggle with a features object.

Core services (always on):

ResourceConfigurationWhy This Specific Setting
VPC/16 CIDR, public + private subnetsNo NAT gateway — saves ~$32/mo
EC2t4g.micro, gp3 30GBt-family enforced by validation
Public IPv41 address on EC2Covered by the 750-hr/mo free allowance (first 12 months); extra IPs bill at $0.005/hr
Lambda128MB, Function URL + API Gateway128MB maximizes free GB-seconds
DynamoDBPROVISIONED 25 RCU/WCUOn-demand would bypass Always Free
SQSStandard queue + DLQFIFO burns requests faster
SNSStandard topic1M publishes/mo free
CloudWatch2 alarms, 7-day retentionStays under 10-alarm Always Free limit
EventBridgeScheduler at rate(5 min)14M Scheduler invocations/mo free
BudgetsZero-spend alertEarns a $20 credit activity
Secrets ManagerCredentials for enabled databases$0.40/secret/mo — intentionally core
IAMRoles for EC2, Lambda, Step FunctionsRequired for least-privilege access

Secrets Manager is a deliberate choice. Database credentials belong in a secrets store, not in Terraform state or environment variables. At $0.40/secret/month it’s a non-free always-on cost I accept on purpose — the IPv4 address, by contrast, is covered by the free allowance as long as it’s the only one.

Optional services (feature toggles):

FeatureWhat It CreatesMonthly Cost
rds = trueRDS PostgreSQL db.t4g.micro, 20GB~$13.98
aurora = true ⚠️Aurora Serverless v2, 0–4 ACUs (auto-pause)Covered by credits within Free Plan limits — may require Paid Plan (see warning above)
elasticache = trueElastiCache Valkey cache.t3.micro~$12.41
cloudfront = trueCloudFront PriceClass_100 + S3 originAlways Free
cognito = trueCognito User PoolAlways Free (10K MAU)
step_functions = trueStep Functions STANDARD state machineAlways Free (4K transitions/mo)
bedrock_logging = trueBedrock invocation logging to CloudWatchConfig free; watch the 5GB/mo CloudWatch Logs allowance

The March 2026 change was meaningful: Aurora PostgreSQL Serverless v2 joined the Free Plan — within the credits window you get up to 4 ACUs and 1 GiB of storage per cluster. Two precision points the announcement posts glossed over. First, “covered by the plan” means credit-funded, not Always Free: when credits expire, it bills. Second, auto-pause only engages at min_capacity = 0 — at the old default of 0.5 ACU the cluster never pauses and idles at roughly $44/month, which silently breaks the whole cost model. The module now defaults aurora_min_capacity to 0 (auto-pause on, idle ≈ $0 compute; storage still accrues at $0.10/GB-month against the 1 GiB allowance). Two caveats worth knowing before you rely on pause: resuming adds a cold-start delay of a few seconds on the first connection, and pausing is silently disabled while anything holds a connection open or an incompatible feature is in play — AWS won’t error, the cluster just stays warm. And storage can’t be capped by Terraform at all — Aurora volumes grow in 10 GiB increments — so the 1 GiB plan limit is a watch-your-usage constraint, not an enforced one.

The Five Credit-Earning Activities

AWS pays out $20 for each of five activities, totaling $100 in bonus credits on top of your $100 base. Four are covered by terraform apply plus trivial follow-ups. One needs a console action:

ActivityWhat Terraform ProvisionsManual Step
EC2aws_instance.web (t4g.micro)Terminate the instance once (activity requires launch and terminate)
RDSaws_db_instance.postgres (db.t4g.micro)None
Lambdaaws_lambda_function_url.handlerNone — URL is the trigger
Budgetsaws_budgets_budget.zero_spendNone
BedrockInvocation logging configEnable model access + 1 prompt in Console

Run terraform apply, terminate the EC2 instance once (the activity checks for launch and terminate — then terraform apply again to bring it back), go to the Bedrock console, enable any model, send one prompt. That’s all five activities — $100 in bonus credits. One caveat: AWS doesn’t document completion telemetry for these activities, so verify each one shows as complete in the console’s Explore AWS widget before counting it.

Credit Budget Math

With $200 total ($100 base + $100 bonus), here’s how long it lasts under different configurations. With the module’s default aurora_min_capacity = 0, Aurora auto-pauses when idle and drops out of the monthly burn — idle compute ≈ $0, with pennies of storage against the 1 GiB allowance. One hard constraint on the whole table: credits expire 12 months after account creation, so no configuration stretches them past that.

ScenarioMonthly Burn$200 Lasts
All defaults (RDS + ElastiCache + Secrets Manager)~$39.79~5 months
Disable RDS after earning $20 credit~$25.41~7.9 months
Disable RDS + ElastiCache~$12.60~15.9 months → capped at 12

The cost-optimized strategy: run with all defaults for the first month to earn all five $20 credits, then disable RDS and ElastiCache. At ~$12.60/month the credits would last 15.9 months on paper — but the 12-month expiry is the real ceiling, so expect to use about $150 of the $200 unless you keep more services running.

Design Decisions Worth Understanding

Validation as Cost Guards

This is the core value proposition of the module over a plain README.md of instance type recommendations. validation blocks make it structurally difficult to misconfigure yourself into charges:

variable "ec2_instance_type" {
  default = "t4g.micro"
  validation {
    condition     = can(regex("^t[0-9]+[a-z]*\\.", var.ec2_instance_type))
    error_message = "ec2_instance_type must be a t-family instance type (e.g., t4g.micro, t3.micro)."
  }
}

variable "elasticache_node_type" {
  default = "cache.t3.micro"
  validation {
    condition     = var.elasticache_node_type == "cache.t3.micro"
    error_message = "elasticache_node_type is pinned to cache.t3.micro to keep credit burn minimal (~$12.41/mo)."
  }
}

variable "aurora_max_capacity" {
  default = 4.0
  validation {
    condition     = var.aurora_max_capacity <= 4.0
    error_message = "aurora_max_capacity must be <= 4.0 to stay within the free plan cap."
  }
}

The ElastiCache pin exists because the node-type choice is a credit-burn decision now, not a free/not-free one: under the new Free Plan every node type consumes credits, and the module picks the cheapest valid default so a Graviton-tinged instinct can’t quietly double the burn. cache.t4g.micro looks like the modern equivalent of cache.t3.micro; it isn’t cheaper, and the plan-time block stops you from finding that out on the bill.

Feature Toggles with Optional Object Type

The features variable uses Terraform’s optional() type constraint, which lets callers omit any combination of keys without triggering an error:

variable "features" {
  type = object({
    rds             = optional(bool, true)
    aurora          = optional(bool, true)
    elasticache     = optional(bool, true)
    cloudfront      = optional(bool, true)
    cognito         = optional(bool, true)
    step_functions  = optional(bool, true)
    bedrock_logging = optional(bool, true)
  })
  default = {}
}

default = {} means the caller can omit the block entirely — all features default to on. This is friendlier than the common pattern of a map(bool) where you’re forced to list every key you want. With optional(), you only declare what you’re changing:

# Disable only the expensive ones
features = {
  rds         = false
  elasticache = false
}

One design note here: earlier versions of this module coupled ElastiCache to RDS/Aurora with a plan-time validation, on the mistaken assumption that ElastiCache shares the RDS DB subnet group. It doesn’t — ElastiCache has its own aws_elasticache_subnet_group resource, and the module was already creating one. The coupling validation was stale cruft that made cache-only configurations impossible, and it’s removed as of the current release. Each service now stands alone: elasticache = true with rds = false is a valid configuration.

No NAT Gateway

The VPC has public and private subnets but no NAT gateway. This saves ~$32/month ($0.045/hr). Resources in private subnets that need outbound internet access use VPC endpoints for AWS services (S3, Secrets Manager, SSM), or they don’t need it at all. EC2 is in the public subnet — fine for a personal learning environment where you can lock down SSH via security groups, or skip SSH entirely in favor of SSM Session Manager.

DynamoDB PROVISIONED vs On-Demand

The Always Free DynamoDB allocation — 25 RCU, 25 WCU, 25 GB — only applies to provisioned capacity mode. On-demand pricing doesn’t participate in the Always Free tier. The module creates a provisioned table at exactly 25/25 by default. Changing to on-demand would immediately start burning credits on every read and write.

Publishing to the Terraform Registry

Terraform Registry module names follow a strict convention: terraform-<PROVIDER>-<NAME>. The GitHub repo cloudplz/terraform-aws-free-tier automatically maps to cloudplz/free-tier/aws on the registry. There’s no application process — connect your GitHub account to the registry, select the repository, and the registry picks up releases tagged with semantic version numbers (v1.0.0, v1.1.0, etc.).

The CI pipeline runs on every push and PR:

# .github/workflows/ci.yml (abbreviated)
- terraform fmt -check -recursive
- terraform validate
- tflint --recursive
- trivy config .
- terraform test

terraform test runs the unit tests in tests/. The tests validate variable defaults, feature toggle behavior, security constraints (no public S3 buckets, no unencrypted storage), and cross-variable validations — without creating any real infrastructure.

5 Lines of HCL

After publishing the module, using it is as minimal as Terraform gets. name is the only required input — everything else has a safe default. The contrast with inlining all 70+ resources is stark: 500+ lines of HCL you’d need to maintain and update whenever AWS adjusts pricing or service behavior. With the published module, a version = "~> 1.0" bump picks up fixes automatically.

module "free_tier" {
  source  = "cloudplz/free-tier/aws"
  version = "~> 1.0"

  name = "myproject"
}

Found a service that should be Always Free but isn’t handled correctly? Open an issue — the cost tables drift as AWS adjusts limits.