/ Docs Guides / Terraform provider CLI ← All guides
Infrastructure as code

terraform-provider-ig1 — desired state over the same contract.

A Go provider on terraform-plugin-framework, OpenTofu-compatible, built against the same generated SDK the CLI uses — so resources behave exactly like the REST surface they front. It lives in terraform-provider-ig1/ at the repo root (workstream W11); the attribute-level contract is the generated registry documentation in that repo's docs/ — this page is the patterns.

Install

Current release: 0.2.2, for linux, macOS and Windows on amd64 and arm64. If a committed lock file pins 0.2.0 or 0.2.1, run terraform init -upgrade once — the mirror serves the current release only. Declare it the normal way:

terraform {
  required_providers {
    ig1 = {
      source  = "MoxForge/ig1"
      version = "~> 0.2"
    }
  }
}

The registry listing is still pending, so point your CLI at this hub’s provider mirror once — after that terraform init behaves exactly as it will when the listing lands, including version constraints and .terraform.lock.hcl. Put this in your CLI config file (~/.terraformrc, or ~/.tofurc for OpenTofu; %APPDATA%\terraform.rc on Windows):

provider_installation {
  network_mirror {
    url     = "https://docs.cloud.ig1.com/providers/"
    include = ["registry.terraform.io/moxforge/ig1",
               "registry.opentofu.org/moxforge/ig1"]
  }
  # Everything else keeps coming from the public registry. Without this block
  # the mirror would be asked for every provider you use, and answer 404.
  direct {
    exclude = ["registry.terraform.io/moxforge/ig1",
               "registry.opentofu.org/moxforge/ig1"]
  }
}

Then, in any configuration that declares the provider:

terraform init      # or: tofu init
# - Finding moxforge/ig1 versions matching "~> 0.2"...
# - Installing moxforge/ig1 v0.2.2...
# - Installed moxforge/ig1 v0.2.2 (verified checksum)
Lower case is not a typo. Terraform normalises a provider source address before resolving it, so the MoxForge/ig1 you write is fetched — and reported — as moxforge/ig1. Write it either way; it is the same provider.
« verified checksum » means the bytes matched, not that the publisher was checked. The mirror publishes each package’s h1: hash, terraform init verifies the download against it, and records it in your .terraform.lock.hcl — so every later run, and every colleague, is pinned to those exact bytes. What is still missing compared with a registry install is the publisher GPG signature; that arrives with the listing. Until then the chain of trust is this hub’s TLS certificate plus the checksum, which is why the mirror URL must be https (Terraform refuses anything else).
Teams on mixed platforms: lock every one you use. Installing from a mirror makes Terraform compute the lock hashes itself, so .terraform.lock.hcl ends up with a checksum for your platform only — and a colleague on a different OS, or Linux CI behind a macOS laptop, then fails to install. Terraform says so at the end of init — « Incomplete lock file information for providers ». Add the others once and commit the lock file:
terraform providers lock \
  -net-mirror=https://docs.cloud.ig1.com/providers/ \
  -platform=linux_amd64  -platform=linux_arm64 \
  -platform=darwin_amd64 -platform=darwin_arm64 \
  -platform=windows_amd64 -platform=windows_arm64

-net-mirror is not optional here, and its absence is a confusing failure rather than a quiet one: terraform providers lock deliberately ignores provider_installation and goes to the origin registry, so without the flag it reports that registry.terraform.io does not have a provider named registry.terraform.io/moxforge/ig1 — which is true, and is the whole reason this mirror exists.

Direct downloads

For an air-gapped filesystem mirror, or to check a package by hand. The zip contains terraform-provider-ig1_v0.2.2 — the name Terraform looks for inside a provider package.

