# 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.

---

LLMS index: [llms.txt](/llms.txt)

---

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`](https://registry.terraform.io/modules/cloudplz/free-tier/aws/latest).

> [!WARNING] **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):**

| Resource | Configuration | Why This Specific Setting |
|----------|---------------|---------------------------|
| VPC | /16 CIDR, public + private subnets | No NAT gateway — saves ~$32/mo |
| EC2 | t4g.micro, gp3 30GB | t-family enforced by validation |
| Public IPv4 | 1 address on EC2 | Covered by the 750-hr/mo free allowance (first 12 months); extra IPs bill at $0.005/hr |
| Lambda | 128MB, Function URL + API Gateway | 128MB maximizes free GB-seconds |
| DynamoDB | PROVISIONED 25 RCU/WCU | On-demand would bypass Always Free |
| SQS | Standard queue + DLQ | FIFO burns requests faster |
| SNS | Standard topic | 1M publishes/mo free |
| CloudWatch | 2 alarms, 7-day retention | Stays under 10-alarm Always Free limit |
| EventBridge | Scheduler at rate(5 min) | 14M Scheduler invocations/mo free |
| Budgets | Zero-spend alert | Earns a $20 credit activity |
| Secrets Manager | Credentials for enabled databases | $0.40/secret/mo — intentionally core |
| IAM | Roles for EC2, Lambda, Step Functions | Required 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):**

| Feature | What It Creates | Monthly Cost |
|---------|-----------------|--------------|
| `rds = true` | RDS 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 = true` | ElastiCache Valkey cache.t3.micro | ~$12.41 |
| `cloudfront = true` | CloudFront PriceClass_100 + S3 origin | Always Free |
| `cognito = true` | Cognito User Pool | Always Free (10K MAU) |
| `step_functions = true` | Step Functions STANDARD state machine | Always Free (4K transitions/mo) |
| `bedrock_logging = true` | Bedrock invocation logging to CloudWatch | Config 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:

| Activity | What Terraform Provisions | Manual Step |
|----------|---------------------------|-------------|
| EC2 | `aws_instance.web` (t4g.micro) | Terminate the instance once (activity requires launch **and** terminate) |
| RDS | `aws_db_instance.postgres` (db.t4g.micro) | None |
| Lambda | `aws_lambda_function_url.handler` | None — URL is the trigger |
| Budgets | `aws_budgets_budget.zero_spend` | None |
| **Bedrock** | Invocation logging config | **Enable 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.

| Scenario | Monthly 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:

```hcl
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:

```hcl
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:

```hcl
# 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:

```yaml
# .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.

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

  name = "myproject"
}
```

- Registry: [registry.terraform.io/modules/cloudplz/free-tier/aws](https://registry.terraform.io/modules/cloudplz/free-tier/aws/latest)
- Source: [github.com/cloudplz/terraform-aws-free-tier](https://github.com/cloudplz/terraform-aws-free-tier)

---

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