Creating the tailscale argv[0] symlinks with RUN in the final scratch stage forced overlayfs to copy-up the whole /usr/local/bin directory, duplicating the ~3 MB binary into a second layer. RouterOS extracts overlay layers separately, so the on-disk rootfs measured ~7 MB instead of ~3.4 MB. Assemble /usr/local/bin in the builder stage and bring it in with a single COPY layer. Verified on RouterOS 7.21.2: du -sx / now ~3.4 MB.
mikrotik-tailscale
A minimal Tailscale Docker image built for MikroTik routers running Container. Fits in 16 MB internal flash. Built from source with only router-relevant features included.
Supported architectures
| Docker platform | RouterOS arch | Example devices |
|---|---|---|
linux/amd64 |
x86 / CHR | x86 installs, Cloud Hosted Router |
linux/arm64 |
arm64 | RB5009, CCR2004/2116/2216, hAP ax³, L009, Chateau |
linux/arm/v7 |
arm (ARMv7) | hAP ac², RB3011, RB4011, RB1100AHx4 |
A single Dockerfile builds all three. The Go binary is cross-compiled (the
builder stage runs natively on the host for speed), while the busybox stage and
final image are built for the target platform (via buildx + QEMU/binfmt for
non-native targets).
ARMv5 is not supported (hEX Refresh / hAP ax S, EN7562CT CPU — RouterOS
calls these arm32v5). ARMv5 has no Alpine/musl base image, so it cannot use
this image's musl + scratch design; it would require a glibc (Debian) base
and produce a substantially larger image (~50 MB+ vs ~4 MB). If you need it,
that's a separate build, not just a --platform change.
Image size
On-disk footprint once extracted (this is what matters — RouterOS stores the extracted rootfs on disk via overlayfs, not the compressed layers):
| Component | On-disk size |
|---|---|
| tailscale.combined (UPX-compressed) | ~3.84 MB |
| custom static busybox (UPX, ~100 applets) | ~229 kB |
| CA certificates | ~218 kB |
| Total extracted rootfs | ~4.1 MB |
(The compressed image / transfer tarball is ~4.3 MB.)
The binary is built with Tailscale's --extra-small feature tag set as the
baseline. Features are opted in explicitly — any new feature Tailscale adds
in a future release stays omitted until deliberately added to the Dockerfile.
Size optimizations applied
-
Feature allowlist (
--extra-smallbaseline + ~10 opt-ins) keeps the binary minimal and forward-safe against new Tailscale features. -
-gcflags=all=-ldisables function inlining across all packages, shrinking the compressed binary by ~600 kB. Inlining is a performance optimization only; disabling it does not affect correctness. The CPU cost is negligible for an I/O-bound router daemon. -
-ldflags="-s -w"strips the symbol table and DWARF debug info. -
-trimpathremoves local filesystem paths from the binary. -
UPX
--lzma --bestcompresses the Tailscale binary (~14 MB → ~3.8 MB). -
Custom static busybox — instead of the official
busybox:muslimage (all ~404 applets, ~1.24 MB), a static busybox is built from source with only ~100 curated applets (~420 kB), then UPX-compressed to ~229 kB on disk. The applet set is defined inbusybox-applets.config.busybox UPX requires care. UPX normally breaks busybox's standalone applet dispatch: the ash shell re-execs
/proc/self/exeto run built-in applets, and UPX breaks that path so typed commands fail (upx#248, closed as "invalid"). We work around it by building without the standalone/nofork features and providing an explicit/bin/<applet>symlink farm. Commands then resolve via the normalPATH→ symlink →argv[0]dispatch, which works under UPX. The cost is afork+execper command instead of a nofork internal call — fine for an occasional debug shell.Because RouterOS stores the extracted rootfs on disk, UPX'ing busybox saves a real ~195 kB of flash (424 kB → 229 kB), not just transfer size.
The final image is built FROM scratch — there is no base distro layer.
It contains only the busybox binary + applet symlinks, the CA bundle, and
the Tailscale binary.
Features included
| Feature | Why |
|---|---|
advertise-exit-node |
Run the router as a Tailscale exit node |
advertise-routes |
Expose LAN subnets to the tailnet |
use-exit-node |
Route the router's own traffic via a remote exit node |
accept-routes |
Receive subnet routes from other tailnet nodes |
| DNS / MagicDNS | Resolve *.ts.net names (see DNS section below) |
| portmapper (NAT-PMP/PCP/UPnP) | Punch through upstream NAT |
| listenrawdisco | Raw socket disco for better NAT traversal |
| health | Powers tailscale status output |
| cachenetmap | Cache network map for faster reconnect after reboot |
| iptables | Linux iptables support for routing rules |
| osrouter | Configure kernel network stack and routing tables |
Features intentionally omitted
| Feature | Reason |
|---|---|
clientupdate |
Updates are managed by rebuilding the Docker image |
logtail |
Would attempt persistent log writes; wear flash |
netlog |
Network flow logging; separate concern |
netstack + gro |
Userspace/gVisor networking; router uses kernel TUN |
ssh |
Access via MikroTik SSH + tailscale CLI instead |
linuxdnsfight |
inotify on /etc/resolv.conf; no systemd in container |
networkmanager / resolved / dbus / sdnotify |
No systemd stack in container |
drive / taildrop / webclient |
Not useful on a headless router |
| All GUI / desktop / cloud / k8s features | Irrelevant |
Volume layout
Three mount points, with different persistence requirements:
/var/lib/tailscale persistent — node identity, auth state
bind-mount to MikroTik disk storage
written rarely (only on auth / key rotation)
/var/lib/tailscale/cache ephemeral — netmap cache
mount as tmpfs to avoid flash writes
recreated automatically on next connect
/var/run/tailscale ephemeral — daemon Unix socket
mount as tmpfs
lost on reboot, recreated on start
Keeping the cache and socket directories on tmpfs prevents unnecessary flash wear while still allowing fast reconnect after reboot (the cache is repopulated from the Tailscale coordination server on first connect).
Building
All architectures at once
Use the helper script (requires docker buildx + QEMU/binfmt for non-native
targets):
# One-time: register emulators for cross-arch builds
docker run --privileged --rm tonistiigi/binfmt --install arm64,arm
# Build all arches and load into local docker
./build.sh
# Build all arches and also export per-arch tarballs into ./dist/
./build.sh --tar
# Build a single arch
./build.sh arm64
./build.sh --tar armv7
Manual single-arch build
The architecture is selected via buildx --platform; the Dockerfile maps it to
the correct GOARCH/GOARM automatically:
docker buildx build --platform linux/arm64 --load -t mikrotik-tailscale:arm64 .
docker buildx build --platform linux/arm/v7 --load -t mikrotik-tailscale:armv7 .
docker buildx build --platform linux/amd64 --load -t mikrotik-tailscale:amd64 .
To build for a different Tailscale version, add:
--build-arg TAILSCALE_VERSION=v1.98.3
Notes
-
The Go builder cross-compiles natively (fast); only the busybox stage runs under emulation for non-native targets.
-
The build prints the resolved target and Go build tags, e.g.:
Cross-compiling: GOOS=linux GOARCH=arm64 GOARM= Build tags: ts_include_cli,ts_omit_ace,ts_omit_acme,...
Per-architecture image sizes
| Arch | Image |
|---|---|
| amd64 | ~4.2 MB |
| arm64 | ~3.5 MB |
| arm/v7 | ~3.5 MB |
Running (local test)
# Create a volume for persistent state
docker volume create tailscale-state
# Start the daemon
docker run -d \
--name tailscale \
--cap-add NET_ADMIN \
--cap-add NET_RAW \
--device /dev/net/tun \
--tmpfs /var/lib/tailscale/cache \
--tmpfs /var/run/tailscale \
-v tailscale-state:/var/lib/tailscale \
mikrotik-tailscale
# Authenticate (opens browser / prints auth URL)
docker exec tailscale tailscale login
# Check status
docker exec tailscale tailscale status
# Advertise a subnet
docker exec tailscale tailscale set --advertise-routes=192.168.88.0/24
# Advertise as exit node
docker exec tailscale tailscale set --advertise-exit-node
Subnet routes and exit node advertisement must also be approved in the Tailscale admin console.
Unattended authentication
For automated / headless deployment, use an auth key:
docker exec tailscale tailscale up \
--authkey=tskey-auth-<key> \
--advertise-routes=192.168.88.0/24 \
--advertise-exit-node
Auth keys can be created in the Tailscale admin console under Settings → Keys. Use a reusable key tagged with a device tag for infrastructure nodes.
MagicDNS
The binary includes DNS support but the daemon is started with
--no-logs-no-support, which does not affect DNS. To use MagicDNS name
resolution, configure MikroTik's DNS to forward .ts.net queries to
Tailscale's magic DNS resolver:
/ip dns static
add name="ts.net" type=FWD forward-to=100.100.100.100 match-subdomain=yes
This avoids writing to /etc/resolv.conf inside the container (which would
happen if --accept-dns is passed to tailscale up). The container resolves
Tailscale node names; the rest of the router uses its own DNS.
Flash wear protection
Several measures are in place to avoid wearing out internal flash:
clientupdateomitted from binary — no background update downloadslogtailomitted from binary — no log upload attempts--no-logs-no-supportpassed to daemon — suppresses any remaining log bufferingnetmapcache mounted on tmpfs — cache writes never reach flash/var/run/tailscalesocket on tmpfs — runtime files never reach flash- Only
/var/lib/tailscale/tailscaled.statetouches persistent storage, and it is written only when the node authenticates or rotates its key
Upgrading
Version bumps (Tailscale, busybox, base image digests) are normally proposed automatically via Renovate — see Dependency pinning & automated updates. Merge the Renovate PR, then rebuild and redeploy.
The feature allowlist in the Dockerfile carries forward automatically across
Tailscale versions — any new ts_omit_* tags introduced in a new release will
be omitted by default.
To bump manually, edit ARG TAILSCALE_VERSION in the Dockerfile (so the pin
stays in version control) and rebuild:
./build.sh --tar # rebuild all arches at the pinned version
# or, override at build time without editing the Dockerfile:
docker buildx build --platform linux/arm64 \
--build-arg TAILSCALE_VERSION=v1.100.0 \
--load -t mikrotik-tailscale:arm64 .
Versioning & releases
Released images are versioned as:
v<TAILSCALE_VERSION>-mt.<N>
e.g. v1.98.3-mt.1. The two parts mean:
v<TAILSCALE_VERSION>— the bundled Tailscale version (the "what's inside" identifier), taken fromARG TAILSCALE_VERSIONin the Dockerfile.mt.<N>— the local revision. It only changes on a meaningful release, never on a build-system-only rebuild.
When a release happens
| Trigger | Result |
|---|---|
Renovate bumps TAILSCALE_VERSION (merged to main) |
CI auto-creates git tag v<new>-mt.1 → image published |
| You make a meaningful fix/change on the current Tailscale version | You create the next tag manually (v<ts>-mt.2, mt.3, …) → image published |
| Dependency-only bump (Go / Alpine / busybox / Dockerfile syntax) | No release. Rides along with the next Tailscale bump or manual tag |
So routers only ever see a new release for Tailscale bumps or your deliberate fixes — build-system churn doesn't trigger updates.
Each published image is stamped with org.opencontainers.image.version equal to
its full tag; this is the value the MikroTik update job compares against the
registry to decide whether to recreate the container.
How it's wired (Woodpecker)
.woodpecker/release-tag.yaml— on push tomain, parsesTAILSCALE_VERSION; if nov<ts>-mt.*tag exists yet, creates and pushesv<ts>-mt.1(using the Gitea token from OpenBao). It never createsmt.2+..woodpecker/release.yaml— on av*-mt.*tag push, builds the multi-arch manifest (amd64 + arm64 + arm/v7) and pushes it togitea.lumpiasty.xyz/lumpiasty/mikrotik-tailscaleas both:<tag>and:stable. Registry creds come from OpenBao (secret/container-registry).
Cutting a manual release
# fix something, commit to main, then:
git tag -a v1.98.3-mt.2 -m "Fix X"
git push origin v1.98.3-mt.2
The tag push triggers the build+publish automatically.
Dependency pinning & automated updates
All upstream dependencies are version-pinned for reproducible builds:
All versions are fully qualified (no floating major.minor tags):
| Dependency | Where | Pinned form |
|---|---|---|
| Go toolchain | Dockerfile FROM golang:… |
full version tag + @sha256 digest |
| Alpine (busybox build base) | Dockerfile FROM alpine:… |
full version tag + @sha256 digest |
| Tailscale | Dockerfile ARG TAILSCALE_VERSION |
full git release tag |
| busybox | Dockerfile ARG BUSYBOX_VERSION |
full release version |
| Renovate / OpenBao | .woodpecker/renovate.yaml image: |
full version tag |
Updates are proposed automatically by Renovate, run self-hosted from a Woodpecker cron pipeline (Woodpecker has no native Renovate support):
renovate.json— repository rules. All dependencies follow the latest upstream releases (including major versions); each bump arrives as its own PR that the multi-arch build validates before you merge. Base image tags also get their@sha256digests refreshed viapinDigests. The one special rule:tailscaleonly follows stable releases — Tailscale uses even minor versions for stable (v1.98.x) and odd for unstable (v1.99.x), so the rule filters to even minors.
.woodpecker/renovate.yaml— the scheduled job that runsrenovate/renovateagainst this repo.
# Renovate repo config
docker run --rm -e RENOVATE_CONFIG_TYPE=repo -v "$PWD":/work -w /work \
--entrypoint renovate-config-validator renovate/renovate
# Woodpecker pipeline
docker run --rm -v "$PWD":/work -w /work \
woodpeckerci/woodpecker-cli:v3 lint .woodpecker/renovate.yaml