IaC AWS

Terraform Module: AWS Elastic IP — stable public IPs without the orphaned-allocation bill

Quick take — A reusable hashicorp/aws ~> 5.0 module for aws_eip: allocate VPC Elastic IPs, optionally bind them to instances, NAT gateways, or ENIs, with tagging, validation, and cost-safe lifecycle controls. New here? Jump to the Quickstart below to deploy it in minutes; read on for how it works and when to reach for it.

Quickstart (copy-paste)

Minimal, runnable configuration — drop this in a .tf file and fill in the "..." placeholders (each required input is commented):

provider "aws" {
  region = "us-east-1"
}

module "elastic_ip" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-elastic-ip?ref=v1.0.0"

  name = "..."  # Logical name; used as the `Name` tag. Validated to 1-25…
}

Then terraform init && terraform apply. Every other input has a sensible default — see Inputs below to override behaviour.

What this module is

An AWS Elastic IP (EIP) is a static, public IPv4 address you allocate to your account and keep until you explicitly release it. Unlike the ephemeral public IP an instance gets at launch — which changes every stop/start — an EIP stays constant, so it survives instance replacement, lets you remap traffic to a standby host during failover, and gives you a fixed address to put behind DNS, firewall allowlists, or partner IP whitelists.

The mechanics around aws_eip are deceptively easy to get wrong. A loose EIP that isn’t associated with a running resource is billed by the hour, IPv4 addresses themselves now carry an hourly charge whether attached or not, and an EIP attached to a NAT gateway behaves differently from one attached to an instance versus a bare network interface. Wrapping it in a module bakes in the domain = "vpc" default, forces an explicit association target, applies consistent tagging so the address shows up in cost allocation, and gives you one place to reason about the lifecycle so addresses don’t leak across terraform destroy cycles.

When to use it

If your workload sits behind an ALB/NLB, an API Gateway, or CloudFront, you usually do not want a raw EIP — let the managed service own its addressing (an NLB can take EIPs, but that is configured on the load balancer, not here).

Module structure

terraform-module-aws-elastic-ip/
├── versions.tf
├── main.tf
├── variables.tf
└── outputs.tf

versions.tf

terraform {
  required_version = ">= 1.5.0"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }
}

main.tf

The module allocates one VPC Elastic IP and lets the caller bind it to at most one target: an EC2 instance directly, a network interface (the path for NAT gateways and multi-IP ENIs), or nothing at all so you can pre-allocate now and attach later. A precondition rejects ambiguous wiring at plan time.

locals {
  # The inline instance / network_interface arguments are mutually exclusive on
  # aws_eip, so count how many direct targets the caller supplied.
  active_targets = length(compact([
    var.instance_id,
    var.network_interface_id,
  ]))

  base_tags = merge(
    {
      Name      = var.name
      ManagedBy = "terraform"
      Module    = "terraform-module-aws-elastic-ip"
    },
    var.tags,
  )
}

resource "aws_eip" "this" {
  domain = "vpc"

  # Direct association to an EC2 instance (mutually exclusive with the ENI path).
  instance = var.instance_id

  # Direct association to a specific network interface; this is the path used
  # for NAT gateways and for ENIs that carry several secondary private IPs.
  network_interface         = var.network_interface_id
  associate_with_private_ip = var.associate_with_private_ip

  # Bring-your-own-IP: allocate from a BYOIP or customer-owned pool instead of
  # Amazon's pool. Leave null to use the default Amazon-provided pool.
  public_ipv4_pool         = var.public_ipv4_pool
  customer_owned_ipv4_pool = var.customer_owned_ipv4_pool

  # Pin to an AZ's network border group (Local Zones / Wavelength). Defaults to
  # the Region's group when null.
  network_border_group = var.network_border_group

  tags = local.base_tags

  lifecycle {
    # Reject ambiguous wiring at plan time instead of failing at apply.
    precondition {
      condition     = local.active_targets <= 1
      error_message = "Set at most one of instance_id or network_interface_id; they are mutually exclusive on aws_eip."
    }
    # The IP is the contract (DNS, partner allowlists). On replacement, bring up
    # the new allocation before tearing down the old one.
    create_before_destroy = true
  }
}