PlatformPackage
Linux · x86-64terraform-provider-ig1_0.2.2_linux_amd64.zip
Linux · arm64terraform-provider-ig1_0.2.2_linux_arm64.zip
macOS · Apple siliconterraform-provider-ig1_0.2.2_darwin_arm64.zip
macOS · Intelterraform-provider-ig1_0.2.2_darwin_amd64.zip
Windows · x86-64terraform-provider-ig1_0.2.2_windows_amd64.zip
Windows · arm64terraform-provider-ig1_0.2.2_windows_arm64.zip
checksumsterraform-provider-ig1_0.2.2_SHA256SUMS
signatures<package>.minisig beside each zip and the checksumsminisign, signed at build time
public keyig1-minisign.pubid 8C6142C3D56C052E
curl -fLO https://docs.cloud.ig1.com/providers/dist/terraform-provider-ig1_0.2.2_linux_amd64.zip
curl -fLO https://docs.cloud.ig1.com/providers/dist/terraform-provider-ig1_0.2.2_linux_amd64.zip.minisig
curl -fLO https://docs.cloud.ig1.com/ig1-minisign.pub
minisign -Vm terraform-provider-ig1_0.2.2_linux_amd64.zip -P $(sed -n 2p ig1-minisign.pub)
curl -fLO https://docs.cloud.ig1.com/providers/dist/terraform-provider-ig1_0.2.2_SHA256SUMS
shasum -a 256 --ignore-missing --check terraform-provider-ig1_0.2.2_SHA256SUMS

Build from source instead

Only needed to modify it — the packages above are built from this same tree by the docs image, so they are never behind it.

cd terraform-provider-ig1
make build        # → bin/terraform-provider-ig1 (version read from config/build.yml)
make build-all    # → dist/ the six release zips + SHA256SUMS
make vet test
Not dev_overrides. This page used to tell you to build the binary and add a dev_overrides entry. That is a development mechanism: it skips terraform init altogether, warns on every plan, and makes your version constraint and your lock file do nothing at all. Use the mirror above — it is the supported way to install a provider that is not in a registry, and it behaves like the real thing.

Provider configuration

Auth is the phase-26 scoped key pair (create one in the portal under Security → API keys or with ig1 credential create --kind api-key) — the same model the REST surface uses, never an OIDC device flow:

provider "ig1" {
  endpoint   = "https://api.cloud.ig1.com"
  api_key    = var.ig1_client_id        # credential client_id
  api_secret = var.ig1_client_secret    # credential client_secret
  project_id = "a3d17d97b6444c20b54a20056e055ed7"   # the lab pilot project
  # ca_bundle = "ig1-internal-ca.pem"  # only for in-lab *.nip.io endpoints; the
  #                                    # public cloud.ig1.com surfaces need no bundle
  # insecure  = true                   # lab-only escape hatch — prefer ca_bundle
}

The tier model applies unchanged: plans that create need a tier-1 key, destroys need tier 2 — the API's 403 tier text surfaces verbatim in terraform apply output.

Resources (v1)

Twenty CRUD-complete resources — the registrations in internal/provider/provider.go, one section per domain below. Import by the API's native identity (server/volume/network ids, bucket, keypair, cluster and ASG names, exposure ids — exactly as the REST endpoints address them):

Compute

data "ig1_images"  "ubuntu" { name = "Ubuntu-24.04" }
data "ig1_flavors" "small"  { name = "m1.small" }

resource "ig1_keypair" "ops" {
  name       = "ops"
  public_key = file("~/.ssh/id_ed25519.pub")
  # IMPORT ONLY — public_key is required by design. Omitting it would be
  # Nova's key-GENERATION mode, which returns the private half; that key
  # would land in Terraform state, so the provider makes it unreachable.
}
resource "ig1_server" "web" {
  name      = "web-1"
  image_id  = data.ig1_images.ubuntu.images[0].id
  flavor_id = data.ig1_flavors.small.flavors[0].id
  key_pair  = ig1_keypair.ops.name
  networks  = [ig1_network.app.id]
  tags      = ["env:lab", "role:web"]
}
# terraform import ig1_server.web <server-id>

Block storage

