Skip to content

Deploy a Netbox design¤

design_deploy renders a YAML design with Jinja2, validates the rendered document, then creates or updates Netbox objects in dependency order. It never deletes objects omitted from the design. Existing objects receive the supplied fields on every run. The task does not calculate a diff before sending updates.

Design overview¤

Netbox Design Driven Modeling (NDDM) is an approach to building and maintaining the Netbox data model from reusable network designs. A design describes the objects and relationships needed for a site, service, or network pattern in YAML. Jinja2 templates let you reuse the same design for multiple deployments by supplying different context values, such as site names, device names, or address pools.

NorFab implements this approach through design_deploy: it turns the design into Netbox objects, resolves supported references, and processes collections in dependency order. Designs can include resource allocation requests for values that are not known in advance. Keeping designs and their context in version control makes the intended model reviewable and reusable.

NDDM designs are additive and idempotent. Deployment creates missing objects and updates matched objects according to each handler's rules. Reapplying the same design reuses those objects instead of creating duplicates. Objects omitted from a design remain in Netbox. Custom functions must implement their own idempotent behavior. The task models data in Netbox. Applying device configuration is a separate automation step.

design_input_schema:
  type: object
  properties:
    site:
      type: string
    prefix:
      type: string

roles:
  - name: p2p

prefixes:
  - prefix: {{ context.prefix }}
    role: p2p
    site: {{ context.site }}

devices:
{% for device_id in [1, 2] %}
  - name: router{{ device_id }}
    site: {{ context.site }}
    role: router
    platform: cisco_ios
    device_type:
      manufacturer: Cisco
      model: ASR1001
    status: active
    interfaces:
      GigabitEthernet0/0/0:
        ip_addresses:
          - create_ip:
              prefix:
                role: p2p
                site: {{ context.site }}
              mask_len: 30
        {% if device_id == 1 %}
        connection:
          device: router2
          interface: GigabitEthernet0/0/0
        {% endif %}
        bgp_peerings:
          - local_as: 65000
            remote_as: 65000
            description: iBGP between Routers
{% endfor %}

For an agent-oriented authoring workflow, see Author a Netbox design with an agent.

A design is one YAML document with object collections such as sites and devices. It can also contain three optional control sections:

Section Purpose
design_input_schema Validate values supplied through context before rendering.
jinja_functions Load Python functions for use while Jinja2 renders the YAML.
custom_functions Load Python functions called later, during collection deployment.
Remaining top-level keys Define lists of Netbox objects dictionaries to create or update, such as sites, devices, and ip_addresses. See supported Netbox objects and deployment order.

For YAML text or a file URL, put design_input_schema and jinja_functions before the first object collection. The loader reads only that initial metadata block to validate context and register Jinja2 functions before rendering the full document. custom_functions is loaded after rendering and does not have this placement requirement.

Keep pre-render metadata at the start

The loader parses only the initial YAML lines containing design_input_schema, jinja_functions, custom_functions, comments, and indented lines. It stops at the first other top-level line. This portion of document must be valid YAML on its own: keep Jinja2 expressions and top-level template directives out of it, and put design_input_schema and jinja_functions before any object collection. Use one YAML document. Do not insert --- between metadata and collections. A schema or Jinja2 function declared after a collection will not be available before rendering.

Design input schema¤

design_input_schema validates the supplied context before Jinja2 renders the design. Use inline JSON Schema or an nf:// / git:// Python file defining a Pydantic DesignInput model. The validated context is available as context in the template.

design_input_schema:
  type: object
  properties:
    site: {type: string, minLength: 1}
  required: [site]
  additionalProperties: false

sites:
  - name: "{{ context.site }}"
design_input_schema: nf://netbox/designs/site_input.py

sites:
  - name: "{{ context.site }}"
    status: active

The referenced nf://netbox/designs/site_input.py contains:

from pydantic import BaseModel, ConfigDict

class DesignInput(BaseModel):
    model_config = ConfigDict(extra="forbid")
    site: str

Jinja2 rendering¤

When design is YAML text or a file URL, Jinja2 renders it before YAML parsing, flattening, validation, or deployment. It receives context and a netbox pynetbox instance. File-based designs also support relative includes. jinja_functions loads named Python callables from nf:// or git:// files as both filters and template globals. When design is supplied as a Python dictionary, the task uses its values directly: Jinja2 expressions such as {{ context.site }} inside that dictionary are not evaluated.

Jinja2 runs before deployment

Jinja2 filters and global functions run while the design is rendered, before any object in that design is created. They may query objects already in Netbox, but must not depend on objects the same design plans to create: those objects do not exist yet. Use deployment-time custom_functions when a function needs objects created by an earlier collection.

devices:
{% for name in context.devices %}
  - name: "{{ name }}"
    site: "{{ context.site }}"
    role: ROUTER
    device_type: {manufacturer: ACME, model: ROUTER}
{% endfor %}
jinja_functions:
  site_label: nf://netbox/designs/site_label.py

sites:
  - name: "{{ context.site }}"
    description: "{{ context.site | site_label('ACME') }}"

The referenced nf://netbox/designs/site_label.py contains:

def site_label(site: str, prefix: str) -> str:
    return f"{prefix} site: {site}"

In context.site | site_label('ACME'), Jinja2 passes context.site as site and 'ACME' as prefix. The function does not receive context or netbox unless the template passes them explicitly.

jinja_functions:
  site_label: nf://netbox/designs/site_label.py

sites:
  - name: "{{ context.site }}"
    description: "{{ site_label(context.site, 'ACME') }}"

The referenced nf://netbox/designs/site_label.py contains:

def site_label(site: str, prefix: str) -> str:
    return f"{prefix} site: {site}"

In site_label(context.site, 'ACME'), the template passes site and prefix explicitly. Pass netbox explicitly too if the function needs to query it.

Jinja2 reads may query netbox, but object writes belong in collection handlers or deployment-time custom functions: rendering runs before design prerequisites are created.

Netbox objects and deployment order¤

Each design section contains a list of object records. The rows show the order in which Netbox objects are processed. Deployment stops at the first failure. For records with specified values, Match fields identify an existing object to update instead of creating another. Allocation tasks and custom functions use their own lookup rules.

Order Design top-level key Netbox object Match fields Description and current limitations
1 tenants netbox.tenancy.tenants name -
2 regions netbox.dcim.regions name Parent can be a name.
3 manufacturers netbox.dcim.manufacturers name -
4 platforms netbox.dcim.platforms name Manufacturer can be a name.
5 device_types netbox.dcim.device_types manufacturer, model Default platform can be a name.
6 device_roles netbox.dcim.device_roles slug Uses the supplied slug or generates it from the name.
7 sites netbox.dcim.sites name Region and tenant can be names.
8 rack_roles netbox.dcim.rack_roles name -
9 racks netbox.dcim.racks site, name Site, role, and tenant can be names.
10 roles netbox.ipam.roles name -
11 rirs netbox.ipam.rirs name -
12 asn_ranges netbox.ipam.asn_ranges name Ranges are not site-scoped.
13 asns netbox.ipam.asns asn Explicit numbers are matched globally. create_asn allocates from a named range. Supports sites and role.
14 vlan_groups netbox.ipam.vlan_groups name Scope by rack, location, site, site group, region, cluster, or cluster group.
15 vlans netbox.ipam.vlans group, vid VLANs with a specified ID require both fields. Existing VLANs gain new tags and custom-field list items. create_vlan allocates a VID. Direct VLAN site is unsupported.
16 route_targets netbox.ipam.route_targets name VRF and L2VPN references can inline target dictionaries.
17 vrfs netbox.ipam.vrfs name, rd Import/export route targets accept named definitions or query filters. Existing target lists gain missing targets.
18 l2vpns netbox.vpn.l2vpns name Import/export route targets accept named definitions or query filters. Existing target lists gain missing targets. Nested terminations are flattened. Named VLAN terminations also populate vlans.
19 prefixes netbox.ipam.prefixes prefix, vrf Explicit prefixes can select location, site, site group, or region scope, and refer to a VLAN by {group, vid}. Existing prefixes gain new tags and custom-field list items. create_prefix supports site scope and VLAN association through vlan (VID) plus vlan_group (name).
20 devices netbox.dcim.devices name. tenant disambiguates duplicate names Nested components are flattened before writes.
21 interfaces netbox.dcim.interfaces device, name Independent interfaces are written before those with parent, lag, or bridge.
22 l2vpn_terminations netbox.vpn.l2vpn_terminations Attached interface or VLAN Attachments require l2vpn and either device with interface, or VLAN group with vid. An attachment already used by another L2VPN is rejected.
23 power_ports netbox.dcim.power_ports device, name Nested connection is flattened.
24 console_ports netbox.dcim.console_ports device, name Nested connection is flattened.
25 power_outlets netbox.dcim.power_outlets device, name Nested connection is flattened.
26 console_server_ports netbox.dcim.console_server_ports device, name Nested connection is flattened.
27 connections netbox.dcim.cables Existing cable on both terminations Creates interface, console, and power cables. Tenant can be a name or reference dictionary. Each end currently has one termination. An already-cabled port cannot be moved.
28 vrrp_groups netbox.ipam.fhrp_groups protocol, group_id Inline vip becomes an explicit IP address. Use top-level create_ip.vrrp_group for allocation.
29 ip_addresses netbox.ipam.ip_addresses address, vrf Addresses with specified values are bulk-written. create_ip allocates the next available address and can assign it to an interface or named VRRP group.
30 vrrp_group_assignments netbox.ipam.fhrp_group_assignments group, interface Associates an existing group and interface with priority.
31 primary_ip netbox.dcim.devices Device: site and name, plus tenant when supplied Sets an existing primary_ip4 or primary_ip6. It does not create the address.
32 bgp_communities netbox.plugins.bgp.community value, plus description when supplied A different description creates another community. Without description, the value must identify one object.
33 routing_policies netbox.plugins.bgp.routing_policy name Peering references can inline policy dictionaries or use policy names.
34 peer_groups netbox.plugins.bgp.peer_group name Peering references can provide a name or inline dictionary.
35 bgp_peerings netbox.plugins.bgp.session Supplied or generated name Uses create_bgp_peering to create missing sessions. Existing sessions are left unchanged. Design peerings default to create_reverse: false. Supports named or inline peer groups and import/export policies.
36 config_context netbox.extras.config_contexts name data is a dictionary. sites and tenants accept name lists for scope.
37 local_context_data netbox.dcim.devices Device: site and name, plus tenant when supplied Replaces each device's local context dictionary last.

Ordinary records use Netbox API field names. Handlers bulk-create missing objects and bulk-update existing ones. Common name references, such as device.site, device.role, and vlan.group, are resolved by their handlers. Fields without a documented name shorthand must use a payload accepted by the target Netbox API. Unknown top-level collections are rejected.

Devices accept device_type: {manufacturer: ACME, model: AGG SWITCH} or device_type: AGG SWITCH. A string must match exactly one existing Netbox model, including during dry runs. Missing or ambiguous models fail. Use the dictionary form to disambiguate or preview types defined in the same design. New devices default to status: planned. Omitted status preserves existing device status. Explicit status applies to both creates and updates.

Set a device's config_template to an existing template's exact name or integer ID. Names must resolve to exactly one template, including during dry runs. Omit the field to preserve an existing association, or use null to clear it. Templates must exist before devices are deployed.

devices:
  - name: branch-router-1
    config_template: branch-router

When updating an existing record, design handlers add requested tags and custom-field list values without removing existing members. They also add VRF and L2VPN route targets, interface tagged VLANs, and config-context scope lists. Config-context sites and tenants accept name lists. Other scope lists use NetBox IDs. Scalar custom fields replace their old values. VLAN-group vid_ranges, local context data, and config-context data retain replacement behavior. Named object and multiobject custom-field values are resolved to NetBox IDs before writes.

Supported design verbs¤

Design verbs are YAML keys that request an operation, such as querying existing objects, allocating resources, or calling custom Python. Each verb is supported only in the locations listed below. Its value supplies the filters or arguments used during deployment.

