# EQT Network — Meraki Infrastructure as Code All Meraki network configuration is managed as Infrastructure as Code using [Terraform](https://www.terraform.io/) with the [`CiscoDevNet/meraki`](https://registry.terraform.io/providers/CiscoDevNet/meraki/latest) 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//`: | 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](#step-by-step--vs-code) or [Step by step — CLI](#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 ```bash 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`: ```hcl 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](#1-repository-structure) 2. [How It Works — Architecture Overview](#2-how-it-works--architecture-overview) 3. [Change Workflow](#3-change-workflow) 4. [Making a Change to an Existing Site](#4-making-a-change-to-an-existing-site) 5. [Adding a New Site](#5-adding-a-new-site) 6. [Configuration Reference](#6-configuration-reference) 7. [GitHub Actions Workflows](#7-github-actions-workflows) 8. [Sensitive Variables and Secrets](#8-sensitive-variables-and-secrets) 9. [Running Terraform Locally (plan only)](#9-running-terraform-locally-plan-only) 10. [Manual Steps — Provider Limitations](#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// 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/-` | `feature/BCN01-LAB-add-iot-vlan` | | Commit | `feat(): ` | `feat(BCN01-LAB): add IoT VLAN 112` | | Bugfix branch | `fix/-` | `fix/BCN01-LAB-ssid-visible` | | Bugfix commit | `fix(): ` | `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/-` 2. Edit the relevant file(s) under `sites//` 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](https://marketplace.visualstudio.com/items?itemName=GitHub.vscode-pull-request-github) extension and create the PR directly from VS Code without opening the browser. ### Step by step — CLI ```bash # 1. Create the branch git checkout -b feature/- # 2. Edit files, then stage and commit git add sites//.tf git commit -m "feat(): " # 3. Push the branch git push origin feature/- # 4. Open a PR (interactive) or directly in the browser gh pr create --title "feat(): " 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](#tldr) at the top. The examples below show the syntax for the most common changes. ### Example: adding a firewall rule Edit `sites//firewall.tf`. The `src_port` and `dest_port` fields default to `"any"` and can be omitted: ```hcl 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//vlans.tf`. The map key is the VLAN ID: ```hcl 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//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: ```hcl 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//ssids.tf`. Meraki numbers SSIDs from 0 to 14: ```hcl 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 ```bash 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: ```hcl 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](#adding-a-new-site) for the per-file summary, and the [Configuration Reference](#6-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](#step-by-step--vs-code) or [Step by step — CLI](#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](#10-manual-steps--provider-limitations) for known provider limitations. --- ## 6. Configuration Reference ### VLANs (`vlans.tf`) ```hcl switch_vlans = { "" = { 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. ```hcl 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`) ```hcl 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`) ```hcl 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: ```hcl # 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`) ```hcl 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//` 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 ```bash terraform init \ -backend-config=../../backend.hcl \ # shared: bucket, region, dynamodb_table -backend-config="key=/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. ```bash 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`](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`.