Skip to content

Repository files navigation

aws-nat-ec2

A self-hosted, Spot-capable, AL2023-based NAT instance — a cheaper alternative to AWS Managed NAT Gateway for outbound-only internet access from private subnets. One module call provisions exactly one NAT instance (via an Auto Scaling Group) and one persistent Elastic IP in a single Availability Zone; call the module once per AZ that needs NAT egress.

Highlights:

  • AL2023, resolved via the public SSM AMI parameter — no custom AMI.
  • Spot capacity by default, with reactive (ASG replacement) and proactive (Spot-interruption-warning → Lambda) failover.
  • Spot exhaustion fallback: CloudWatch alarm + Lambda flips the ASG to On-Demand when zero instances stay InService (SPEC §5.4).
  • Optional SNS notifications (BYO topic) for reactive/proactive failover and Lambda error/throttle backstop alarms — opt-in, nothing created when sns_topic_arn is unset.
  • The public IP stays the same across every instance replacement.
  • SSM Session Manager only — no SSH, no key pairs, no bastion host.

Inputs and outputs are documented below. See SPEC.md for the full design rationale, IAM permissions (§7.3), and the runnable manual verification plan (§13).

Architecture

Architecture diagram

Editable source: docs/architecture.drawio — open it at diagrams.net (File → Open From → Device) or in the draw.io VS Code/desktop app.

Requirements

Name Version
terraform >= 1.9
aws provider ~> 6.0
archive provider ~> 2.4

This module has no provider block of its own (standard reusable-module convention) — the caller's Terragrunt/root-module configuration supplies AWS credentials and region.

Usage (Terragrunt)

The module never creates VPC/subnet/route-table topology — it consumes an existing public subnet (for the NAT instance) and existing private route table(s) to repoint at it. A minimal terragrunt.hcl:

terraform {
  # Replace with wherever this module actually lives — a git URL
  # (e.g. "git::https://github.com/<org>/<repo>.git//?ref=v1.0.0"), a
  # relative local path, or a Terraform registry source.
  source = "<path-or-git-url-to-this-module>"
}

dependency "vpc" {
  config_path = "../vpc"
}

dependency "subnets" {
  config_path = "../subnets"
}

inputs = {
  name_prefix             = "myapp-dev"
  vpc_id                  = dependency.vpc.outputs.vpc_id
  public_subnet_id        = dependency.subnets.outputs.public_subnet_ids[0]
  private_route_table_ids = [dependency.subnets.outputs.private_route_table_ids[0]]

  # Optional — shown at their defaults for clarity; omit any of these
  # to use the default. See the Inputs table below for the full list.
  architecture        = "x86_64" # or "arm64"
  use_spot            = true
  allow_inbound_cidrs = []
  tags = {
    environment = "dev"
  }
}

For a second AZ, add a second Terragrunt unit calling this same module with that AZ's public_subnet_id/private_route_table_ids and a distinct name_prefix — this module deliberately has no multi-AZ logic of its own (SPEC §3/§4.1). name_prefix must be unique per module call: unlike the ASG/IAM role/EventBridge rule (which get Terraform's own randomized name_prefix suffix), the Lambda function names, CloudWatch Log Group names, and CloudWatch alarm names are derived directly from it with no added uniqueness, so two calls sharing the same value collide on those resources.

Inputs

At minimum, every module call must supply name_prefix, vpc_id, public_subnet_id, and private_route_table_ids; everything else has a sensible default.

