East-west is yours. North-south is ours.
IG1 tenant networks live on one shared fabric segment, and only 10.57.8.0/24 is routed by that fabric. Everything inside the tenant boundary — networks, subnets, security groups, routers, floating IPs, load balancers — is self-service and east-west by default. The internet boundary is platform-provided: public exposure goes through the managed .75 edge, per hostname — requested for any address you own, or switched on in the same call for a load balancer marked internet-facing. This page explains each piece, and — where IG1 deliberately differs from what AWS habits expect — why.
1 · Networks and subnets
A network plus its subnet is one create on the curated surfaces:
ig1 network vpc create --cidr 10.10.0.0/24 makes the Neutron network and
the subnet together (IPv4 only). CIDRs are validated strictly before
Neutron sees them: Neutron itself accepts 10.240.0.7/24 by
silently meaning 10.240.0.0/24, so a typo in the host octet
yields a subnet nobody asked for — IG1 refuses it instead. On raw REST the resources
stay separate (/v1/openstack/network/v2.0/networks,
…/subnets), exactly as Neutron serves them.
2 · Security groups are default-deny — on purpose
On AWS, the default security group lets members talk to each other and lets everything out. On IG1, egress is the same (allow-all) — outbound traffic inside the tenant boundary works — but a new group allows no ingress at all. An instance launched without an explicit rule set is unreachable, on purpose: agents and pipelines have published SSH to the internet as a side effect of "make me a VM", and default-deny is the posture that makes that impossible by accident.
- Empty by default.
create_security_groupwith just a name creates an empty group — no seeded ICMP/SSH pair. - Convenience is scoped. Passing an
ingress_cidrre-adds the ICMP + SSH/22 pair from that range only; explicit rules always name aremote_ip_prefix— it never defaults to the whole internet. - World-SSH is a decision made out loud. Any rule opening TCP/22 to 0.0.0.0/0 or ::/0 is refused unless you say
allow_ssh_from_anywhere=true(API/MCP) or confirm on the CLI — and the refusal happens before the group is created, so nothing half-open is left behind. - The aws-like template exists for teams that consciously want the AWS posture during cutover: in the portal it is the security-group preset switch at creation (one self-referencing ingress rule — ingress-from-group-members — plus the allow-all egress both clouds share). You will usually find that writing the three rules you actually need takes less time than debugging the ones you inherited.
3 · Your tenant router carries the gateway — you do not attach one yourself
Every tenant is provisioned with a network, a subnet and a router that is uplinked to the external network (SNAT on; floating IPs attach through it) — since 2026-08-15 the platform's external segment is one shared L2 and per-tenant gateways coexist (operations log, gotcha 151; before that, two outages — gotchas 107 and 121 — came from a second gateway-bearing router on what turned out to be three host-local segments). External addresses stay a platform-governed resource: additional routers you create are east-west (attach their subnets to your uplinked tenant router for north-south), and the ability to self-assemble a second internet gateway does not exist, at three depths:
- The API returns 403 with the full story when a tenant attaches an external gateway to a router.
- The MCP tool
create_routerhas no gateway parameter at all — refusal by schema, so an agent cannot even express the request.delete_routerlikewise refuses a gateway-bearing router rather than clearing the gateway first: that router is your tenant's uplink. - The Terraform resource
ig1_routercarries anexternal_networkattribute for the platform operator; a tenant credential using it receives the same 403.
Router interfaces — plumbing between your own subnets — are fully self-service
(add_router_interface / remove_router_interface). Your
east-west topology is entirely yours; the internet boundary is entirely ours. That
trade is what lets a shared-fabric private cloud make an availability promise at all.
4 · Floating IPs — public by default since 2026-08-26
allocate / associate / disassociate /
release map exactly onto the Elastic IP verbs — reachability
included. A floating IP allocated today comes from
public-network (185.255.84.64/26):
attach it to an instance, open the port you need in its security group, and the
address answers from the internet — SSH included. A floating IP is billed
€0.005/hour — AWS's exact public-IPv4 rate — returns to the pool
when you release it, and your tier's quota caps how many you can hold at once
(1 / 2 / 4 by tier, raised on request like AWS). Two pools exist, and
every surface picks the right one
for you: public-network for anything that must answer from the
internet; the legacy 10.168.210.0/24 (internal,
site-only) stays for the platform's own tenant and for anything the .75 edge
forwards to — the edge cannot route to the public range, which is why its targets
keep the legacy pool.
If your workload is HTTP(S), you may not need an address at all: the edge publishes a hostname with TLS for €0.02/exposure-hour (the same class of service as AWS's Application Load Balancer) — one exposure can front any number of backends.
5 · Load balancing — one call, L4, health-checked by default, internal by default
Load balancing is Octavia with the OVN provider driver, reached at
/v1/openstack/load-balancer/v2/lbaas/. Octavia's native model
is five objects created one at a time; a caller driving them singly eventually creates
the load balancer and fails on the listener, leaving a VIP that balances nothing and
still bills. So every curated surface ships the composite: one create
builds VIP + listener + pool + health monitor + members, or rolls back what it made,
newest first — and names exactly what survived if the rollback itself failed.
ig1 network lb create, the ig1_loadbalancer Terraform
resource and the MCP create_load_balancer tool are all this composite.
- L4 only. The OVN driver terminates nothing: TCP/UDP passed through, no HTTP/HTTPS listener, no TLS termination, no L7 rules. Put the certificate on the backends — or terminate TLS at the edge for north-south traffic, which is exactly what an internet-facing load balancer does (below).
- The listener port is open on arrival. Octavia hands the VIP port your tenant's default security group, which denies all ingress — so a load balancer came up ACTIVE, its members ONLINE, and answered nothing. Every create now also builds the group {name}-lb, opens each listener port on it, and attaches it to the VIP port beside anything already there; deleting the load balancer deletes it. See the listener security group.
- Internal by default, published two ways. The VIP lives on a tenant subnet. Set
internet_facingand the composite publishes it through the .75 edge as {name}-{tenant-slug}.10.57.8.75.nip.io — a hostname with TLS, zero public IPv4 (its floating IP comes from the legacy internal pool, the only one the edge can reach) — see publish a load balancer to the internet. Or attach a floating IP from the public pool to the VIP yourself: direct L4 reachability, no hostname, TLS stays on the members. Without either, a load balancer creates no north-south exposure, and one that is unreachable from the office is behaving as designed. - The health monitor is on by default — the AWS posture, where a target group without health checks does not exist. Without one, OVN keeps hashing flows onto a dead member forever. The probe type is derived from the listener protocol (TCP → TCP, UDP → UDP-CONNECT — the two the driver implements); delay, timeout and retries are tunable; opting out (
health_check=false,--health-check=false) is possible and the surfaces state the consequence when you do. - Member health is readable.
ig1 lb get/get_load_balancerreport each pool's monitor and every member's operating_status, so a dead member is found by reading, not by bisecting backends. A member that fails its consecutive-probe threshold goes ERROR and stops receiving new flows. - Members are IP literals. A rebuilt instance is a stale member — unless the members are managed by an autoscaling group, which registers and deregisters them for you (see the elasticity guide).
Open the listener port — the security group the composite manages
Octavia's OVN provider creates the VIP like any other Neutron port: port security on, and the tenant's default security group attached — the group that denies every ingress packet (§2). So a load balancer built through any surface used to come up ACTIVE, with every member ONLINE, and answer nothing on its own listener port: the worst failure shape there is, because every status you can read says it is fine. AWS never asks this of you — an ALB makes you attach a security group and its wizard opens the listener port in it; a classic NLB has no security group at all and simply answers — so we stopped asking it too. After the graph is ACTIVE, and before the internet-facing hops below, every curated surface runs three more calls:
# 1. one group per load balancer, named after it
POST /v1/openstack/network/v2.0/security-groups
{"security_group": {"name": "<lb-name>-lb",
"description": "listener ports for load balancer <lb-name> (managed by IG1)"}}
# 2. one ingress rule per DISTINCT listener port; the protocol follows the listener (TCP or UDP)
POST /v1/openstack/network/v2.0/security-group-rules
{"security_group_rule": {"security_group_id": "<sg>", "direction": "ingress",
"protocol": "tcp", "port_range_min": 443, "port_range_max": 443,
"remote_ip_prefix": "0.0.0.0/0", "ethertype": "IPv4"}}
# 3. attach it BESIDE what the VIP port already carries — the port is read first, never clobbered
PUT /v1/openstack/network/v2.0/ports/<vip_port_id>
{"port": {"security_groups": ["<existing…>", "<sg>"]}}
- 0.0.0.0/0 is the honest default, and it is narrower than it reads: the boundary here is the network, not this rule. The VIP sits on a tenant subnet, so it is reachable only where you have already allowed it — east-west inside your tenant, and from outside only through a floating IP or an edge exposure you asked for. A narrower default — the VIP's own subnet, say — would recreate exactly the bug above the moment you mark the load balancer internet-facing. The group is yours: narrow the rule whenever your reachability model is narrower than your network's.
- The opt-out states its consequence.
manage_security_group=false(REST, portal, provider, MCP) or--manage-security-group=false(CLI) creates nothing and attaches nothing — and the answer says so: the VIP refuses traffic on the listener port until you attach a group that allows it. It is never silent. - Every read carries the group.
security_group_idon the REST, CLI, provider and MCP answers, a Security group facet in the portal — joined from the VIP port itself, so a group you attached by hand is visible too. When the platform manages none it reads —, next to the same warning. - Delete removes only what this platform created. The order is exposure, floating IP, the load-balancer cascade, then the group — Neutron refuses to delete a group a port still uses, and the cascade is what releases the VIP port. A group qualifies only if it is named exactly {name}-lb and was attached to this load balancer's VIP port; a group you attached yourself is never touched. If that last delete fails the load balancer is still gone and the answer says the group may remain — it costs nothing, unlike a floating IP, but it is litter.
- The load balancer is never rolled back because this hop failed — the same posture as publishing. A create that could not open the port names exactly what exists (the group id, if one was made) and what did not happen, and how to finish by hand:
ig1 network sg rule add, then attach the group to the VIP port on REST (PUT /v1/openstack/network/v2.0/ports/{port_id} — no CLI verb sets a port's groups today). It never reports a listener as reachable when it is not.
Publish a load balancer to the internet
One toggle, default off, shaped like AWS's NLB scheme (internal
or internet-facing): the portal wizard's
Internet-facing checkbox, the REST body's internet_facing,
ig1 network lb create --internet-facing, the provider's
internet_facing attribute and the MCP create_load_balancer
parameter of the same name. It is not a new service and not a new route: it composes
three calls that already exist, in this order, once the load-balancer graph is
ACTIVE.
# 1. a floating IP on the VIP port — 10.168.210.x, internal; on its own this publishes NOTHING
POST /v1/openstack/network/v2.0/floatingips
{"floatingip": {"floating_network_id": "<external-net>", "port_id": "<lb.vip_port_id>"}}
# 2. an edge exposure targeting that floating IP (ownership verified server-side — it is yours)
POST /v1/edge/exposures
{"name": "<lb-name>", "target_ip": "<floating_ip_address>", "target_port": <listener port>}
# → {"hostname": "<name>-<tenant-slug>.10.57.8.75.nip.io", "status": "pending"}
# 3. nothing else — the edge terminates TLS and forwards HTTPS to the floating IP.
- The status is honest. The hostname is pending until the edge acknowledges the configuration and active after — the same derived status as any exposure (§6), never a green light nothing has wired. Every list and get carries the public facet: hostname + status when the load balancer is published, internal only when it is not.
- TCP listeners only. The edge speaks HTTPS to the target — it re-encrypts to the floating IP on your listener port — so
internet_facingis for a TCP listener whose members serve TLS themselves on the member port (a plain-HTTP backend fails the edge's handshake; put a certificate on the members, self-signed is fine — the edge does not verify it). A UDP listener withinternet_facingis refused with a one-sentence reason before the first call — never silently ignored. - One listener is published, and which one is a rule, not a guess. The exposure targets TCP/443 when a TCP listener has it, otherwise the lowest TCP port. Only the Terraform provider's
listenerslist can present more than one —ig1 network lb createandcreate_load_balancertake a single listener port, so those surfaces cannot diverge from the rule. - The name becomes the hostname. Lowercased, anything outside [a-z0-9-] becomes a hyphen, leading/trailing hyphens trimmed, truncated to 30 characters. A name that leaves nothing after that is refused before the first call.
- The load balancer is never rolled back because publishing failed — a working internal load balancer is not destruction-worthy. If the floating IP or the exposure fails, the answer names exactly what exists (load-balancer id and VIP; floating-IP id and address if it was allocated), what does not, and how to finish by hand (
ig1 network fip …/ig1 edge expose …). Nothing ever claims internet-facing when only the floating IP landed. - Delete cleans up in order: exposure, then floating IP, then the load balancer (cascade). Both are found by the join key — the exposure whose target is the floating IP sitting on the VIP port. If that discovery fails, the load balancer is deleted and the answer says so: the floating IP and the exposure may remain, and they are billed until you release them.
- The certificate depends on the name, and the default name is the private one. The generated …10.57.8.75.nip.io hostname carries the internal-CA wildcard — clients install the lab CA as they already do for the portal — and that is permanent rather than pending: the name resolves to a private address, and no public CA will ever certify a name it cannot connect to. Put your own domain on the exposure (§6.1) and it gets a publicly-trusted certificate automatically, because that name points at the public front door. Same load balancer, two names, two certificates.
What "internet-facing" means here, said once. Reachable from outside the tenant through the platform edge at 10.57.8.75, published under the generated .nip.io hostname — which is the fabric/VPN reach, not the internet, because that hostname resolves to a private address. The edge itself has been reachable from the internet since 2026-08-25 (185.255.84.178, direct-attached to the controllers), and the way to use that reach is to attach a domain you own (§6.1): the same VIP, answered under a public name with a publicly-trusted certificate. internet_facing builds the plumbing; the custom domain is what makes it public.
6 · The .75 edge — the north-south door
Public exposure is self-service, per hostname, through the managed edge gateway at 10.57.8.75 — the one address block the fabric routes, and so the one door every public path uses: an exposure you create yourself, or the one an internet-facing load balancer creates for you (§5). An exposure is {name, target_ip, target_port, protocol}; creating one publishes {name}-{tenant-slug}.10.57.8.75.nip.io, TLS terminated at the edge for each exposure, forwarded to the target inside your tenant network.
POST /v1/edge/exposures # create (tier 1)
GET /v1/edge/exposures # list yours, with derived status + the gateway block
DELETE /v1/edge/exposures/{id} # un-publish (tier 2 — this takes a production hostname DOWN)
POST /v1/edge/exposures/{id}/domain # claim a domain YOU own (tier 1) — reserves, routes nothing
POST /v1/edge/exposures/{id}/verify # read the TXT challenge back (tier 1) — THIS starts the routing
DELETE /v1/edge/exposures/{id}/domain # release it (tier 2)
- Ownership is verified, not asserted. The target must be a floating IP or fixed IP that Neutron — queried under your project credential — says is yours. A fabric address (10.57.8.0/24) is refused outright: pointing the edge at the platform's own front doors is not exposure, it is proxying the admin surfaces.
- Status is derived, never asserted. An exposure is pending until the edge acknowledges a configuration at least as new as the row — never a green "active" that nothing has actually wired.
- The generated hostname carries the internal-CA wildcard, and always will. Clients install the lab CA, exactly as they already do for the portal. This is not a pending config swap: *.10.57.8.75.nip.io resolves to a private address, a public CA proves control by connecting to the name, and so no such certificate can exist. The exit is not waiting — it is bringing a name of your own (§6.1), which does get one.
- Exposures are metered per tier, and every refusal names the numbers and the tier that would grant more.
6.1 · Your own domain
An exposure can also serve a name you bought — app.example.com from GoDaddy, Gandi, Cloudflare, anywhere. It is additive: the derived .nip.io hostname keeps working, and releasing the domain later does not take your app down.
The order is the whole trick, and getting it wrong looks like an outage:
- Claim the name (
POST …/domain, or--domainonig1 edge expose). This reserves it and mints a token. Nothing is served yet. - Publish the TXT record the answer gives you: _ig1-challenge.app.example.com = the token.
- Verify (
POST …/verify). We look the record up; this is the step that puts the name on the edge. - Then point app.example.com at the address the answer names.
- Publishing the A record before step 3 returns 421. The edge answers only for names it has been told to serve, so an unverified name is refused rather than forwarded to whoever happens to be behind the default backend. That is the gateway working, not failing.
- Take the target address from the API, never from this page. It is configuration (gateway.custom_domain_target on any list), and it has already changed once — the 2026-08-25 public-ingress cutover moved it off the private VIP. Anyone who had hard-coded the old value broke that day; anyone reading the field did not.
- A claim is global, first claim wins. A real domain has no tenant slug to disambiguate it, so two tenants holding one name would mean one of them reading the other's traffic. A name somebody else holds answers 409 — without naming who: the name is public information, the holder is another customer's business.
- Verification failures say which side is at fault. 400 means the record is not published yet and shows what was found at that name; 503 means our resolver did not answer, which is our problem and not yours — your record may be perfectly correct. Registrar changes commonly take a few minutes.
- Wildcards and platform names are refused — *.example.com would cover names you never proved you own, and anything under nip.io / ig1.com belongs to the platform.
- Custom domains start at the Standard tier, and the discovery tier grants none: a free tier that can obtain publicly-trusted certificates for arbitrary names is an abuse surface.
Two consequences worth planning around. The order matters twice — the certificate is ordered after step 4, so a name verified but not yet pointed at us has no certificate to serve. And certificates are a shared budget: Let's Encrypt rate-limits per account, which is why custom domains start at the Standard tier and are capped per tier. Any list still tells you the reachability state you are in (gateway.internet_reachable) — it reads true here today.
7 · DNS — self-service records, honestly scoped
DNS is Designate, curated at /v1/dns/zones — zones and recordsets
are tenant self-service on all four surfaces: REST,
ig1 dns zone|record, the
ig1_dns_zone / ig1_dns_record Terraform
resources, and the console. The MCP surface carries zone listing, record CRUD
(A, AAAA, CNAME, TXT, MX), and — since 2026-08-26 — the delegation check and the zone
import below (get_dns_delegation, import_dns_records), so an
agent can now finish a domain move rather than stopping at the half that does not prove
anything. Terraform reads the delegation as state on the
ig1_dns_zone data source
(delegation_status, delegation_missing);
the import is a one-shot migration action and has no Terraform form by design.
Two rules Designate enforces that trip newcomers:
record names are fully qualified, trailing dot included (the curated
surfaces refuse a name without it rather than let "www" silently become something else),
and a record can only be as fresh as its TTL — lower the TTL before a change, not
after.
This page said "delegated, hardened, serving, and resolving for nobody" for eight days, and that was true: the original delegation on 2026-08-17 pointed at an RFC 1918 glue address, which no public resolver will chase. The exit was named then and taken since — one glue address something outside can reach.
What follows for you: a zone you create here is a zone the internet can be pointed at. Designate accepts it, serves it from the same public nameservers, and the practical limit is no longer who can reach us but what the record points at — an A record naming a floating IP still names a private address wherever it is queried from. The records that are meaningful off-lab are the ones pointing at your custom-domain edge exposures (§6.1), which is also the path that earns a publicly-trusted certificate.
Moving a domain you already run elsewhere is a first-class flow rather than a support ticket, on every surface including the console:
GET /v1/dns/zones/{zone_id}/delegation # what to publish, and whether it took (tier 0)
POST /v1/dns/zones/{zone_id}/import # paste a zone file or a provider's table (tier 1)
- The nameservers come from your zone, not from this page. delegation reads the zone's own apex NS records and compares them with what the internet currently says, returning a verdict: delegated / partial / elsewhere / absent / unreachable. Publish what it lists — that way the day the serving pool grows, every zone gains the new nameserver and every surface says so, instead of this page telling you a stale pair for weeks.
- unreachable is a finding, not an outage. A delegation pointing at nameservers that do not answer SERVFAILs everywhere. Reported as "no records" that reads as "you have not started" — the opposite of the truth, and it sends you to redo work that is already done.
- Import is dry-run by default. It turns a pasted zone file or a provider's export into recordsets and shows you the plan, with conflicts named, before anything is written.
Two things remain true regardless. The zone is hardened: zone transfer is refused (AXFR → Transfer failed), recursion is REFUSED, and version.bind answers "not disclosed" — so it will not serve as a resolver for your workloads, by design. And the platform's own per-instance and per-floating-IP sink records stay unpublished: putting them in a public zone would make every VM's name and address enumerable. Your zones are yours to publish; ours are not a directory of your estate.
8 · Where each surface carries it
| Feature | REST | CLI | Terraform | MCP |
|---|---|---|---|---|
| Networks + subnets | /v1/openstack/network/v2.0/… | ig1 network vpc | ig1_network | create_network, create_subnet, … |
| Security groups | …/security-groups | ig1 network sg | ig1_security_group | create_security_group, rules |
| Routers | …/routers (gateway attach: tenant 403) | — | ig1_router (gateway = operator) | create_router (no gateway param) |
| Floating IPs | …/floatingips | ig1 network fip | ig1_floating_ip | allocate/associate/… |
| Load balancers | /v1/openstack/load-balancer/v2/lbaas/… (+ the listener security group; + floating IP + exposure when internet-facing) | ig1 network lb (--internet-facing, --manage-security-group) | ig1_loadbalancer (internet_facing, manage_security_group) | composite + member health + internet_facing + manage_security_group |
| Edge exposures | /v1/edge/exposures | ig1 edge | ig1_edge_exposure | create_edge_exposure, list, delete |
| DNS | /v1/dns/zones (+ /delegation, /import) | ig1 dns zone|record | ig1_dns_zone (+ delegation_status), ig1_dns_record | zones + records CRUD, get_dns_delegation, import_dns_records |
A dash is a real absence, not a hidden feature: what is not on a surface today is not on this table. The parity gate keeps the surfaces that do carry a feature in lockstep.