Mikrotik RouterOS struggles with tailscale containers and terraform

In this post I’ll show you example of how to configure tailscale subnet router in a container on amikrotik router.

This is not a terraform tutorial. It will not show you how to set up terraform for mikrotik from scratch. (If there is interest I may write basic routeros terraform tutorial and routeros terraform firewall tutorial.)

Some context

Terraform provider for routeros is one of my best discoveries of 2024.
It’s configuration as code with real idempotency we so love.
Since learning it I’ve switched completely o managing my mikrotik devices with terraform.
It’s soo good I personally wouldn’t consider buying managed networking device without terraform provider.
https://registry.terraform.io/providers/terraform-routeros/routeros/latest/docs

I’ve recently started a project to rebuild core of my home network into something less homelab-y.
Time to ditch “HA everything” mindset and build smaller core of the network on less hardware and save cash on power bill.
One part of that project was testing out replacing my forbidden router linux VM on a HA proxmox cluster with a simple RB5009UPr+S+IN router. 4 arm64 cores, 1GB of RAM and POE for all my APs in one box, what’s not to love.

Right now my “downsized lab” lives on the separate, fake ISP vlan and consists of the rb5009 router and optiplex k8s nodes for different services. Due to bugs and firewall incompatibilities I cannot run tailscale so I decided to try running tailscale subnet router on my router.

  1. Not very straight up installation

Enable container support on the device (requires you to physically reset or power off the device)

/system/device-mode/update container=yes

You actually need to also install the container package on the device.
check for updated to make sure router know how to install the package (I already have it installed)

/system/package/update/check-for-updates
/system/package/print  
Flags: X - DISABLED; A - AVAILABLE
Columns: NAME, VERSION, BUILD-TIME, SIZE
 #    NAME            VERSION  BUILD-TIME           SIZE     
 0    routeros        7.20.8   2026-01-30 09:17:54  12.6MiB  
 1    container       7.20.8   2026-01-30 09:17:54  208.1KiB 
 2 XA calea                                         20.1KiB  
 3 XA dude                                          1224.1KiB
 4 XA extra-nic                                     2384.1KiB

Tell the router to install the package

/system/package/enable container

Tell the router to apply changes and actually install the package

/system/package/apply-changes

After this second reboot you should have /container path in shell of your router.

  1. Container storage

Mikrotik routers don’t enough flash to store and run containers. You will need an usb flash drive.
I’ve purchased SanDisk Ultra Fit for it. It’s reall small and doesn’t stick out too much from the router.
Usb drive should show up as usb1. You can check it’s name via /disk/print

Format your usb drive

/disk format-drive usb1 file-system=ext4

Create some directories (routeros is not smart enough to create them for you)
Paths don’t start with /
Router os has real paths starting with / internally but you don’t have access to them. Passing paths / risks confusing routeros.

/file add type=directory name=usb1/containers/tmp
/file add type=directory name=usb1/containers/layers
/file add type=directory name=usb1/containers/images
/file add type=directory name=usb1/containers/tailscale/var
  1. Terraform config, Preparing the network

One of the minor pain points of containers on this platform is networking. You have to do everything yourself and by hand.

You will need bridge and veth for your container and some routers.
In this case this bridge is dedicated just to tailscale container.

Here is example of my network config for this container:

resource "routeros_interface_veth" "veth-container-tailscale" {
        name    = "veth-container-tailscale"
        address = [
                "10.x.xx.2/24",
                "fdxx:x:x:x::2/64"
        ]
        gateway = "10.x.x.1"
        comment = "Virtual interface for tailscale container"
}
import {
        to = routeros_interface_veth.veth-container-tailscale
        id = "*X"
}

resource "routeros_interface_bridge" "bridge_container_tailscale" {
        name    = "bridge_container_tailscale"
}
import { 
        to = routeros_interface_bridge.bridge_container_tailscale
        id = "*X"
}

resource "routeros_ip_address" "ip_address_bridge_container_tailscale" {
        address         = "10.x.x.1/24"
        #comment                = null
        #disabled       = false
        interface       = "bridge_container_tailscale"
}
import {
        to = routeros_ip_address.ip_address_bridge_container_tailscale
        id = "*X"
}

