Cost AWS cost

public IPv4 is charged even when it is attached

Search for Elastic IP costs and you will find the same advice everywhere: an address is free while it is attached to a running instance, and only costs money when it is not. That was true, and it stopped being true on 1 February 2024. Every public IPv4 address now costs $0.005 an hour whether it is attached to anything or not, which is roughly $3.60 a month each — and most of the advice online still has not caught up.

EC2 API Python and Node.js Advice that went stale
The short answer

Since 1 February 2024, AWS charges $0.005 per hour for every public IPv4 address, in use or idle. The old rule — free when attached, charged when not — no longer applies.

That is about $3.60 per address per month, and it covers addresses you may not think of as yours: internet-facing load balancers, NAT Gateways, RDS instances with public access, EKS nodes. Finding them is an ec2 describe-addresses call plus a sweep of the services that allocate their own.

The problem in plain words

The bill grows by an amount that does not obviously map to anything. Each address is small, so no single line looks wrong, and the total sits inside EC2-Other where it is hard to attribute.

The bigger problem is the stale mental model. A team that believes attached addresses are free will not think to count them, so an account with forty instances each holding a public IP carries around $144 a month that nobody has ever looked at. Worse, the standard remedy people reach for — releasing unattached addresses — only touches a fraction of the total.

Why it happens

The pricing changed and the internet did not. Years of blog posts, Stack Overflow answers and internal runbooks encode the old rule. AWS made the change because IPv4 addresses are scarce and acquisition costs rose sharply, but that reasoning did not reach the second page of search results.

Many addresses are allocated by other services. You did not create the public IP on your ALB or your NAT Gateway; the service did. They are still charged, and they do not appear in the Elastic IP list where people look.

The free tier masks it early on. The EC2 free tier includes 750 hours of public IPv4 per month for the first twelve months, which is one address running continuously. Accounts feel the change only after the first year or the second instance.

How to fix it

Count the addresses you allocated

These are the ones people already know about, and they are still worth listing because the association status changes what you can do, not whether you are charged.

aws ec2 describe-addresses \
  --query 'Addresses[].{IP:PublicIp,Assoc:AssociationId,Instance:InstanceId}'

Count the ones AWS allocated for you

Network interfaces carry public IPs assigned by load balancers, NAT Gateways and managed databases. describe-network-interfaces is where they surface, and it is the step most audits skip.

Release what is genuinely unused

An unassociated Elastic IP is the easy win: nothing depends on it and releasing is one call. Be careful that the address is not simply between instances during a deploy — the script reports how long it has been idle where it can.

Reduce the count, not just the idle ones

The real saving is architectural. Instances in private subnets behind one load balancer need no public IP each. IPv6 is free of this charge entirely. Consolidating forty public addresses down to two is a far bigger number than releasing the three that happen to be detached today.

How to check it worked

Count before and after. The number that matters is total public IPv4 addresses, not unattached ones:

aws ec2 describe-addresses --query 'length(Addresses)'
aws ec2 describe-network-interfaces \
  --query 'length(NetworkInterfaces[?Association.PublicIp!=null])'

Multiply the total by $3.60 and compare it against the EC2-Other line next month.

The full code

The script counts every public IPv4 address in the region — both Elastic IPs you allocated and addresses attached to network interfaces by other services — and totals what they cost. Release requires an explicit allocation id and --apply, because releasing an address in use during a deploy is disruptive and irreversible.

public_ipv4_audit.py
"""Count every charged public IPv4 address and total the monthly cost.

Since 1 February 2024 AWS charges for ALL public IPv4 addresses, attached or not,
so counting only the unassociated ones -- which is what most published advice
tells you to do -- misses the majority of the bill.
"""
import argparse
import logging
import sys

import boto3

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("public_ipv4_audit")

# Charged since 2024-02-01 for every public IPv4 address, in use or idle.
HOURLY_USD = 0.005
MONTHLY_USD = HOURLY_USD * 24 * 30


