Jose MartinezandClaude Sonnet 4.6 29614e1dc4 feat(bcn01-lab): onboard BCN01-LAB site and extend module with WAN2 support
- Add sites/BCN01-LAB with full Meraki configuration: VLANs, SSIDs,
  switch ports, 802.1X policy, firewall rules, WAN uplinks and warm spare
- Extend modules/meraki-site to support wan2_* fields in mx_wan_uplinks

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 07:02:06 +02:00
2026-03-13 15:15:59 +01:00
2026-04-21 10:29:53 +02:00

EQT Network — Meraki Infrastructure as Code

All Meraki network configuration is managed as Infrastructure as Code using Terraform with the CiscoDevNet/meraki provider (v1.9.0). Every change is deployed through a GitHub Actions CI/CD pipeline — no one runs terraform apply locally. Terraform state is stored remotely in an S3 bucket with DynamoDB locking to prevent concurrent modifications.


TL;DR

Caution

Never run terraform apply locally. All applies go through the GitHub Actions pipeline to ensure auditability and prevent state drift.

Modifying an existing site

1. Find the right file in sites/<SITE>/:

What you want to change File
VLAN IDs, subnets, gateway IPs, DHCP vlans.tf
Wi-Fi SSIDs ssids.tf
Firewall rules firewall.tf
Switch ports, stacks, 802.1X policies switch.tf
MX LAN ports appliance.tf
WAN IPs, Warm Spare (HA) wan.tf
Organization or network name main.tf (top locals block)

2. Edit the value inside the locals { } block. All device names (stacks, switches, MX) must match the exact display name in the Meraki Dashboard.

3. Commit, push and open a PR — see Step by step — VS Code or Step by step — CLI.

GitHub Actions runs terraform plan automatically and posts the output as a PR comment. Review the plan, then merge — terraform apply runs automatically on merge.


Adding a new site

cp -r sites/BCN01-LAB sites/MAD01

Then edit only these values in the copied files:

main.tf — module name, organization_name and network_name:

locals {
  organization_name = "..."   # exact org name in Meraki Dashboard
  network_name      = "MAD01" # exact network name in Meraki Dashboard
}

module "mad01" {              # rename to match the new site
  source = "../../modules/meraki-site"
  # everything else stays the same
}

vlans.tf — replace subnets and gateway IPs with the new site's IP ranges.

ssids.tf — update RADIUS server IPs if different.

firewall.tf — update any CIDRs that reference site-specific subnets.