resource "routeros_ipv6_address" "ipv6_address_bridge_container_tailscale" {
        address         = "fdx:x:x:x::1/64"
        #comment                = null
        #disabled       = false
        interface       = "bridge_container_tailscale"
        advertise       = false
}
import {
        to = routeros_ipv6_address.ipv6_address_bridge_container_tailscale
        id = "*X"
}

resource "routeros_interface_bridge_port" "bridge_container_tailscale_veth_port" {
        bridge          = "bridge_container_tailscale"
        interface       = "veth-container-tailscale"
}
import {
        to = routeros_interface_bridge_port.bridge_container_tailscale_veth_port
        id = "*X"
}

resource "routeros_ip_route" "route_bridge_container_tailscale" {
        dst_address = "100.64.0.0/10" # tailscale internal subnet
        gateway     = "10.x.x.2"      # container IP
}
import {
        to = routeros_ip_route.route_bridge_container_tailscale
        id = "*X"
}

resource "routeros_ipv6_route" "routev6_bridge_container_tailscale" {
        dst_address = "fd7a:115c:a1e0::/48" # tailscale internal subnet
        gateway     = "fdx:x:x:x::2"        # container IPv6
}
import {
        to = routeros_ipv6_route.routev6_bridge_container_tailscale
        id = "*X"
}

I try to always write imports in my configs. In mikrotik you usually get the ID by running print show-ids
For example

/ip/address/print show-ids
Flags: D - DYNAMIC
Columns: ADDRESS, NETWORK, INTERFACE
*    ADDRESS          NETWORK       INTERFACE                 
...
*A   10.x.x.1/24    10.x.x.0    bridge_container_tailscale

Id is *A and my import config should look like this:

import {
        to = routeros_ip_address.ip_address_bridge_container_tailscale
        id = "*A"
}

Now run terraform apply, confirm changes and you should have your network configured.

  1. Creating config for you container.

First, we have to setup enviroment variables for our container.
I’m configuring the container to:

  • use my headscale server login server.
  • act as an exit node
  • accept routes
  • authenticate from one time preauth key
  • advertise some routes (subnet router)
  • and save config between restarts (you don’t want to reauthenticate node every container restart)

resource "routeros_container_envs" "env_tailscale_ts_extra_args" {
        name    = "tailscale"
        key             = "TS_EXTRA_ARGS"
        value   = "--login-server=https://XXXX.XXXXX.XXXX --accept-routes --advertise-exit-node "
}
import {
        to = routeros_container_envs.env_tailscale_ts_extra_args
        id = "*X"
}
resource "routeros_container_envs" "env_tailscale_ts_auth_key" {
        name    = "tailscale"
        key             = "TS_AUTHKEY"
        value   = "XXXXXXXXXXXX"
}
import {
        to = routeros_container_envs.env_tailscale_ts_auth_key
        id = "*X"
}

resource "routeros_container_envs" "env_tailscale_ts_routes" {
        name    = "tailscale"
        key             = "TS_ROUTES"
        value   = "10.x.x.0/24,fdx:x:x:x::/64"
}
import {
        to = routeros_container_envs.env_tailscale_ts_routes
        id = "*X"
}

resource "routeros_container_envs" "env_tailscale_ts_state_dir" {
        name    = "tailscale"
        key             = "TS_STATE_DIR"
        value   = "/var/lib/tailscale"
}
import {
        to = routeros_container_envs.env_tailscale_ts_state_dir
        id = "*X"
}

Next we configure volume mount for our container
Sadly import for routeros_container_mounts is currently borked in 1.99.0, no import.

resource "routeros_container_mounts" "tailscale" {
       name    = "tailscale"
       src             = "usb1/containers/tailscale/var"
       dst             = "/var/lib/tailscale"
}

And finally we have to configure some default /container settings
Configure default registry and working directories on our usb storage.

resource "routeros_container_config" "config" {
        registry_url    = "https://docker.io"
        tmpdir                  = "usb1/containers/tmp"
        layer_dir               = "usb1/containers/layers"
}

You can run terraform apply to apply changes.

  1. Actually starting the container???

Finally we have arrived at the important part. Something that takes just seconds with docker compose.

