The previous two articles ended at the same wall: on-box scripting is imperative. It can react to events beautifully, but it cannot express “this is what the router should look like” and converge reality toward it. For that you need an external system talking to an API — and RouterOS 7 finally made that respectable by shipping a REST API in the base system. Combined with the mature community.routeros Ansible collection, MikroTik boxes can be managed the same way I manage VyOS, Cisco and Juniper gear in the network-automation articles elsewhere on this site: config in git, changes through review, idempotent runs.
The REST API
RouterOS 7’s REST API rides the web service: enable www-ssl (never plain www for API use), and every console path becomes a URL under /rest:
# On the router — TLS service with a real or self-signed cert/certificate add name=api-cert common-name=router.example.com key-size=2048/certificate sign api-cert/ip service set www-ssl certificate=api-cert disabled=no address=10.10.0.0/24/ip service set www disabled=yesThen from any HTTP client, with basic auth against a RouterOS user:
# Read: GET maps to printcurl -sk -u automation:PASS https://10.10.0.1/rest/interface | jq .curl -sk -u automation:PASS https://10.10.0.1/rest/ip/address | jq .
# Filtered querycurl -sk -u automation:PASS 'https://10.10.0.1/rest/ip/route?dst-address=0.0.0.0/0'
# Create: PUT adds an entrycurl -sk -u automation:PASS -X PUT -H 'Content-Type: application/json' \ -d '{"list":"blocklist","address":"203.0.113.66","timeout":"1h"}' \ https://10.10.0.1/rest/ip/firewall/address-list
# Modify: PATCH by internal .idcurl -sk -u automation:PASS -X PATCH -H 'Content-Type: application/json' \ -d '{"comment":"managed by automation"}' \ https://10.10.0.1/rest/ip/firewall/address-list/*2A
# Arbitrary commands: POST to the path with a verbcurl -sk -u automation:PASS -X POST -H 'Content-Type: application/json' \ -d '{".query":["disabled=false"]}' https://10.10.0.1/rest/interface/printThe mapping is mechanical: console path → URL, print → GET, add → PUT, set → PATCH, remove → DELETE, anything else → POST. Responses are JSON, and entries carry the internal .id you use for updates. Two operational notes: the API respects user group policies (an automation user with read,write,api,rest-api policy and no ftp/telnet is the right shape), and everything is synchronous — a long-running command holds the HTTP connection.
The binary API (port 8728/8729) predates REST and speaks a length-prefixed sentence protocol. It still exists, it’s what many libraries (librouteros, RouterOS-api) and Ansible’s api_* modules use, and it’s fine — but for anything you’re writing yourself today, REST + JSON is less friction and easier to debug with curl. Whichever you use, restrict it: /ip service set api,api-ssl address=<mgmt-subnet> or disable outright if only REST is in play.
Ansible: community.routeros
The collection ships three lanes and choosing correctly matters:
api_modify / api_find_and_modify — declarative, idempotent modules over the binary API. This is the lane for desired state.
api — raw API calls from playbooks; escape hatch for paths api_modify doesn’t model.
routeros.command — SSH/CLI-based; no API needed, not idempotent, right for one-off operational commands (/system backup save) and boxes where the API isn’t reachable yet.
Setup on the control node:
ansible-galaxy collection install community.routerospip install librouterosInventory:
mikrotik: hosts: gw-office: ansible_host: 10.10.0.1 vars: ansible_network_os: community.routeros.routeros ansible_user: automation ansible_password: "{{ vault_routeros_password }}"And a playbook that expresses state, not steps:
- name: Baseline firewall address-lists hosts: mikrotik gather_facts: false connection: local tasks: - name: Management subnet list is exactly this community.routeros.api_modify: hostname: "{{ ansible_host }}" username: "{{ ansible_user }}" password: "{{ ansible_password }}" tls: true validate_certs: false # true + proper CA in real life path: ip firewall address-list data: - list: mgmt address: 10.10.0.0/24 - list: mgmt address: 172.16.255.0/24 handle_absent_entries: remove handle_entries_content: remove_as_much_as_possible restrict: - field: list values: [mgmt]The restrict + handle_absent_entries combination is the payoff: Ansible makes the mgmt list contain exactly those entries — adds what’s missing, removes what shouldn’t be there, touches nothing outside the restriction. Run it twice, the second run reports zero changes. That’s the idempotency the scheduler article said you’d otherwise hand-roll with find-before-act guards.
--check --diff works and is non-negotiable in a pipeline: every change to router config gets a dry-run diff in the merge request before it gets an apply. This is the same discipline as the VyOS and Cisco Ansible articles — MikroTik just took a few extra years to make it this clean.
The Idempotency Reality Check
Honesty section. api_modify models many paths well (firewall, addresses, interfaces, DHCP, DNS, routes…), but RouterOS wasn’t designed as declarative-first, and edges exist:
Ordering is your problem. Firewall rules are order-sensitive; api_modify manages entry content but rule sequencing needs care — manage whole chains with explicit ordering, or structure chains so order-critical segments are jump targets built entirely by automation. Mixing hand-placed and Ansible-placed rules in one chain ends in tears.
Some paths are stateful or dynamic. Dynamic entries (DHCP leases marked D, connection-tracking, dynamic address-list entries with timeouts) don’t belong in desired state — restrict your tasks so automation never fights the router’s own dynamism.
Secrets echo. Passwords set via API/Ansible appear in playbooks and logs unless you vault them (ansible-vault, no_log: true on tasks). A router credential in plaintext in git is a full compromise of everything downstream — treat inventory vars as production secrets, because they are.
Check mode isn’t proof. --check validates against the module’s model, not the router’s runtime behavior. A rule can be idempotent-correct and operationally wrong (wrong chain, wrong position relative to fasttrack). The verification habit stays: after apply, confirm counters move on the rules you expect (/ip firewall filter print stats).
SSH Fallback and Backups
Not everything deserves API plumbing. Operational one-offs ride routeros.command over SSH:
- name: Export config for the git archive community.routeros.command: commands: - /export show-sensitive register: config_export
- name: Save export locally ansible.builtin.copy: content: "{{ config_export.stdout[0] }}" dest: "backups/{{ inventory_hostname }}-{{ '%Y%m%d' | strftime }}.rsc" delegate_to: localhostNightly, this gives you every router’s config in git with history — which quietly solves the “what changed before it broke” question forever. (The backup article coming later in this series covers the binary-backup side and why you want both.)
A Sane Pipeline
What this looks like assembled, and what I actually run for network gear: inventory + playbooks in a git repo; changes as merge requests; CI runs --check --diff against a lab CHR (the CHR article closes this series — a free CHR instance is a perfect pre-prod twin); human reviews the diff; merge applies to production with serial: 1 so a bad change hits one router, not the fleet. On-box scripting from the last two articles doesn’t disappear — it keeps handling local reactions (failover, alerts) while Ansible owns configuration. Reactions on the box, state in git.
Closing Thoughts
RouterOS 7’s REST API turned MikroTik automation from “install a library that speaks a binary protocol” into “curl and jq.” The Ansible collection turned config management from imperative scripts into reviewable desired state with real idempotency — restricted, diffed, dry-runnable. The edges are real: you own rule ordering, you vault secrets, you verify with counters after apply. But the overall shift is the one that matters: the router stops being a box you log into and becomes an endpoint your infrastructure code converges. Every serious network I run has crossed that line; the boxes that haven’t are the ones I still get paged about.