Verb Supported objects Description
query VRF/L2VPN import and export route-target entries Selects existing targets by Netbox filters after rendering. Attaches all matches. Fails if none match.
custom_function All top-level collection records. Device local_context_data Calls registered Python with context, Netbox, dry-run mode, and supplied arguments. Functions handle their own writes. Local-context functions return a dictionary.
create_asn asns. BGP peering local_as and remote_as, including nested peerings Allocates or reuses an ASN from a named range. See the ASN examples below.
create_vlan vlans Allocates a VID from a VLAN group.
create_prefix prefixes Allocates a child prefix.
move_ip ip_addresses, including nested interface IPs Moves all source-interface addresses to the destination interface.
create_ip ip_addresses, including nested interface IPs Allocates an IP, optionally assigning it to an interface or FHRP group.

Each query entry contains only query with a nonempty filter dictionary. Route-target lists accept queries and named definitions, not custom_function. Put custom allocation calls in route_targets, then reference their targets. Function return values are not automatically used as relationship references.

See Custom functions, L2VPNs and Terminations, and the allocation examples below.

Built-In Task Wrappers¤

Task wrappers call standalone NorFab NetBox service tasks during the relevant deployment stage. The wrapper contains that task's arguments, without renaming them. The allocation wrappers are useful when a design knows the pool and the object's purpose, but not its final ASN, VLAN ID, subnet, or IP address. Several branches can use the same design and receive different available values from their own pools.

Task wrapper How it works
create_asn Allocates or reuses an ASN in asns or BGP peering local_as and remote_as.
create_vlan Selects an available VID from an existing VLAN group's allowed ranges.
create_prefix Allocates a child prefix of the requested length inside an existing parent prefix. Supports VLAN association by VID and group name.
move_ip Moves all addresses from a source interface and optionally sets destination primaries.
create_ip Allocates an address from an existing prefix and can assign it to a device interface or named VRRP group.
create_bgp_peering Creates a BGP session under bgp_peerings. Can derive the local address from an interface and create a reverse session.

Both forms belong under ip_addresses. Specify each address when its value is known:

To put the same VIP or anycast address on two interfaces, add the address under each interface with role: vip or role: anycast. NetBox creates a separate IP record for each interface. Running the design again keeps both records on their original interfaces.

ip_addresses:
  - address: 192.0.2.10/24
    description: ACME gateway 1
  - address: 192.0.2.11/24
    description: ACME gateway 2
  - address: 192.0.2.12/24
    description: ACME gateway 3
  - address: 192.0.2.13/24
    description: ACME gateway 4
  - address: 192.0.2.14/24
    description: ACME gateway 5

When the values are not known, ask the standalone create_ip task to select five available addresses from an existing prefix. Each request has a distinct description so it can find its address on a repeat run:

ip_addresses:
  - create_ip:
      prefix: 192.0.2.0/24
      description: ACME gateway 1
  - create_ip:
      prefix: 192.0.2.0/24
      description: ACME gateway 2
  - create_ip:
      prefix: 192.0.2.0/24
      description: ACME gateway 3
  - create_ip:
      prefix: 192.0.2.0/24
      description: ACME gateway 4
  - create_ip:
      prefix: 192.0.2.0/24
      description: ACME gateway 5

For each object type, specified values are created or updated before next-available requests run. Allocation tasks look for an existing match on repeat deployment. Give each request a stable name or description and enough scope to identify it, otherwise a repeat run can allocate another value.

Entries under bgp_peerings call create_bgp_peering after the design's devices, interfaces, IP addresses, and ASNs have been deployed. Use a stable supplied or generated session name so repeat deployments find the existing session. Existing sessions are not updated by the design:

Design peerings create only the specified direction by default. Set create_reverse: true on a peering to create the reverse session as well. The standalone create_bgp_peering task retains its own default. Peering records can specify an existing tenant name with tenant. Reverse sessions inherit it. Existing sessions are left unchanged.

For a dual-stack interface, use two peering entries with scoped local_ip_query filters containing family: 4 and family: 6. Each query must match one address. With local_interface supplied, an omitted remote address is derived from the first interface IP in the selected family, for IPv4 /30 or /31 and IPv6 /127 subnets.

bgp_peerings:
  - name: ACME BRANCH PEER
    device: acme-branch-rtr-1
    local_interface: Ethernet1
    local_as: 4200650001
    remote_as: 4200650000

Allocation examples¤

The design creates an RIR, ASN range, VLAN group, and parent prefix. It then allocates an ASN, VLAN ID, child prefix, and IP address from those objects, in deployment order.

rirs:
  - name: ACME PRIVATE
    is_private: true

asn_ranges:
  - name: ACME BRANCH ASNS
    rir: ACME PRIVATE
    start: 4200650001
    end: 4200650099

asns:
  - create_asn:
      asn_range: ACME BRANCH ASNS
      description: ACME branch ASN

vlan_groups:
  - name: ACME BRANCH VLANS
    vid_ranges: [[100, 199]]

vlans:
  - create_vlan:
      vlan_group: ACME BRANCH VLANS
      name: ACME USERS

prefixes:
  - prefix: 198.19.224.0/20
    description: ACME allocation pool
  - create_prefix:
      parent: 198.19.224.0/20
      prefixlen: 24
      description: ACME user subnet

ip_addresses:
  - create_ip:
      prefix: {description: ACME user subnet}
      description: ACME user gateway

Prerequisites: A text custom field named deployment_owner enabled for ASN objects (ipam.asn) in Netbox.

The design creates an ASN range under the RIR, then create_asn selects an available number from that range and sets its custom fields. Its description lets the task find the same ASN on a repeat run.

rirs:
  - name: ACME PRIVATE
    is_private: true

asn_ranges:
  - name: ACME BRANCH ASNS
    rir: ACME PRIVATE
    start: 4200650001
    end: 4200650099

asns:
  - create_asn:
      asn_range: ACME BRANCH ASNS
      description: ACME branch ASN
      custom_fields:
        deployment_owner: ACME

You can also allocate an ASN directly in a BGP peering. This example reuses the branch ASN above and allocates a peer ASN from the same range:

bgp_peerings:
  - name: branch-upstream
    device: branch-router-1
    local_address: 192.0.2.1
    remote_address: 192.0.2.2
    local_as:
      create_asn:
        asn_range: ACME BRANCH ASNS
        description: ACME branch ASN
    remote_as:
      create_asn:
        asn_range: ACME BRANCH ASNS
        description: ACME peer ASN

The device and IP addresses must exist or be defined in the design. Wrappers also work on peerings nested under devices or interfaces. Do not combine a wrapper with the corresponding local_as_query or remote_as_query. Use a stable description or role/site combination for repeatable allocation. Dry runs do not reserve numbers, so allocations from the same range may report the same available ASN.

The design creates a VLAN group allowing IDs 100-199, then create_vlan selects the next available ID for ACME USERS. Its name and group identify it on a repeat run.

vlan_groups:
  - name: ACME BRANCH VLANS
    vid_ranges: [[100, 199]]

vlans:
  - create_vlan:
      vlan_group: ACME BRANCH VLANS
      name: ACME USERS
      description: User access VLAN

The design creates the /20 parent prefix, then create_prefix allocates an available /24 within it. Its description helps the task find that child on a repeat run.

prefixes:
  - prefix: 198.19.224.0/20
    description: ACME allocation pool
  - create_prefix:
      parent: 198.19.224.0/20
      prefixlen: 24
      description: ACME user subnet

The design creates a /24 prefix, then create_ip selects an available address from the prefix identified by its description.

prefixes:
  - prefix: 198.19.225.0/24
    description: ACME user subnet

ip_addresses:
  - create_ip:
      prefix: {description: ACME user subnet}
      description: ACME user gateway

Create ASN Next Available Allocation¤

The standalone create_asn task takes an asn_range name and selects an available ASN. Supply a stable description so repeat deployments can find the ASN already allocated within that range. The allocated ASN can use sites: [SITE-1, SITE-2] and role: ROLE NAME. sites assigns the ASN to sites, it does not select the range by site. ASN ranges are not site-scoped.

Create VLAN Next Available Allocation¤

The standalone create_vlan task takes vlan_group and name, then selects an available VID from the group's ranges. It reuses a VLAN with the same name in that group on repeat deployment.

VLANs require a group

Every VLAN in a design must belong to a VLAN group. Direct VLAN-to-site association is unsupported. Scope the group instead.

A VLAN group accepts one of rack, location, site, site_group, region, cluster, or cluster_group as a scope name. The referenced object must exist. For a rack or location, site may also be supplied to distinguish equal names at different sites.

vlan_groups:
  - name: BRANCH FLOOR VLANS
    location: Floor 2
    site: BRANCH-1
    vid_ranges: [[100, 199]]

Create Prefix Next Available Allocation¤

The standalone create_prefix task takes a parent prefix or parent filter dictionary and a prefixlen, then allocates an available child prefix. A stable description, optionally with site, role, or vrf, helps it find the same child on repeat deployment. create_prefix.parent can select the parent through a dictionary of filters. The task accepts site for the allocated child's scope and the pair vlan (VID) and vlan_group (group name) to associate an existing VLAN. Site cannot replace the group for VLAN identification. It has no location, site_group, or region argument for the child.

Both vlan and vlan_group must be supplied together. The VLAN must exist when allocation runs. VLANs defined in the same design are created before prefixes. A missing group or VLAN stops allocation. On repeat deployment, the association is updated if needed. Omitting both arguments preserves it.

vlan_groups:
  - name: BRANCH VLANS
    vid_ranges:
      - [100, 199]
vlans:
  - name: USERS
    vid: 100
    group: BRANCH VLANS
prefixes:
  - prefix: 198.19.224.0/20
  - create_prefix:
      parent: 198.19.224.0/20
      prefixlen: 24
      description: Branch user subnet
      vlan: 100
      vlan_group: BRANCH VLANS

Create IP Next Available Allocation¤

IPv4 /32 and IPv6 /128 allocations work with the default create_peer_ip setting. The task automatically skips peer creation and peer-subnet reuse for these host prefixes. There is no need to set create_peer_ip: false explicitly.

The standalone create_ip task takes a prefix string or filter dictionary and allocates an available IP. It can assign that IP to a device and interface. If nested under an interface's ip_addresses, the design supplies those two arguments during flattening.

To allocate a VIP, supply vrrp_group with a unique existing VRRPv2 or VRRPv3 group name. Groups defined in the same design are deployed before IP allocations. The task reuses an address assigned to that group within the selected prefix, optionally matching description, and defaults the IP role to vrrp. Missing, ambiguous, or non-VRRP group names fail before allocation.

Put this wrapper in the top-level ip_addresses collection. Do not nest it under an interface: vrrp_group cannot accompany device, interface, or is_primary: true. Group allocation disables peer IP allocation automatically. See the allocated VIP example below.

A dry run does not create the VLANs, groups, or prefixes proposed by the design. Allocation lookups therefore require those prerequisites to exist already. The create_ip dry-run limitation still applies: mask_len is ignored and no child subnet is created.

Move Interface IP Addresses¤

ip_addresses accepts a move_ip wrapper containing from_device, from_interface, device, interface, and optional is_primary (default false). Nested wrappers inherit the destination device and interface. Moves run before explicit IP writes and allocations, after interfaces exist. Deployment controls instance, branch, and dry_run are inherited. Moved addresses appear in updated results.

Primary-IP behavior is documented in the Move IP task. Dry runs do not simulate chained moves.

ip_addresses:
  - move_ip:
      from_device: router-1
      from_interface: Ethernet1
      device: router-2
      interface: Ethernet2
      is_primary: true

Custom fields¤

Put a custom_fields dictionary on an object record. Its keys are Netbox custom-field names, not labels. Definitions must already exist and be enabled for that object type. The design passes values through to Netbox, which validates their types and choices. Updates PATCH supplied values without clearing omitted custom fields. Use null to clear a nullable scalar field.