Our container config (some of the config values are take straight from other terraform objects

resource "routeros_container" "tailscale" {
        remote_image    = "tailscale/tailscale:v1.94.2"
        interface               = routeros_interface_veth.veth-container-tailscale.name
        logging                 = true
        root_dir                = "usb1/containers/tailscale/root"
        envlist                 = routeros_container_envs.env_tailscale_ts_extra_args.name
        mounts                  = [ "tailscale" ]
        check_certificate = true
        running                 = true
        start_on_boot   = true
}

terraform apply will provide us with nice changelog:

Terraform used the selected providers to generate the following execution plan. Resource actions are
indicated with the following symbols:
  + create

Terraform will perform the following actions:

  # routeros_container.tailscale will be created
  + resource "routeros_container" "tailscale" {
      + arch              = (known after apply)
      + check_certificate = true
      + envlist           = "tailscale"
      + id                = (known after apply)
      + interface         = "veth-container-tailscale"
      + logging           = true
      + mounts            = [
          + "tailscale",
        ]
      + name              = (known after apply)
      + os                = (known after apply)
      + remote_image      = "tailscale/tailscale:v1.94.2"
      + root_dir          = "usb1/containers/tailscale/root"
      + running           = true
      + start_on_boot     = true
      + status            = (known after apply)
      + stop_signal       = "15-SIGTERM"
      + tag               = (known after apply)
      + workdir           = (known after apply)
    }

Plan: 1 to add, 0 to change, 0 to destroy.

Do you want to perform these actions?
  Terraform will perform the actions described above.
  Only 'yes' will be accepted to approve.

  Enter a value: 

After terraform creates the container it should be visible in routeros…

 /container/print 
Flags: S - STOPPED
Columns: NAME, ROOT-DIR, INTERFACE, MOUNTS, ENVLISTS, TAG
#   NAME               ROOT-DIR                        INTERFACE                 MOUNTS     ENVLISTS   TAG                                  
;;; import error: check url failed: SSL: ssl: no trusted CA certificate found (6)
0 S tailscale:v1.94.2  usb1/containers/tailscale/root  veth-container-tailscale  tailscale  tailscale  docker.io/tailscale/tailscale:v1.94.2

Yea, In 2026 we don’t have enough CA installed to pull the image from dockerhub.
(Github registry also has the same issue.)
You don’t even get the error with hostname at fault.
Disapointment levels: immeasurable

But we can disable TLS check and just hope for the best…
Setting check_certificate = false in container settings does just that.

Routeros /container doesn’t handle well changes to container settings.
I found out that it’s best to manually remove container before recreating it
cli command /container/remove tailscale:v1.94.2

After running terraform apply again we endup with…

/container/print 
Flags: S - STOPPED
Columns: NAME, ROOT-DIR, INTERFACE, MOUNTS, ENVLISTS, WORKDIR, TAG
#   NAME               ROOT-DIR                        INTERFACE                 MOUNTS     ENVLISTS   WORKDIR  TAG                                  
;;; import error: failed to extract layer: createDirectory /flash/rw/disk/usb1/containers/layers/3449061238c60ed2f8808f5cc4bf23112bf819eda6bda4635b01c30aec6c9eb9//usr/local/bin mkdir failed: No such file or directory
0 S tailscale:v1.94.2  usb1/containers/tailscale/root  veth-container-tailscale  tailscale  tailscale  /        docker.io/tailscale/tailscale:v1.94.2

It just cannot start container pulled from the web.

I had to switch to manually downloaded container file.
Quick tutorial for podman:

Download arm64 container on your system

podman pull ghcr.io/tailscale/tailscale:v1.94.2 --arch arm64

get arm64 container digest from the manifest

podman manifest inspect ghcr.io/tailscale/tailscale:v1.94.2 | jq -r '.manifests[] | select(.platform.architecture == "arm64") | .digest'

Save arm64 container to a tar file using it’s correct sha256 from manifest (useful if you already have amd64 containers pulled on the same host)

podman save ghcr.io/tailscale/tailscale@sha256:3e3bebce61524ec3fc416ecafba5b378a4d26d1609d5af8d25c9f49d4bb1ff59 -o tailscale_v1.94.2.tar

Upload container to the router usb1 drive

scp tailscale_v1.94.2.tar rb5009upr-0:/usb1/containers/images/ 

Now have to delete old routeros container
/container/remove tailscale:v1.94.2
Change our container definition to:

resource "routeros_container" "tailscale" {
        file                    = "usb1/containers/images/tailscale_v1.94.2.tar"
        interface               = routeros_interface_veth.veth-container-tailscale.name
        logging                 = true
        root_dir                = "usb1/containers/tailscale/root"
        envlist                 = routeros_container_envs.env_tailscale_ts_extra_args.name
        mounts                  = [ "tailscale" ]
        check_certificate = false
        running                 = true
        start_on_boot   = true
}

Run tarraform apply
And manually start the container on our router /container/start tailscale:v1.94.2

If everything goes well you should see R in from of your container

/container> print 
Flags: R - RUNNING
Columns: NAME, ROOT-DIR, INTERFACE, MOUNTS, ENVLISTS, WORKDIR, MEMORY-CURRENT
#   NAME               ROOT-DIR                        INTERFACE                 MOUNTS     ENVLISTS   WORKDIR  MEMORY-CURRENT
0 R tailscale_v1.94.2  usb1/containers/tailscale/root  veth-container-tailscale  tailscale  tailscale  /        45.5MiB       

and a moment of truth

tailscale ping rb5009upr-0
pong from rb5009upr-0 (100.64.0.X) via DERP(xxx) in 25ms
pong from rb5009upr-0 (100.64.0.X) via DERP(xxx) in 20ms
pong from rb5009upr-0 (100.64.0.X) via DERP(xxx) in 18ms
pong from rb5009upr-0 (100.64.0.X) via DERP(xxx) in 17ms
pong from rb5009upr-0 (100.64.0.X) via DERP(xxx) in 20ms

If you encounter some tailscale issues, you can check the container logs here: /container/log/print

You can enter container shell via /container/shell tailscale_v1.94.2
Or execute commands straight from routeros shell /container/shell tailscale_v1.94.2 cmd="tailscale status"

TS_STATE_DIR is really important, it keeps config of your node. Your preauth key usually works only once on headscale. Without persistent TS_STATE_DIR you will need a new preauthkey every container restart.

It took me few tries to get tailscale config working. When reconfiguring things it’s best to delete routeros container and recreate it. Terraform can sometimes do this but not always.
Sadly it also means, that your container ID changes, you may need to update import config a lot.

To summarize

I’ve sunk few hours yesterday setting this up. I got it working but I cannot recommend containers on routeros.
I would have been fine with tiresome network setup and environment variable handling.
But inability to pull containers from THE docker container registry in 2026 is shameful.
Uploading container files to router could be automated, but it’s significantly more difficult than just changing container tag and letting k8s or docker replace container with latest image. Barriers to upgrade make you lazier when performing to upgrades and less secure.
Everything in container package seems to circle around manually deleting and manually recreating containers. It’s so bad people have written 60 line scripts just to restart containers to newer version.

Lack of polish and general design of containers on routeros are in my opinion also a reason why mikrotik RDS2216-2XG-4S+4XS-2XQ will be a complete failure. (Even without considering current flash prices). Mikrotik has created a very interesting piece of hardware and shacked it to it’s bizarre software.

2 Likes

Small peek a the performance.
I didn’t really set out to do proper benchmark but this may illustrate the works case scenario.

In this case I’m uploading ISO to proxmox server over rb5009’s tailscale subnet router container.
In my current network setup Because of my network topology, only way to establish that network connection is via tailscale relay.

Here is the bmon network interface speed graph from proxmox server:

20260216_21h05m13s_grim-2

And here is rb5009 cpu usage

20260216_21h05m13s_grim-1

Normally CPU usage is close to zero even when proxmox was directly downloading ISOs from the internet (with basic routing , firewall and NAT running on the router)

I don’t remember if tailscale relay traffic uses significantly different network packets than normal wireguard for direct connection. Tailscale peer relay posts have overshadowed all older posts about how relay traffi works.

In this case CPU speed was switching between 700MHz and 1400MHz. It may be possible to squeeze more permance out of this setup, but ~6MiB/s (~50.331648 Mb/s) with 50% CPU utilization doesn’t look exciting.

In this scenario speed is mainly bottlenecked by tailscale relay server handicap. But CPU utilization is still quite high for what what it gives you.