Blog ·

An IPAM API is not a Terraform integration

Your IPAM "integrates with Terraform." Does it allocate, or do you script it?

A lot of IPAM tools now say they integrate with Terraform. Worth checking what that actually means before you rely on it, because there are two very different things hiding behind the same sentence.

One is a real Terraform provider: pools and subnets are resources in your configuration. Terraform's own graph creates them, records them in state, notices when they go missing, and releases them when you remove them. The other is an API, usually REST or GraphQL, that Terraform can reach the way it reaches anything else on the internet: through a script, a local-exec, a null_resource, a generic HTTP provider pointed at it. Both get called "Terraform integration" in marketing copy. Only one of them is a Terraform IPAM provider, and the difference is not cosmetic.

What a script-based integration actually costs you

If your IPAM's Terraform story is an API you call from a script, here is what that means in practice, not in theory:

Something outside Terraform has to run first. A CI step, a wrapper script, a human running a curl command: it calls the API, gets a CIDR back, and feeds it into Terraform as a variable. That something is a second system with its own failure modes, and it is not in anything Terraform reads, writes, plans or destroys.

Terraform never learns where the value came from. It sees a variable with a value already in it, no different from one somebody typed by hand. Whatever correctness the IPAM enforced when the script called it is invisible to the state file, to the diff a reviewer reads, and to the next person who edits that variable.

Nothing stops two people, or two CI runs, calling the script at the same time. The IPAM's own API might handle that safely. The wrapper script around it, written on a Tuesday afternoon to unblock a deploy, usually was not built with that in mind, and you find out during an incident, not during a code review.

There is no terraform import path back, because there is nothing Terraform-shaped to import. The address is just a string in a tfvars file. If it is wrong, or duplicated, or was hand-typed by someone who has since left, fixing it means fixing a script and a variable, not running one command against a resource that already understands what it represents.

And when the environment is torn down, the allocation is still there. Terraform destroyed everything it knew about, which never included the address, so something else has to remember to hand it back.

None of this makes the underlying IPAM bad at IP address management. It means Terraform IP address management, specifically, still runs through a side door.

What changes with an actual provider

The pool is the space you own. The subnet asks for a size and never names a block:

resource "nxip_pool" "production_euw2" {
  name        = "prod-eu-west-2"
  cidr        = "10.0.0.0/16"
  family      = "IPV4"
  environment = "production"
  region      = "eu-west-2"
}

# environment, region and family come from the pool rather than being
# retyped, which is what tells Terraform to create the pool first.
resource "nxip_subnet" "web_tier" {
  environment   = nxip_pool.production_euw2.environment
  region        = nxip_pool.production_euw2.region
  family        = nxip_pool.production_euw2.family
  prefix_length = 24
  name          = "web-tier"
}

resource "aws_subnet" "web" {
  vpc_id            = aws_vpc.main.id
  cidr_block        = nxip_subnet.web_tier.cidr
  availability_zone = "eu-west-2a"
}

No CIDR anywhere in that subnet request, and no script to put one there. The subnet takes its environment, region and family from the pool, which is the reference Terraform orders the two by, so the pool exists before the subnet asks for anything. You ask for a /24 in production eu-west-2, and terraform plan shows the subnet being created with cidr = (known after apply). Which /24 you get is decided during the apply, by nxip, against every pool and subnet it holds for your whole organization rather than against one state file. The answer comes back into state, and nxip_subnet.web_tier.cidr feeds aws_subnet.web through Terraform's graph, so the dependency between the allocation and the thing using it sits in the configuration instead of in somebody's memory.

Why an authority outside the state file is the only thing that can answer this correctly, and when to pin a CIDR explicitly instead of letting it allocate, is the subject of conflict-free CIDR allocation in Terraform. This piece is about the shape of the integration rather than the allocation itself.

The rest follows from being a resource rather than a side effect. The allocation is in state, so terraform destroy releases it. A subnet deleted outside Terraform comes back on the next apply, though an auto-allocated one comes back at whatever block is free by then, and because cidr forces a new resource, anything referencing it is replaced too. And existing address space is not a re-typing exercise: pools and subnets nxip already knows about, whether entered by hand, discovered from a cloud scan, or created before Terraform was ever pointed at them, come in with terraform import, the same as any other resource.

The check that exists because the plan cannot tell you

Here is the honest limitation, and it is worth stating plainly, because the useful part comes directly out of it. terraform plan will not tell you which /24 this subnet is about to get. It says (known after apply), and it is right to: nothing has asked nxip yet.

Terraform could not answer it anyway. A plan is authoritative about exactly one state file, and address space is an organization-wide resource spanning workspaces, accounts, teams and often more than one cloud. State is per-workspace by design, and no per-workspace tool can answer an organization-wide question.

So the check asks on your behalf. A GitHub Action reads the plan JSON, sends each new subnet's request to nxip's preview endpoint, and puts the answer on the pull request before anyone merges. Here is one of those comments, from a pull request in our own test repo:

github-actions[bot] commented on a pull request

nxip Terraform Plan

3 of 7 previewed subnet changes would fail if applied.