When updating an ASN, a design adds new sites and tags without removing existing ones. When updating a VLAN or prefix, it adds new tags. For these objects, if both the existing and supplied values of a custom field are lists, the design adds only new items. Supplying [] leaves an existing list unchanged. For custom fields, null clears the value, and a new scalar value replaces the old one.

Object and multiobject custom fields accept numeric Netbox IDs or string references. The field's related object type determines how strings are matched:

Related object type String reference
IP address (ipam.ipaddress) Address including prefix length, such as 192.0.2.10/32
Prefix (ipam.prefix) Prefix, such as 192.0.2.0/24
BGP community (netbox_bgp.community) Community value, such as 65100:100
Circuit (circuits.circuit) Circuit ID (cid)
Device type (dcim.devicetype) Model
Other object types Name

Each reference must match exactly one existing object when its record is processed. Ambiguous matches cause an error. Use a numeric ID to distinguish objects with the same address, prefix, value, CID, model, or name. Referenced objects must already exist or be created earlier in the deployment order. Devices are processed before IP addresses, so device custom fields cannot reference IPs created later in the same design. Dry runs also require referenced objects to exist.

For example, with device custom fields management_ip (object) and peer_ips (multiobject) both related to ipam.ipaddress:

devices:
  - name: branch-router-1
    site: BRANCH-1
    role: ROUTER
    device_type:
      manufacturer: ACME
      model: BRANCH ROUTER
    custom_fields:
      management_ip: "192.0.2.10/32"
      peer_ips: ["192.0.2.11/32", "192.0.2.12/32"]

The site, role, device type, custom-field definitions, and reference IPs must exist before this example is deployed. Multiobject updates add references without removing existing members.

FHRP group assignments and ConfigContext objects do not support custom fields. Device-local context is ordinary JSON data, not a custom-field definition. The design does not create custom-field definitions.

Prerequisites: the referenced device prerequisites and custom-field definitions enabled for devices and interfaces respectively.

devices:
  - name: branch-router-1
    site: BRANCH-1
    role: ROUTER
    device_type: {manufacturer: ACME, model: ROUTER}
    custom_fields:
      deployment_owner: ACME
    interfaces:
      Loopback0:
        type: virtual
        custom_fields:
          service_name: routing

Prerequisites: deployment_owner enabled for prefixes and IP addresses. Arguments belong inside the allocation wrapper.

prefixes:
  - prefix: 192.0.2.0/24
    custom_fields: {deployment_owner: ACME}
  - create_prefix:
      parent: 192.0.2.0/24
      prefixlen: 28
      description: ACME services
      custom_fields: {deployment_owner: ACME}
ip_addresses:
  - create_ip:
      prefix: {description: ACME services}
      description: ACME service IP
      custom_fields: {deployment_owner: ACME}

Prerequisites: the prefix and a nullable deployment_owner custom field.

prefixes:
  - prefix: 192.0.2.0/24
    custom_fields: {deployment_owner: null}

Custom functions¤

Custom functions let a design run Python during deployment for use cases such as custom next-available allocation, Netbox lookups, or object creation that needs additional logic.

The following contract applies to custom_function on an object record. A function inside local_context_data has a different return contract: its dictionary becomes the device's context data.

When branch is supplied to design_deploy, the provided netbox client is already configured for that branch. Queries and writes through this client use the deployment branch. Custom functions do not need to initialize branching themselves.

Argument Type Supplied by Meaning
context dict Deployment Design input context after optional schema validation.
netbox pynetbox.core.api.Api Deployment Pynetbox client bound to the deployment's Netbox instance and branch. Use it for queries and writes.
dry_run bool Deployment Whether this is a preview. The function must avoid writes when True. Deployment does not intercept its API calls.
Other record fields As defined in YAML Design record Every field except custom_function is passed unchanged as a keyword argument. For example, profile: foo becomes profile="foo".

custom_function selects the registered function and is not passed as an argument. Do not supply context, netbox, or dry_run in the record, because deployment supplies them. No device object is passed automatically.

Every ordinary top-level object collection can mix object definitions and custom function records. Custom function records pass through flattening and lookup preparation without requiring object identity fields such as a route target name or a VLAN group. The collection handler processes object definitions first, then the custom functions run in record order. The function receives its record's remaining fields and handles its own Netbox writes. local_context_data uses the separate device-local contract.

For these object records, the return value is a report to the caller. Deployment appends it to the collection's custom list in the same order as the custom function records. It does not use the value to create or update an object, supply fields to another handler, or resolve references in later collections. The function must make any Netbox writes itself and honor dry_run. A string, number, dictionary, list, or None can be returned. Choose a JSON-serializable value that is useful to callers.

Function outcome Deployment behavior
Returns a value Appends {"function": NAME, "result": VALUE} under the collection's custom results list, such as route_targets.custom.
Returns nothing Records null as the function result.
Raises an exception Reports the failure and stops deployment. Earlier writes are not rolled back.

This example allocates a route target. Register the Python file under custom_functions, then use custom_function: allocate_bgp_route_target in a route_targets record. The function runs during the route-target deployment stage, after route-target records without custom_function have been processed. The record's other fields become keyword arguments, and deployment also supplies context, netbox, and dry_run.

custom_functions:
  allocate_bgp_route_target: nf://netbox/designs/allocate_bgp_route_target.py

tenants:
  - name: ACME

route_targets:
  - custom_function: allocate_bgp_route_target
    tenant: ACME
    description: ACME branch route target

The referenced nf://netbox/designs/allocate_bgp_route_target.py contains:

def allocate_bgp_route_target(context, netbox, dry_run, tenant, description):
    # Replace this fixed value with a real allocation rule when needed.
    name = "65000:100"
    existing = netbox.ipam.route_targets.get(name=name)
    if not dry_run:
        if existing:
            existing.update({"tenant": {"name": tenant}, "description": description})
        else:
            netbox.ipam.route_targets.create(
                {"name": name, "tenant": {"name": tenant}, "description": description}
            )
    return {"name": name}

The tenant and description fields become function arguments. Deployment supplies context, netbox, and dry_run, so do not put them in the record. This dummy allocator chooses 65000:100 and creates or updates that route target unless dry_run is true. Its return {"name": name} produces this excerpt of the deployment result:

{"route_targets": {"custom": [{"function": "allocate_bgp_route_target", "result": {"name": "65000:100"}}]}}

The dictionary describes what the function did. The returned name is not used for the Netbox write or for later VRF or L2VPN references. Returning name as a string would instead put "65000:100" in result. Custom functions must handle their own lookups and writes, including dry-run behavior. Only load trusted Python files.

This function calculates a VLAN name from the additional profile argument, then creates or updates the VLAN. The VLAN group must already exist when the vlans stage runs.

custom_functions:
  create_profile_vlan: nf://netbox/designs/create_profile_vlan.py

vlan_groups:
  - name: BRANCH VLANS

vlans:
  - custom_function: create_profile_vlan
    group: BRANCH VLANS
    vid: 120
    profile: foo

The referenced nf://netbox/designs/create_profile_vlan.py contains:

def create_profile_vlan(context, netbox, dry_run, group, vid, profile):
    name = f"{profile.upper()}-{vid}"
    vlan_group = netbox.ipam.vlan_groups.get(name=group)
    if vlan_group is None:
        raise ValueError(f"VLAN group not found: {group}")
    existing = netbox.ipam.vlans.get(group_id=vlan_group.id, vid=vid)
    if not dry_run:
        if existing:
            existing.update({"name": name})
        else:
            netbox.ipam.vlans.create(
                {"group": vlan_group.id, "vid": vid, "name": name}
            )
    return {"group": group, "vid": vid, "name": name}

group, vid, and profile are passed unchanged from the design record. The calculated name is FOO-120. Because this is a custom-function record, the function performs its own lookup and write. Its return value appears under vlans.custom.

Jinja2 expands context.devices into one record per device. It does not allocate anything. During the later bgp_communities stage, each record calls the custom function with its device name. The function reuses a community identified by its description or takes the next free value in the example range.

custom_functions:
  allocate_device_community: nf://netbox/designs/allocate_device_community.py

bgp_communities:
{% for device in context.devices %}
  - custom_function: allocate_device_community
    device: "{{ device }}"
{% endfor %}

Supply a context such as {"devices": ["branch-rtr-1", "branch-rtr-2"], "asn": 65100}. The referenced nf://netbox/designs/allocate_device_community.py contains:

def allocate_device_community(context, netbox, dry_run, device):
    asn = context["asn"]
    description = f"Community for {device}"
    communities = list(netbox.plugins.bgp.community.all())
    existing = next(
        (item for item in communities
         if item.description == description
         and item.value in {f"{asn}:{number}" for number in range(100, 200)}),
        None,
    )
    used = {item.value for item in communities}
    value = existing.value if existing else next(
        (f"{asn}:{number}" for number in range(100, 200)
         if f"{asn}:{number}" not in used),
        None,
    )
    if value is None:
        raise ValueError(f"No free BGP community in {asn}:100-199")
    if not dry_run and existing is None:
        netbox.plugins.bgp.community.create(
            {"value": value, "description": description}
        )
    return {"device": device, "value": value}

The design context passes asn=65100 to the function, so allocated values start at 65100:100. Each created community is identified on repeat deployment by Community for <device>. The returned dictionary appears under bgp_communities.custom. A dry run makes no reservations, so multiple records may propose the same free value.

This simplified allocator is intended for sequential deployments. It does not reserve values against concurrent allocations. The built-in handler for explicit community records matches value and, when supplied, description. A different description creates a separate community. If description is omitted, more than one matching value is an error.

Nested Device Records¤

Nesting keeps a device and its related objects together, making the design more natural to read and shorter to write. Interfaces, IP addresses, and peerings inherit their device or interface names instead of repeating them in separate top-level records.

Device records may contain interfaces, bgp_peerings, power_ports, console_ports, power_outlets, console_server_ports, and local_context_data. An interface may contain ip_addresses, bgp_peerings, connection, vrf, untagged_vlan, tagged_vlans, and vrrp. The task flattens these before validation or writes. It adds device and interface names to extracted records and merges them with records already at the top level. Inline VRFs, VLANs, VRRP groups, route targets, routing policies, and BGP peer groups are also collected into their earlier collections. Interface VRF identity dictionaries and VLAN group/VID dictionaries remain references. Nested route-target, routing-policy, and peer-group dictionaries are collected as definitions, even with only a name.

Prerequisites: Named sites, roles, device types, VLAN group, referenced VLANs/VRF, remote endpoints, peering IPs/ASNs, and the BGP plugin.

This shows every device-nested collection and every interface-nested field. Referenced sites, roles, device types, VLANs, and remote endpoints must exist or be defined earlier in the design. Use the focused tabs for complete examples of the less obvious fields.

devices:
  - name: branch-agg-1
    site: BRANCH-1
    role: AGGREGATION
    device_type: {manufacturer: ACME, model: AGG SWITCH}
    interfaces:
      Port-Channel1: {type: lag}
      Ethernet1:
        type: 10gbase-x-sfpp
        lag: {name: Port-Channel1}
        connection: {device: branch-router-1, interface: Ethernet1}
        tagged_vlans: [{group: BRANCH VLANS, vid: 100}]
      Vlan100:
        type: virtual
        vrf: BRANCH VRF
        untagged_vlan: {group: BRANCH VLANS, vid: 100}
        ip_addresses: [{address: 192.0.2.21/24}]
        vrrp: {group_id: 10, vip: 192.0.2.1/24, priority: 200}
        bgp_peerings:
          - name: branch-agg-1-to-branch-router-1
            remote_address: 192.0.2.11
            local_as: 65100
            remote_as: 65200
            create_reverse: false
    bgp_peerings:
      - name: branch-agg-1-loopback-peer
        local_address: 192.0.2.101
        remote_address: 192.0.2.102
        local_as: 65100
        remote_as: 65200
        create_reverse: false
    power_ports:
      PSU1: {type: iec-60320-c14}
    console_ports:
      Console: {type: rj-45}
    power_outlets:
      Outlet-1: {type: iec-60320-c13}
    console_server_ports:
      Line-1: {type: rj-45}
    local_context_data: {profile: aggregation}