def summarise(elastic_ips, interface_ips):
    """Pure decision function over two lists of addresses.

    Splits the total into what you can release today and what needs an
    architectural change, because those are different pieces of work.
    """
    releasable = [a for a in elastic_ips if not a.get("AssociationId")]
    attached_eip = [a for a in elastic_ips if a.get("AssociationId")]
    total = len(elastic_ips) + len(interface_ips)
    return {
        "total": total,
        "monthly_usd": total * MONTHLY_USD,
        "releasable_now": releasable,
        "attached_elastic": len(attached_eip),
        "service_allocated": len(interface_ips),
    }


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--region", default="us-east-1")
    ap.add_argument("--release", help="allocation id of an unassociated address")
    ap.add_argument("--apply", action="store_true")
    args = ap.parse_args()

    ec2 = boto3.client("ec2", region_name=args.region)

    eips = ec2.describe_addresses()["Addresses"]
    # Addresses other services allocated: ALBs, NAT Gateways, public RDS, EKS nodes.
    enis = [n for n in ec2.describe_network_interfaces()["NetworkInterfaces"]
            if n.get("Association", {}).get("PublicIp")
            and not n.get("Association", {}).get("AllocationId")]

    s = summarise(eips, enis)
    log.info("%d public IPv4 addresses -- about $%.2f/month",
             s["total"], s["monthly_usd"])
    log.info("  %d Elastic IPs attached to something", s["attached_elastic"])
    log.info("  %d allocated by other services (ALB, NAT, RDS, EKS)",
             s["service_allocated"])
    for a in s["releasable_now"]:
        log.warning("  RELEASABLE %s (allocation %s) -- $%.2f/month",
                    a["PublicIp"], a.get("AllocationId"), MONTHLY_USD)

    if not s["releasable_now"]:
        log.info("nothing is unassociated; the saving here is architectural, "
                 "not a cleanup -- fewer public addresses, not fewer idle ones")

    if args.release:
        if args.apply:
            ec2.release_address(AllocationId=args.release)
            log.info("released %s", args.release)
        else:
            log.info("WOULD release %s -- pass --apply", args.release)
    return 0


if __name__ == "__main__":
    sys.exit(main())
public-ipv4-audit.mjs
/**
 * Count every charged public IPv4 address and total the monthly cost.
 *
 * Since 1 February 2024 AWS charges for ALL public IPv4 addresses, attached or
 * not, so counting only the unassociated ones -- which is what most published
 * advice tells you to do -- misses the majority of the bill.
 */
import {
  EC2Client,
  DescribeAddressesCommand,
  DescribeNetworkInterfacesCommand,
  ReleaseAddressCommand,
} from '@aws-sdk/client-ec2';

// Charged since 2024-02-01 for every public IPv4 address, in use or idle.
const HOURLY_USD = 0.005;
const MONTHLY_USD = HOURLY_USD * 24 * 30;

/**
 * Pure decision function over two lists of addresses.
 *
 * Splits the total into what you can release today and what needs an
 * architectural change, because those are different pieces of work.
 */
export function summarise(elasticIps, interfaceIps) {
  const releasable = elasticIps.filter((a) => !a.AssociationId);
  const total = elasticIps.length + interfaceIps.length;
  return {
    total,
    monthlyUsd: total * MONTHLY_USD,
    releasableNow: releasable,
    attachedElastic: elasticIps.length - releasable.length,
    serviceAllocated: interfaceIps.length,
  };
}

async function main() {
  const region = process.env.AWS_REGION ?? 'us-east-1';
  const apply = process.argv.includes('--apply');
  const release = process.argv[process.argv.indexOf('--release') + 1];
  const ec2 = new EC2Client({ region });

  const { Addresses = [] } = await ec2.send(new DescribeAddressesCommand({}));
  const { NetworkInterfaces = [] } = await ec2.send(new DescribeNetworkInterfacesCommand({}));
  const enis = NetworkInterfaces.filter(
    (n) => n.Association?.PublicIp && !n.Association?.AllocationId);

  const s = summarise(Addresses, enis);
  console.log(`${s.total} public IPv4 addresses -- about $${s.monthlyUsd.toFixed(2)}/month`);
  console.log(`  ${s.attachedElastic} Elastic IPs attached to something`);
  console.log(`  ${s.serviceAllocated} allocated by other services (ALB, NAT, RDS, EKS)`);
  for (const a of s.releasableNow) {
    console.warn(`  RELEASABLE ${a.PublicIp} (allocation ${a.AllocationId}) -- $${MONTHLY_USD.toFixed(2)}/month`);
  }
  if (!s.releasableNow.length) {
    console.log('nothing is unassociated; the saving here is architectural, not a cleanup');
  }

  if (process.argv.includes('--release')) {
    if (apply) {
      await ec2.send(new ReleaseAddressCommand({ AllocationId: release }));
      console.log(`released ${release}`);
    } else {
      console.log(`WOULD release ${release} -- pass --apply`);
    }
  }
}