resource "ig1_volume" "data" {
  name    = "data-vol"
  size_gb = 10        # growing in place is supported (os-extend); shrinking is not
}
resource "ig1_volume_attachment" "data_to_web" {
  volume_id = ig1_volume.data.id
  server_id = ig1_server.web.id      # device is optional — Nova assigns /dev/vdX
}
resource "ig1_volume_snapshot" "pre_upgrade" {
  volume_id = ig1_volume.data.id
  name      = "pre-upgrade"
  # force = true snapshots an attached volume — the result is CRASH-CONSISTENT
}
# terraform import ig1_volume.data <volume-id>

Network

resource "ig1_network" "app" {
  name = "app-net"
  cidr = "10.10.0.0/24"     # inline subnet; subnet_ids exposes what it created
}
resource "ig1_router" "egress" {
  name             = "app-egress"
  external_network = "ext-net"   # name OR id; resolved id lands in external_network_id
}
resource "ig1_router_interface" "app" {
  router_id = ig1_router.egress.id
  subnet_id = ig1_network.app.subnet_ids[0]
}
resource "ig1_security_group" "web" {
  name        = "web-sg"
  description = "web tier ingress"
  # Rules are FULLY managed: Neutron's default egress rules are removed at
  # create — add an explicit egress rule to keep outbound traffic.
  rules {
    direction        = "ingress"
    protocol         = "tcp"
    port_range_min   = 443
    port_range_max   = 443
    remote_ip_prefix = "10.10.0.0/24"
  }
  rules {
    direction        = "egress"
    remote_ip_prefix = "0.0.0.0/0"
  }
}
resource "ig1_floating_ip" "web" {
  pool      = "ext-net"            # name or id; empty = the first external network
  server_id = ig1_server.web.id    # associates in place; removing disassociates
}
# terraform import ig1_network.app <network-id>

DNS (Designate)

resource "ig1_dns_zone" "corp" {
  name  = "example.com"          # a domain YOU control; a name inside IG1's own
                                 # namespace is refused by the gateway
  email = "dns@example.com"      # SOA contact; omitted = the platform's
  ttl   = 3600
}
resource "ig1_dns_record" "www" {
  zone_id = ig1_dns_zone.corp.id
  name    = "www"                # short or fully qualified; "@" is the apex.
                                 # The GATEWAY qualifies it — exactly one
                                 # normaliser, so "www" and "www.example.com."
                                 # cannot become two records.
  type    = "A"
  records = ["203.0.113.10"]
  ttl     = 300
}
# name, type and zone_id force replacement: Designate cannot rename a recordset
# or change its type in place. records/ttl/description update in place, so a
# target change is an UPDATE — never a destroy/create, which would take the name
# out of resolution between the two calls.
# terraform import ig1_dns_zone.corp <zone-id>
# terraform import ig1_dns_record.www <zone-id>/<recordset-id>

Load balancing (Octavia, OVN driver)

resource "ig1_loadbalancer" "web" {
  name          = "web-lb"
  vip_subnet_id = ig1_network.app.subnet_ids[0]

  # Each listener gets its own pool holding every member below.
  listeners = [{ protocol = "TCP", port = 443 }]

  # Members are ADDRESSES, not instance handles — derive them from the
  # instances so a rebuilt backend shows up as a diff, not a black hole.
  # With internet_facing below, members MUST serve HTTPS on their port: the
  # edge re-encrypts to the VIP, so a plain-HTTP backend fails the handshake
  # (a self-signed certificate is accepted — the edge does not verify it).
  members = [
    for s in ig1_server.web : { address = s.addresses["app-net"][0], port = 8443 }
  ]

  # ON BY DEFAULT (the AWS posture) — without it, OVN keeps hashing flows
  # onto a dead member forever. No `type`: TCP listeners get a TCP probe,
  # UDP listeners get UDP-CONNECT, the only two the OVN driver implements.
  health_monitor = {
    delay       = 5
    timeout     = 3
    max_retries = 3
  }

  # Default false. true publishes the VIP through the .75 edge — a floating
  # IP on the VIP port, then an edge exposure targeting it — and toggles IN
  # PLACE: flipping it attaches or detaches the public path, never replaces
  # the load balancer. TCP listeners only (the edge speaks HTTPS to the target);
  # with several listeners the edge forwards to TCP/443 when there is one,
  # else the lowest TCP port.
  internet_facing = true

  # ON BY DEFAULT. Octavia's OVN VIP port is born with the tenant's DEFAULT
  # security group, which denies all ingress — so without this the load
  # balancer is ACTIVE, its members ONLINE, and nothing answers. true creates
  # "<name>-lb", opens each listener port on it (0.0.0.0/0), and attaches it to
  # the VIP port beside anything already there; destroy deletes it. false hands
  # you the VIP's ingress: it refuses traffic until you attach a group yourself.
  # Toggles IN PLACE, and the rules follow `listeners` on the next apply.
  manage_security_group = true
}