Equivalent using top-level keys:

devices:
- name: branch-agg-1
  site: BRANCH-1
  role: AGGREGATION
  device_type:
    manufacturer: ACME
    model: AGG SWITCH
interfaces:
- type: lag
  name: Port-Channel1
  device: branch-agg-1
- type: 10gbase-x-sfpp
  lag:
    name: Port-Channel1
  tagged_vlans:
  - group: BRANCH VLANS
    vid: 100
    name: BRANCH USERS
  name: Ethernet1
  device: branch-agg-1
- type: virtual
  vrf: BRANCH VRF
  untagged_vlan:
    group: BRANCH VLANS
    vid: 100
  name: Vlan100
  device: branch-agg-1
power_ports:
- type: iec-60320-c14
  name: PSU1
  device: branch-agg-1
console_ports:
- type: rj-45
  name: Console
  device: branch-agg-1
power_outlets:
- type: iec-60320-c13
  name: Outlet-1
  device: branch-agg-1
console_server_ports:
- type: rj-45
  name: Line-1
  device: branch-agg-1
connections:
- a_terminations:
  - device: branch-agg-1
    interface: Ethernet1
  b_terminations:
  - device: branch-router-1
    interface: Ethernet1
vrrp_groups:
- group_id: 10
  protocol: vrrp2
  name: VRRP 10
ip_addresses:
- address: 192.0.2.21/24
  device: branch-agg-1
  interface: Vlan100
- address: 192.0.2.1/24
  fhrp_group:
    protocol: vrrp2
    group_id: 10
vrrp_group_assignments:
- group_id: 10
  protocol: vrrp2
  device: branch-agg-1
  interface: Vlan100
  priority: 200
bgp_peerings:
- local_address: 192.0.2.101
  remote_address: 192.0.2.102
  local_as: 65100
  remote_as: 65200
  create_reverse: false
  name: branch-agg-1-loopback-peer
  device: branch-agg-1
- remote_address: 192.0.2.11
  local_as: 65100
  remote_as: 65200
  create_reverse: false
  name: branch-agg-1-to-branch-router-1
  device: branch-agg-1
  local_interface: Vlan100
local_context_data:
- device: branch-agg-1
  site: BRANCH-1
  local_context_data:
    profile: aggregation

The peering under Vlan100 receives local_interface: Vlan100. The peering directly under the device does not. Interface creation uses two passes: independent interfaces first, then interfaces referencing parent, lag, or bridge.

Prerequisites: Named site, role, manufacturer, and device type.

interfaces is a dictionary keyed by interface name. The device name is added to every extracted interface. Independent interfaces are created before subinterfaces and LAG members.

devices:
  - name: branch-agg-1
    site: BRANCH-1
    role: AGGREGATION
    device_type: {manufacturer: ACME, model: AGG SWITCH}
    interfaces:
      Port-Channel1: {type: lag}
      Ethernet1: {type: 10gbase-x-sfpp, lag: {name: Port-Channel1}}
      Ethernet2.100: {type: virtual, parent: {name: Ethernet2}}
      Ethernet2: {type: 10gbase-x-sfpp}

Equivalent using top-level keys:

devices:
- name: branch-agg-1
  site: BRANCH-1
  role: AGGREGATION
  device_type:
    manufacturer: ACME
    model: AGG SWITCH
interfaces:
- type: lag
  name: Port-Channel1
  device: branch-agg-1
- type: 10gbase-x-sfpp
  lag:
    name: Port-Channel1
  name: Ethernet1
  device: branch-agg-1
- type: virtual
  parent:
    name: Ethernet2
  name: Ethernet2.100
  device: branch-agg-1
- type: 10gbase-x-sfpp
  name: Ethernet2
  device: branch-agg-1

Prerequisites: Named site, role, device type, both IP addresses, both ASNs, and the BGP plugin.

Device-level bgp_peerings is a list. Flattening adds device. name is optional and is generated by create_bgp_peering when omitted. Supply addresses explicitly when no local interface is given.

devices:
  - name: branch-agg-1
    site: BRANCH-1
    role: AGGREGATION
    device_type: {manufacturer: ACME, model: AGG SWITCH}
    bgp_peerings:
      - name: branch-agg-1-to-router-1
        local_address: 192.0.2.101
        remote_address: 192.0.2.102
        local_as: 65100
        remote_as: 65200
        create_reverse: false

Equivalent using top-level keys:

devices:
- name: branch-agg-1
  site: BRANCH-1
  role: AGGREGATION
  device_type:
    manufacturer: ACME
    model: AGG SWITCH
bgp_peerings:
- local_address: 192.0.2.101
  remote_address: 192.0.2.102
  local_as: 65100
  remote_as: 65200
  create_reverse: false
  name: branch-agg-1-to-router-1
  device: branch-agg-1

Prerequisites: Named site, role, manufacturer, and device type.

A compact interface vrrp record creates an FHRP group, its VIP IP address, and an assignment to the interface. The protocol defaults to vrrp2. The same group can be assigned to a second device with a different priority. This example shows both device-type reference forms. AGG SWITCH must uniquely identify an existing model.

devices:
  - name: branch-agg-1
    site: BRANCH-1
    role: AGGREGATION
    device_type: {manufacturer: ACME, model: AGG SWITCH}
    interfaces:
      Vlan100:
        type: virtual
        vrrp:
          group_id: 10
          name: BRANCH GATEWAY
          vip: 192.0.2.1/24
          priority: 200
  - name: branch-agg-2
    site: BRANCH-1
    role: AGGREGATION
    device_type: AGG SWITCH
    interfaces:
      Vlan100:
        type: virtual
        vrrp:
          group_id: 10
          name: BRANCH GATEWAY
          vip: 192.0.2.1/24
          priority: 100

Equivalent using top-level keys:

devices:
- name: branch-agg-1
  site: BRANCH-1
  role: AGGREGATION
  device_type:
    manufacturer: ACME
    model: AGG SWITCH
- name: branch-agg-2
  site: BRANCH-1
  role: AGGREGATION
  device_type: AGG SWITCH
interfaces:
- type: virtual
  name: Vlan100
  device: branch-agg-1
- type: virtual
  name: Vlan100
  device: branch-agg-2
vrrp_groups:
- group_id: 10
  name: BRANCH GATEWAY
  protocol: vrrp2
ip_addresses:
- address: 192.0.2.1/24
  fhrp_group:
    protocol: vrrp2
    group_id: 10
vrrp_group_assignments:
- group_id: 10
  protocol: vrrp2
  device: branch-agg-1
  interface: Vlan100
  priority: 200
- group_id: 10
  protocol: vrrp2
  device: branch-agg-2
  interface: Vlan100
  priority: 100

Top-level vrrp_groups can instead provide a vip and an assignments list.

Prerequisites: Named site, role, device type, and allocation prefix.

An interface can contain explicit addresses and create_ip next-available allocations. Flattening adds the device and interface names to each address. primary_ip runs after address creation and selects an existing address.

devices:
  - name: branch-agg-1
    site: BRANCH-1
    role: AGGREGATION
    device_type: {manufacturer: ACME, model: AGG SWITCH}
    interfaces:
      Loopback0:
        type: virtual
        ip_addresses:
          - address: 192.0.2.21/32
          - create_ip:
              prefix: 198.51.100.0/24
              description: branch-agg-1 service IP
primary_ip:
  - device: branch-agg-1
    site: BRANCH-1
    address: 192.0.2.21/32

Equivalent using top-level keys:

devices:
- name: branch-agg-1
  site: BRANCH-1
  role: AGGREGATION
  device_type:
    manufacturer: ACME
    model: AGG SWITCH
interfaces:
- type: virtual
  name: Loopback0
  device: branch-agg-1
ip_addresses:
- address: 192.0.2.21/32
  device: branch-agg-1
  interface: Loopback0
- create_ip:
    prefix: 198.51.100.0/24
    description: branch-agg-1 service IP
    device: branch-agg-1
    interface: Loopback0
primary_ip:
- device: branch-agg-1
  site: BRANCH-1
  address: 192.0.2.21/32

The create_ip prefix must already exist or be created earlier in the design. primary_ip.field defaults to primary_ip4. Set it to primary_ip6 for IPv6.

Prerequisites: Named site, role, device type, and remote device/interface.

connection under an interface specifies the remote device and interface. The local endpoint comes from its parent device and interface. Both interfaces must exist by the connections stage.

devices:
  - name: branch-agg-1
    site: BRANCH-1
    role: AGGREGATION
    device_type: {manufacturer: ACME, model: AGG SWITCH}
    interfaces:
      Ethernet1:
        type: 10gbase-x-sfpp
        connection: {device: branch-router-1, interface: Ethernet1}

Equivalent using top-level keys:

devices:
- name: branch-agg-1
  site: BRANCH-1
  role: AGGREGATION
  device_type:
    manufacturer: ACME
    model: AGG SWITCH
interfaces:
- type: 10gbase-x-sfpp
  name: Ethernet1
  device: branch-agg-1
connections:
- a_terminations:
  - device: branch-agg-1
    interface: Ethernet1
  b_terminations:
  - device: branch-router-1
    interface: Ethernet1

Prerequisites: Named site, role, manufacturer, and device type.

A string names an existing VRF. A dictionary with fields beyond its identity is also collected as a VRF definition and deployed before interfaces.

devices:
  - name: branch-agg-1
    site: BRANCH-1
    role: AGGREGATION
    device_type: {manufacturer: ACME, model: AGG SWITCH}
    interfaces:
      Vlan100:
        type: virtual
        vrf: {name: BRANCH VRF, rd: "65100:100", description: Branch routing}

Equivalent using top-level keys:

vrfs:
- name: BRANCH VRF
  rd: 65100:100
  description: Branch routing
devices:
- name: branch-agg-1
  site: BRANCH-1
  role: AGGREGATION
  device_type:
    manufacturer: ACME
    model: AGG SWITCH
interfaces:
- type: virtual
  vrf:
    name: BRANCH VRF
    rd: 65100:100
  name: Vlan100
  device: branch-agg-1

Prerequisites: Named site, role, device type, and VLAN group.

untagged_vlan identifies one VLAN by group and VID. If its dictionary also contains name, it becomes a VLAN definition deployed before interfaces.

devices:
  - name: branch-access-1
    site: BRANCH-1
    role: ACCESS
    device_type: {manufacturer: ACME, model: ACCESS SWITCH}
    interfaces:
      Ethernet1:
        type: 1000base-t
        untagged_vlan: {group: BRANCH VLANS, vid: 100, name: USERS}

Equivalent using top-level keys:

vlans:
- group: BRANCH VLANS
  vid: 100
  name: USERS
devices:
- name: branch-access-1
  site: BRANCH-1
  role: ACCESS
  device_type:
    manufacturer: ACME
    model: ACCESS SWITCH
interfaces:
- type: 1000base-t
  untagged_vlan:
    group: BRANCH VLANS
    vid: 100
  name: Ethernet1
  device: branch-access-1

Prerequisites: Named site, role, device type, VLAN group, and VLAN 200.

tagged_vlans is a list. Entries with name and vid define VLANs. Entries with just group and vid refer to VLANs already defined elsewhere.

devices:
  - name: branch-access-1
    site: BRANCH-1
    role: ACCESS
    device_type: {manufacturer: ACME, model: ACCESS SWITCH}
    interfaces:
      Ethernet48:
        type: 10gbase-x-sfpp
        mode: tagged
        tagged_vlans:
          - {group: BRANCH VLANS, vid: 100, name: USERS}
          - {group: BRANCH VLANS, vid: 200}

Equivalent using top-level keys:

vlans:
- group: BRANCH VLANS
  vid: 100
  name: USERS
devices:
- name: branch-access-1
  site: BRANCH-1
  role: ACCESS
  device_type:
    manufacturer: ACME
    model: ACCESS SWITCH
interfaces:
- type: 10gbase-x-sfpp
  mode: tagged
  tagged_vlans:
  - group: BRANCH VLANS
    vid: 100
  - group: BRANCH VLANS
    vid: 200
  name: Ethernet48
  device: branch-access-1

