- Shell 44.8%
- Python 40.6%
- Dockerfile 14.6%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
The router now pulls docker.io/cloudflare/cloudflared:latest with a static busybox mounted on /bin for the healthcheck and cloudflared's own 'tunnel ready' probe. Drops the nightly schedule; the workflow stays for an ARMv7 board. README records the RouterOS settings and the rollback. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> |
||
| .forgejo/workflows | ||
| .gitignore | ||
| build.sh | ||
| deploy.py | ||
| Dockerfile | ||
| fetch-binaries.sh | ||
| README.md | ||
cloudflared for MikroTik RB450Gx4 (ARMv7)
Builds a .tar image with the Cloudflare Tunnel client for a RouterOS container on the
home router (192.168.88.1). Purpose: keep public HTTP/HTTPS access after the A1 line is
shut down, leaving only Starlink behind CGNAT.
Context and the full migration plan: ~/Nextcloud/Home/@Claude/starlink-wan-migration.md.
Why a custom image
The official cloudflare/cloudflared has no linux/arm/v7 manifest, so it cannot be
pulled directly. The existing community image intrasistema/cloudflared-arm-mikrotik does
the right thing (its Dockerfile was reviewed through the image history — seven lines,
nothing suspicious), but its binary is frozen at cloudflared 2025.2.1 from March 2025.
That Dockerfile downloads releases/latest at build time, and the image is never rebuilt —
so the version never moves. Cloudflare progressively rejects outdated tunnel clients.
Here the binary is downloaded on the host with an explicit version, so upgrading is just
another ./build.sh.
How the build works
The Dockerfile has no RUN at all — only FROM + COPY. That is why the ARMv7 image builds
on an x86 host without qemu emulation, in a couple of seconds. The Alpine base already
ships /etc/ssl/certs/ca-certificates.crt, which cloudflared needs to validate TLS towards
the origin (NPM with a Let's Encrypt wildcard certificate).
./build.sh # latest release
./build.sh 2026.8.1 # a specific version
Result: dist/cloudflared-mikrotik-<version>.tar (~41 MB).
Two things the build verifies automatically, because both would otherwise only surface when RouterOS rejects the image with a useless error message:
- Tar format. RouterOS only loads the old docker-archive layout (
manifest.jsonat the root). Podman builds OCI by default, so--format dockeris forced. +xon the binary.curldownloads the binary without the executable bit, andCOPYpreserves permissions — the image builds fine, but the container dies at startup withexists but it is not executable.
The same build also runs in CI: .forgejo/workflows/build.yml, producing the .tar as an
artifact on Forgejo.
RouterOS 7.23: /container/envs wants list=, not name=
Every tutorial (and older versions) uses name=. On 7.23 that returns bad parameter name
and the env list is silently not created — the container starts without a token and exits
immediately with status 255: See 'cloudflared tunnel run --help'. The correct form:
/container/envs/add list=cloudflared key=TUNNEL_TOKEN value="eyJhIjoi..."
Retired on 2026-10-09. The RB5009 (arm64) runs the official
docker.io/cloudflare/cloudflared:latest, which the router's nightlycontainer-update-checkkeeps current. This image is only needed again for an ARMv7 board. How the official, distroless image is run on RouterOS:remote-image=docker.io/cloudflare/cloudflared:latest cmd="tunnel --metrics 127.0.0.1:2000 run" # entrypoint is cloudflared --no-autoupdate mount=/usb/containers/mount/cloudflared/bin:/bin:ro # static busybox + sh symlink healthcheck-cmd="/bin/busybox nc -w2 -z 192.168.8.1 8291 >/dev/null 2>&1 || exit 1; cloudflared tunnel --metrics 127.0.0.1:2000 ready"The image has no shell, and
/binis a symlink to an empty/usr/bin, so the busybox mount shadows nothing.cloudflared tunnel readyis the built-in probe of the/readyendpoint, so nowgetis needed. It runs as 65532, which is why the network probe isncto Winbox rather thanping. Rollback:remote-imageback togit.exithub.net/ivan.pavlina/cloudflared-mikrotik:latest,cmd="",mount="", the oldwget .../readyhealthcheck, then/container/repull.
Deploy
The router pulls the image from the Forgejo container registry — CI publishes one
multi-arch manifest (linux/arm/v7 + linux/arm64), the package is public, so no registry
credentials are needed on the router (credentials there have broken pulls before). The
registry hostname is spelled out in remote-image, which overrides the global
/container/config registry-url.
/container/envs/add list=cloudflared key=TUNNEL_TOKEN value="eyJhIjoi..."
/container/add remote-image=git.exithub.net/ivan.pavlina/cloudflared-mikrotik:latest \
interface=docker-cloudflared root-dir=/sdcard/docker/cloudflared \
envlist=cloudflared dns=192.168.8.88 hostname=cloudflared \
ignore-remote-image-change=yes \
restart-policy=always logging=yes start-on-boot=yes comment="Cloudflared"
No healthcheck-cmd is needed — the image carries its own HEALTHCHECK on
http://127.0.0.1:2000/ready, which RouterOS adopts as default-healthcheck-cmd.
./deploy.py remains for the file= path: it uploads a locally built .tar and prints the
RouterOS commands, which is the rollback route if the registry is unreachable. The password
comes from ROUTER_PASSWORD or an interactive prompt.
Settings:
| item | value |
|---|---|
| veth | docker-cloudflared, 192.168.8.77/24, gw 192.168.8.1 |
| bridge | docker-bridge |
| root-dir | sdcard/docker/cloudflared |
| DNS | 192.168.8.88 (AdGuard) |
| env | env list cloudflared, key TUNNEL_TOKEN |
192.168.8.77 was a free address on docker-bridge (taken: .2, .6, .9, .10, .20, .55, .80,
.88, .100, .111).
Cloudflare side
The tunnel is remote-managed (token), so public hostnames live in the Zero Trust dashboard and can be changed without restarting the container.
-
Zero Trust → Networks → Tunnels → Create a tunnel → Cloudflared.
-
Copy the token out of the
cloudflared service install <TOKEN>line. -
For each hostname add a route with the same origin:
- Service:
HTTPS→192.168.8.80:443 - TLS → Match SNI to Host: On (leave Origin Server Name empty)
Add hostnames individually, not with a wildcard.
⚠️ Do not set
Origin Server Name: exithub.net— that was the initial assumption and it does not work. NPM has no server block for the bare apex, so on that SNI it returnstlsv1 unrecognized name(alert 112) and the handshake breaks before the certificate; from the outside it looks like a 502. The wildcard*.exithub.netdoes not cover the apex anyway.SNI=exithub.net -> tlsv1 unrecognized name SNI=bookstack.exithub.net -> subject=CN=*.exithub.net, Verify return code: 0 (ok)Match SNI to Hostsets the SNI to the hostname of the incoming request, so the same setting is correct for every route. The alternative is Origin Server Name = the full hostname of that route, but then it has to be typed in per host.In the new dashboard the form lives under Networks → Tunnels → tunnel → Published application routes. Hostname routes and CIDR routes are something else — private access that requires the Cloudflare One client and Gateway.
- Service:
-
Once the tunnel works, move the CNAMEs from
hf9096ja6zs.sn.mynetname.netto the tunnel.
Hosts that go through the tunnel: bookstack, dashboard, gotify, homeassistant,
immich, nextcloud.
Pitfalls
Plex does not go through the tunnel — the Cloudflare ToS forbids bulk video. It stays reachable over ZeroTier only.
100 MB request body limit (free plan) affects Immich and Nextcloud. Deliberately
accepted: large uploads happen while the device is home on WiFi, where the AdGuard rewrite
*.exithub.net → 192.168.8.80 keeps the traffic local and Cloudflare never sees it.
Nextcloud clients do chunked uploads (~10 MB) anyway, so the limit applies per chunk; for
Immich it depends on whether the version does resumable (TUS) uploads.
Testing the tunnel from a phone is misleading. The ZeroTier tunnel carries DNS
192.168.8.88, so while ZT is active immich.exithub.net resolves to 192.168.8.80 and the
traffic goes over ZeroTier, not Cloudflare — you get a 200 and conclude the tunnel works. For
a real test, turn ZT off and use mobile data.
The token sits in plaintext in /container/envs on the router, same as the Cloudflare
Global API Key in the DDNS scripts (/system/script, disabled, still not rotated). Whoever
gets access to the router gets the tunnel too.
Disk. Not the bottleneck, contrary to first impressions: /system/resource reports
~180 MB free, but that is the internal NAND. Everything — both the .tar and the root-dir —
lives on sdcard, a 64 GB microSD with ~59 GB free. Still delete the old .tar after an
upgrade.
Home Assistant trusted_proxies — the tunnel adds one more hop to X-Forwarded-For. If
Cloudflare edge IPs start showing up as the source in the HA logs, ip_ban will start
banning the wrong address.
Upgrading
Automatic. The build workflow runs nightly (21:15 UTC), resolves the latest cloudflared
release and builds only if that version tag is not in the registry yet; the router's
container-update-check (00:45) then repulls on the new :latest digest and reports to
Gotify. CI notifies Gotify when the nightly run builds a release, and on any failure.
By hand: run the build workflow (empty version input = latest release, or pin one),
then on the router:
/container/repull [find comment="Cloudflared"]
Repull honours the current remote-image even with ignore-remote-image-change=yes, keeps
mounts, envs and healthcheck, and on a running container does kill → pull → restart by
itself. /container/start never repulls, so changing a tag without a repull does nothing.
Every build also publishes :<cloudflared version> and :<commit sha>, so pinning
remote-image=...:2026.8.1 and repulling is the way back to a known version.
For a build without CI: ./build.sh (add ARCH=arm64 for the 64-bit board) produces a
.tar for ./deploy.py and the file= path.