if (import.meta.url === `file://${process.argv[1]}`) main();

Add a test

The whole point of the note is that attached addresses count too, so the test that matters is the one asserting an account with nothing releasable still has a bill.

test_public_ipv4_audit.py
from public_ipv4_audit import summarise, MONTHLY_USD


def test_attached_addresses_still_cost_money():
    """The heart of it: nothing is releasable, and there is still a bill."""
    eips = [{"PublicIp": "1.2.3.4", "AssociationId": "eipassoc-1"}]
    s = summarise(eips, [])
    assert s["releasable_now"] == []
    assert s["monthly_usd"] > 0


def test_service_allocated_addresses_are_counted():
    s = summarise([], [{"Association": {"PublicIp": "5.6.7.8"}}] * 3)
    assert s["total"] == 3
    assert s["service_allocated"] == 3


def test_unassociated_addresses_are_flagged():
    eips = [{"PublicIp": "1.2.3.4", "AllocationId": "eipalloc-1"}]
    s = summarise(eips, [])
    assert len(s["releasable_now"]) == 1


def test_total_cost_is_per_address():
    s = summarise([{"PublicIp": "a", "AssociationId": "x"}] * 10, [])
    assert round(s["monthly_usd"], 2) == round(10 * MONTHLY_USD, 2)


def test_empty_account_is_free():
    assert summarise([], [])["monthly_usd"] == 0
public-ipv4-audit.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { summarise } from './public-ipv4-audit.mjs';

test('attached addresses still cost money', () => {
  const s = summarise([{ PublicIp: '1.2.3.4', AssociationId: 'eipassoc-1' }], []);
  assert.deepEqual(s.releasableNow, []);
  assert.ok(s.monthlyUsd > 0);
});

test('service-allocated addresses are counted', () => {
  const enis = Array.from({ length: 3 }, () => ({ Association: { PublicIp: '5.6.7.8' } }));
  const s = summarise([], enis);
  assert.equal(s.total, 3);
  assert.equal(s.serviceAllocated, 3);
});

test('unassociated addresses are flagged', () => {
  const s = summarise([{ PublicIp: '1.2.3.4', AllocationId: 'eipalloc-1' }], []);
  assert.equal(s.releasableNow.length, 1);
});

test('an empty account is free', () => {
  assert.equal(summarise([], []).monthlyUsd, 0);
});

FAQ

Is an attached Elastic IP still free?

No. That changed on 1 February 2024. Every public IPv4 address is charged $0.005 per hour whether it is attached to a running instance or sitting idle. A great deal of published advice still describes the old rule.

How much is that per address?

About $3.60 a month, or $43.20 a year, per address. Individually trivial, which is why an account with forty of them carries roughly $144 a month that nobody has counted.

Which addresses count?

All of them, including ones you did not allocate yourself: internet-facing load balancers, NAT Gateways, RDS instances with public access, EKS nodes. They do not appear in the Elastic IP list, which is why audits that only check describe-addresses undercount.

Does the free tier cover this?

The EC2 free tier includes 750 hours of public IPv4 per month for the first twelve months — one address running continuously. Accounts typically notice the charge after the first year or the second instance.

What is the actual fix?

Fewer public addresses, not fewer idle ones. Instances in private subnets behind a single load balancer need no public IP each. IPv6 carries no equivalent charge. Releasing the odd detached address is worth doing but it is not where the money is.

Related field notes

Sources

Every figure in this note is traced to one of these. Prices are list rates and change — check them for your own region before acting.

Stuck on a tricky one?

If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.