Prerequisites: Named site, role, device type, remote IP, both ASNs, and the BGP plugin.

Nesting a peering under an interface supplies device and local_interface to create_bgp_peering. The task resolves the local address from IPAM on that interface. Define the interface IP before the peering stage.

devices:
  - name: branch-agg-1
    site: BRANCH-1
    role: AGGREGATION
    device_type: {manufacturer: ACME, model: AGG SWITCH}
    interfaces:
      Loopback0:
        type: virtual
        ip_addresses: [{address: 192.0.2.101/32}]
        bgp_peerings:
          - name: branch-agg-1-to-branch-router-1
            remote_address: 192.0.2.102
            local_as: 65100
            remote_as: 65200
            create_reverse: false

Equivalent using top-level keys:

devices:
- name: branch-agg-1
  site: BRANCH-1
  role: AGGREGATION
  device_type:
    manufacturer: ACME
    model: AGG SWITCH
interfaces:
- type: virtual
  name: Loopback0
  device: branch-agg-1
ip_addresses:
- address: 192.0.2.101/32
  device: branch-agg-1
  interface: Loopback0
bgp_peerings:
- remote_address: 192.0.2.102
  local_as: 65100
  remote_as: 65200
  create_reverse: false
  name: branch-agg-1-to-branch-router-1
  device: branch-agg-1
  local_interface: Loopback0

Prerequisites: Named site, role, device type, and remote PDU outlet.

power_ports is keyed by port name. A nested connection specifies a remote power outlet. The PDU and outlet must exist by the connections stage.

devices:
  - name: branch-router-1
    site: BRANCH-1
    role: ROUTER
    device_type: {manufacturer: ACME, model: ROUTER}
    power_ports:
      PSU1:
        type: iec-60320-c14
        connection: {device: branch-pdu-1, power_outlet: Outlet-1}

Equivalent using top-level keys:

devices:
- name: branch-router-1
  site: BRANCH-1
  role: ROUTER
  device_type:
    manufacturer: ACME
    model: ROUTER
power_ports:
- type: iec-60320-c14
  name: PSU1
  device: branch-router-1
connections:
- a_terminations:
  - device: branch-router-1
    power_port: PSU1
  b_terminations:
  - device: branch-pdu-1
    power_outlet: Outlet-1

Prerequisites: Named site, role, device type, and remote console server port.

console_ports is keyed by port name. Its connection can name a console server port.

devices:
  - name: branch-router-1
    site: BRANCH-1
    role: ROUTER
    device_type: {manufacturer: ACME, model: ROUTER}
    console_ports:
      Console:
        type: rj-45
        connection: {device: branch-console-1, console_server_port: Line-1}

Equivalent using top-level keys:

devices:
- name: branch-router-1
  site: BRANCH-1
  role: ROUTER
  device_type:
    manufacturer: ACME
    model: ROUTER
console_ports:
- type: rj-45
  name: Console
  device: branch-router-1
connections:
- a_terminations:
  - device: branch-router-1
    console_port: Console
  b_terminations:
  - device: branch-console-1
    console_server_port: Line-1

Prerequisites: Named site, role, device type, and remote device power port.

power_outlets is keyed by outlet name. A nested connection can point to a device power port.

devices:
  - name: branch-pdu-1
    site: BRANCH-1
    role: PDU
    device_type: {manufacturer: ACME, model: PDU}
    power_outlets:
      Outlet-1:
        type: iec-60320-c13
        connection: {device: branch-router-1, power_port: PSU1}

Equivalent using top-level keys:

devices:
- name: branch-pdu-1
  site: BRANCH-1
  role: PDU
  device_type:
    manufacturer: ACME
    model: PDU
power_outlets:
- type: iec-60320-c13
  name: Outlet-1
  device: branch-pdu-1
connections:
- a_terminations:
  - device: branch-pdu-1
    power_outlet: Outlet-1
  b_terminations:
  - device: branch-router-1
    power_port: PSU1

Prerequisites: Named site, role, device type, and remote device console port.

console_server_ports is keyed by port name. A nested connection can point to a device console port.

devices:
  - name: branch-console-1
    site: BRANCH-1
    role: CONSOLE SERVER
    device_type: {manufacturer: ACME, model: TERMINAL SERVER}
    console_server_ports:
      Line-1:
        type: rj-45
        connection: {device: branch-router-1, console_port: Console}

Equivalent using top-level keys:

devices:
- name: branch-console-1
  site: BRANCH-1
  role: CONSOLE SERVER
  device_type:
    manufacturer: ACME
    model: TERMINAL SERVER
console_server_ports:
- type: rj-45
  name: Line-1
  device: branch-console-1
connections:
- a_terminations:
  - device: branch-console-1
    console_server_port: Line-1
  b_terminations:
  - device: branch-router-1
    console_port: Console

Define a cable at one end only. Existing cables are patched on repeat deployment. A port already cabled to a different endpoint causes an error. The design does not disconnect it.

Prerequisites: Named site, role, manufacturer, and device type.

local_context_data is a device-local dictionary. It is processed last, after the other nested records have been deployed. It replaces the device's previous local context.

devices:
  - name: branch-router-1
    site: BRANCH-1
    role: ROUTER
    device_type: {manufacturer: ACME, model: ROUTER}
    local_context_data:
      routing: {profile: core}

Equivalent using top-level keys:

devices:
- name: branch-router-1
  site: BRANCH-1
  role: ROUTER
  device_type:
    manufacturer: ACME
    model: ROUTER
local_context_data:
- device: branch-router-1
  site: BRANCH-1
  local_context_data:
    routing:
      profile: core

The configuration context section also shows how to calculate this dictionary with a custom function.

Interface dependency limits

All parent, LAG, and bridge interfaces must be defined in the design or already exist in Netbox. A newly created dependent interface cannot itself be the parent of another new interface in the same deployment, because only two passes are performed.

Device deployment searches by name only. A single match is updated, with site and tenant treated as update attributes. When multiple devices share a name, the supplied tenant must select exactly one. Missing tenant, no tenant match, or multiple tenant matches cause an ambiguity error. Site does not disambiguate devices. Interfaces and IP assignments refer to a device by name, so use unambiguous device names for designs that include those nested objects. The separate primary-IP and local-context stages retain their site/name and optional tenant matching.

Nested VRRP Group Records¤

vrrp_groups supports an assignments list and an optional vip. Each assignment supplies device, interface, and priority. The design extracts these into vrrp_group_assignments, adding protocol and group_id from the parent group. The optional vip becomes an IP address assigned to the group, not to an individual interface.

For a top-level group, specify both protocol and group_id. Unlike the compact interface vrrp form, this form does not default the protocol. Groups are created before their VIP addresses and interface assignments.

Prerequisites: both devices and their Vlan100 interfaces. The VIP is created as an IP address assigned to the group. Assignments attach that group to each interface with a priority.

vrrp_groups:
  - protocol: vrrp2
    group_id: 10
    vip: 192.0.2.1/24
    assignments:
      - {device: branch-agg-1, interface: Vlan100, priority: 200}
      - {device: branch-agg-2, interface: Vlan100, priority: 100}

Equivalent using top-level keys:

vrrp_groups:
- protocol: vrrp2
  group_id: 10
ip_addresses:
- address: 192.0.2.1/24
  fhrp_group:
    protocol: vrrp2
    group_id: 10
vrrp_group_assignments:
- device: branch-agg-1
  interface: Vlan100
  priority: 200
  protocol: vrrp2
  group_id: 10
- device: branch-agg-2
  interface: Vlan100
  priority: 100
  protocol: vrrp2
  group_id: 10

Prerequisites: both devices and their Vlan100 interfaces. This design creates the pool and group before allocating the VIP. Interface assignments remain separate from the VIP's assignment to the group.

prefixes:
  - prefix: 192.0.2.0/24
    description: Branch management subnet
vrrp_groups:
  - protocol: vrrp2
    group_id: 20
    name: BRANCH MANAGEMENT GATEWAY
    assignments:
      - device: branch-agg-1
        interface: Vlan100
        priority: 200
      - device: branch-agg-2
        interface: Vlan100
        priority: 100
ip_addresses:
  - create_ip:
      prefix:
        description: Branch management subnet
      description: Branch management VIP
      vrrp_group: BRANCH MANAGEMENT GATEWAY

The group's vip field accepts an explicit CIDR address, not a create_ip wrapper. Omit vip when allocating the address through ip_addresses as above. The stable group name, prefix selection, and description let repeated deployments reuse the same VIP.

L2VPNs and Terminations¤

Define L2VPNs in l2vpns. Like VRFs, they accept import_route_targets and export_route_targets as lists of dictionaries with name. These definitions are extracted into route_targets and deployed before the L2VPN. Repeating the same target dictionary in both lists creates it once.

Import/export lists also accept query to select existing route targets using Netbox filters, including custom fields. Queries run after template rendering and attach all matches. They do not create or update targets. A query matching nothing causes an error, including in dry runs. Queries can match targets created earlier in the same deployment. During a dry run those targets must already exist. Duplicate matches are attached once. Use either query or a named definition in each entry.

l2vpns:
  - name: BRANCH EVPN
    type: vxlan
    import_route_targets:
      - query:
          name: "65100:100"
    export_route_targets:
      - query:
          cf_customer_id: "{{ context.customer_id }}"

The same syntax works under vrfs. Supported filter keys depend on your Netbox instance and its custom-field definitions.

terminations under an L2VPN uses the same record shape as the top-level l2vpn_terminations collection, except that the parent supplies l2vpn. Each termination attaches one device interface (device and interface) or one VLAN (group and vid). Add name to a VLAN termination to create or update that VLAN before attaching it. Additional fields on that entry describe the VLAN. Flattening retains only l2vpn, group, and vid on the termination. This works for nested and top-level terminations. VLAN definitions are deduplicated by group and VID: definitions already in vlans take precedence, followed by the first extracted termination definition. Entries without name remain references. The interface or referenced VLAN must already exist or be defined earlier in the design. NetBox permits only one L2VPN termination per attached object. Deployment reports a conflict rather than moving an object from another L2VPN. Omitted terminations are not deleted.

vlan_groups:
  - name: BRANCH VLANS

l2vpns:
  - name: BRANCH EVPN
    type: vxlan
    identifier: 10100
    import_route_targets:
      - name: "65100:100"
    export_route_targets:
      - name: "65100:100"
    terminations:
      - device: branch-router-1
        interface: Ethernet1
      - group: BRANCH VLANS
        vid: 100
        name: BRANCH USERS
        status: active
        description: User access VLAN created before attachment
        custom_fields:
          customer_id: ACME

l2vpn_terminations:
  - l2vpn: BRANCH EVPN
    device: branch-router-2
    interface: Ethernet1

The example creates or updates VLAN 100 in BRANCH VLANS, then attaches it to BRANCH EVPN. The routers and their interfaces must already exist or be defined elsewhere in the design. The customer_id custom field must already be defined in Netbox. Omit custom_fields if it is not available.

During flattening, the named VLAN termination produces two records:

vlans:
  - group: BRANCH VLANS
    vid: 100
    name: BRANCH USERS
    status: active
    description: User access VLAN created before attachment
    custom_fields:
      customer_id: ACME

l2vpn_terminations:
  - l2vpn: BRANCH EVPN
    group: BRANCH VLANS
    vid: 100

To attach an existing VLAN without updating its properties, supply only group and integer vid in the nested termination. That reference does not create a missing VLAN. Deployment fails if it cannot resolve the VLAN. Named entries apply all additional fields, including tags and custom fields, to the VLAN, rather than to the termination. VLAN groups must exist or be defined in vlan_groups. Termination extraction does not create groups.

The l2vpn_terminations stage runs after interfaces. A dry run can report planned terminations even when their interfaces or VLANs are also planned and therefore have no IDs yet.

Nested VRF Records¤

VRFs accept import_route_targets and export_route_targets as lists of dictionaries. These route targets become top-level definitions and are deployed before their VRF, even when only name is supplied. A VRF can be defined at the top level or nested under a device interface.