output "web_public" {
  # public_hostname   web-lb-<tenant-slug>.10.57.8.75.nip.io   (internal-CA cert; private name)
  # public_status     "pending" until the edge acknowledges the config, then "active"
  # floating_ip_address  10.168.210.x — the internal hop the edge forwards to
  # security_group_id    the managed group above; null when you manage it yourself
  value = "${ig1_loadbalancer.web.public_hostname} (${ig1_loadbalancer.web.public_status})"
}

L4 pass-through, internal by default: the VIP answers on the tenant subnet, TLS terminates on the members, and the pool algorithm is fixed to SOURCE_IP_PORT (the only one the OVN driver implements). internet_facing = true is the opt-in; the computed public_hostname, public_status and floating_ip_address carry the honest state — a plan that shows public_status = "pending" is telling the truth about the edge, not failing. If publishing fails the load balancer is kept and the diagnostic names what exists and how to finish by hand; nothing claims internet-facing on a floating IP alone. Destroy cascades through listeners, pools, monitors and members — taking down every exposure that targets the floating IP first, then the floating IP. The address is released only once the exposure list was read successfully and every exposure on it is confirmed gone; if the list cannot be read or a delete fails, the floating IP is kept (Neutron would hand it to the next project while a live hostname still forwards to it) and the diagnostic names what remains and the ig1 edge delete / ig1 network fip release commands. The multi-listener rule in the comment above is the provider's alone — ig1 network lb create and the MCP create_load_balancer take a single listener port, so those surfaces cannot diverge from it. "Internet-facing" here means the VIP is published through the platform edge at 10.57.8.75 under a generated .nip.io hostname — which resolves to a private address, so the flag alone buys fabric/VPN reach. For public reach, put a domain you own on an ig1_edge_exposure: the edge has answered on 185.255.84.178 since 2026-08-25, and a verified custom domain is served with a publicly-trusted certificate.

manage_security_group is the other half of "the load balancer works when the apply finishes". The OVN VIP port arrives carrying the tenant's default security group, which denies all ingress, so the plan used to converge on a resource that was ACTIVE with ONLINE members and served nothing — no attribute you could read said otherwise. Left at its default the resource creates {name}-lb, one ingress rule per distinct listener port from 0.0.0.0/0, attaches it beside whatever the port already carries, and exposes the id as the computed security_group_id. That default is honest rather than lax: the boundary is the network — the VIP answers only where your subnets, floating IPs and exposures already reach — and a narrower rule would recreate the same silence the first time internet_facing flips to true. Day 2 behaves the way a Terraform user expects: a port added to or removed from listeners is added to or removed from the group on the next apply, while a rule you added yourself to narrow it — another prefix, another protocol — is left alone, and the flag toggles in place (true → false detaches and deletes the managed group, false → true builds and attaches one; the load balancer is never replaced for it). Set it to false when the ingress is yours to model (a group you manage in ig1_security_group, say), and read the consequence in the plan: the VIP refuses traffic on the listener port until you attach one. Destroy deletes the group last — after the cascade releases the VIP port, because Neutron refuses to delete a group still in use — and only when it is the group this resource made: {name}-lb, attached to that VIP port. A group you attached yourself is never touched, and a group left behind by a failed delete is named in the diagnostic.

Kubernetes, autoscaling & the tenant edge (elasticity wave)

