Provisioning a Tenant

Provisioning a Tenant #

**Historical. Do not follow this page.** Tenants are created through the Deevnet API ([ADR-0015](/docs/architecture/decisions/0015-tenant-onboarding-through-api/)), deployed by [CHG-0010](/docs/changes/2026/0010-tenant-api-cutover/) on 2026-09-17: a tenant is admitted, then declares itself, its workloads and its names through the `deevnet/deevnet` provider, holding no Proxmox credential, no vault access and no index. **Every mechanism below has been removed**, not merely superseded. `TENANTS.md`, `deevnet_tenants`, `vault_tenant_tsig_keys` and `vault_tenant_state_keys` are gone, and so are the role tasks that read them. Adding a row where this page says to would create nothing; hand-allocating an index would collide with what the API has already allocated, because the API allocates against its own registry **and** the live fabric. Kept because the *why* below is still accurate, and because a rebuild of a much older substrate may need to read it. For the current procedure see [Building Tenants](/docs/runbook/building-recovery/build-tenants/).

How to create, verify and destroy a tenant on the mobile substrate.

This is the operational procedure. The why is ADR-0001 (the fabric model) and ADR-0002 (the numbering); the conceptual model is Tenant Building.


What the substrate gives you #

A tenant is not a VLAN, and creating one requires no switch change, no core router change, and no ticket. The tenant hypervisor runs an EVPN fabric; a tenant is an isolated routing domain inside it.

You getDetail
An isolated routing domainOne VRF per tenant β€” traffic cannot reach another tenant’s
A /24 of your ownAllocated from 10.20.128.0/18
An anycast gateway.1 of your subnet, hosted by the fabric
Outbound internetSNAT at the fabric exit node, via the perimeter
VMs from a base imageFedora, cloud-init enabled
A DNS zone of your own<tenant>.<site>.deevnet.net, plus its reverse β€” you author the records

What you supply is the Terraform that declares it. Everything is code β€” a tenant is rebuilt from its own repository, not from a backup. That includes your names: the substrate gives you the zone and the key to write it with, and never writes a record on your behalf.


Prerequisites #

Two clones, because onboarding and the tenant lifecycle live in different places ( ADR-0006):

  • deevnet-tenant-fabric (the repository this step still uses, renamed) β€” to allocate the index, copy the reference implementation, and issue the fabric attachment. Substrate side.
  • deevnet-tenant-<name> β€” the tenant’s own repository, which is where every apply happens.

Also:

  • terraform on your PATH, and an ssh-agent that can reach GitHub (the tenant module is fetched by git tag at init)
  • Read access to the Deevnet inventory vault (credentials are rendered from it, never stored)
  • Your tenant’s TSIG secret, exported as TF_VAR_tsig_key_secret β€” the substrate issues it during onboarding (step 2) and it is what lets the tenant publish its own names
  • If you want the state store, your state credential as AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY. It is optional ( ADR-0007)

1. Allocate a tenant index #

Do this first. Every identifier a tenant uses β€” its VRF, its VNets, its subnet β€” is derived from a single number, so this is the one decision that can collide with another tenant.

Open TENANTS.md, take the next free index, and add your row:

IndexTenantVRF VNIVNet VNI(s)Overlay subnetStatus
2grooveiq100022002010.20.130.0/24active

For index n on mobile, everything follows:

IdentifierFormulan = 2
VRF VXLAN10000 + n10002
VNet VNI20000 + n*10 + i20020
Subnet10.20.{128+n}.0/2410.20.130.0/24
Gateway.1 of that subnet10.20.130.1

Never reuse an index while its tenant exists, and never hand-pick a VNI β€” an identifier that happens to be free on this node today will collide the moment the fabric gains a second member.

Names are capped at 8 characters, lowercase alphanumeric, starting with a letter. Proxmox limits SDN zone and VNet IDs to 8 characters and the zone ID is the tenant name. The module validates this so you find out at plan rather than from the API.

2. Onboard the tenant on the substrate #

Two substrate steps go with a new index, both driven from the same declared list and both done once. After this, nothing you do to the tenant touches a substrate repository again.

Add the tenant to deevnet_tenants in ansible-inventory-deevnet/<site>/group_vars/all/tenants.yml:

deevnet_tenants:
  - name: tdemo
    index: 1
  - name: grooveiq
    index: 2

Generate a TSIG secret and put it in the vault under vault_tenant_tsig_keys['grooveiq']:

openssl rand -base64 32

Then run the two substrate plays:

# Egress: give the tenant VRF a way out (ADR-0003)
cd ansible-collection-deevnet.net
ansible-playbook playbooks/proxmox-node-network.yml --tags tenant-egress

# DNS: create the tenant's zones, issue and bind its key (ADR-0004)
cd ../ansible-collection-deevnet.mgmt
ansible-playbook playbooks/site.yml --limit tenant_dns --tags tenant-dns