Prerequisites: none for these IPAM objects. Route targets are created before the VRF and associated through its import/export fields.

vrfs:
  - name: BRANCH VRF
    rd: "65100:100"
    import_route_targets:
      - name: "65100:100"
        description: Branch routes
    export_route_targets:
      - name: "65100:100"
        description: Branch routes

Equivalent using top-level keys:

route_targets:
- name: 65100:100
  description: Branch routes
vrfs:
- name: BRANCH VRF
  rd: 65100:100
  import_route_targets:
  - name: 65100:100
  export_route_targets:
  - name: 65100:100

Prerequisites: the named site, device role, manufacturer, and device type. The nested VRF and its route targets are created before the interface.

devices:
  - name: branch-router-1
    site: BRANCH-1
    role: ROUTER
    device_type: {manufacturer: ACME, model: ROUTER}
    interfaces:
      Vlan100:
        type: virtual
        vrf:
          name: BRANCH VRF
          rd: "65100:100"
          import_route_targets:
            - name: "65100:100"
          export_route_targets:
            - name: "65100:100"

Equivalent using top-level keys:

route_targets:
- name: 65100:100
vrfs:
- name: BRANCH VRF
  rd: 65100:100
  import_route_targets:
  - name: 65100:100
  export_route_targets:
  - name: 65100:100
devices:
- name: branch-router-1
  site: BRANCH-1
  role: ROUTER
  device_type:
    manufacturer: ACME
    model: ROUTER
interfaces:
- type: virtual
  vrf:
    name: BRANCH VRF
    rd: 65100:100
  name: Vlan100
  device: branch-router-1

Nested BGP Peering Records¤

BGP peerings accept import_policies and export_policies as lists of routing-policy names or dictionaries. The design extracts these into top-level routing_policies, creates or updates them before BGP sessions, and associates them with the peering. A policy name or a dictionary containing only name is also treated as a policy definition.

peer_group accepts a name or a dictionary with name and other peer-group fields. Both forms are collected into top-level peer_groups and created or updated before the BGP session. The peering then passes the group name to create_bgp_peering.

This works for top-level bgp_peerings and peerings nested under devices or interfaces.

Prerequisites: the device, both IP addresses, both ASNs, and the BGP plugin. Inline peer groups and policies are deployed before the peering. create_reverse: false creates only the specified direction.

bgp_peerings:
  - name: branch-to-core
    device: branch-router-1
    local_address: 192.0.2.1
    remote_address: 192.0.2.2
    local_as: 65100
    remote_as: 65200
    create_reverse: false
    peer_group:
      name: BRANCH PEERS
      description: Branch BGP neighbors
    import_policies:
      - name: BRANCH IMPORT
        description: Branch inbound policy
    export_policies:
      - BRANCH EXPORT

Equivalent using top-level keys:

routing_policies:
- name: BRANCH IMPORT
  description: Branch inbound policy
- name: BRANCH EXPORT
peer_groups:
- name: BRANCH PEERS
  description: Branch BGP neighbors
bgp_peerings:
- name: branch-to-core
  device: branch-router-1
  local_address: 192.0.2.1
  remote_address: 192.0.2.2
  local_as: 65100
  remote_as: 65200
  create_reverse: false
  peer_group: BRANCH PEERS
  import_policies:
  - BRANCH IMPORT
  export_policies:
  - BRANCH EXPORT

Configuration context¤

Top-level config_context creates or updates reusable Netbox ConfigContext objects. Its data is a dictionary, and its scope determines which devices receive it. Device-specific values belong in device local context.

A custom_function on a config_context record uses the same arguments and return handling as any ordinary custom function: deployment supplies context, netbox, and dry_run, and the record supplies additional keyword arguments. It receives no device object. Its return value is recorded under config_context.custom, not saved as context data automatically. The function must perform its own writes. The Jinja calculation example below instead renders data for the normal handler to save.

config_context matches an existing ConfigContext by name, creating it if absent or updating it if present. sites and tenants scope the context by name to objects created earlier or already in Netbox. Unknown tenant names fail. A dry run cannot resolve tenants that are only planned. Updates add scope associations without removing existing ones. Without a scope, the context applies globally, so specify one when the data is site-specific.

sites:
  - name: BRANCH-1
    slug: branch-1

config_context:
  - name: BRANCH DEFAULTS
    sites: [BRANCH-1]
    data:
      routing:
        bgp:
          hold_time: 90
      ntp:
        servers: [192.0.2.10, 192.0.2.11]

The data dictionary is sent to Netbox as supplied. The handler does not merge it with the previous data value.

This Jinja function calculates DNS and NTP server addresses from a site services subnet. It only returns a dictionary. The normal config_context handler creates or updates the site-scoped ConfigContext after rendering. The subnet must be supplied in context, because Jinja functions run before the design creates any Netbox objects.

jinja_functions:
  site_services_context: nf://netbox/designs/site_services_context.py

sites:
  - name: BRANCH-1
    slug: branch-1

config_context:
  - name: BRANCH SERVICES
    sites: [BRANCH-1]
    data: {{ site_services_context(context.services_prefix) | tojson }}

Supply a context such as {"services_prefix": "192.0.2.0/24"}. The referenced nf://netbox/designs/site_services_context.py contains:

from ipaddress import ip_network


def site_services_context(services_prefix):
    subnet = ip_network(services_prefix)
    if subnet.num_addresses < 8:
        raise ValueError("Services subnet needs at least eight addresses")
    return {
        "dns": {"servers": [str(subnet[2]), str(subnet[3])]},
        "ntp": {"servers": [str(subnet[4]), str(subnet[5])]},
    }

For 192.0.2.0/24, the function returns DNS servers 192.0.2.2 and 192.0.2.3, plus NTP servers 192.0.2.4 and 192.0.2.5. tojson renders the returned dictionary as valid YAML content. The function does not query or mutate Netbox. Calculation from objects created by the same design would require support for a deployment-time calculator inside config_context.data, which the handler does not currently provide.

Device local context¤

local_context_data updates a local_context_data field on one device, not a reusable ConfigContext object. Both nested and top-level local context definitions run in the final deployment stage, after all other design objects have been created or updated. Custom functions can query those objects through the provided netbox client to calculate device context. Local context replaces the previous dictionary rather than merging with it.

A custom_function inside local_context_data must return the complete context-data dictionary to store on the matched device. For example, return {"routing": {"router_id": "192.0.2.1"}} sets the device's local_context_data to exactly that dictionary. The handler performs the device update. The function does not need to call device.update() or netbox.dcim.devices.update(). The return value is not placed under a custom result list or passed to another design collection.

Final-stage queries and branching

During a normal deployment, objects from all preceding stages are available in Netbox when device-local context functions run. If branch was supplied to design_deploy, the provided netbox client already targets that branch. During a dry run, planned objects are not created and may be unavailable.

Device-local context functions receive the device object

A function referenced inside local_context_data receives device, context, netbox, and dry_run, plus any arguments beside custom_function. device is the matched pynetbox device object, not its name. The function can inspect device.id, device.name, or other device fields. It must return a dictionary, which the handler writes to the device's local context.

The function receives the full device record so it can inspect fields beyond its identity. Related objects such as interfaces can be queried through netbox.

Local Context Custom Function Input

Argument Type Supplied by Meaning
device pynetbox.core.response.Record Deployment Matched Netbox device object, available after earlier deployment stages. Inspect fields such as device.id and device.name.
context dict Deployment Design input context after optional schema validation.
netbox pynetbox.core.api.Api Deployment Pynetbox client bound to the deployment's Netbox instance and branch. Use it to query the modeled device state.
dry_run bool Deployment Whether this is a preview. The handler skips its write when True. The function should calculate and return data without making writes itself.
Other fields inside local_context_data As defined in YAML Design record Fields beside custom_function are passed unchanged as keyword arguments, such as interface and area in the example below.

custom_function selects the function and is not passed to it. Do not supply device, context, netbox, or dry_run inside the local-context dictionary. Outer device identity fields, such as site and tenant, select the device and are not forwarded as function arguments.

Function outcome Deployment behavior
Returns a dictionary Writes that dictionary as the device's entire local_context_data, replacing the previous value. The result lists the device name under local_context_data.updated, not under custom.
Returns a dictionary during a dry run Does not write it. The function runs only if the device already exists. Earlier planned objects may still be absent. Missing devices are skipped and omitted from local_context_data.updated.
Returns another type, including None Reports an error and stops deployment. A dictionary is required.
Raises an exception Reports the failure and stops deployment. Earlier writes are not rolled back.

Prerequisites: Named site, role, manufacturer, and device type.

Put local_context_data under a device for values specific to that device. The design writes it after all other object types, even though it appears inside the device record.

devices:
  - name: branch-router-1
    site: BRANCH-1
    role: ROUTER
    device_type: {manufacturer: ACME, model: ROUTER}
    local_context_data:
      routing:
        profile: core
        router_id: 192.0.2.101

Equivalent using top-level keys:

devices:
- name: branch-router-1
  site: BRANCH-1
  role: ROUTER
  device_type:
    manufacturer: ACME
    model: ROUTER
local_context_data:
- device: branch-router-1
  site: BRANCH-1
  local_context_data:
    routing:
      profile: core
      router_id: 192.0.2.101

The dictionary is sent as-is. It replaces the device's previous local context.

Prerequisites: Named site, role, manufacturer, and device type.

Allocate a loopback IP first, then calculate the IS-IS NET ID from the address stored in Netbox. Pass the loopback interface name and IS-IS area beside custom_function. Local context runs last, so the function can read the address allocated earlier in the deployment.

custom_functions:
  calculate_isis_context: nf://netbox/designs/calculate_isis_context.py

prefixes:
  - prefix: 192.0.2.0/24
    description: Branch loopback pool

devices:
  - name: branch-router-2
    site: BRANCH-1
    role: ROUTER
    device_type: {manufacturer: ACME, model: ROUTER}
    interfaces:
      Loopback0:
        type: virtual
        ip_addresses:
          - create_ip:
              prefix: 192.0.2.0/24
              mask_len: 32
              description: branch-router-2 loopback
    local_context_data:
      custom_function: calculate_isis_context
      interface: Loopback0
      area: "49.0001"

Equivalent using top-level keys:

custom_functions:
  calculate_isis_context: nf://netbox/designs/calculate_isis_context.py
prefixes:
- prefix: 192.0.2.0/24
  description: Branch loopback pool
devices:
- name: branch-router-2
  site: BRANCH-1
  role: ROUTER
  device_type:
    manufacturer: ACME
    model: ROUTER
interfaces:
- type: virtual
  name: Loopback0
  device: branch-router-2
ip_addresses:
- create_ip:
    prefix: 192.0.2.0/24
    mask_len: 32
    description: branch-router-2 loopback
    device: branch-router-2
    interface: Loopback0
local_context_data:
- device: branch-router-2
  site: BRANCH-1
  local_context_data:
    custom_function: calculate_isis_context
    interface: Loopback0
    area: '49.0001'

The referenced nf://netbox/designs/calculate_isis_context.py contains:

from ipaddress import ip_interface
from typing import Any

from pynetbox.core.api import Api
from pynetbox.core.response import Record


def calculate_isis_context(
    device: Record,
    context: dict,
    netbox: Api,
    dry_run: bool,
    interface: str,
    area: str,
) -> dict[str, Any]:
    loopback = netbox.dcim.interfaces.get(
        device_id=device.id, name=interface, fields="id,name"
    )
    if loopback is None:
        raise ValueError(f"Interface not found: {device.name}:{interface}")
    addresses = list(netbox.ipam.ip_addresses.filter(
        interface_id=loopback.id, family=4, fields="id,address"
    ))
    if len(addresses) != 1:
        raise ValueError(
            f"Expected one IPv4 address on {device.name}:{interface}, "
            f"found {len(addresses)}"
        )
    address = ip_interface(addresses[0].address).ip
    digits = "".join(f"{octet:03d}" for octet in address.packed)
    system_id = ".".join(digits[index:index + 4] for index in (0, 4, 8))
    return {
        "device": device.name,
        "routing": {
            "router_id": str(address),
            "isis": {"net": f"{area}.{system_id}.00"},
        }
    }