Variable Type Default Description
name_prefix string — (required) Prefix for resource names and tags. Must be 20 characters or fewer (validated) — combined with this module's own resource-name suffixes and Terraform's name_prefix random suffix, a longer value can exceed AWS's 64-character limit on EventBridge rule / IAM role names. Must also be unique per module call (e.g. per AZ): the Lambda function, CloudWatch Log Group, and CloudWatch alarm names are derived directly from it with no added uniqueness, unlike the ASG/IAM role/EventBridge rule.
vpc_id string — (required) Existing VPC ID.
public_subnet_id string — (required) Existing public subnet the NAT instance runs in. AZ is derived from this subnet.
private_route_table_ids list(string) — (required) Route table IDs to repoint at the active NAT instance.
architecture string "x86_64" "x86_64" or "arm64". Drives AMI SSM parameter and default instance types.
instance_types list(string) arch-based default (e.g. ["t3a.nano","t3.nano"] or ["t4g.nano"]) Candidate instance types for the ASG mixed-instances policy.
root_volume_size number 8 Root EBS volume size (GB), 8-100 (validated).
root_volume_kms_key_id optional(string) null Optional customer-managed KMS key ARN/ID for root volume encryption. If null, the account's default aws/ebs key is used (no extra setup needed). If you supply a customer-managed key, its key policy must separately authorize the AWSServiceRoleForAutoScaling service-linked role — the ASG launches the encrypted volume, not you directly, and that role has no access to customer-managed keys by default (only to the account's own aws/ebs key). Add two statements to the key's policy for principal arn:aws:iam::<account-id>:role/aws-service-role/autoscaling.amazonaws.com/AWSServiceRoleForAutoScaling: one allowing kms:Encrypt, kms:Decrypt, kms:ReEncrypt*, kms:GenerateDataKey*, kms:DescribeKey; another allowing kms:CreateGrant with condition kms:GrantIsForAWSResource: true. Without both, instance launches fail. See AWS's own key policy requirements doc for the full example.
use_spot bool true Whether to prefer Spot capacity.
spot_on_demand_fallback bool true When use_spot is true, flip to 100% On-Demand via alarm + Lambda if zero instances stay InService (Spot exhaustion). Ignored when use_spot is false.
spot_fallback_alarm_period_seconds number 120 CloudWatch alarm period for Spot-exhaustion fallback.
spot_fallback_alarm_evaluation_periods number 2 Consecutive breaching periods before fallback runs.
eip_allocation_id optional(string) null Bring-your-own EIP allocation ID. If null, the module allocates a new EIP.
release_eip_on_destroy bool true Documentation-only — has no Terraform-enforced effect. lifecycle meta-arguments can't reference variables, so no resource reads this value; a normal terraform destroy always releases the module-allocated EIP regardless of what it's set to. To actually preserve the EIP across a destroy, run terraform state rm '<module address>.aws_eip.nat[0]' before destroying — it becomes unmanaged and must be reimported or released manually afterward. Ignored if eip_allocation_id is supplied (caller owns lifecycle). See SPEC.md §10.
allow_inbound_cidrs list(string) [] Optional additional ingress CIDRs on the NAT security group, beyond the default (no inbound from the internet).
tags map(string) {} Additional tags applied to all resources.
sns_topic_arn optional(string) null Bring-your-own SNS topic ARN for failover/health notifications. If null, no notifications are sent and the module creates no SNS resources.
sns_topic_kms_key_arn optional(string) null ARN of the customer-managed KMS key used to encrypt sns_topic_arn, if any. Grants the failover Lambda's role kms:GenerateDataKey*/kms:Decrypt on this key. This IAM grant alone is not sufficient: KMS key policies take precedence over IAM policies, so the key's own key policy must also either name this Lambda's role directly or retain the default key policy statement that delegates access control to IAM (Allow, principal = account root, kms:*) — a restrictive custom key policy that omits both leaves the Lambda's sns:Publish call failing with KMSAccessDeniedException even with this variable set. Does not cover the Spot-fallback alarm, the Lambda backstop alarms, or the ASG notification — those publish to SNS as native CloudWatch/Auto Scaling service actions, not through this module's Lambda code, so they instead require statements on the key's own key policy: one for principal cloudwatch.amazonaws.com (kms:Decrypt, kms:GenerateDataKey*, Resource: "*") covering the Spot-fallback and backstop alarms, and one for the AWSServiceRoleForAutoScaling service-linked role (the same role already referenced under root_volume_kms_key_id above) covering the ASG notification. These three cannot use the default alias/aws/sns AWS-managed key at all — AWS requires a customer-managed key for any AWS service (CloudWatch, Auto Scaling, etc.) to publish to an encrypted SNS topic; only the failover Lambda's own publish works with either key type. See AWS's SNS KMS permissions doc, AWS's KMS key policy doc, and Auto Scaling's SNS key-policy doc for the exact statements. Ignored if sns_topic_arn is unset (validated).
log_retention_days number 14 Retention (days) for both Lambdas' CloudWatch Log Groups. Must be one of CloudWatch Logs' accepted retention_in_days values (validated).

Outputs

Output Description
nat_instance_asg_name Name of the Auto Scaling Group.
nat_instance_security_group_id Security Group ID attached to the NAT instance.
eip_public_ip The persistent public IP address.
eip_allocation_id Allocation ID of the EIP in use (module-created or BYO passthrough).
iam_role_arn ARN of the NAT instance's IAM role.
failover_lambda_arn ARN of the proactive-failover Lambda function.
eventbridge_rule_arn ARN of the Spot-interruption-warning EventBridge rule.
spot_fallback_lambda_arn ARN of the Spot-exhaustion fallback Lambda (null when fallback is disabled).
spot_fallback_alarm_arn ARN of the CloudWatch alarm for Spot-exhaustion fallback (null when disabled).

Note: if the fallback Lambda flips the live ASG to On-Demand at runtime, Terraform state still shows Spot-only until you apply a config change. To persist On-Demand after fallback, set use_spot = false and apply. A blind terraform apply with use_spot = true resets the ASG back to Spot-only.

Inspiration

This module took inspiration from:

About

Spot-capable, AL2023-based NAT instance Terraform module — cheaper alternative to AWS Managed NAT Gateway

Resources

Stars

5 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages