Use '8021x-radius' instead of '8021x' — required by CiscoDevNet/meraki provider v1.9.0 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
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 applylocally. 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
- Repository Structure
- How It Works — Architecture Overview
- Change Workflow
- Making a Change to an Existing Site
- Adding a New Site
- Configuration Reference
- GitHub Actions Workflows
- Sensitive Variables and Secrets
- Running Terraform Locally (plan only)
- 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 themodules/meraki-sitemodule 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 onlyvariables.tfin a site holds the two sensitive variables that must arrive viaTF_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 aname → serialmap 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 applylocally. 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
- Click the branch name in the bottom-left status bar → Create new branch → enter
feature/<site>-<description> - Edit the relevant file(s) under
sites/<site>/ - Open the Source Control panel (
Ctrl+Shift+G/Cmd+Shift+G) - Click
+next to each changed file (or next to "Changes" to stage all) - Type the commit message in the text box and click Commit
- Click Publish Branch — this pushes the branch to GitHub
- 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
ghCLI 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/orbackend.hclchanged → 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 initdownloads 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.