create_ip allocates a /32 from the pool and assigns it to Loopback0. The function reads that interface's IPv4 address and converts it into a twelve-digit system ID. For example, if the allocated address is 192.0.2.102/32, the NET ID is 49.0001.1920.0000.2102.00. It rejects missing or multiple IPv4 addresses rather than selecting one arbitrarily.

Deployment supplies device, context, netbox, and dry_run. YAML supplies interface and area, not a predetermined router ID. The function only returns a dictionary. The local-context handler writes it to the device.

During a dry run, the function is skipped if the device does not yet exist. If the device exists but its loopback or IP is only planned, the lookup fails because dry runs do not create them.

Prerequisites: The specified device, site, and tenant.

Use a top-level local_context_data list when the device is not defined under devices in this design. site and device identify it. Add tenant when needed to distinguish devices with the same name and site.

local_context_data:
  - device: branch-router-3
    site: BRANCH-1
    tenant: ACME
    local_context_data:
      routing: {profile: edge}

A missing or ambiguous device fails outside dry-run mode. The update replaces its prior local context.

Markdown designs¤

A Markdown design keeps network notes, input data and Netbox object definitions in one readable file. Reviewers can understand the design and check the records it will create or update. Use it for branch networks, repeatable site builds and service definitions. Store the file in version control to review notes and data changes together.

Key features:

  • Keep headings, diagrams and explanations alongside YAML/Jinja design blocks.
  • Define input data with YAML, CSV or visible Markdown tables.
  • Split collections across sections. Repeated collections are combined.
  • Validate inputs with a schema and reuse designs with different task context.
  • Define shared Jinja variables and use file-backed Jinja or deployment functions.
  • Set shared Netbox configuration context and device-local context.
  • Preview rendered YAML or deploy the file directly.

Pass a .md or .markdown file URL to design_deploy. Only marked blocks and tables supply data. Other text and unmarked examples are ignored. Preview with dry_run_render=True before deploying.

Split a design across yaml design fences. Repeated collections are joined in document order. Records are not deduplicated. Keep each loop inside one collection section and indent records. Inline collections such as sites: [] are unsupported.

```yaml design
{% set prefix = 'branch-' %}
```

```yaml design
tenants:
  - name: "{{ prefix }}{{ context.site }}"
```

Assembly order is schema, custom functions, Jinja functions, standalone Jinja blocks, then collections. Put each metadata key in one block. Standalone Jinja blocks retain document order and must each contain complete syntax. Relative includes resolve from the Markdown file's directory.

Use yaml context.KEY, csv context.KEY, or a marked Markdown table. Each key must be unique and top-level. YAML preserves types. CSV/table cells stay strings. Use YAML | scalars for multiline text. JSON and text fences are unsupported.

```yaml context.settings
enabled: true
note: |
  Branch rollout
```

```csv context.links
name,description
{% for n in range(context.count) %}
r{{ n }},"Router {{ n }}"
{% endfor %}
```

<!-- context.regions -->
| name |
| --- |
| {{ context.site }}-region |

YAML/CSV blocks render before parsing. Markdown cells render individually. Only explicit task context is available, not document values, schema defaults, shared Jinja variables or custom functions. Rendering is single-pass and undefined variables fail. Escape source table pipes as \|. Quote generated YAML/CSV values appropriately. Table headers stay literal.

Document context supplies defaults. Task context overrides whole top-level keys. Overridden definitions are not rendered. Context must be JSON-serializable.

Save as nf://netbox/designs/branch.md. This creates a tenant, region and site using all three context formats and shared Jinja logic:

# Branch rollout

```yaml context.settings
note: Branch rollout
```

```csv context.tenants
name
{{ context.site }}-tenant
```

<!-- context.regions -->
| name |
| --- |
| {{ context.site }}-region |

```yaml design
design_input_schema:
  type: object
  properties:
    site: {type: string}
    settings: {type: object}
    tenants: {type: array}
    regions: {type: array}
  required: [site]
```

```yaml design
{% set description = 'Approved: ' ~ context.settings.note %}
```

```yaml design
tenants:
{% for tenant in context.tenants %}
  - name: {{ tenant.name | tojson }}
{% endfor %}
regions:
{% for region in context.regions %}
  - name: {{ region.name | tojson }}
{% endfor %}
sites:
  - name: {{ context.site | tojson }}
    tenant: {{ context.tenants[0].name | tojson }}
    region: {{ context.regions[0].name | tojson }}
    description: {{ description | tojson }}
```

Preview through Python. Remove dry_run_render to deploy:

result = client.run_job(
    "netbox", "design_deploy", workers="any",
    kwargs={
        "design": "nf://netbox/designs/branch.md",
        "context": {"site": "BRISBANE"},
        "dry_run_render": True,
    },
)

Or use NFCLI:

nf# netbox design deploy design nf://netbox/designs/branch.md context '{"site":"BRISBANE"}' dry-run-render

Authoring rules¤

  • Mark design blocks with yaml design and close each fence explicitly.
  • Define each context key once. Use top-level names such as context.settings. Put nested values inside a YAML block instead of using context.settings.name.
  • Keep YAML context JSON-serializable. Quote numeric mapping keys. Avoid binary values and sets.
  • Keep CSV and Markdown table headers unique and nonempty. Check table widths. Markdown tables can pad missing cells or ignore extra cells.
  • Put each schema or function metadata key in one design block.
  • Indent collection records and multiline values. Do not use inline collections such as devices: [].
  • Put shared Jinja assignments in separate design blocks. Keep each loop or conditional complete within one block and one collection section.
  • Render context from explicit task input only. Context blocks cannot reference other document context, schema defaults or shared design variables.
  • Make rendered YAML and CSV valid for their format. Escape source pipes in Markdown table cells as \|.
  • Preview with dry_run_render=True to check the assembled design before deployment.

Design Deployment Output¤

With dry_run_render=True, result contains the rendered text and the result's dry_run flag is True. Dictionary designs return the supplied dictionary unchanged. This option takes precedence over dry_run and returns before YAML parsing, flattening, design validation, lookups, or deployment-time custom functions. Context and initial metadata parsing, input-schema validation, Netbox client setup, and Jinja2 functions still run.

Deployment and ordinary dry-run results are keyed by collection. Each collection reports object identities in created and updated. No-op allocation results are omitted. Repeat deployments can report updates because existing records are sent to Netbox without a local diff.

{
  "tenants": {"created": ["ACME"], "updated": []},
  "sites": {"created": ["BRANCH-1"], "updated": []}
}

Errors identify the collection that stopped deployment. Earlier collections are not rolled back.

Examples¤

nf# netbox design deploy design nf://netbox/designs/branch.yaml context '{"site":"BRANCH-1"}'
nf# netbox design deploy design nf://netbox/designs/branch.yaml context '{"site":"BRANCH-1"}' dry-run-render
from norfab.core.nfapi import NorFab

with NorFab(inventory="inventory.yaml") as nf:
    result = nf.client.run_job(
        service="netbox",
        task="design_deploy",
        workers="any",
        kwargs={
            "design": "nf://netbox/designs/branch.yaml",
            "context": {"site": "BRANCH-1"},
            "dry_run_render": True,  # Preview the rendered design.
        },
    )

Enable the Netbox service endpoints on the NorFab FastAPI worker and configure a bearer token. Send task arguments directly in the JSON body, without a kwargs wrapper. Replace the API address and token with your environment's values. This example deploys to branch branch-review, which requires Netbox branching.

curl -X POST "http://localhost:8000/api/netbox/design_deploy/" \
  -H "Authorization: Bearer <TOKEN>" \
  -H "Content-Type: application/json" \
  -d '{"design":"nf://netbox/designs/branch.yaml","context":{"site":"BRANCH-1"},"branch":"branch-review","workers":"any"}'

See FastAPI setup and authentication for service configuration. Inspect the response's worker results for deployment errors.

Connect your agent to the NorFab MCP service with the Netbox design_deploy tool exposed. Ask it to deploy using a chat message such as:

Use the NorFab Netbox design_deploy tool to instantiate the design at
nf://netbox/designs/branch.yaml with context {"site": "BRANCH-1"}.
Deploy to Netbox branch `branch-review`. If branching is unavailable,
stop and ask before writing to main data. Report the deployment result
and any errors.

The agent should pass the file path as design, the dictionary as context, and the branch name as branch. The file must be accessible to the NorFab worker.

Notes¤

  • A dry run does not create prerequisites, so it cannot fully resolve references to objects proposed earlier in the same design.
  • Explicit ASN, VLAN, and prefix records can supply object and multiobject custom-field references by string reference or ID, using the matching fields described in Custom fields. String references must identify exactly one existing object when that record is processed. Updates add multiobject references and other list values without removing current values. Scalar and null values replace them. Custom-field definitions must already exist in NetBox.
  • Check that named parents, device types, sites, interfaces, and allocation pools exist or are created earlier in the design order.
  • Jinja filters, input models, and custom functions execute Python inside the worker. Use trusted files.
  • See the ACME design example for a full nested design.

Task command shell reference¤

nf#man tree netbox.design.deploy

R - required field, M - supports multiline input, D - dynamic key

root
└── netbox:    Netbox service
    └── design:    Deploy NetBox designs
        └── deploy:    Deploy an additive NetBox design
            ├── design (R):    NetBox design as YAML text, file URL, or parsed dictionary
            ├── context:    Template context validated by design_input_schema, default '{}'
            ├── instance:    NetBox instance name to target
            ├── dry-run:    Validate design without writing to NetBox, default 'False'
            ├── dry-run-render:    Return rendered design before parsing or deployment, default 'False'
            ├── branch:    NetBox branching plugin branch name to use
            ├── timeout:    Job timeout
            ├── workers:    Filter worker to target, default 'any'
            ├── verbose-result:    Control output details, default 'False'
            └── nowait:    Do not wait for job to complete, default 'False'
nf#

Python API reference¤

Render and flatten a design, validate it, then deploy ordered collections.

Fetches Jinja and custom function files in separate batches, executing each group in its declared order.

Updates add tags, custom-field list entries, and the supported native relationship lists. Scalar fields and context-data dictionaries replace their current values. Object custom-field names resolve to NetBox IDs.

Parameters:

Name Type Description Default
job Job

NorFab job injected by the task framework.

required
design Union[str, dict]

YAML text, NorFab file URL, or parsed design dictionary. File URLs ending in .md or .markdown are converted from Markdown first. Device-type references accept manufacturer/model dictionaries or model strings matching exactly one existing NetBox type. String references must exist in NetBox even during dry runs. New devices default to planned status; omitted status preserves existing device status.

required
context Union[str, dict]

Jinja2 variables, optionally checked by design_input_schema. Explicit values override Markdown context by top-level key.

{}
instance str

NetBox instance name; defaults to the worker's instance.

None
dry_run bool

Return planned writes without changing NetBox.

False
branch str

NetBox Branching plugin branch name.

None
dry_run_render bool

Return rendered text before YAML parsing, flattening, validation, or deployment. Dictionary designs return unchanged. Takes precedence over dry_run; context validation and Jinja2 functions still run.

False

Returns:

Type Description
Result

Object identities grouped by collection and creation/update action,

Result

or rendered text (the original dictionary for dictionary designs)

Result

with dry_run=True when dry_run_render is enabled. Preparation and

Result

deployment errors, including job timeout expiry, are reported in a

Result

failed Result. Timeout checks stop subsequent operations; in-flight

Result

calls are not interrupted.

