26 Commits

Author SHA1 Message Date
Renovate Bot ef17c190d5 chore(deps): update renovate/renovate docker tag to v43.209.1
ci/woodpecker/pr/pr-build Pipeline was successful
2026-06-03 02:01:04 +00:00
Renovate f576dc6f1f Merge pull request 'chore(deps): update dependency tailscale to v1.98.5' (#14) from renovate/tailscale-1.x into main
ci/woodpecker/push/release-tag Pipeline was successful
ci/woodpecker/push/pr-build Pipeline was successful
ci/woodpecker/tag/release Pipeline was successful
ci/woodpecker/cron/renovate Pipeline was successful
2026-06-02 22:10:28 +00:00
Renovate Bot e7dcdba8aa chore(deps): update dependency tailscale to v1.98.5
ci/woodpecker/pr/pr-build Pipeline was successful
2026-06-02 21:57:48 +00:00
Lumpiasty bd6c6cf4b2 Merge pull request 'fix: preserve v prefix for tailscale version' (#15) from fix/renovate-datasource into main
ci/woodpecker/push/release-tag Pipeline was successful
ci/woodpecker/push/pr-build Pipeline was successful
ci/woodpecker/cron/renovate Pipeline was successful
Reviewed-on: #15
2026-06-02 21:47:13 +00:00
Lumpiasty 1a8b065283 fix: preserve v prefix for tailscale version
ci/woodpecker/pr/pr-build Pipeline was successful
2026-06-02 23:46:51 +02:00
Lumpiasty 7dacdccc01 Merge pull request 'make renovate recognise Tailscale version' (#13) from fix/renovate-datasource into main
ci/woodpecker/push/release-tag Pipeline was successful
ci/woodpecker/cron/renovate Pipeline was successful
ci/woodpecker/push/pr-build Pipeline was successful
Reviewed-on: #13
2026-06-02 21:33:31 +00:00
Lumpiasty 8a34988dd4 make renovate recognise Tailscale version
ci/woodpecker/pr/pr-build Pipeline was canceled
2026-06-02 23:33:05 +02:00
Lumpiasty 6e5004aa0e Merge pull request 'chore(deps): update renovate/renovate docker tag to v43.207.4' (#10) from renovate/renovate-renovate-43.x into main
ci/woodpecker/push/release-tag Pipeline was successful
ci/woodpecker/cron/renovate Pipeline was successful
ci/woodpecker/push/pr-build Pipeline was successful
Reviewed-on: #10
2026-06-02 15:25:55 +00:00
Lumpiasty 57df037137 Merge pull request 'Fix tailscale up by building ipnbus and enable ip forwarding in entrypoint' (#11) from fix/forwarding-and-ipnbus into main
ci/woodpecker/push/release-tag Pipeline was successful
ci/woodpecker/push/pr-build Pipeline was successful
ci/woodpecker/tag/release Pipeline was successful
Reviewed-on: #11
2026-06-02 14:15:40 +00:00
Lumpiasty 315fd630e3 enable IP forwarding via entrypoint (fixes IPv6 subnet routes)
ci/woodpecker/pr/pr-build Pipeline was successful
tailscaled does not reliably enable IPv6 forwarding inside a container
network namespace ('IPv6 forwarding is disabled'), so advertised IPv6
subnet routes silently fail. Add a tiny entrypoint.sh that sets
net.ipv4.ip_forward and net.ipv6.conf.all.forwarding (writable inside a
RouterOS container netns), then exec's tailscaled. Built in the builder
stage so it stays in the single /usr/local/bin COPY layer.

Verified: privileged run flips v6 forwarding 0->1 and exec's tailscaled
with CMD args intact.
2026-06-02 16:06:10 +02:00
Lumpiasty 1bc10bcb6e include ipnbus so 'tailscale up' waits and prints login URL
Without ipnbus, 'tailscale up' fires config at the daemon and returns
immediately ('built with ts_omit_ipnbus; not waiting for completion')
without printing the auth URL or confirming success. Add it to the
allowlist so interactive 'up' behaves normally.
2026-06-02 15:54:52 +02:00
Renovate Bot 745075f38c chore(deps): update renovate/renovate docker tag to v43.207.4
ci/woodpecker/pr/pr-build Pipeline was successful
2026-06-02 02:01:04 +00:00
Lumpiasty 9ff1623958 Merge pull request 'Refactor of docs' (#9) from refac/readme-cleanup into main
ci/woodpecker/push/pr-build Pipeline was successful
ci/woodpecker/push/release-tag Pipeline was successful
ci/woodpecker/cron/renovate Pipeline was successful
Reviewed-on: #9
2026-06-01 18:41:46 +00:00
Lumpiasty 94427bd3f4 Merge pull request 'chore(deps): update renovate/renovate docker tag to v43.205.3' (#7) from renovate/renovate-renovate-43.x into main
ci/woodpecker/push/release-tag Pipeline was successful
ci/woodpecker/push/pr-build Pipeline was successful
ci/woodpecker/cron/renovate Pipeline was successful
Reviewed-on: #7
2026-06-01 18:27:32 +00:00
Lumpiasty 37938ac471 Merge pull request 'chore(deps): update alpine/git docker tag to v2.52.0' (#6) from renovate/alpine-git-2.x into main
ci/woodpecker/push/release-tag Pipeline is pending
ci/woodpecker/push/pr-build Pipeline was canceled
Reviewed-on: #6
2026-06-01 18:27:24 +00:00
Lumpiasty 2ce364ea15 Merge pull request 'chore(deps): update alpine docker tag to v3.23.4' (#5) from renovate/alpine-3.x into main
ci/woodpecker/push/release-tag Pipeline is pending
ci/woodpecker/push/pr-build Pipeline was canceled
Reviewed-on: #5
2026-06-01 18:27:07 +00:00
Lumpiasty 3057685588 Merge pull request 'chore(deps): update woodpeckerci/plugin-docker-buildx docker tag to v6' (#8) from renovate/woodpeckerci-plugin-docker-buildx-6.x into main
ci/woodpecker/push/release-tag Pipeline is pending
ci/woodpecker/push/pr-build Pipeline was canceled
Reviewed-on: #8
2026-06-01 18:27:02 +00:00
Lumpiasty 3cf6a1faab Manual refactor of docs
ci/woodpecker/pr/pr-build Pipeline was successful
2026-06-01 20:23:28 +02:00
Renovate Bot 43ed7efe98 chore(deps): update renovate/renovate docker tag to v43.205.3
ci/woodpecker/pr/pr-build Pipeline was successful
2026-06-01 02:01:06 +00:00
Renovate Bot d45799a314 chore(deps): update woodpeckerci/plugin-docker-buildx docker tag to v6
ci/woodpecker/pr/pr-build Pipeline was successful
2026-05-30 02:04:22 +00:00
Renovate Bot a1da2564fd chore(deps): update alpine/git docker tag to v2.52.0
ci/woodpecker/pr/pr-build Pipeline was successful
2026-05-29 14:30:02 +00:00
Renovate Bot 9788fe146b chore(deps): update alpine docker tag to v3.23.4
ci/woodpecker/pr/pr-build Pipeline was successful
2026-05-29 14:29:59 +00:00
Lumpiasty f69263c480 Merge pull request 'test pr-build' (#4) from test/pr-build-trigger into main
ci/woodpecker/push/release-tag Pipeline was successful
ci/woodpecker/push/pr-build Pipeline was successful
ci/woodpecker/cron/renovate Pipeline was successful
Reviewed-on: #4
2026-05-29 14:25:00 +00:00
Lumpiasty ae8c114109 trigger pr-build
ci/woodpecker/pr/pr-build Pipeline was successful
2026-05-29 16:11:03 +02:00
Lumpiasty ea0d90d8f0 automerge tailscale + component patch updates behind a PR build
ci/woodpecker/push/release-tag Pipeline was successful
ci/woodpecker/push/pr-build Pipeline was successful
Add .woodpecker/pr-build.yaml: builds all three arches (dry-run, no push)
on PRs and pushes to main, reporting status to Gitea. This is the gate
for automerge.

renovate.json automerge rules (platformAutomerge, merged only after the
PR build passes):
- tailscale stable patch AND minor
- Go/Alpine/busybox PATCH only
- base-image digest refreshes
Minor/major of build deps and tooling stay manual.

Move pinDigests into a dockerfile packageRule (top-level dockerfile.* is
deprecated). Document the automerge policy and its caveat (PR build proves
build-only, not runtime) in DESIGN.md.
2026-05-29 15:49:47 +02:00
Lumpiasty 7d1b9f99a5 correct extracted-size measurement guidance
ci/woodpecker/push/release-tag Pipeline was successful
The ~7 MB seen via 'du' inside the container is RouterOS block-allocation
rounding (a 3 MB file occupies ~6 MB of blocks), NOT layer duplication —
verified: the published image carries tailscale.combined in exactly one
layer, and the real flash cost is ~3.7 MiB (free-hdd-space delta).

Fix the docs to measure on-flash footprint via free-hdd-space delta, not
du; clarify the overlayfs section is about keeping the image clean (still
valid best practice) and explicitly decouple it from the du number.
2026-05-29 04:49:54 +02:00
9 changed files with 247 additions and 90 deletions
+26
View File
@@ -0,0 +1,26 @@
# Build validation for pull requests (and pushes to main).
#
# Builds the full multi-arch image but does NOT push it anywhere — it only
# proves the Dockerfile still builds for every supported architecture. This is
# the gate Renovate automerge waits on: a dependency bump that breaks the build
# fails this check and will NOT be automerged (and therefore never reaches
# :stable or the routers).
#
# Reports pass/fail status back to Gitea, so it shows up as a required check on
# the PR.
when:
- event: pull_request
- event: push
branch: main
steps:
- name: Build all arches (no push)
image: woodpeckerci/plugin-docker-buildx:6.1.0
privileged: true
settings:
repo: mikrotik-tailscale
platforms: linux/amd64,linux/arm64,linux/arm/v7
dry-run: true
build_args:
- OCI_VERSION=ci-${CI_COMMIT_SHA}
+1 -1
View File
@@ -34,7 +34,7 @@ steps:
- bao kv get -mount secret -field RENOVATE_TOKEN renovate > /woodpecker/git_token - bao kv get -mount secret -field RENOVATE_TOKEN renovate > /woodpecker/git_token
- name: Auto-tag mt.1 on Tailscale bump - name: Auto-tag mt.1 on Tailscale bump
image: alpine/git:2.49.1 image: alpine/git:v2.52.0
environment: environment:
CI_REPO_URL: https://gitea.lumpiasty.xyz/lumpiasty/mikrotik-tailscale.git CI_REPO_URL: https://gitea.lumpiasty.xyz/lumpiasty/mikrotik-tailscale.git
commands: commands:
+1 -1
View File
@@ -43,7 +43,7 @@ steps:
- 'printf "PLUGIN_PASSWORD=%s\n" "$(bao kv get -mount secret -field REGISTRY_PASSWORD container-registry)" >> /woodpecker/registry.env' - 'printf "PLUGIN_PASSWORD=%s\n" "$(bao kv get -mount secret -field REGISTRY_PASSWORD container-registry)" >> /woodpecker/registry.env'
- name: Build and push multi-arch image - name: Build and push multi-arch image
image: woodpeckerci/plugin-docker-buildx:5.2.2 image: woodpeckerci/plugin-docker-buildx:6.1.0
privileged: true privileged: true
settings: settings:
registry: gitea.lumpiasty.xyz registry: gitea.lumpiasty.xyz
+1 -1
View File
@@ -46,7 +46,7 @@ steps:
- bao kv get -mount secret -field GITHUB_COM_TOKEN renovate > /woodpecker/github_com_token - bao kv get -mount secret -field GITHUB_COM_TOKEN renovate > /woodpecker/github_com_token
- name: renovate - name: renovate
# Renovate's built-in "woodpecker" manager tracks this image automatically. # Renovate's built-in "woodpecker" manager tracks this image automatically.
image: renovate/renovate:43.194.0 image: renovate/renovate:43.209.1
environment: environment:
# --- platform / target --- # --- platform / target ---
RENOVATE_PLATFORM: gitea RENOVATE_PLATFORM: gitea
+31 -4
View File
@@ -21,8 +21,8 @@
# ============================================================================= # =============================================================================
FROM --platform=$BUILDPLATFORM golang:1.26.3-alpine@sha256:91eda9776261207ea25fd06b5b7fed8d397dd2c0a283e77f2ab6e91bfa71079d AS builder FROM --platform=$BUILDPLATFORM golang:1.26.3-alpine@sha256:91eda9776261207ea25fd06b5b7fed8d397dd2c0a283e77f2ab6e91bfa71079d AS builder
# renovate: datasource=github-releases depName=tailscale packageName=tailscale/tailscale # renovate: datasource=github-releases depName=tailscale packageName=tailscale/tailscale versioning=semver
ARG TAILSCALE_VERSION=v1.98.3 ARG TAILSCALE_VERSION=v1.98.5
# Provided automatically by buildx for the target platform. # Provided automatically by buildx for the target platform.
ARG TARGETOS ARG TARGETOS
@@ -69,6 +69,12 @@ WORKDIR /src/tailscale
# trusted unix socket, so PermitRead/PermitWrite are # trusted unix socket, so PermitRead/PermitWrite are
# always false and EVERY CLI call (status, up, set, ...) # always false and EVERY CLI call (status, up, set, ...)
# returns "access denied" (tailscale/tailscale#17873). # returns "access denied" (tailscale/tailscale#17873).
# ipnbus — IPN bus watch. Without it, 'tailscale up' cannot wait
# for completion: it fires config at the daemon and
# returns immediately ("built with ts_omit_ipnbus; not
# waiting for completion") WITHOUT printing the auth URL
# or confirming success. Including it makes interactive
# 'up' behave normally (blocks, prints login URL).
# #
# Everything else remains omitted, including (rationale): # Everything else remains omitted, including (rationale):
# clientupdate — DELIBERATELY removed. The built-in updater would download # clientupdate — DELIBERATELY removed. The built-in updater would download
@@ -111,6 +117,7 @@ RUN mkdir -p /out && \
-e 's/ts_omit_health,\{0,1\}//g' \ -e 's/ts_omit_health,\{0,1\}//g' \
-e 's/ts_omit_iptables,\{0,1\}//g' \ -e 's/ts_omit_iptables,\{0,1\}//g' \
-e 's/ts_omit_unixsocketidentity,\{0,1\}//g' \ -e 's/ts_omit_unixsocketidentity,\{0,1\}//g' \
-e 's/ts_omit_ipnbus,\{0,1\}//g' \
-e 's/,$//' \ -e 's/,$//' \
) && \ ) && \
echo "Build tags: ${TAGS}" && \ echo "Build tags: ${TAGS}" && \
@@ -150,6 +157,24 @@ RUN mkdir -p /out/usrlocalbin && \
ln -s /usr/local/bin/tailscale.combined /out/usrlocalbin/tailscale && \ ln -s /usr/local/bin/tailscale.combined /out/usrlocalbin/tailscale && \
ln -s /usr/local/bin/tailscale.combined /out/usrlocalbin/tailscaled ln -s /usr/local/bin/tailscale.combined /out/usrlocalbin/tailscaled
# Entrypoint wrapper: enable IP forwarding inside the container's network
# namespace, then exec tailscaled. tailscaled does NOT reliably enable IPv6
# forwarding itself in a container netns ("IPv6 forwarding is disabled" warning),
# which silently breaks advertised IPv6 subnet routes. The sysctls ARE writable
# from inside a RouterOS container, so we set both here. Written in the builder
# stage so it ships in the same single /usr/local/bin COPY layer (preserves the
# overlayfs single-copy property). `exec` keeps tailscaled as PID 1.
RUN printf '%s\n' \
'#!/bin/sh' \
'# Enable IPv4/IPv6 forwarding. Required for advertised subnet routes and' \
'# exit-node functionality.' \
'for f in /proc/sys/net/ipv4/ip_forward /proc/sys/net/ipv6/conf/all/forwarding; do' \
' if [ -w "$f" ]; then echo 1 > "$f" 2>/dev/null || echo "warn: could not write $f"; fi' \
'done' \
'exec /usr/local/bin/tailscaled "$@"' \
> /out/usrlocalbin/entrypoint.sh && \
chmod +x /out/usrlocalbin/entrypoint.sh
# ============================================================================= # =============================================================================
# Stage 2: Custom minimal busybox # Stage 2: Custom minimal busybox
# ============================================================================= # =============================================================================
@@ -171,7 +196,7 @@ RUN mkdir -p /out/usrlocalbin && \
# This stage runs on the TARGET platform (no --platform override): gcc then # This stage runs on the TARGET platform (no --platform override): gcc then
# produces native target-arch binaries directly. Under buildx this is # produces native target-arch binaries directly. Under buildx this is
# transparently emulated via binfmt/QEMU for non-native targets. # transparently emulated via binfmt/QEMU for non-native targets.
FROM alpine:3.21.7@sha256:48b0309ca019d89d40f670aa1bc06e426dc0931948452e8491e3d65087abc07d AS busybox FROM alpine:3.23.4@sha256:5b10f432ef3da1b8d4c7eb6c487f2f5a8f096bc91145e68878dd4a5019afde11 AS busybox
# renovate: datasource=docker depName=busybox versioning=docker # renovate: datasource=docker depName=busybox versioning=docker
ARG BUSYBOX_VERSION=1.37.0 ARG BUSYBOX_VERSION=1.37.0
@@ -267,7 +292,9 @@ ENV PATH=/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
VOLUME ["/var/lib/tailscale"] VOLUME ["/var/lib/tailscale"]
ENTRYPOINT ["/usr/local/bin/tailscaled"] # entrypoint.sh enables IP forwarding (incl. IPv6) in the container netns, then
# exec's tailscaled with the CMD flags below as its arguments.
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
# Default flags: # Default flags:
# --no-logs-no-support disables logtail uploads (logtail binary code is # --no-logs-no-support disables logtail uploads (logtail binary code is
+19
View File
@@ -7,6 +7,8 @@ A minimal Tailscale Docker image built for MikroTik routers running
16 MB internal flash. Built from source with only router-relevant features 16 MB internal flash. Built from source with only router-relevant features
included. included.
> Disclaimer: This project has been largely vibe-coded, but I stand behind design and implementation choices made.
- **~4 MB** extracted rootfs (`FROM scratch` + UPX'd Tailscale binary + a custom - **~4 MB** extracted rootfs (`FROM scratch` + UPX'd Tailscale binary + a custom
static busybox debug shell). static busybox debug shell).
- **Multi-arch**: amd64, arm64, arm/v7 — one tag, RouterOS pulls the right one. - **Multi-arch**: amd64, arm64, arm/v7 — one tag, RouterOS pulls the right one.
@@ -16,6 +18,23 @@ included.
- **Flash-wear conscious**: minimal persistent state, no netmap disk-caching, - **Flash-wear conscious**: minimal persistent state, no netmap disk-caching,
tmpfs for scratch and runtime. tmpfs for scratch and runtime.
## Motivation
There is no built-in Tailscale integration in MikroTik, and other solutions
feel underwhelming. I've used Fluent-networks' tailscale-mikrotik until now,
but that basically forced me to connect external storage to my router
just to use Tailscale. This approach, while works, is fragile, wasteful
and overcomplicated, so I decided to do better one myself.
| | **This project** | Fluent-networks/tailscale-mikrotik |
|---|---|---|
| Size | **~4 MB** | ~106 MB |
| Size reduction technique | **Minimal container with custom Tailscale and Busybox builds, compressed by UPX** | Alpine Linux base, Tailscale binary compressed by UPX on build, but auto-update completely nullifies that on first launch |
| Update mechanism | **Automatically released optimized container images with new Tailscale versions, scheduled script updating deployment on new version** | None, opt-in Tailscale built-in auto-update downloading official binaries |
| Flash wear | **Write-heavy functionality compiled out, suitable for low-endurance flash chips** | High, constant netmap cache updates |
| Stability | **Immutable container** | Tailscale app can update on its own |
| Features | **Only router-useful Tailscale features compiled, Busybox providing shell and utils** | Full tailscale, OpenSSH server, Bash, IPTables |
## Documentation ## Documentation
- **[Usage](docs/USAGE.md)** — deploy the published image on a MikroTik router - **[Usage](docs/USAGE.md)** — deploy the published image on a MikroTik router
+78 -28
View File
@@ -28,10 +28,12 @@ Measured flattened rootfs for the arm64 image:
| arm64 | ~3.5 MB | | arm64 | ~3.5 MB |
| arm/v7 | ~3.5 MB | | arm/v7 | ~3.5 MB |
> The extracted rootfs must contain the binary only **once**. If you measure On a deployed RouterOS device the container consumes **~3.7 MiB of flash**
> ~7 MB on the device with `du -sx /`, the Dockerfile has reintroduced an (measured by `free-hdd-space` delta). Note that `du` *inside* the container
> overlayfs copy-up — see reports roughly double that (~7 MB) — that is RouterOS block-allocation
> [Avoiding overlayfs layer duplication](#avoiding-overlayfs-layer-duplication). rounding, **not** real usage or duplication; see
[Avoiding overlayfs layer duplication](#avoiding-overlayfs-layer-duplication)
for how to measure correctly.
The binary is built with Tailscale's `--extra-small` feature tag set as the 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 baseline. Features are opted in explicitly — any new feature Tailscale adds
@@ -68,38 +70,64 @@ in a future release stays omitted until deliberately added to the Dockerfile.
saves a real ~195 kB of flash (424 kB → 229 kB), not just transfer size. 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. 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 It contains only the busybox binary + applet symlinks, the CA bundle, the
the Tailscale binary. Tailscale binary, and a tiny `entrypoint.sh`.
### Entrypoint: IP forwarding
`ENTRYPOINT` is a small `entrypoint.sh` that enables IPv4 and IPv6 forwarding
(`net.ipv4.ip_forward`, `net.ipv6.conf.all.forwarding`) in the container's
network namespace, then `exec`s `tailscaled` (so the daemon stays PID 1). This
is necessary because `tailscaled` does **not** reliably enable IPv6 forwarding
itself inside a container netns — it logs "IPv6 forwarding is disabled" and
advertised IPv6 subnet routes silently fail. The sysctls are writable from
inside a RouterOS container, so the entrypoint sets them directly; no
host-side or `/container` configuration is required. The script is created in
the builder stage so it ships in the same single `/usr/local/bin` `COPY` layer
(preserving the [single-copy property](#avoiding-overlayfs-layer-duplication)).
### Avoiding overlayfs layer duplication ### Avoiding overlayfs layer duplication
A subtle but important detail: **the final image must not run a `RUN` that Best practice for the final image: **don't run a `RUN` that mutates a directory
mutates a directory already populated by an earlier layer**, or the extracted already populated by an earlier layer.** Each Dockerfile instruction is its own
on-disk size roughly doubles for that directory's contents. layer; if `/usr/local/bin/` is created by a `COPY` (containing the ~3 MB
`tailscale.combined`) and a later `RUN ln -s …` adds a symlink *inside that same
RouterOS Container uses overlayfs and stores the **extracted** layers on disk. directory*, overlayfs performs a **copy-up** of the entire directory — including
Each Dockerfile instruction is its own layer. If `/usr/local/bin/` is created by the 3 MB binary — into the new layer. The binary then physically exists in two
a `COPY` (containing the ~3 MB `tailscale.combined`) and a later `RUN ln -s …` image layers.
adds a symlink *inside that same directory*, overlayfs performs a **copy-up**:
it copies the entire `/usr/local/bin/` directory — including the 3 MB binary —
into the new layer's upper dir. RouterOS then extracts both copies to flash, so
`du -sx /` reports ~7 MB instead of ~3.4 MB for a directory whose only real file
is 3 MB. (The compressed image hides this — compression dedupes identical blocks
— which is why it only shows up when you measure the *extracted* rootfs on the
device.)
The fix: assemble `/usr/local/bin/` completely in the **builder** stage (binary The fix: assemble `/usr/local/bin/` completely in the **builder** stage (binary
+ both `argv[0]` symlinks) and bring it into the final image with a **single + both `argv[0]` symlinks) and bring it into the final image with a **single
`COPY` layer**, never mutating it afterwards. The Dockerfile does this; don't `COPY` layer**, never mutating it afterwards. The Dockerfile does this; don't
reintroduce a post-`COPY` `RUN` against that path. reintroduce a post-`COPY` `RUN` against that path. You can confirm the published
image carries the binary in exactly one layer:
To verify the extracted footprint on a deployed router:
``` ```
/container/shell [find where name=tailscale] docker save <image> -o img.tar && tar xf img.tar -C img/
du -sx / # expect ~3500 KiB (1 KiB blocks), not ~7000 # then grep each blob layer for usr/local/bin/tailscale.combined — it must
# appear in exactly ONE layer.
``` ```
Note: this is about keeping the *image* clean. It does **not** change what `du`
reports on the device — see the measurement note below.
To verify the on-flash footprint on a deployed router, use the **free-space
delta**, not `du`:
```
/system/resource/print # note free-hdd-space before and after adding the container
```
The container should consume **~3.7 MiB** of flash (e.g. 94.6 → 90.9 MiB free).
Do **not** trust `du` inside the container for this. Busybox `du` reports
*allocated blocks*, and RouterOS's container store rounds a ~3 MB file up to
~6 MB of blocks — so `du -sx /` reports ~7 MB even though real flash use is
~3.7 MB. `ls -la /usr/local/bin` confirms the binary's true content size
(~3.1 MB) and that it is a single file with two symlinks (no duplication).
The image itself carries the binary in exactly one layer (verified at the blob
level); the inflation is purely the filesystem's block accounting.
## Architecture support ## Architecture support
A single Dockerfile builds all three supported RouterOS architectures. The Go A single Dockerfile builds all three supported RouterOS architectures. The Go
@@ -128,6 +156,7 @@ that's a separate build, not just a `--platform` change.
| iptables | Linux iptables support for routing rules | | iptables | Linux iptables support for routing rules |
| osrouter | Configure kernel network stack and routing tables | | osrouter | Configure kernel network stack and routing tables |
| unixsocketidentity | **Required** — without it the localapi denies every CLI call with "access denied" ([tailscale#17873](https://github.com/tailscale/tailscale/issues/17873)) | | unixsocketidentity | **Required** — without it the localapi denies every CLI call with "access denied" ([tailscale#17873](https://github.com/tailscale/tailscale/issues/17873)) |
| ipnbus | Lets `tailscale up` wait for completion and print the login URL; without it `up` returns immediately without confirming success |
## Features intentionally omitted ## Features intentionally omitted
@@ -309,14 +338,35 @@ run **self-hosted** from a Woodpecker cron pipeline (Woodpecker has no native
Renovate support): Renovate support):
- `renovate.json` — repository rules. All dependencies follow the latest - `renovate.json` — repository rules. All dependencies follow the latest
upstream releases (including major versions); each bump arrives as its own PR upstream releases; each bump arrives as its own PR. Base image tags also get
that the multi-arch build validates before you merge. Base image tags also their `@sha256` digests refreshed via `pinDigests`. Notable rules:
get their `@sha256` digests refreshed via `pinDigests`. The one special rule:
- `tailscale` only follows **stable** releases — Tailscale uses even minor - `tailscale` only follows **stable** releases — Tailscale uses even minor
versions for stable (`v1.98.x`) and odd for unstable (`v1.99.x`), so the versions for stable (`v1.98.x`) and odd for unstable (`v1.99.x`), so the
rule filters to even minors. rule filters to even minors.
- `.woodpecker/renovate.yaml` — the scheduled job that runs `renovate/renovate` - `.woodpecker/renovate.yaml` — the scheduled job that runs `renovate/renovate`
against this repo. against this repo.
- `.woodpecker/pr-build.yaml` — builds all three arches (no push) on every PR
and reports status to Gitea. This is the gate for automerge.
### Automerge policy
These updates **automerge** once the PR build passes — they reach `:stable`
(and the routers) without manual review:
| Update | Automerge? | Why |
|---|---|---|
| Tailscale stable (patch **and** minor) | ✅ | the point of the project; the PR build catches breakage |
| Go / Alpine / busybox **patch** | ✅ | bugfix-only, build-internal |
| Base-image **digest** refresh (same tag) | ✅ | content refresh, no version change |
| Go / Alpine / busybox **minor/major** | ❌ manual | larger toolchain/base changes warrant review |
| Renovate runner, syntax frontend | ❌ manual | tooling — review deliberately |
**Important:** automerge depends on the PR build being a **required status
check** in Gitea branch protection. The PR build only proves the image *builds*
for all arches — it does not run the daemon, so a runtime regression in a new
Tailscale release could still be automerged. That is an accepted trade-off for
the convenience of unattended Tailscale updates; if a release misbehaves, roll
back by re-tagging the previous `v…-mt.N` (the immutable tags are kept).
Validate the configs locally: Validate the configs locally:
+42 -48
View File
@@ -10,64 +10,58 @@ reasoning behind these choices, see [DESIGN.md](DESIGN.md).
## Deploy on MikroTik (RouterOS) ## Deploy on MikroTik (RouterOS)
Verified on RouterOS 7.21.2 (arm64, CRS418). Commands are grouped into Verified on RouterOS 7.21.2 (arm64, CRS418). Commands are grouped into
copy-paste blocks; **only the values marked `CHANGE ME` need editing**. copy-paste blocks, defaults should fit most configurations.
> Because the image has no built-in updater (the `clientupdate` feature is > Because the image has no built-in updater (the `clientupdate` feature is
> [intentionally compiled out](DESIGN.md#why-the-built-in-updater-is-removed)), > [intentionally compiled out](DESIGN.md#why-the-built-in-updater-is-removed)),
> updates are handled by a small script that only re-pulls when the published > updates are handled by a small script that recreates container when
> image actually changed — see [step 7](#7-enable-automatic-updates). > the update is published — see [step 7](#7-enable-automatic-updates).
### 0. Prerequisites ### 0. Prerequisites
- RouterOS 7.x with the **container** package installed. - RouterOS >7.13 with the **container** package installed.
- Container mode enabled (needs physical access — press reset / cold-boot when - Container mode enabled ([documentation](https://manual.mikrotik.com/docs/System%20Information%20and%20Utilities/device-mode/#changing-mode-of-device-mode)):
prompted):
``` ```
/system/device-mode/update container=yes /system/device-mode/update container=yes
``` ```
- A Tailscale **auth key** from the admin console ### 1. Networking (veth + routing)
(**Settings → Keys**, reusable, optionally tagged). You'll use it in step 6.
### 1. Networking (veth + bridge + NAT) Gives the container an internal IP and configures routing to the tailnet.
Pick a subnet that doesn't clash with your LAN.
Gives the container an internal IP and outbound internet via NAT. Pick a subnet
that doesn't clash with your LAN.
``` ```
/interface/veth/add name=veth-tailscale address=172.20.0.2/24 gateway=172.20.0.1 /interface/veth/add name=veth-tailscale address=172.20.0.2/24 gateway=172.20.0.1
/interface/bridge/add name=containers /interface/bridge/add name=containers
/ip/address/add address=172.20.0.1/24 interface=containers /ip/address/add address=172.20.0.1/24 interface=containers
/interface/bridge/port/add bridge=containers interface=veth-tailscale /interface/bridge/port/add bridge=containers interface=veth-tailscale
/ip/firewall/nat/add chain=srcnat action=masquerade src-address=172.20.0.0/24 /ip/route/add dst-address=100.64.0.0/10 gateway=172.20.0.2 comment=Tailnet
``` ```
If you want the router to have access to subnets shared by other tailscale nodes,
add route for each one.
```
/ip/route/add dst-address=[subnet CIDR] gateway=172.20.0.2 comment="Another network via tailscale"
```
If you want to share your LAN via tailscale, add it as an advertised route in
[step 5](#5-authenticate). You may also need additional firewall configuration
to accept connections to or from tailnet if you have one configured.
You should not need any additional NAT rules.
### 2. Extraction scratch dir (tmpfs) ### 2. Extraction scratch dir (tmpfs)
Put the image extraction scratch dir on **tmpfs** (RAM) so the pull/extract Put the image extraction scratch dir on **tmpfs** (RAM) so the pull/extract
never writes to flash: happen in RAM and doesn't fill up or wear out flash:
``` ```
/disk/add type=tmpfs tmpfs-max-size=256M slot=tmp /disk/add type=tmpfs tmpfs-max-size=256M slot=tmp
/container/config/set tmpdir=tmp /container/config/set tmpdir=tmp
``` ```
> **No `registry-url` change needed.** This guide puts the full registry host in ### 3. Persistent state mount (the only thing on flash)
> `remote-image` (step 5), and RouterOS pulls directly from that host — the
> global `registry-url` is ignored when the image reference includes a host.
> This is intentional: it leaves your existing `registry-url` untouched, so
> other containers (e.g. ones pulling from Docker Hub or ghcr.io) keep working,
> and multiple registries can be used side by side.
### 3. Authentication note (no env needed)
This image runs `tailscaled` directly and does **not** bundle Tailscale's
`containerboot` wrapper, so the `TS_AUTHKEY` environment variable is **not**
read automatically. You authenticate with `tailscale up --authkey=...` after the
container starts (step 6) — this keeps the image minimal and needs no env list.
### 4. Persistent state mount (the only thing on flash)
Only the tiny `tailscaled.state` (node identity / key) needs to persist. Mount Only the tiny `tailscaled.state` (node identity / key) needs to persist. Mount
just that directory: just that directory:
@@ -76,14 +70,7 @@ just that directory:
/container/mounts/add list=tailscale_state src=tailscale/state dst=/var/lib/tailscale /container/mounts/add list=tailscale_state src=tailscale/state dst=/var/lib/tailscale
``` ```
`src=tailscale/state` is on internal storage. This holds `tailscaled.state` ### 4. Add and start the container
(and `derpmap.cached.json`), written only on auth / key rotation / prefs
change — **not** on every netmap update, because netmap disk-caching is omitted
([why](DESIGN.md#why-netmap-disk-caching-is-removed)). Flash wear is therefore
minimal. If you want *zero* persistent writes, point `src` at a tmpfs disk slot
instead and accept re-authentication after a reboot.
### 5. Add and start the container
``` ```
/container/add \ /container/add \
@@ -106,16 +93,25 @@ Wait for the pull/extract to finish (`status=stopped`), then start it:
The daemon is now running but **not yet authenticated**. The daemon is now running but **not yet authenticated**.
### 6. Authenticate ### 5. Authenticate
Enter the container shell and bring Tailscale up with your auth key. You can set > This image runs `tailscaled` via a tiny entrypoint (which enables IP
subnet routes / exit-node advertisement in the same command: forwarding, then `exec`s the daemon) and does **not** bundle Tailscale's
`containerboot` wrapper, so the `TS_AUTHKEY` environment variable is **not**
read automatically. You authenticate with `tailscale up --authkey=...` after the
container starts.
Enter the container shell and bring Tailscale up with your auth key.
Use `tailscale up --help` to see list of commands, customize it to your needs,
add subnets (eg. your LAN) or exit-node advertisements in command below.
``` ```
/container/shell [find where name=tailscale] /container/shell [find where name=tailscale]
# inside the container — CHANGE ME: your key (and adjust routes/subnet): # inside the container — CHANGE ME: your key (and adjust routes/subnet):
tailscale up --authkey=tskey-auth-CHANGEME \ tailscale up --authkey=tskey-auth-CHANGEME \
--advertise-routes=192.168.88.0/24 \ --accept-routes \
--snat-subnet-routes=false \
--advertise-routes=172.20.0.0/24 \
--advertise-exit-node --advertise-exit-node
exit exit
``` ```
@@ -124,7 +120,7 @@ The node now appears in your Tailscale admin console. Approve the advertised
routes / exit node there. Because the auth state is written to the persisted routes / exit node there. Because the auth state is written to the persisted
`tailscaled.state`, you only do this once — it survives reboots and updates. `tailscaled.state`, you only do this once — it survives reboots and updates.
### 7. Enable automatic updates ### 6. Enable automatic updates
First, edit the `CONFIG` block at the top of `routeros/update-tailscale.rsc` if First, edit the `CONFIG` block at the top of `routeros/update-tailscale.rsc` if
you changed any names in the steps above. The defaults match this guide you changed any names in the steps above. The defaults match this guide
@@ -136,8 +132,6 @@ create a **named script** from it and schedule it:
``` ```
# Create the named script from the uploaded file's contents. # Create the named script from the uploaded file's contents.
# (Do NOT use `/import` — that just runs the file once and does not create a
# reusable script for the scheduler to call.)
/system/script/add name=update-tailscale source=[/file/get update-tailscale.rsc contents] /system/script/add name=update-tailscale source=[/file/get update-tailscale.rsc contents]
# Run it daily. # Run it daily.
@@ -179,14 +173,14 @@ queries to Tailscale's magic DNS resolver:
add name="ts.net" type=FWD forward-to=100.100.100.100 match-subdomain=yes 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 When this is configured, you can connect to other tailscale machines using
happen if `--accept-dns` is passed to `tailscale up`). The container resolves `[device name].[tailnet name].ts.net`. You can see and change assigned
Tailscale node names; the rest of the router uses its own DNS. Tailnet DNS name in Tailscale admin panel under DNS tab.
## Updating ## Updating
You don't normally do anything: when a new release is published, the You don't normally do anything: when a new release is published, the
auto-update script ([step 7](#7-enable-automatic-updates)) detects the changed auto-update script ([step 6](#6-enable-automatic-updates)) detects the changed
`:stable` image on its next scheduled run and recreates the container. Your `:stable` image on its next scheduled run and recreates the container. Your
node identity and settings persist across the update via the state mount. node identity and settings persist across the update via the state mount.
+47 -6
View File
@@ -7,17 +7,58 @@
], ],
"labels": ["dependencies"], "labels": ["dependencies"],
"rebaseWhen": "behind-base-branch", "rebaseWhen": "behind-base-branch",
"dockerfile": { "customManagers": [
{
"customType": "regex",
"description": "Update version ARGs annotated with a `# renovate:` comment (the dockerfile manager only handles FROM/image lines, not ARG values).",
"managerFilePatterns": ["/(^|/)Dockerfile$/"],
"matchStrings": [
"#\\s*renovate:\\s*datasource=(?<datasource>\\S+)\\s+depName=(?<depName>\\S+)(?:\\s+packageName=(?<packageName>\\S+))?(?:\\s+versioning=(?<versioning>\\S+))?\\s+ARG \\w+=(?<currentValue>\\S+)"
],
"matchStringsStrategy": "any"
}
],
"packageRules": [
{
"matchManagers": ["dockerfile"],
"description": "Keep base-image tags pinned to a digest.",
"pinDigests": true "pinDigests": true
}, },
"packageRules": [
{ {
"matchDatasources": ["github-releases"], "matchDatasources": ["github-releases"],
"matchPackageNames": ["tailscale/tailscale"], "matchPackageNames": ["tailscale/tailscale"],
"description": "TAILSCALE_VERSION ARG: only stable releases. Tailscale uses EVEN minor versions for stable (v1.98.x); ODD minors (v1.99.x) are unstable, so filter to even minors and ignore pre-releases.", "description": "TAILSCALE_VERSION ARG: only stable releases. Tailscale uses EVEN minor versions for stable (v1.98.x); ODD minors (v1.99.x) are unstable, so filter to even minors and ignore pre-releases. The `v` prefix is kept (no extractVersion) so the ARG value stays v-prefixed to match the git tags cloned in the Dockerfile.",
"extractVersion": "^v(?<version>\\d+\\.\\d+\\.\\d+)$", "allowedVersions": "/^v\\d+\\.\\d*[02468]\\.\\d+$/",
"allowedVersions": "/^\\d+\\.\\d*[02468]\\.\\d+$/",
"ignoreUnstable": true "ignoreUnstable": true
},
{
"matchDatasources": ["github-releases"],
"matchPackageNames": ["tailscale/tailscale"],
"description": "Automerge all stable Tailscale releases (patch AND minor) once the PR build passes.",
"matchUpdateTypes": ["minor", "patch"],
"automerge": true
},
{
"matchManagers": ["dockerfile"],
"matchPackageNames": ["golang", "alpine"],
"description": "Automerge PATCH-only bumps of build components (Go/Alpine) once the PR build passes; review minor/major manually.",
"matchUpdateTypes": ["patch"],
"automerge": true
},
{
"matchDatasources": ["docker"],
"matchPackageNames": ["busybox"],
"description": "busybox ARG (custom manager): automerge PATCH bumps once the PR build passes; review minor/major manually.",
"matchUpdateTypes": ["patch"],
"automerge": true
},
{
"matchManagers": ["dockerfile"],
"matchUpdateTypes": ["digest", "pinDigest"],
"description": "Automerge base-image digest refreshes (same tag, new sha256) once the PR build passes.",
"automerge": true
} }
] ],
"automergeType": "pr",
"platformAutomerge": true
} }