ResourceStatusCIDRDestinationUtilization
nxip_subnet.checkout_production✅10.100.1.0/24subnet us-east-1 region block6.25% → 12.5%
nxip_subnet.payments_preprod✅10.102.1.0/24subnet us-east-1 preprod region block12.5% → 25%
nxip_subnet.payments_production✅10.100.1.0/24subnet us-east-1 region block6.25% → 12.5%
nxip_subnet.payments_production_v6✅fd00:100:0:100::/56subnet us-east-1 region block (IPv6)1 → 2 subnets
nxip_subnet.preprod_us_east_region❌ kind-conflictA kind-tagged subnet already exists for environment 'preprod', region 'us-east-1', family 'IPV4'. Only one kind-tagged landing point is allowed per key. Use its parentSubnetId to nest under it explicitly instead.n/an/a
nxip_subnet.production_us_east_region❌ kind-conflictA kind-tagged subnet already exists for environment 'production', region 'us-east-1', family 'IPV4'. Only one kind-tagged landing point is allowed per key. Use its parentSubnetId to nest under it explicitly instead.n/an/a
nxip_subnet.production_us_east_region_v6❌ kind-conflictA kind-tagged subnet already exists for environment 'production', region 'us-east-1', family 'IPV6'. Only one kind-tagged landing point is allowed per key. Use its parentSubnetId to nest under it explicitly instead.n/an/a

Predictions from nxip are not reserved: nothing is locked, so a concurrent preview or a real apply against the same pool or subnet can land differently. For a replace (destroy-then-create), this preview ran before the old block was actually freed, so real apply-time allocation may differ from what is shown here.

Shortened: the comment also carries a collapsed list of four resources it skipped, because their routing depends on another resource in the same plan. Every row, number and message above is exactly as the action wrote it. What the kind-conflict rows mean, and what to do about them, is on the troubleshooting page.

Each row is a separate question, asked and answered on its own, and asking reserves nothing. That is why the first and third rows both predict 10.100.1.0/24: two resources wanted the same size in the same place, so each was told the next free block. Whichever applies first takes it, and the other gets the one after. The footnote at the bottom of the comment makes the same point in the action's own words.

What the check will not tell you is that another workspace already holds the range this plan wants. For a subnet shaped like the ones above it does not need to, because nobody is claiming a range in the first place: nxip hands out space it already knows is free across the whole organization, so there is nothing to collide with. That is what not choosing buys you. Pin a block instead, with cidr in place of prefix_length, and you are claiming a specific range again, which this preview does not cover.

What it does predict is the apply failing, and how full things get. No pool matching the environment and region, a pool with no room left, a tier limit reached, a kind-conflict like the three above: each is a red row with the reason written out. Turn on fail-on-predicted-failure and any of them fails the pull request's check rather than surfacing when somebody runs apply. The green rows carry the utilization change, so "this pushes us past 80%" becomes a conversation in review instead of a discovery later.

None of this is available to a script-based integration, because there is nothing in the plan to read: the address was chosen outside it. The API might refuse the request when the script finally calls it. By then the pull request has already been reviewed and approved on the strength of a plan that looked fine.

The same authority, kept apart per customer

If you run address space for more than one customer, the question gets sharper: which customer's address space is this configuration even touching?

A script-based integration usually answers that with whichever API credentials the script happens to hold, which is exactly the kind of implicit default that goes wrong at the worst moment: a script meant for one customer's pipeline, copied into another customer's pipeline, quietly writing into the first customer's address space because nobody changed which key it used.

provider "nxip" {
  alias        = "acme"
  organization = "org_acme_id"
}

provider "nxip" {
  alias        = "globex"
  organization = "org_globex_id"
}

resource "nxip_pool" "acme_prod" {
  provider    = nxip.acme
  name        = "acme-prod"
  cidr        = "10.0.0.0/16"
  family      = "IPV4"
  environment = "production"
  region      = "eu-west-2"
}

The configuration states which customer it manages, in a line a reviewer can see. Acme and Globex can both hold 10.0.0.0/16, neither plan mentions the other, and each is a separate organization: reading across into one that is not yours returns a 404, the same answer as an organization that does not exist. How the link works, what a role ceiling does, and which plan this needs, are under customer organizations.

Where this leaves a script-based setup

None of the above means the address data in a script-integrated IPAM is wrong. It usually is not. It means the correctness lives somewhere Terraform cannot reach: in a step that ran outside the plan, recorded in a system the state file knows nothing about.

Moving off that shape does not require abandoning what already exists. Cloud address space comes in through a read-only scan that needs no account and nothing installed:

npx nxip-cli scan aws azure

What it reads, and the least-privilege credentials it needs, are at nx-ip.com/docs/discovery. Anything else, on-prem ranges, addresses nobody has scanned, comes in by declaring the same pools and subnets directly and importing them once they exist; the routes in are at getting your address space in. Either way, the configuration ends up describing address space rather than a script requesting it, the allocations become resources Terraform owns, and the question of what you are about to get is answered on the pull request instead of after the fact.

The Terraform quick start gets a real pool and subnet running in a couple of minutes.

If your current setup is exactly this, an API you script against and call Terraform support, I would like to hear what a real migration looks like from where you are standing.


← More writing