Source code in norfab\workers\netbox_worker\design_tasks.py
4325
4326
4327
4328
4329
4330
4331
4332
4333
4334
4335
4336
4337
4338
4339
4340
4341
4342
4343
4344
4345
4346
4347
4348
4349
4350
4351
4352
4353
4354
4355
4356
4357
4358
4359
4360
4361
4362
4363
4364
4365
4366
4367
4368
4369
4370
4371
4372
4373
4374
4375
4376
4377
4378
4379
4380
4381
4382
4383
4384
4385
4386
4387
4388
4389
4390
4391
4392
4393
4394
4395
4396
4397
4398
4399
4400
4401
4402
4403
4404
4405
4406
4407
4408
4409
4410
4411
4412
4413
4414
4415
4416
4417
4418
4419
4420
4421
4422
4423
4424
4425
4426
4427
4428
4429
4430
4431
4432
4433
4434
4435
4436
4437
4438
4439
4440
4441
4442
4443
4444
4445
4446
4447
4448
4449
4450
4451
4452
4453
4454
4455
4456
4457
4458
4459
4460
4461
4462
4463
4464
4465
4466
4467
4468
4469
4470
4471
4472
4473
4474
4475
4476
4477
4478
4479
4480
4481
4482
4483
4484
4485
4486
4487
4488
4489
4490
4491
4492
4493
4494
4495
4496
4497
4498
4499
4500
4501
4502
4503
4504
4505
4506
4507
4508
4509
4510
4511
4512
4513
4514
4515
4516
4517
4518
4519
4520
4521
4522
4523
4524
4525
4526
4527
4528
4529
4530
4531
4532
4533
4534
4535
4536
4537
4538
4539
4540
4541
4542
4543
4544
4545
4546
4547
4548
4549
4550
4551
4552
4553
4554
4555
4556
4557
4558
4559
4560
4561
4562
4563
4564
4565
4566
4567
4568
4569
4570
4571
4572
4573
4574
4575
4576
4577
4578
4579
4580
4581
4582
4583
4584
4585
4586
4587
4588
4589
4590
4591
4592
4593
4594
4595
4596
4597
4598
4599
4600
4601
4602
4603
4604
4605
4606
4607
4608
4609
4610
4611
4612
4613
4614
4615
4616
4617
4618
4619
4620
4621
4622
4623
4624
4625
4626
4627
4628
4629
4630
4631
4632
4633
4634
4635
4636
4637
4638
4639
4640
4641
4642
4643
4644
4645
4646
4647
4648
4649
@Task(
    input=DesignDeployInput,
    output=DesignDeployResult,
    fastapi={"methods": ["POST"], "schema": NetboxFastApiArgs.model_json_schema()},
    mcp={
        "annotations": {
            "title": "Deploy Design",
            "readOnlyHint": False,
            "destructiveHint": False,
            "idempotentHint": True,
            "openWorldHint": True,
        }
    },
)
def design_deploy(
    self,
    job: Job,
    design: Union[str, dict],
    context: Union[str, dict] = {},
    instance: str = None,
    dry_run: bool = False,
    branch: str = None,
    dry_run_render: bool = False,
) -> Result:
    """Render and flatten a design, validate it, then deploy ordered collections.

    Fetches Jinja and custom function files in separate batches, executing
    each group in its declared order.

    Updates add tags, custom-field list entries, and the supported native
    relationship lists. Scalar fields and context-data dictionaries replace
    their current values. Object custom-field names resolve to NetBox IDs.

    Args:
        job: NorFab job injected by the task framework.
        design: YAML text, NorFab file URL, or parsed design dictionary.
            File URLs ending in .md or .markdown are converted from Markdown first.
            Device-type references accept manufacturer/model dictionaries
            or model strings matching exactly one existing NetBox type.
            String references must exist in NetBox even during dry runs.
            New devices default to planned status; omitted status preserves
            existing device status.
        context: Jinja2 variables, optionally checked by design_input_schema.
            Explicit values override Markdown context by top-level key.
        instance: NetBox instance name; defaults to the worker's instance.
        dry_run: Return planned writes without changing NetBox.
        branch: NetBox Branching plugin branch name.
        dry_run_render: Return rendered text before YAML parsing, flattening,
            validation, or deployment. Dictionary designs return unchanged.
            Takes precedence over dry_run; context validation and Jinja2
            functions still run.

    Returns:
        Object identities grouped by collection and creation/update action,
        or rendered text (the original dictionary for dictionary designs)
        with dry_run=True when dry_run_render is enabled. Preparation and
        deployment errors, including job timeout expiry, are reported in a
        failed Result. Timeout checks stop subsequent operations; in-flight
        calls are not interrupted.
    """
    instance = instance or self.default_instance
    ret = Result(
        task=f"{self.name}:design_deploy",
        result={},
        resources=[instance],
        dry_run=dry_run,
    )
    msg = f"starting netbox design deployment: instance={instance}, branch={branch}, dry_run={dry_run}"
    log.info(msg)
    job.event(msg)
    try:
        job.check_timeout()
        if isinstance(context, str):
            if self.is_url(context):
                context = self.fetch_file(context, raise_on_fail=True)
            context = yaml.safe_load(context) or {}
        if not isinstance(context, dict):
            raise TypeError("Design context must be a YAML mapping")

        design_url = (
            design if isinstance(design, str) and self.is_url(design) else None
        )
        if isinstance(design, dict):
            document = design
            metadata = DesignDocument.model_validate(
                {
                    "design_input_schema": design.get("design_input_schema"),
                    "jinja_functions": design.get("jinja_functions", {}),
                }
            )
            template = None
        elif isinstance(design, str):
            if design_url:
                design_path = self.fetch_file(
                    design_url, read=False, raise_on_fail=True
                )
                with open(design_path, "r", encoding="utf-8") as file_obj:
                    source = file_obj.read()
            else:
                source = design
            if design_url and design_url.endswith((".md", ".markdown")):
                parsed = self.parse_markdown_design(
                    job=job, markdown=source, context=context
                )
                source = parsed["design"]
                context = {**parsed["context"], **context}
            metadata_lines = []
            for line in source.splitlines():
                if (
                    line
                    and not line[0].isspace()
                    and not line.startswith(
                        (
                            "#",
                            "design_input_schema:",
                            "jinja_functions:",
                            "custom_functions:",
                        )
                    )
                ):
                    break
                metadata_lines.append(line)
            metadata = DesignDocument.model_validate(
                yaml.safe_load("\n".join(metadata_lines)) or {}
            )
            template = source
        else:
            raise TypeError("Design must be YAML text, a file URL, or a dictionary")

        input_schema = metadata.design_input_schema
        jinja_functions = metadata.jinja_functions

        if input_schema:
            if isinstance(input_schema, str):
                if not self.is_url(input_schema):
                    raise ValueError(
                        "Design input model must use an nf:// or git:// URL"
                    )
                model_source = self.fetch_file(input_schema, raise_on_fail=True)
            elif isinstance(input_schema, dict):
                model_source = generate(
                    input_schema,
                    input_file_type=InputFileType.JsonSchema,
                    output_model_type=DataModelType.PydanticV2BaseModel,
                    class_name="DesignInput",
                    disable_timestamp=True,
                    formatters=[],
                )
            else:
                raise TypeError(
                    "Design input schema must be JSON Schema or a model URL"
                )
            model_globals = {}
            exec(model_source, model_globals, model_globals)  # nosec B102
            input_model = model_globals.get("DesignInput")
            if not isinstance(input_model, type) or not issubclass(
                input_model, BaseModel
            ):
                raise ValueError(
                    "Design input schema must define a Pydantic DesignInput model"
                )
            input_model.model_rebuild(_types_namespace=model_globals)
            context = input_model.model_validate(context).model_dump()

        for name, url in jinja_functions.items():
            if not self.is_url(url):
                raise ValueError(
                    f"Jinja function '{name}' must use an nf:// or git:// URL"
                )
        filters = {}
        for (name, url), source in zip(
            jinja_functions.items(),
            self.fetch_file(list(jinja_functions.values()), raise_on_fail=True),
        ):
            namespace = {}
            exec(source, namespace, namespace)  # nosec B102
            function = namespace.get(name)
            if not callable(function):
                raise ValueError(
                    f"Jinja function '{name}' is not callable in '{url}'"
                )
            filters[name] = function

        nb = self._get_pynetbox(instance, branch=branch, job=job)
        job.check_timeout()
        if template is not None:
            loader = None
            if design_url:
                self.jinja2_fetch_includes(template, design_url)
                loader = FileSystemLoader(os.path.dirname(design_path))
            environment = Environment(loader=loader)
            environment.filters.update(filters)
            environment.globals.update(filters)
            environment.globals["netbox"] = nb
            document = environment.from_string(template).render(context=context)

        job.check_timeout()
        if dry_run_render:
            ret.result = document
            ret.dry_run = True
            return ret

        if template is not None:
            document = yaml.safe_load(document) or {}

        msg = "flattening and validating netbox design"
        log.info(msg)
        job.event(msg)
        flattened = flatten_design(document)
        validated = DesignDocument.model_validate(flattened)
        lookup_cache = build_lookup_cache(self, nb, validated)
        job.check_timeout()
        for name, url in validated.custom_functions.items():
            if not self.is_url(url):
                raise ValueError(f"custom function '{name}' must use a file URL")
        custom_functions = {}
        for (name, url), source in zip(
            validated.custom_functions.items(),
            self.fetch_file(
                list(validated.custom_functions.values()), raise_on_fail=True
            ),
        ):
            namespace = {}
            exec(source, namespace, namespace)  # nosec B102
            function = namespace.get(name)
            if not callable(function):
                raise ValueError(
                    f"custom function '{name}' is not callable in '{url}'"
                )
            custom_functions[name] = function
        for collection in DESIGN_HANDLERS_ORDER:
            for record in getattr(validated, collection):
                if "custom_function" in record:
                    function = custom_functions[record["custom_function"]]
                    arguments = {
                        key: value
                        for key, value in record.items()
                        if key != "custom_function"
                    }
                    inspect.signature(function).bind(
                        **arguments, context=context, netbox=nb, dry_run=dry_run
                    )
        for record in validated.local_context_data:
            local_context = record["local_context_data"]
            if "custom_function" in local_context:
                function_kwargs = {
                    key: value
                    for key, value in local_context.items()
                    if key != "custom_function"
                }
                inspect.signature(
                    custom_functions[local_context["custom_function"]]
                ).bind(
                    device=None,
                    context=context,
                    netbox=nb,
                    dry_run=dry_run,
                    **function_kwargs,
                )
        handlers = dict(DESIGN_HANDLERS_ORDER)
        handlers["local_context_data"] = partial(
            process_local_context_data,
            custom_functions=custom_functions,
            context=context,
        )
    except Exception as exc:
        msg = f"failed to prepare netbox design: {exc}"
        log.exception(msg)
        job.event(msg, severity="ERROR")
        ret.errors.append(msg)
        ret.failed = True
        return ret
    for collection, handler in handlers.items():
        records = getattr(validated, collection)
        if not records:
            ret.result[collection] = {"created": [], "updated": []}
            continue
        msg = f"deploying {collection}: {len(records)} records, dry_run={dry_run}"
        log.info(msg)
        job.event(msg)
        try:
            job.check_timeout()
            ret.result[collection] = handler(
                self,
                nb,
                [record for record in records if "custom_function" not in record],
                dry_run,
                lookup_cache=lookup_cache,
                job=job,
                instance=instance,
                branch=branch,
            )
            job.check_timeout()
            execute_custom_functions(
                nb,
                context,
                records,
                custom_functions,
                dry_run,
                ret.result[collection],
            )
            job.check_timeout()
            changes = ret.result[collection]
            msg = (
                f"completed {collection}: {len(changes['created'])} created, "
                f"{len(changes['updated'])} updated, "
                f"{len(changes.get('custom', []))} custom calls"
            )
            log.info(msg)
            job.event(msg)
        except Exception as exc:
            message = f"failed to deploy {collection}: {exc}"
            log.exception(message)
            job.event(message, severity="ERROR")
            ret.errors.append(message)
            ret.failed = True
            return ret
    msg = (
        f"netbox design complete: "
        f"{sum(len(items['created']) for items in ret.result.values())} objects created, "
        f"{sum(len(items['updated']) for items in ret.result.values())} objects updated"
    )
    log.info(msg)
    job.event(msg)
    return ret