EST. 2026 — Notes from real server work SYSADMIN · SECURITY · AI · DIRECTADMIN Issue 01
THE SYSADMIN ALMANAC
Home / Network / Network-as-code on MikroTik RouterOS
Network

Network-as-code on MikroTik RouterOS

by Ton-Or ·19 Jul 2026·9 min read
Network-as-code on MikroTik RouterOS

A RouterOS config sitting on a single device, edited one Winbox page at a time, is technical debt waiting to be dealt with — so I moved it into documents + templates + scripts to generate configs for multiple sites and strip secrets before sharing every time

I manage MikroTik routers at several sites, and I got tired of having the entire config sitting on a single router. Every change meant opening Winbox and clicking through page after page. When several sites needed the same setup with different subnets, I repeated those clicks on every device. It was slow and much more error-prone than I expected. So I switched to a network-as-code approach — keep the config as documents in a folder I can track with git, with templates + scripts in place, then generate configs for the actual devices later.

Before getting into it, a note — I made up all the IPs, subnets, and hostnames in this article (192.168.10.x, 203.0.113.x, the name rtr-site-a). Since the point of this approach is to "remove real values before sharing," I applied that to the article itself too. The structure and scripts are all real, but the numbers are not.

The idea: keep configs as documents, not embedded in devices

The core idea is to move the config's "source of truth" out of the router and into files. Once it is in files, it gets everything code gets — diff, review, rollback, and repeatable generation.

Click through Winbox on each device

The truth lives in one person's head

Config changes mean clicking through pages, with no history of who changed what or when. When several sites need the same setup, I repeat the work and can get a subnet wrong without noticing.

network-as-code

The truth lives in files + git

Configs are documents I can diff / review / roll back. Only the variable values differ between sites, the same templates work for all of them, and a script strips secrets before sharing.

The whole flow follows a fixed sequence — export, sanitize, split into sections, turn into templates, then render back into a config for each site:

network-as-code pipeline/exportget config->sanitizestrip secrets->splitsplit sections->template + valuesJinja2->configs/targetper site

/export — save the config as a file

The starting point is to export the config from an actual device into a single .rsc file. RouterOS does this in one line:

[admin@rtr-site-a] > /export compact file=all-config-2026-07-16
[admin@rtr-site-a] > /export show-sensitive     ;# ระวัง: อันนี้ไม่ซ่อนรหัสอะไรเลย

compact exports only values that differ from the defaults, making it much easier to read than verbose. I use this file as the starting point, named all-config-sensitive.rsc. Do not send it anywhere yet, because the next step is to sanitize it first.

Sanitize before sharing — sanitize-rsc.ps1

/export does not sanitize everything

RouterOS v7 hides some passwords (hide-sensitive), but real IPs, hostnames, pppoe usernames, and comments are not hidden. And if someone accidentally runs /export show-sensitive, everything is exposed, including psk and passwords. So never put a raw export file into git or send it to anyone before sanitizing it.

This script is a safety net — it masks the values after every sensitive key with a generic token before I share the file. The actual logic is a single regex lookbehind:

Param(
  [Parameter(Mandatory=$true)] [string]$InputPath,
  [Parameter(Mandatory=$true)] [string]$OutputPath
)
$lines = Get-Content -Path $InputPath -Raw

# คีย์ที่ต้อง mask + token ที่จะเอาไปแทน
$map = @(
  @{ Key = 'password';        Replacement = '<SECRET>' },
  @{ Key = 'pre-shared-key';  Replacement = '<SECRET>' },
  @{ Key = 'psk';             Replacement = '<SECRET>' },
  @{ Key = 'chap-secret';     Replacement = '<SECRET>' },
  @{ Key = 'user';            Replacement = '<USERNAME>' }
)

foreach ($entry in $map) {
  $k = [Regex]::Escape($entry.Key)
  # แทนค่าที่ตามหลัง key= ไปจนเจอ space หรือ backslash
  $pattern = "(?<=\b$k=)[^\s\\]+"
  $lines = [Regex]::Replace($lines, $pattern, $entry.Replacement)
}

Set-Content -Path $OutputPath -Value $lines -NoNewline
Write-Host "Sanitized -> $OutputPath"