# ig1_cluster and ig1_asg ride the FACTORY service (factory_endpoint —
# derived from endpoint by swapping the api. host label for factory.).
resource "ig1_cluster" "web" {
  name                = "web"
  autoscaling_enabled = true   # the cluster-autoscaler owns the worker count:
  autoscaling_min     = 1      # manual scales are refused 409 while enabled,
  autoscaling_max     = 5      # so leave worker_count unset here
}

resource "ig1_asg" "web" {
  name              = "web"
  flavor            = "m1.small"
  image             = "ubuntu-24.04"
  min               = 1
  max               = 5        # desired unset: the reconciler owns it
  health_check_type = "tcp"    # consecutive-failure probes; one blip never
  health_check_port = 8080     # deletes a serving VM
  scale_out_alarm   = "web-cpu-high"   # ok -> alarm TRANSITIONS scale;
  scale_in_alarm    = "web-cpu-low"    # insufficient_data never does
}

resource "ig1_edge_exposure" "shop" {
  name          = "shop"                        # hostname becomes
  target_ip     = ig1_floating_ip.shop.address  # shop-<tenant-slug>.10.57.8.75.nip.io
  target_port   = 8443                          # ownership verified via Neutron
  custom_domain = "shop.example.com"            # CLAIMED, not routed — see below
}

# Your own domain is proved before it is served, and the proof lives in a zone
# this provider does not manage. Hence a separate resource and a depends_on —
# the aws_acm_certificate_validation shape, for exactly the same reason.
resource "cloudflare_record" "challenge" {
  zone_id = var.zone
  name    = ig1_edge_exposure.shop.domain_challenge_name  # _ig1-challenge.shop.example.com
  type    = "TXT"
  value   = ig1_edge_exposure.shop.domain_token
}

resource "ig1_edge_domain_verification" "shop" {
  exposure_id = ig1_edge_exposure.shop.id
  depends_on  = [cloudflare_record.challenge]   # apply FAILS if the TXT is not live
}

resource "cloudflare_record" "traffic" {
  zone_id = var.zone
  name    = "shop.example.com"
  type    = "A"
  # From the API, never hard-coded: it is the edge VIP while this platform has
  # no public ingress, and the public address afterwards.
  value   = ig1_edge_domain_verification.shop.domain_target
}

The elasticity contracts carry through unchanged: ASG scale-out passes tenant-quota admission (the 409 names the numbers), scale-in is drain-first (destroy waits for the teardown to finish), autoscaling_max is what admission charges, and an exposure reports pending until the edge acknowledges the rendered config — never a false active. Every factory/API refusal surfaces verbatim in the plan/apply diagnostics.

