cloudflared ARMv7 container image za RouterOS (RB450Gx4)
This repository has been archived on 2026-10-13. You can view files and clone it, but you cannot make any changes to its state, such as pushing and creating new issues, pull requests or comments.
  • Shell 44.8%
  • Python 40.6%
  • Dockerfile 14.6%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Ivan Pavlina 8ad4baa7dc
Some checks failed
build / image (push) Failing after 3s
build / notify (push) Successful in 1s
Retire: the RB5009 runs the official cloudflared image
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>
2026-10-09 20:37:26 +02:00
.forgejo/workflows Retire: the RB5009 runs the official cloudflared image 2026-10-09 20:37:26 +02:00
.gitignore Publish one multi-arch image to the registry instead of an ARMv7 tar 2026-09-01 11:11:13 +02:00
build.sh Publish one multi-arch image to the registry instead of an ARMv7 tar 2026-09-01 11:11:13 +02:00
deploy.py Translate all Croatian text to English 2026-08-16 15:44:15 +02:00
Dockerfile Publish one multi-arch image to the registry instead of an ARMv7 tar 2026-09-01 11:11:13 +02:00
fetch-binaries.sh Publish one multi-arch image to the registry instead of an ARMv7 tar 2026-09-01 11:11:13 +02:00
README.md Retire: the RB5009 runs the official cloudflared image 2026-10-09 20:37:26 +02:00

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.json at the root). Podman builds OCI by default, so --format docker is forced.
  • +x on the binary. curl downloads the binary without the executable bit, and COPY preserves permissions — the image builds fine, but the container dies at startup with exists 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 nightly container-update-check keeps 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 /bin is a symlink to an empty /usr/bin, so the busybox mount shadows nothing. cloudflared tunnel ready is the built-in probe of the /ready endpoint, so no wget is needed. It runs as 65532, which is why the network probe is nc to Winbox rather than ping. Rollback: remote-image back to git.exithub.net/ivan.pavlina/cloudflared-mikrotik:latest, cmd="", mount="", the old wget .../ready healthcheck, 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.

  1. Zero Trust → Networks → Tunnels → Create a tunnel → Cloudflared.

  2. Copy the token out of the cloudflared service install <TOKEN> line.

  3. 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 returns tlsv1 unrecognized name (alert 112) and the handshake breaks before the certificate; from the outside it looks like a 502. The wildcard *.exithub.net does 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 Host sets 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.

  4. Once the tunnel works, move the CNAMEs from hf9096ja6zs.sn.mynetname.net to 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.