MikroTik RouterOS CHR inside Docker, with persistent disks, guest-aware health checks, and bounded clean shutdown.
This project packages MikroTik RouterOS (Cloud Hosted Router) inside a Docker container using QEMU for full x86_64 virtualization. It is designed for infrastructure engineers who need a reproducible, version-controlled MikroTik environment on any Linux server — with complete persistence, host file sharing, and safe port routing.
Because QEMU handles the x86_64 emulation layer, the Docker image itself is built for multiple CPU architectures. Whether your host is an AMD64 server, an ARM64 Raspberry Pi, or any other supported platform, you can pull and run the same image without any modifications.
| Feature | Description |
|---|---|
| Multi-Architecture | New CHR image releases target amd64 and arm64; qualification differs by platform |
| Persistent Storage | Router configuration survives container rebuilds via a host-mounted virtual drive |
| Host ↔ Guest File Sharing | A local directory is exposed inside MikroTik's File Manager as a virtual FAT drive |
| Safe SSH Access | MikroTik SSH is remapped to port 2222 — your host SSH on port 22 is untouched |
| Dynamic Port Range | Ports 9000–9100 are pre-allocated for custom services — no Compose restarts needed |
| Version Pinning | Pin the container and factory seed version; upgrade existing guests inside RouterOS |
| KVM Acceleration | Hardware virtualization is enabled automatically when /dev/kvm is available, with graceful fallback to software emulation |
| Shutdown Handling | Requests QMP guest poweroff, verifies guest-origin shutdown, and reports forced fallback |
- Linux host with Docker Engine installed
- Docker Compose v2+
- KVM support recommended (
/dev/kvmavailable) for acceptable performance
Note: KVM is optional. If unavailable, QEMU falls back to software emulation automatically. Performance will be lower but the router will function correctly.
| Architecture | New CHR image release status |
|---|---|
linux/amd64 |
Qualified through Docker runtime integration before publication |
linux/arm64 |
Cross-built; not runtime-qualified |
Legacy tags may include arm/v7, arm/v6, and 386; these platforms are not
published by the new CHR image release workflow. Do not substitute a legacy image
and assume it contains the new health, shutdown, or disk migration code.
git clone https://github.com/im-ecorp/mikrotik-routeros.git
cd mikrotik-routerosCopy the example and set your desired RouterOS version:
cp .env.example .env
# Edit .env: set ROUTEROS_VERSION, MANAGEMENT_BIND_IP and optionally TZ.env example (use a published CHR image release):
ROUTEROS_VERSION=7.21.4
TZ=Asia/Tehran
# Recommended for a new setup; remote access requires an SSH tunnel:
MANAGEMENT_BIND_IP=127.0.0.1If .env is omitted, the 7.21.4 tag and Asia/Tehran timezone are used by default.
Seed vs. pulled tag.
ROUTEROS_VERSIONhere selects an image tag that must already exist in the registry. The Dockerfile'sARG ROUTEROS_VERSIONselects the CHR seed the next build will bake in, and is currently 7.21.5 (the current longTerm release; MikroTik no longer offers 7.21.4). Compose moves to7.21.5only after that seed is built, qualified and published. Public image tags are CHR seed versions:7.21.4andv7.21.4. Before using these aliases, confirm the successful recovery manifest shows the runtime-capable image in both registries. The previous alias digest does not contain/routeros/bin/runtime.py; this recovery leaveslatestuntouched; it is not the Compose default. Pin the verified digest for an immutable deployment.MANAGEMENT_BIND_IPdefaults to0.0.0.0(all host IPv4 addresses) when unset or empty. The checked-in.env.examplepreserves that compatibility default; change it explicitly to127.0.0.1before first boot for local/tunneled management.
First-boot warning: A fresh CHR may permit admin without a password. Do not
expose management to an untrusted network while setting credentials. Loopback
binding prevents direct remote management access; establish an SSH tunnel through
the Docker host, not RouterOS. See networking and the tunnel example.
VPN and custom mappings remain unrestricted by MANAGEMENT_BIND_IP.
docker compose up -dThese addresses assume the compatibility/public binding. With 127.0.0.1, use
the local endpoints in the SSH tunnel example.
| Method | Address |
|---|---|
| Winbox | <server-ip>:8291 |
| SSH | ssh admin@<server-ip> -p 2222 |
| WebFig | http://<server-ip>:80 |
| API | <server-ip>:8728 |
Default credentials on a fresh CHR may be admin / (no password — set one immediately).
Port publication does not enable a RouterOS service or configure its firewall.
┌──────────────────────────────────────────────────────┐
│ Docker Container (Alpine Linux) │
│ │
│ ┌────────────────────────────────────┐ │
│ │ QEMU (x86_64 system emulation) │ │
│ │ │ │
│ │ MikroTik RouterOS CHR │ │
│ │ ├── chr.vdi (persistent disk) │ │
│ │ └── FAT drive (./shared) │ │
│ └────────────────┬───────────────────┘ │
│ │ TAP / bridge (qemubr0) │
└────────────────────┼─────────────────────────────────┘
│
Docker bridge network
172.24.0.0/16
QEMU always emulates an x86_64 guest. ARM64 uses software emulation; a successful ARM64 image build is not an ARM runtime qualification. See runtime requirements.
.
├── bin/
│ ├── entrypoint.sh # Container entrypoint & QEMU launcher
│ ├── init-disk.py # Stable disk selection & legacy migration
│ ├── runtime.py # Network validation, health, QMP & supervision
│ ├── qemu-ifup # TAP interface bring-up script
│ └── qemu-ifdown # TAP interface teardown script
├── data/ # Auto-created — stores chr.vdi (persistent)
├── shared/ # Auto-created — shared with MikroTik File Manager
├── .github/
│ └── workflows/
│ ├── docker-image.yml # Manual image publishing
│ └── validate.yml # PR/main checks; no publication or deployment
├── Dockerfile
├── docker-compose.yml
└── .env.example
| Host Path | Container Path | Purpose |
|---|---|---|
./data |
/routeros/data |
Stores the persistent virtual hard drive (chr.vdi) |
./shared |
/routeros/shared |
Exposed to MikroTik as a virtual FAT drive |
Important: Preserve
./dataand back it up while the guest is stopped. A single legacychr-*.vdiis copied tochr.vdiwithout changing the original; multiple legacy disks require explicit selection. See safe upgrades and recovery. The provided mounts are bind mounts, sodocker compose down -vdoes not remove these host directories, but it is not an upgrade or reset procedure.
| Port | Protocol | Service |
|---|---|---|
21 |
TCP | FTP |
22 → host 2222 |
TCP | SSH (remapped) |
23 |
TCP | Telnet |
80 |
TCP | WebFig (HTTP) |
443 |
TCP | WebFig (HTTPS) |
1194 |
TCP/UDP | OpenVPN |
1450 |
TCP | Legacy custom mapping; purpose unknown, not L2TP |
1701 |
UDP | L2TP |
1723 |
TCP | PPTP control only; also requires GRE |
8291 |
TCP | Winbox |
8728 |
TCP | RouterOS API |
8729 |
TCP | RouterOS API-SSL |
13231 |
UDP | WireGuard |
9000–9100 |
TCP | Reserved for custom services |
Management TCP ports (21, 2222, 23, 80, 443, 8291, 8728, 8729) use
MANAGEMENT_BIND_IP. VPN and custom ports do not. A service on 9000–9100 must be
configured to listen on TCP and allowed through the guest/host firewall and NAT;
moving management there bypasses the management binding restriction.
VPN limitations: UDP 1701 is L2TP, not a complete L2TP/IPsec setup. IPsec also
needs UDP 500/4500 (not published here) and potentially ESP (IP protocol 50).
PPTP requires GRE (IP protocol 47), not merely TCP 1723. ESP and GRE are not
TCP/UDP ports and cannot be added as normal port mappings. Publishing a port does
not enable the service or prove a working VPN. See networking scope and limitations.
ROUTEROS_VERSION selects the container image and its factory seed. Existing
routers always reuse ./data/chr.vdi; changing a tag does not upgrade or
downgrade the guest OS. Upgrade RouterOS through its own package manager.
Before changing either layer, shut down the guest and create a verified offline backup. Follow safe upgrades, legacy-disk migration, and rollback for the complete procedure. These behaviors require an image built with the new disk initializer; updating this checkout alone does not update a running image.
TZ=Europe/Berlindocker compose downYour configuration is stored in ./data for the next startup. For a clean offline
backup, shut down RouterOS inside the guest before stopping the service; see the
backup procedure.
A separate .github/workflows/validate.yml runs on pull requests and pushes to
main with contents: read. It installs Docker Compose, requires a successful
version/config check, runs bash -n and python3 -m unittest discover -s tests -v,
and never publishes or deploys. Networking tests render actual Compose JSON;
local validation supports a
standalone COMPOSE_BINARY and needs no Docker daemon. Missing Compose may skip
locally, but is an error in CI. Existing ShellCheck findings are not a new gate.
Runtime integration builds the actual Dockerfile with its default seed on an
isolated hosted runner. The fresh guest version must match the image's nonempty
io.mikrotik-routeros.seed.version OCI label. It also tests DHCP, HTTP/SSH TCP
forwarding, UDP DNS, health transitions, guest-clean stop/start, and recreation
persistence. Its evidence is uploaded separately from authentication-bearing
disks. See runtime validation.
Publish CHR image is manual and main-only. Successful Validate and
Runtime integration main-push runs must cover the exact source SHA.
Public aliases are the tested CHR seed version and its v alias:
hossein3piol/mikrotik-routeros:7.21.4
ghcr.io/im-ecorp/mikrotik-routeros:7.21.4
# Both registries also publish the v7.21.4 alias.
Publication creates no sha-<commit> tag. The source commit travels inside the
image as the standard OCI label, so the tag list stays readable:
docker inspect -f '{{index .Config.Labels "org.opencontainers.image.revision"}}' \
hossein3piol/mikrotik-routeros:7.21.4Existing CHR aliases require the explicit approve_version_overwrite=true
boolean dispatch input. SHA tags are never overwritten. The default-seed publisher
does not write latest; no workflow creates new -r suffix tags. Internal wrapper metadata remains in
OCI labels, separate from the CHR seed version. Docker Hub uses
DOCKERHUB_USERNAME / DOCKERHUB_TOKEN; GHCR uses the scoped GITHUB_TOKEN.
See release gates, platform qualifications, and exact-digest recovery.
The separate manual current-chr-matrix.yml workflow covers the reviewed website
CHR versions in config/chr-versions.json. Seventeen are recorded; fifteen
qualify. 6.49.21 and 6.49.22 are listed under runtimeBlocked because the
guest does not accept the persisted admin password after its first restart, so
runtime qualification cannot complete — see
RouterOS 6 and current-version qualification. They keep their
reviewed checksums but are excluded from the matrix and from the latest gate.
Each checksum-verified seed is built for amd64/arm64; the exact final amd64 digest
must pass full Docker/CHR integration before version aliases move. Only after all
fifteen pass and both registries read back correctly may it promote latest to
7.24.4, with explicit overwrite approval. 7.25beta5 is a development
channel build and is never a promotion target: the manifest pins latest to
7.24.4 and validation rejects any other value. ARM64 is build-only;
the Dockerfile/Compose default remains 7.21.4. Workflow availability is not
proof of publication: require its successful aggregate report before deployment.
Click to expand — step-by-step OpenVPN setup guide
Add a private IP range for VPN clients under IP → Pool.
For example: 172.24.0.0/16
Create or import the required certificates under System → Certificates.
Configure the PPP Profile under PPP → Profiles with the appropriate local and remote address settings.
Define user credentials under PPP → Secrets.
Create an OpenVPN Server interface under Interfaces → OpenVPN Server.
The example below uses port 4646 instead of the default 1194.
Add a matching TCP or UDP mapping to Compose for 4646 and match the server/client
transport; that port is not published by the default file. Publishing both
transports on 1194 does not enable both on the RouterOS server.
This step is critical for client internet access. Add a masquerade rule under IP → Firewall → NAT.
Container exits immediately
- Verify KVM is available:
ls -la /dev/kvm - Check logs:
docker compose logs -f - If KVM is unavailable, the container will fall back to software emulation automatically
Cannot connect via Winbox
- Confirm the container is running:
docker compose ps - Health checks verify running QEMU plus fresh guest ARP, not Winbox itself.
- After the boot grace period, inspect guest service and firewall settings separately.
MikroTik lost its configuration after restart or a tag change
- Verify the same
./datadirectory is mounted. - Do not delete or replace any disks. Older images selected versioned filenames.
- If several legacy disks exist, startup intentionally refuses to guess. Follow legacy-disk selection and recovery.
- Existing
chr.vditakes priority over all legacy files. A preserved legacy file does not receive subsequent guest writes.
SSH connection refused
- MikroTik SSH is on port
2222, not22 - Connect with:
ssh admin@<server-ip> -p 2222
Bridge already exists error on restart
- The entrypoint checks for an existing bridge before creating one — this is handled automatically
- lordbasex — original inspiration for the QEMU-in-Docker approach
If this project saves you time in your infrastructure work, consider supporting its development:
- USDT (TRC20):
TH1iDsFr2wjgpptghFBn6h7DVt88pp5WoH