custom_domain is the one attribute on ig1_edge_exposure that does not force replacement: moving a domain in place avoids taking the derived .nip.io hostname down along with it. Removing it from the configuration releases the name back to the global pool, where another tenant may claim it — so that is not reliably reversible by adding it back. ig1_edge_domain_verification destroys as a no-op: a verification is a fact about the past, and there is no un-prove call to make. A customer domain on the managed edge gets a publicly-trusted (Let's Encrypt) certificate automatically since 2026-08-25 — only the platform-generated *.10.57.8.75.nip.io names keep the internal CA, because no public CA can validate a name that resolves into private space.

Object storage & databases (W6)

resource "ig1_bucket" "artifacts" {
  name = "ci-artifacts"        # the bucket name IS the identity
}
resource "ig1_database" "pg" {
  engine  = "postgres"      # or "kafka"
  name    = "app-db"
  size_gb = 20              # grow-only in place — a smaller size is refused

  # phase 52 (PostgreSQL): replicas scale IN PLACE, either direction;
  # backup is create-time only (WAL archiving + scheduled base backups)
  replicas = 2
  backup {
    enabled = true          # retention_days 7, schedule "0 3 * * *" by default
  }
}
# terraform import ig1_database.pg postgres/app-db

Credentials, webhooks & budgets (W4 / W7)

resource "ig1_credential" "ci" {
  kind  = "api-key"     # or "mcp"
  tier  = 1
  label = "ci-runner"
}
resource "ig1_webhook" "audit" {
  url    = var.webhook_receiver_url     # https only (the events SSRF guard)
  topics = ["iam.credentials", "agent.actions", "billing.budget.breached"]
}
resource "ig1_budget" "monthly" {
  amount_cents = 25000    # EUR 250.00 — breaches publish billing.budget.breached
}                         # v1 period is monthly only
# terraform import ig1_credential.ci <credential-id>

Egress & workload identity (phases 59-60)

resource "ig1_nat_gateway" "egress" {}   # no arguments: one per project, or none
# The platform enables SNAT on this project's router. Destroying it disables SNAT,
# and every instance without a floating IP loses outbound internet.
# Tier grant: discovery 1 / standard 1 / extension 1 / suspended 0.
# terraform import ig1_nat_gateway.egress <gateway-id>

resource "ig1_workload_identity" "ci" {
  cluster_id      = ig1_cluster.prod.id
  namespace       = "build"
  service_account = "runner"        # this SA's projected token is exchanged for IG1 creds
}                                   # the IRSA equivalent: no long-lived key in the cluster
# terraform import ig1_workload_identity.ci <binding-id>

Secrets manager (Barbican)

resource "ig1_secret" "db_password" {
  name    = "app-db-password"
  payload = var.db_password      # write-only on the API: uploaded at create, NEVER read back
}                                # changing payload replaces the secret — that IS rotation
# terraform import ig1_secret.db_password <secret-id>   # imports metadata; payload stays null
Secrets live in state. ig1_credential's client_secret and ig1_webhook's signing secret are returned ONCE at create — after that, state is the only place they exist. ig1_secret's payload is the same contract from the other direction: the provider never reads it back, so state holds exactly what you PUT (the standard aws_secretsmanager_secret_version.secret_string caveat). All three attributes are marked sensitive. Treat state as a secret: a remote, encrypted backend; never commit it; rotate by terraform taint + apply (revoke + re-issue) — or, for ig1_secret, by editing payload — not by reading it back.

Data sources

Data sourceAnswers
ig1_imagesthe Glance catalog (filter to one image id)
ig1_flavorsthe Nova flavor list (filter to one flavor id)
ig1_projectthe whoami-derived tenant info for the configured key
ig1_serverone server, looked up by name
ig1_networkone network, looked up by name
ig1_dns_zoneone zone by id or exact name — and whether the domain is actually delegated here. nameservers is what you give a registrar; delegation_status says whether they have published it (delegated / partial / elsewhere / absent / unreachable). Shares its name with the resource, which is normal: resource declares a zone you own, data reads one that already exists — the common case, since a zone is created once and its records churn
data "ig1_network" "app" { name = "app-net" }
data "ig1_project" "self" {}

# Look up a zone somebody else created, and refuse to pretend it is live
# when the registrar has not pointed the domain here yet.
data "ig1_dns_zone" "corp" { name = "example.com" }

resource "ig1_dns_record" "www" {
  zone_id = data.ig1_dns_zone.corp.id
  name    = "www"
  type    = "A"
  records = [ig1_floating_ip.web.address]
}

output "publish_these_nameservers" { value = data.ig1_dns_zone.corp.nameservers }
output "delegated"                 { value = data.ig1_dns_zone.corp.delegation_status }
A zone full of records is inert until the domain points here. Creating a zone is half of delegation; the registrar still has to publish the NS records. Until this release no client surface could tell you whether they had — the API served the check, all three SDKs carried it, and the CLI, the agent tool surface and this provider were all blind to it, so a plan could apply cleanly over records nothing on the internet resolves. delegation_status is that check. It reaches a resolver, so it degrades to a warning rather than failing the plan: a hiccup upstream must not take down every configuration that merely wanted the zone id.
Out of v1. Org-user management (an admin surface, not customer Terraform), and fleet monitoring (/v1/monitoring/fleet, /v1/monitoring/alarms) — platform-admin only since 2026-08-12, because the fleet digest carries physical node names and operator alarm text, which belong to no tenant. Read-only domains (status, usage, audit, the tier catalogue) stay data you read, not state you manage.