# Delegation: point the resolver at them
cd ../ansible-collection-deevnet.net
ansible-playbook playbooks/opnsense.yml --tags tenant-dns

Egress must be applied after the tenant’s Terraform has created the VRF, so in practice this step is split: register the tenant and do the DNS half now, and run the egress tags after step 4.

3. Create the tenant’s repository #

A tenant is its own repository. Create deevnet-tenant-grooveiq and fill it from the reference implementation β€” a copy out of the factory, not into it:

cp -r examples/tenant/. ../deevnet-tenant-grooveiq/

The example cannot be applied as it stands: it ships with tenant_index = 0, which the module rejects. That is deliberate, so an unedited copy fails loudly rather than quietly becoming a second tenant with someone else’s name. Replace every REPLACE-ME, and set the two values that actually differ, in terraform.tfvars:

tenant_name  = "grooveiq"   # <= 8 chars
tenant_index = 2            # from TENANTS.md

Then have the substrate issue the fabric attachment β€” run this in the factory:

make tenant-attachment TENANT=../deevnet-tenant-grooveiq

That writes fabric.auto.tfvars with the controller and node. A tenant never invents them: the substrate hands them over at onboarding, in the same act that issues the TSIG key and the egress. They used to be read out of the fabric’s Terraform state, which only worked while tenants lived inside the factory.

If you want the state store, uncomment the backend "s3" block and set its key to tenants/grooveiq/terraform.tfstate. Leaving it commented is a valid choice β€” you then carry your own custody.

4. Apply #

Everything from here happens in the tenant’s repository:

cd ../deevnet-tenant-grooveiq
make init      # fetches the tagged module - needs GitHub and your ssh-agent
make plan
make apply

Credentials are rendered from the inventory vault by the targets themselves; nothing is stored in the repo. AUTO=1 skips the approval prompt for non-interactive runs.

This creates the tenant’s EVPN zone (its VRF), its VNet, its subnet, and its VMs.

The module is pinned by tag, and init vendors it into .terraform/modules/. Neither plan nor apply re-fetches it, so moving to a newer module version takes an explicit terraform init -upgrade β€” a repository that never re-inits stays on its old module indefinitely, quietly.

5. Verify #

terraform output

Then confirm the things that actually matter:

CheckExpected
VM address10.20.130.10 upward β€” .1 is the gateway, .2-.9 are reserved for the fabric
Gatewayping 10.20.130.1 from the VM answers
Egresscurl ifconfig.me succeeds
PerimeterThe core router sees source 10.20.50.22, never 10.20.130.0/24
IsolationAnother tenant’s subnet is unreachable
Forward DNSdig @10.20.99.1 grooveiq-1.grooveiq.mobile.deevnet.net +short answers 10.20.130.10
Reverse DNSdig @10.20.99.1 -x 10.20.130.10 +short answers the same name
NamespaceAn update signed with another tenant’s key is REFUSED for your zone

6. Destroy #

make destroy    # in the tenant's own repository

Removes the VMs and the tenant’s SDN objects. Release the index in TENANTS.md in the same change.

Re-applying rebuilds the tenant identically β€” that is the property the whole model rests on, and it is worth exercising deliberately rather than assuming.


Things that surprise people #

Addresses are assigned, not leased. Proxmox implements SDN DHCP for Simple zones only; EVPN zones have no DHCP at all. Workloads are addressed by cloud-init from the tenant’s subnet, so addressing is deterministic and declared rather than dynamic. The fabric still owns the address space, the gateway and the isolation.

The template VMID changes. Proxmox assigns the next free ID on every image rebuild rather than reusing one, so template_vm_id needs updating after the image factory runs.

Your tenant never appears on the core router. No VLAN interface, no DHCP scope, no per-tenant firewall rule. The perimeter sees only the aggregate transit network, because traffic is SNATed at the fabric exit node on the way out. If you are looking for your subnet on the router, you will not find it β€” that is the design working.

Isolation is the VRF, not a firewall rule. Tenants cannot reach each other because they are in separate routing domains, not because something is filtering between them.

Your names are not in the resolver you query. Workloads resolve through the core router, but the core router holds none of your records. It delegates your zone to the tenant DNS server, which is authoritative for it. That is why an opnsense_dns run cannot delete your records β€” they were never there ( ADR-0004).

You cannot publish outside your own zone, and this is enforced. Your TSIG key is bound to your zones by server-side metadata. An update aimed at another tenant’s zone comes back REFUSED from PowerDNS; it is not a convention your Terraform is trusted to respect.

Destroying the tenant removes its names. The records are in your Terraform state, so make tenant-destroy takes them with it. The zone and the key survive β€” those are substrate onboarding, not tenant content β€” so re-applying restores every name with no substrate action.

Page last modified: September 17, 2026