(?<=\b$k=)[^\s\\]+ means "match everything after key= until a space or \" — it matches only the value and leaves the key alone. After it runs, the line set password=xxxxx becomes set password=<SECRET> in the output file, while the real value stays in the source file, which I do not commit. Run it like this:

.\scripts\sanitize-rsc.ps1 -InputPath all-config-sensitive.rsc -OutputPath docs\all-config-sanitized.rsc

My full script includes more keys (passphrase, secret, private-key-password, cert-passphrase, etc.), but the core is a single list, replacing values one key at a time. To add more keys, just extend the array.

Split into sections with split-rsc.ps1

A single export file runs to thousands of lines and is hard to review. So I have a script that splits it at section headers beginning with / (such as /ip firewall filter and /interface vlan), producing one file per section. Once split, I can diff each section separately. When someone changes the firewall, only the firewall file shows up:

$lines = Get-Content -Path $InputPath
$currentPath = Join-Path $OutDir 'misc.rsc'
$buffer = New-Object System.Collections.Generic.List[string]

foreach ($line in $lines) {
  if ($line -match '^/') {                     # เจอหัวข้อใหม่ เช่น /ip firewall filter
    if ($buffer.Count -gt 0) {
      Add-Content -Path $currentPath -Value ($buffer -join "`n")
      $buffer.Clear() | Out-Null
    }
    $currentPath = New-SectionFileName $line   # /ip firewall filter -> ip_firewall_filter.rsc
    Set-Content -Path $currentPath -Value $line
  } else {
    $buffer.Add($line) | Out-Null
  }
}

New-SectionFileName just lowercases the section header and replaces spaces and / with _, giving each section a clean filename. Run:

.\scripts\split-rsc.ps1 -InputPath all-config-sensitive.rsc -OutDir configs\split

Document firewall ports — extract-fw-ports.ps1

Another task I do often is "summarize which ports this device exposes to the internet" — reading through /ip firewall filter and /ip firewall nat manually takes time, and it is easy to miss things. So I wrote a parser that extracts rules into fields and produces a markdown summary:

function Get-Field($line, $name) {
  $m = [regex]::Match($line, "\b" + [regex]::Escape($name) + "=([^\s]+)")
  if ($m.Success) { return $m.Groups[1].Value } else { return $null }
}

foreach ($l in $filterAdds) {
  if ($l -match 'disabled=yes') { continue }          # ข้าม rule ที่ปิดไว้
  $chain  = Get-Field $l 'chain'
  $action = Get-Field $l 'action'
  $proto  = Get-Field $l 'protocol'
  $dport  = Get-Field $l 'dst-port'
  if ($chain -eq 'input' -and $dport) {
    if (-not $action) { $action = 'accept' }          # RouterOS มักละ action=accept ที่เป็น default
    if ($action -eq 'accept') { $result.Filter_Input_Accept += "$proto/$dport" }
    elseif ($action -eq 'drop') { $result.Filter_Input_Drop += "$proto/$dport" }
  }
}

One thing to watch for is that RouterOS often omits action=accept (because it is the default), so the script has to infer that default. Otherwise, rules that open ports silently disappear from the summary. The full version also checks dstnat to summarize port forwarding. For example, this site has just one NVR exposing 8080 externally — with this summary document, I can immediately check that no unexpected ports are exposed beyond what I intended.

Templates + variables: one set generates configs for multiple sites

Once I have a clean config, the next step is to separate the "structure" from the "values." The structure goes into Jinja2 templates, while site-specific values (IPs, VLANs, device names) go into separate values files. The templates are split into files by section, just as in the split step — 00-system, 03-vlan, 04-ipaddress, 06-firewall, through to 99-commit:

# design/templates/routeros/03-vlan.rsc.j2
/interface vlan
{% for v in vlans.interfaces %}
add interface={{ v.parent | default('bridge1') }} name={{ v.name }} vlan-id={{ v.id }}
{% endfor %}

# design/templates/routeros/04-ipaddress.rsc.j2
/ip address
{% for a in addressing.addresses %}
add address={{ a.cidr }} interface={{ a.if }} network={{ a.network }}
{% endfor %}

The actual values for each site live in one file. This is the only place to edit them:

# design/values/site-a.yaml — ค่าจริงต่อไซต์ (อย่า commit ของจริงลง repo กลาง)
device:
  identity: rtr-site-a
  timezone: Asia/Bangkok