switch.tf — replace stack/switch names (bcn01-lab-stack01 → actual name in MAD01's Dashboard).

appliance.tf and wan.tf — update MX device names and WAN IPs.

variables.tf — do not touch. It is identical across all sites.

Open a PR — the workflow detects sites/MAD01/ automatically, no workflow changes needed.


Table of Contents

  1. Repository Structure
  2. How It Works — Architecture Overview
  3. Change Workflow
  4. Making a Change to an Existing Site
  5. Adding a New Site
  6. Configuration Reference
  7. GitHub Actions Workflows
  8. Sensitive Variables and Secrets
  9. Running Terraform Locally (plan only)
  10. Manual Steps — Provider Limitations

1. Repository Structure

.
├── backend.hcl                  # Shared S3 backend config (bucket, region, DynamoDB table)
├── modules/
│   └── meraki-site/             # Reusable module — all Meraki resource logic lives here
│       ├── main.tf              # Resource definitions (VLANs, SSIDs, firewall, switches, WAN, HA)
│       ├── variables.tf         # All input variable declarations with types and defaults
│       └── outputs.tf           # Exported values (network_id, vlan_ids, stack_ids, device_serials)
├── sites/
│   └── BCN01-LAB/               # One directory per physical site
│       ├── main.tf              # Terraform backend + organization/network locals + module call
│       ├── variables.tf         # Only two sensitive vars: radius_secret, wifi_password_psk
│       ├── vlans.tf             # locals: VLAN definitions (IDs, subnets, DHCP)
│       ├── ssids.tf             # locals: Wireless SSID configuration
│       ├── firewall.tf          # locals: L3 firewall rules
│       ├── switch.tf            # locals: Switch ports, stacks, 802.1X policies
│       ├── appliance.tf         # locals: MX LAN port configuration
│       ├── wan.tf               # locals: WAN uplinks and Warm Spare (HA)
│       └── MANUAL_STEPS.md      # Steps that cannot be automated (provider limitations)
└── .github/
    └── workflows/
        ├── plan.yml             # Runs terraform plan on Pull Requests
        └── apply.yml            # Runs terraform apply on merge to main

One directory per site. Each directory under sites/ is a fully independent Terraform root module with its own remote state. Sites share the modules/meraki-site module but have no shared state between them.

No variable boilerplate. Site configuration lives in locals {} blocks — no need to re-declare types and defaults that already exist in the module. The only variables.tf in a site holds the two sensitive variables that must arrive via TF_VAR_* environment variables.


2. How It Works — Architecture Overview

Module pattern

The modules/meraki-site module encapsulates all Meraki resource logic. A site directory is a thin wrapper that calls the module with site-specific locals and declares the remote backend:

sites/BCN01-LAB/
  *.tf (locals)  ──► main.tf ──► module "meraki-site" ──► Meraki API
                                         │
                                  modules/meraki-site/
                                    main.tf  (resources)
                                    variables.tf

Dynamic resource resolution

Terraform never needs device serials hardcoded. At plan time, the module:

  • Calls data "meraki_network_devices" to build a name → serial map for MX and standalone switches
  • Calls data "meraki_switch_stacks" to resolve stack names to their member serials

This means you reference devices by their Dashboard display name in all configuration files.

Port range expansion

Switch port configuration accepts ranges like "1-24", "47-48", or "1-3,5,47". The module expands these into individual port resources at plan time. A single config entry can configure dozens of ports.

VLAN and L3 gateway

The module creates L3 VLAN interfaces on the MX for every VLAN with a subnet defined. VLANs without a subnet (e.g. a pure-switching WAN VLAN) are created as L2-only and excluded from the MX gateway resources.

Firewall rules

meraki_appliance_l3_firewall_rules replaces the entire rule set on every apply. The list in firewall.tf is authoritative. Rules are evaluated top-down; always end the list with an explicit deny-all rule.

SSID split

The Meraki API rejects the wpa_encryption_mode attribute for SSIDs with auth_mode = "open". The module handles this internally by splitting SSIDs into two resources — one for open SSIDs and one for all others. No action needed from the operator.


3. Change Workflow

Never run terraform apply locally. All applies go through GitHub Actions to ensure auditability and prevent state drift.

Every change follows this Git-based process:

1. Create a feature branch
2. Edit the relevant .tf file under sites/<site>/
3. Commit the changes
4. Push the branch and open a Pull Request
5. GitHub Actions runs terraform plan and posts the output as a PR comment
6. Team member reviews the plan output in the PR
7. Approve & merge → GitHub Actions runs terraform apply automatically

Branch and commit naming

Type Pattern Example
Branch feature/<site>-<description> feature/BCN01-LAB-add-iot-vlan
Commit feat(<site>): <description> feat(BCN01-LAB): add IoT VLAN 112
Bugfix branch fix/<site>-<description> fix/BCN01-LAB-ssid-visible
Bugfix commit fix(<site>): <description> fix(BCN01-LAB): set EQT-CORPO-OWE-OK to hidden

Step by step — VS Code

  1. Click the branch name in the bottom-left status bar → Create new branch → enter feature/<site>-<description>
  2. Edit the relevant file(s) under sites/<site>/
  3. Open the Source Control panel (Ctrl+Shift+G / Cmd+Shift+G)
  4. Click + next to each changed file (or next to "Changes" to stage all)
  5. Type the commit message in the text box and click Commit
  6. Click Publish Branch — this pushes the branch to GitHub
  7. Open a PR:
    • Option A — GitHub will show a banner in the repo: "Compare & pull request". Click it.
    • Option B — Install the GitHub Pull Requests extension and create the PR directly from VS Code without opening the browser.

Step by step — CLI

# 1. Create the branch
git checkout -b feature/<site>-<description>

# 2. Edit files, then stage and commit
git add sites/<site>/<file>.tf
git commit -m "feat(<site>): <description>"

# 3. Push the branch
git push origin feature/<site>-<description>

# 4. Open a PR (interactive) or directly in the browser
gh pr create --title "feat(<site>): <description>"
gh pr create --web

The gh CLI must be installed and authenticated (gh auth login).


4. Making a Change to an Existing Site

For a quick reference on which file to edit, see the TL;DR at the top. The examples below show the syntax for the most common changes.

Example: adding a firewall rule

Edit sites/<site>/firewall.tf. The src_port and dest_port fields default to "any" and can be omitted:

locals {
  firewall_rules = [
    # ... existing rules ...
    {
      comment   = "Allow IoT to NTP server"
      policy    = "allow"
      protocol  = "udp"
      src_cidr  = "10.2.60.0/24"   # IoT VLAN
      dest_cidr = "10.2.56.10/32"  # NTP server
      dest_port = "123"
    },
    {
      comment   = "Deny all other traffic"
      policy    = "deny"
      protocol  = "any"
      src_cidr  = "any"
      dest_cidr = "any"
    },
  ]
}

Example: adding a VLAN

Edit sites/<site>/vlans.tf. The map key is the VLAN ID:

locals {
  switch_vlans = {
    # ... existing VLANs ...
    "112" = {
      name         = "IOT"
      subnet       = "10.2.60.0/24"
      appliance_ip = "10.2.60.1"
      reserved_ip_ranges = [
        { comment = "Static reserved", id = "static", start = "10.2.60.1", end = "10.2.60.49" }
      ]
    }
  }
}

Example: configuring switch ports

Edit sites/<site>/switch.tf. Use switch_stack_port_configs to apply a config to all members of a stack, or switch_named_port_configs to target a specific switch by name:

locals {
  # Apply to all members of the stack
  switch_stack_port_configs = [
    {
      stack_name           = "bcn01-lab-stack01"  # exact name from Dashboard
      port_range           = "1-44"
      type                 = "access"
      vlan                 = 100                  # fallback VLAN if RADIUS doesn't assign one
      access_policy_type   = "Custom access policy"
      access_policy_number = 1                    # references the DOT1X-CORPO policy
    },
  ]

  # Target a specific stack member by display name
  switch_named_port_configs = [
    {
      switch_name   = "bcn01-lab-sw01"
      port_range    = "45-48"
      type          = "trunk"
      vlan          = 109   # native (untagged) VLAN
      allowed_vlans = "all"
    },
  ]
}

port_range supports single ports ("1"), ranges ("1-24"), and mixed ("1-3,5,47").

Example: adding a wireless SSID

Edit sites/<site>/ssids.tf. Meraki numbers SSIDs from 0 to 14:

locals {
  wireless_ssids = [
    # ... existing SSIDs ...
    {
      number              = 3
      name                = "EQT-IOT"
      enabled             = true
      auth_mode           = "psk"
      encryption_mode     = "wpa"
      wpa_encryption_mode = "WPA3 Transition Mode"
      # Password is injected via TF_VAR_wifi_password_psk (GitHub Secret)
      ip_assignment_mode  = "Bridge mode"
      use_vlan_tagging    = true
      default_vlan_id     = 112
    },
  ]
}

Auth mode reference:

auth_mode Use case Notes
"open" Open network wpa_encryption_mode must be omitted
"open-enhanced" OWE (Opportunistic Wireless Encryption) Use with wpa_encryption_mode = "WPA3 only"
"psk" WPA2/WPA3 with shared password Requires encryption_mode = "wpa"
"8021x-radius" Enterprise 802.1X Requires radius_servers list

5. Adding a New Site

Adding a new site requires creating one new directory. The GitHub Actions workflows detect it automatically — no workflow changes needed.

Step 1 — Copy an existing site

cp -r sites/BCN01-LAB sites/MAD01

Step 2 — Update sites/MAD01/main.tf

Change the module name and the two locals at the top:

locals {
  organization_name = "..."    # exact org name in Meraki Dashboard
  network_name      = "MAD01"  # exact network name in Meraki Dashboard
}

module "mad01" {               # rename to match the new site
  source = "../../modules/meraki-site"
  # everything else stays the same
}

The S3 state key is derived automatically from the directory name (MAD01/terraform.tfstate) — no manual backend configuration needed.

Step 3 — Update the config files

Replace BCN01-LAB-specific values with the new site's actual configuration. See the TL;DR for the per-file summary, and the Configuration Reference for the full schema of each block.

Device names (stack_name, switch_name, MX names) must match the exact display names in the Meraki Dashboard for that network.

Step 4 — Open a PR

See Step by step — VS Code or Step by step — CLI.

GitHub Actions detects the new sites/MAD01/ directory, runs terraform plan, and posts the output as a PR comment. Review the plan, then merge to apply.

Step 5 — Review MANUAL_STEPS.md

After the initial apply, check sites/MAD01/MANUAL_STEPS.md for any Dashboard steps that could not be automated. See Section 10 for known provider limitations.


6. Configuration Reference

VLANs (vlans.tf)

switch_vlans = {
  "<vlan_id>" = {
    name          = string           # Display name
    subnet        = optional string  # CIDR, e.g. "10.2.32.0/21". Null = L2 only (no MX gateway)
    appliance_ip  = optional string  # MX gateway IP within the subnet
    dhcp_handling = optional string  # "Run a DHCP server" (default)
                                     # "Relay DHCP to another server"
                                     # "Do not respond to DHCP requests"
    reserved_ip_ranges = optional list of {
      comment = string
      id      = string   # unique identifier, e.g. "static"
      start   = string   # first IP to reserve
      end     = string   # last IP to reserve
    }
  }
}

Firewall rules (firewall.tf)

Rules are applied in order. The last rule should always be an explicit deny-all. The entire list replaces the Dashboard rules on every apply.

firewall_rules = [
  {
    comment        = string                        # Human-readable description
    policy         = "allow" | "deny"
    protocol       = "any" | "tcp" | "udp" | "icmp"
    src_cidr       = string                        # CIDR or "any"
    src_port       = optional string               # Port or "any" (default: "any")
    dest_cidr      = string                        # CIDR or "any"
    dest_port      = optional string               # Port or "any" (default: "any")
    syslog_enabled = optional bool                 # default: false
  },
]

Wireless SSIDs (ssids.tf)

wireless_ssids = [
  {
    number              = number          # Meraki SSID slot (0–14)
    name                = string
    enabled             = optional bool   # default: true
    visible             = optional bool   # false = hidden SSID. default: true
    auth_mode           = string          # "open", "open-enhanced", "psk", "8021x-radius"
    encryption_mode     = optional string # "wpa" required for psk; null otherwise
    wpa_encryption_mode = optional string # "WPA3 only", "WPA3 Transition Mode". Null for open
    splash_page         = optional string # default: "None"
    ip_assignment_mode  = optional string # default: "Bridge mode"
    use_vlan_tagging    = optional bool   # default: false
    default_vlan_id     = optional number
    radius_servers = optional list of {
      host = string   # RADIUS server IP
      port = number
      # secret is injected from TF_VAR_radius_secret — never put it here
    }
  },
]

Switch access policies (switch.tf)

switch_access_policies = [
  {
    name                               = string          # referenced by access_policy_number in port configs
    access_policy_type                 = optional string # "802.1x" (default), "Hybrid authentication"
    host_mode                          = optional string # "Multi-Auth" (default)
    radius_failed_auth_vlan_id         = optional number # fallback VLAN if RADIUS unreachable
    radius_re_authentication_interval  = optional number # seconds. 0 = disabled
    radius_servers = list of {
      host = string
      port = number
    }
  },
]

Switch port configs (switch.tf)

Three methods — use whichever fits:

# 1. By explicit serial
switch_port_configs = [
  {
    serial               = "XXXX-XXXX-XXXX"
    port_range           = "1-24"
    type                 = "access" | "trunk"
    vlan                 = optional number  # access VLAN (access) or native VLAN (trunk)
    allowed_vlans        = optional string  # trunk only. default: "all"
    access_policy_type   = optional string  # "Open" (default) or "Custom access policy"
    access_policy_number = optional number  # index of the policy in switch_access_policies
  },
]

# 2. By stack name — applies to ALL members of the stack
switch_stack_port_configs = [
  {
    stack_name = "bcn01-lab-stack01"  # exact Dashboard name
    port_range = "1-44"
    # ... same fields as above ...
  },
]

# 3. By switch display name — resolves serial dynamically
switch_named_port_configs = [
  {
    switch_name = "bcn01-lab-sw01"    # exact Dashboard name
    port_range  = "1,2,3"
    # ... same fields as above ...
  },
]

MX WAN and HA (wan.tf)

mx_wan_uplinks = [
  {
    name                    = "BCN01-F04-MX01"  # exact Dashboard device name
    wan1_static_ip          = "x.x.x.x"
    wan1_static_subnet_mask = "255.255.255.240"
    wan1_static_gateway_ip  = "x.x.x.x"
    wan1_static_dns         = ["8.8.8.8", "8.8.4.4"]
  },
]

mx_warm_spare = {
  enabled     = true
  spare_name  = "BCN01-F04-MX02"  # exact Dashboard device name
  uplink_mode = "virtual"
  virtual_ip1 = "x.x.x.x"        # floating VIP on WAN1
  virtual_ip2 = "x.x.x.x"        # floating VIP on WAN2 (if applicable)
}

7. GitHub Actions Workflows

Both workflows use a detect-sites job that dynamically determines which sites to plan or apply based on which files changed.

Trigger Workflow Action
Pull Request → main plan.yml Runs terraform plan for each changed site, posts output as PR comment
Push to main (merge) apply.yml Runs terraform apply for each changed site, serialized

Site detection logic:

  • modules/ or backend.hcl changed → all sites planned/applied
  • Only sites/<name>/ changed → only that site planned/applied
  • No relevant files changed → workflow skips entirely

plan.yml — Pull Request

PR opened/updated
    │
    ▼
detect-sites (ubuntu-latest) — reads git diff, builds site matrix
    │
    ▼
plan (self-hosted, matrix per site, parallel)
    ├── terraform init
    ├── terraform plan → plan_output.txt
    └── Post plan as PR comment

apply.yml — Merge to main

Merge to main
    │
    ▼
detect-sites (ubuntu-latest)
    │
    ▼
apply (self-hosted, matrix per site, max-parallel: 1)
    ├── terraform init
    └── terraform apply -auto-approve

max-parallel: 1 serializes applies across sites to avoid DynamoDB lock contention.

Backend initialization

terraform init \
  -backend-config=../../backend.hcl \            # shared: bucket, region, dynamodb_table
  -backend-config="key=<site>/terraform.tfstate"  # site-specific state path

8. Sensitive Variables and Secrets

Two variables must never appear in any .tf file. They are injected at runtime via environment variables:

Variable GitHub Secret Injected as
radius_secret RADIUS_SECRET TF_VAR_radius_secret
wifi_password_psk WIFI_PASSWORD_PSK TF_VAR_wifi_password_psk

All other required GitHub Secrets:

Secret Purpose
AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY S3 backend (state storage)
MERAKI_DASHBOARD_API_KEY Meraki API authentication

The RADIUS secret is shared across all RADIUS servers (SSIDs and switch 802.1X policies). If a site requires a different secret, a new GitHub Secret and a separate variable must be added.


9. Running Terraform Locally (plan only)

Local terraform plan is useful for debugging. terraform apply must never be run locally.

export MERAKI_DASHBOARD_API_KEY="your-api-key"
export AWS_ACCESS_KEY_ID="..."
export AWS_SECRET_ACCESS_KEY="..."
export AWS_REGION="us-east-1"
export TF_VAR_radius_secret="..."
export TF_VAR_wifi_password_psk="..."

cd sites/BCN01-LAB

terraform init \
  -backend-config=../../backend.hcl \
  -backend-config="key=BCN01-LAB/terraform.tfstate"

terraform plan

The first terraform init downloads the provider binary into .terraform/. This directory is gitignored.


10. Manual Steps — Provider Limitations

Some Meraki features are not yet supported by the CiscoDevNet/meraki provider v1.9.0 and must be configured directly in the Meraki Dashboard. Each site directory should include a MANUAL_STEPS.md documenting any steps that cannot be automated for that site.

For BCN01-LAB, see sites/BCN01-LAB/MANUAL_STEPS.md.

Feature Status Notes
Client VPN (L2TP/IPSec) Manual No resource exists in provider v1.9.0
OWE initial activation Warning Provider manages auth_mode = "open-enhanced" correctly; a one-time Dashboard confirmation may be needed after the very first apply

When a previously manual step becomes supported by the provider, migrate it to the appropriate .tf config file and remove it from MANUAL_STEPS.md.

S
Description
No description provided
Readme
177 KiB
Languages
HCL 100%