# Optional out-of-band association, used when the target ENI is created in a
# different module/apply so allocation and attachment can be decoupled. Drive a
# given ENI from EITHER the inline network_interface above OR this resource.
resource "aws_eip_association" "this" {
  count = var.create_eni_association && var.network_interface_id != null ? 1 : 0

  allocation_id        = aws_eip.this.id
  network_interface_id = var.network_interface_id
  private_ip_address   = var.associate_with_private_ip
}

variables.tf

variable "name" {
  description = "Logical name for the Elastic IP; used as the Name tag for cost allocation and console identification."
  type        = string

  validation {
    condition     = can(regex("^[a-zA-Z0-9._-]{1,255}$", var.name))
    error_message = "name must be 1-255 characters of letters, numbers, dots, hyphens, or underscores."
  }
}

variable "instance_id" {
  description = "EC2 instance ID to associate the EIP with directly. Mutually exclusive with network_interface_id. Leave null to allocate without an instance."
  type        = string
  default     = null

  validation {
    condition     = var.instance_id == null || can(regex("^i-[0-9a-f]{8,17}$", var.instance_id))
    error_message = "instance_id must be a valid EC2 instance ID (i-xxxxxxxx) or null."
  }
}

variable "network_interface_id" {
  description = "ENI ID to associate the EIP with. Use this for NAT gateways and multi-IP interfaces. Mutually exclusive with instance_id."
  type        = string
  default     = null

  validation {
    condition     = var.network_interface_id == null || can(regex("^eni-[0-9a-f]{8,17}$", var.network_interface_id))
    error_message = "network_interface_id must be a valid ENI ID (eni-xxxxxxxx) or null."
  }
}

variable "associate_with_private_ip" {
  description = "Specific secondary private IP on the target ENI to bind the EIP to. Leave null to use the primary private IP."
  type        = string
  default     = null
}

variable "create_eni_association" {
  description = "Create a separate aws_eip_association for the ENI instead of using the inline network_interface argument. Use when the ENI is managed by a different apply."
  type        = bool
  default     = false
}

variable "public_ipv4_pool" {
  description = "BYOIP address pool ID to allocate from (e.g. ipv4pool-ec2-xxxx). Null uses Amazon's public pool."
  type        = string
  default     = null
}

variable "customer_owned_ipv4_pool" {
  description = "Customer-owned IPv4 pool ID for AWS Outposts allocations. Null disables CoIP allocation."
  type        = string
  default     = null
}

variable "network_border_group" {
  description = "Network border group to allocate the address from (e.g. a Local Zone or Wavelength group). Null uses the Region's default group."
  type        = string
  default     = null
}

variable "tags" {
  description = "Additional tags merged onto the EIP. A cost-allocation tag such as Owner or CostCenter is strongly recommended since IPv4 is billed per address-hour."
  type        = map(string)
  default     = {}
}

outputs.tf

output "id" {
  description = "Allocation ID of the Elastic IP (eipalloc-...), used as allocation_id by NAT gateways and EIP associations."
  value       = aws_eip.this.id
}

output "allocation_id" {
  description = "Alias of the allocation ID, for callers that expect the explicit attribute name."
  value       = aws_eip.this.allocation_id
}

output "name" {
  description = "Name tag assigned to the Elastic IP."
  value       = var.name
}

output "public_ip" {
  description = "The allocated public IPv4 address. Feed this into DNS A records, security-group allowlists, or partner whitelists."
  value       = aws_eip.this.public_ip
}

output "public_dns" {
  description = "Public DNS name AWS assigns to the address (ec2-<ip>.<region>.compute.amazonaws.com)."
  value       = aws_eip.this.public_dns
}

output "association_id" {
  description = "Association ID when the EIP is bound to a target, otherwise null."
  value       = aws_eip.this.association_id
}

output "ptr_record" {
  description = "Convenience CIDR (/32) form of the address for firewall rules and IP allowlist documents."
  value       = "${aws_eip.this.public_ip}/32"
}

How to use it

This example pre-allocates an egress IP, attaches it to a NAT gateway, then publishes the address to a Route 53 record and a partner-facing security group so downstream systems can allowlist it.