addressing:
  addresses:
    - { cidr: 192.168.10.1/24, if: bridge1, network: 192.168.10.0 }
vlans:
  interfaces:
    - { name: vlan101, id: 101, parent: bridge1 }   # ที่ไซต์ผม VLAN = เลขห้อง
    - { name: vlan102, id: 102, parent: bridge1 }
Separate structure from values

The core of network-as-code is that templates must contain no real values. All site-specific values live in values YAML files that are not committed to the shared repo. When setting up a new site, I copy a single values file and change the numbers without touching the templates.

Jinja2 templates00-system to 99-commitsite-a.yamlSite valuessite-b.yamlSite valuesrender-site.pyrtr-site-a.rscconfigs/targetrtr-site-b.rscconfigs/targetdiff before /importStructure, no real valuesNew site = copy values, edit numbers, leave templates alone
The structure lives in one set of templates, and each site's values live in its own values file. The script renders the two together into one .rsc file per site.

Generate with Python + Jinja2

A single short Python script connects the templates and values — it loads the values YAML, renders every template in numeric prefix order, then joins them into one .rsc file per site:

#!/usr/bin/env python3
# tools/render-site.py — เรนเดอร์ template .j2 + values ของไซต์ -> configs/target/<site>.rsc
import sys, pathlib, yaml
from jinja2 import Environment, FileSystemLoader

TPL_DIR = pathlib.Path("design/templates/routeros")
OUT_DIR = pathlib.Path("configs/target")

def render(site_yaml):
    values = yaml.safe_load(pathlib.Path(site_yaml).read_text(encoding="utf-8"))
    env = Environment(loader=FileSystemLoader(TPL_DIR),
                      trim_blocks=True, lstrip_blocks=True, keep_trailing_newline=True)
    parts = []
    for tpl in sorted(TPL_DIR.glob("*.rsc.j2")):     # 00-system, 01-interfaces, ... 99-commit
        parts.append(env.get_template(tpl.name).render(**values))
    OUT_DIR.mkdir(parents=True, exist_ok=True)
    out = OUT_DIR / f"{values['device']['identity']}.rsc"
    out.write_text("\n".join(parts), encoding="utf-8")
    print(f"[OK] {site_yaml} -> {out}")

if __name__ == "__main__":
    for arg in sys.argv[1:]:
        render(arg)

One run handles multiple sites: python tools/render-site.py design/values/site-a.yaml design/values/site-b.yaml, producing configs ready to import:

# configs/target/rtr-site-a.rsc  (เรนเดอร์แล้ว พร้อม /import)
/interface vlan
add interface=bridge1 name=vlan101 vlan-id=101
add interface=bridge1 name=vlan102 vlan-id=102

/ip address
add address=192.168.10.1/24 interface=bridge1 network=192.168.10.0

Upload this file to the device, then run /import file=rtr-site-a.rsc — but I want to stress that I do not import it straight over production. Diff it against the current config first, and always test on a blank device or in a lab first. The whole pipeline comes down to these five steps:

  1. Get the config from the device with /export and save it as an .rsc file.
  2. Strip secrets with sanitize-rsc.ps1 before putting it into git or sending it to anyone.
  3. Split it into sections with split-rsc.ps1 so each part can be diffed and reviewed separately.
  4. Move site-specific values into values YAML, leaving only the structure in the Jinja2 templates.
  5. Render template + values into an .rsc file per site, then diff before running /import on the device.

A summary of my approach

For me, once a config becomes a file, it gets everything code gets — diffs show who changed which lines, I can roll back, I can review before pushing, and setting up a new site means copying a values file and changing a few numbers. Work that used to take half a day of clicking becomes a few seconds of running scripts.

I wrote all three scripts (sanitize / split / extract) in PowerShell because my workstation runs Windows, but the logic is all regex. Anyone more comfortable with bash or Python can rewrite them in whichever they prefer. The core of this is not the language, just the discipline — never let real values get out before sanitizing them. There are no exceptions.

If I have left anything out or made a typo, I will come back and fix it later. Anyone adapting this structure can change the VLAN, subnet, and tier numbers to suit the site.

Want us to set this up?

Get help with server setup, configuration, migration, or troubleshooting. Save time for the work that matters.

Get a quote

Hit something like this? Stuck somewhere?

Come trade notes on the LinuxThai board

→ Continue on the board

Comments 0

No comments yet — be the first