module "elastic_ip" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-elastic-ip?ref=v1.0.0"

  name = "prod-nat-egress-az1"

  tags = {
    Environment = "prod"
    Owner       = "platform-networking"
    CostCenter  = "CC-4412"
    Purpose     = "nat-gateway-egress"
  }
}

# The NAT gateway consumes the module's allocation ID output.
resource "aws_nat_gateway" "az1" {
  allocation_id = module.elastic_ip.id
  subnet_id     = aws_subnet.public_az1.id

  tags = {
    Name = "prod-nat-az1"
  }

  depends_on = [aws_internet_gateway.this]
}

# Publish the static egress IP to DNS for partners that resolve by name.
resource "aws_route53_record" "egress" {
  zone_id = var.public_zone_id
  name    = "egress.kloudvin.com"
  type    = "A"
  ttl     = 300
  records = [module.elastic_ip.public_ip]
}

# Allowlist our own egress IP on an inbound rule (e.g. a partner API callback).
resource "aws_security_group_rule" "partner_allow_egress_ip" {
  type              = "ingress"
  from_port         = 443
  to_port           = 443
  protocol          = "tcp"
  cidr_blocks       = [module.elastic_ip.ptr_record]
  security_group_id = aws_security_group.partner_callback.id
  description       = "Allow our published NAT egress IP"
}

With Terragrunt

Terragrunt keeps this module DRY across environments — define the backend and provider once in a root config, then a thin terragrunt.hcl per environment supplies only the inputs that differ.

1. Root configlive/terragrunt.hcl (inherited by every module):

remote_state {
  backend = "s3"
  generate = { path = "backend.tf", if_exists = "overwrite" }
  config = {
    # ...s3 state bucket/container + key per path...
  }
}

2. Module configlive/prod/elastic_ip/terragrunt.hcl:

include "root" {
  path = find_in_parent_folders()
}

terraform {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-elastic-ip?ref=v1.0.0"
}

inputs = {
  name = "..."
}

3. Deploy one environment, or roll out all modules together:

cd live/prod/elastic_ip && terragrunt apply        # this module
terragrunt run-all apply                      # every module under live/prod

Why Terragrunt here: the backend and provider live in one place instead of being copy-pasted into every module; inputs is overridden per environment (dev / stage / prod) without forking the module; and run-all orchestrates dependencies across modules. Reach for it once you have more than one environment or more than a handful of modules — for a single stack, the plain Quickstart above is enough.

Inputs

Name Type Default Required Description
name string Yes Logical name; used as the Name tag. Validated to 1-255 safe characters.
instance_id string null No EC2 instance to bind to directly. Mutually exclusive with network_interface_id.
network_interface_id string null No ENI to bind to (NAT gateways, multi-IP ENIs). Mutually exclusive with instance_id.
associate_with_private_ip string null No Secondary private IP on the ENI to bind to; null uses the primary.
create_eni_association bool false No Use a separate aws_eip_association instead of the inline network_interface argument.
public_ipv4_pool string null No BYOIP pool ID to allocate from; null uses Amazon’s pool.
customer_owned_ipv4_pool string null No Customer-owned IPv4 pool ID for Outposts (CoIP).
network_border_group string null No Local Zone / Wavelength border group; null uses the Region default.
tags map(string) {} No Extra tags merged onto the EIP; add a cost-allocation tag.

Outputs

Name Description
id Allocation ID (eipalloc-...); pass to NAT gateways and associations.
allocation_id Explicit alias of the allocation ID.
name The Name tag value.
public_ip The allocated public IPv4 address.
public_dns AWS-assigned public DNS name for the address.
association_id Association ID when bound to a target, else null.
ptr_record The address in /32 CIDR form for firewall and allowlist use.

Enterprise scenario

A payments platform must give its acquiring bank a fixed set of source IPs to whitelist on the bank’s settlement API. The networking team pre-allocates three of these modules — one per AZ — names them prod-settlement-egress-azN, and tags each with the CostCenter of the payments product. The public_ip outputs are exported through a shared terraform_remote_state and handed to the bank weeks before the NAT gateways are built, so the firewall change request and the infrastructure rollout proceed in parallel instead of serially.

Best practices

TerraformAWSElastic IPModuleIaC
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments

Keep Reading