apps.header.allAppsCount
weblinked
apps.detail.types.app from Stoatworks Labs' Repository
apps.detail.sections.overview
Readme
View on GitHubweblinked-docker
[!WARNING] This is designed to run purely inside a Tailscale tailnet (or another private network you trust end to end). Do not expose it to the public internet. The control API has no authentication until you set
--token, andPOST /api/scriptruns arbitrary JavaScript in the rendered page — an unauthenticated port here is remote code execution in a browser on your network, not merely a feed someone can retune. Its security has not been independently reviewed by a human. This image binds0.0.0.0by default and runs with host networking, so it is on every interface the server has. SetWEBLINKED_BINDto your tailnet address, set a token, and keep it off the open internet. No reverse proxy, no port forward, no "it's fine, it has a password".
AI-assisted project. This packaging was created with Claude (Anthropic), directed and reviewed by a human author. The image has been built and run: it renders real pages headless at 1080p50 with zero dropped ticks, and WebLinked's 89-test suite passes inside it — the first time that suite has ever run on Linux. The NDI output has never been received from this image, because libndi is not in it and is not ours to ship. Everything below the NDI line is verified; the NDI line itself is not. What is actually verified is specific about which is which.
A URL in. NDI out. No display, no card, no desktop.
WebLinked renders a web page offscreen at a broadcast raster and exact frame rate and sends it out as video. This repository is the container for the part of it that makes sense on a server: point it at a dashboard, a scoreboard or a clock, and it becomes an NDI source your vision mixer, decoder or recorder can take.
docker run -d --name weblinked --network host \
-v /mnt/user/appdata/weblinked/ndi:/opt/ndi/lib:ro \
ghcr.io/stoatworks-labs/weblinked-docker:edge \
--url 'https://grafana.example/public-dashboards/abc123' \
--format 1080p50 --ndi=Grafana
What is in here, and what cannot be
| Output | In the image | Why |
|---|---|---|
| NDI | Yes (needs a mounted runtime) | The point of the exercise |
| OMT | Yes | Compiled in, but has never been tested against any receiver |
| Preview + HTTP/OSC control | Yes | The control page is the whole UI |
| DeckLink, AJA | No | SDI wants a card and a kernel driver on the host |
| Syphon / Spout | No | macOS and Windows respectively; Linux has no equivalent |
| Fullscreen screen output | No | Needs X11 and a GPU-attached display, which a server has not got |
Chromium renders through SwiftShader on the CPU, deliberately — no GPU
passthrough, no /dev/dri, nothing to arrange on the host. A dashboard is
nowhere near heavy enough to want a GPU.
The NDI runtime is not in this image, and you have to supply it
libndi is loaded with dlopen at run time, by design — WebLinked never links
it. Its licence permits redistribution only under terms forbidding modification
and reverse engineering, which MIT cannot impose, so baking it into a public
image would be a licence violation, not a packaging shortcut.
Without it, the container still starts, still serves the control page, and simply reports the NDI backend unavailable. To get NDI:
- Download the NDI SDK for Linux from ndi.video and accept their licence yourself. It is free, and gated behind an email.
- Take
libndi.so.6(and the symlinks beside it) out oflib/x86_64-linux-gnu/. - Put them in a directory on the host and mount it at
/opt/ndi/lib:
-v /mnt/user/appdata/weblinked/ndi:/opt/ndi/lib:ro
LD_LIBRARY_PATH in the image already includes that path. Confirm it took:
curl -s http://localhost:7654/api/state | grep -o '"compiled_backends":[^]]*]'
Host networking is not optional for NDI
NDI discovery is mDNS. On Docker's bridge network the sender is invisible to
every receiver on the LAN — it does not error, it just never appears. Use
--network host, or run an NDI Discovery Server and point both ends at it.
The same applies to WebLinked's own advertisement. From 0.8.0 it publishes
its control API as _weblinked._tcp so rookery and the Companion module can
find it, and that is mDNS too: on bridge networking it never reaches the LAN.
It needs three things in this image, and it is worth knowing that missing any
of them costs you discovery and nothing else — the container runs, and the
control API works perfectly for anyone who types the address:
--network host, as above.- avahi-daemon reachable, since Linux registration goes through it. This
image does not run one; mount the host's socket
(
-v /var/run/avahi-daemon/socket:/var/run/avahi-daemon/socket) and have avahi running on the host. WEBLINKED_BINDnot left on loopback. WebLinked deliberately refuses to advertise while its control API is bound to127.0.0.1, because the record would resolve, on every machine that browses, to that machine's own loopback. It logs the reason, and/api/settingsreports it.
Set WEBLINKED_MDNS=0 to switch the advertisement off — appropriate where
multicast is filtered, or where the container is reached only over a tailnet
and the record would be noise that never arrives.
On host networking the container binds the host's ports directly: 7654/tcp
for the control page and API, 7655/udp for OSC. Change them with --port
and --osc-port if something else already has them.
Keeping it on the tailnet
Host networking means the control port is on every interface the server has, including whatever faces your LAN. Two things narrow that, and you want both:
WEBLINKED_BIND— a literal address to bind, e.g.100.64.0.5. Unlike unfuckarr there is no interface-name form: WebLinked's--bindtakes an address, so use the tailnet IP. Because host networking puts the container in the host's network namespace, the host'stailscale0address is directly bindable; Unraid's per-container Tailscale toggle also works.WEBLINKED_TOKEN— required on every HTTP request once set. Set it even on a tailnet, because/api/scriptis arbitrary code execution in the page.
A container on the default bridge network cannot see the host's tailscale0 at
all — but bridge networking breaks NDI discovery anyway, so that combination is
not one to reach for here.
Pointing it at Grafana
Grafana's own auth will not follow an offscreen browser that cannot be typed into, so give it a URL that needs no login. In order of preference:
- A public dashboard — Dashboard → Share → Public dashboard. Read-only, one dashboard, no server-wide change. This is the one to use.
- Anonymous viewer access (
GF_AUTH_ANONYMOUS_ENABLED=true,GF_AUTH_ANONYMOUS_ORG_ROLE=Viewer) if the Grafana is on a trusted network and you would rather not mint links per dashboard.
Add &kiosk to drop Grafana's chrome, and &refresh=10s to keep the panels
moving. WebLinked repeats the last frame between changes, so a slow refresh
costs nothing in output pacing — the feed stays at rate either way.
Configuration
Everything is a command-line flag, appended after the image name; see WebLinked's docs. The ones that matter here:
| Flag | Does |
|---|---|
--url <url> |
The page. Default about:blank |
--format <spec> |
1080p50, 720p59.94, 1920x1080i25. Default 1080p50 |
--ndi[=name] |
NDI sender name. Default WebLinked |
--alpha |
Send BGRA with the page's alpha intact |
--port <n> |
Control HTTP port. Default 7654 |
--token <secret> |
Require a token on every HTTP request |
--cache <dir> |
Persist cookies and storage. Give each instance its own |
--name <text> |
What this instance calls itself when it advertises. Default: host and port |
--no-mdns |
Do not advertise over mDNS (see host networking, above) |
The environment variables the entrypoint understands map one to one onto those:
WEBLINKED_URL, WEBLINKED_FORMAT, WEBLINKED_NDI_NAME, WEBLINKED_OMT_NAME,
WEBLINKED_PORT, WEBLINKED_OSC_PORT, WEBLINKED_TOKEN, WEBLINKED_CACHE,
WEBLINKED_NAME, WEBLINKED_BIND, WEBLINKED_ALPHA, WEBLINKED_NO_AUDIO, and
WEBLINKED_MDNS=0.
The entrypoint already supplies --ozone-platform=headless, --use-gl=angle,
--use-angle=swiftshader, --bind 0.0.0.0 and --no-settings. The first three
are what make Chromium render with no display at all; without them it exits with
Missing X server or $DISPLAY.
Building it yourself
docker build -t weblinked-docker .
Roughly 20 minutes and ~4 GB of scratch space: it downloads a 315 MB pinned CEF distribution, compiles WebLinked and CEF's wrapper, and runs the unit tests as a build step — a failing suite fails the build. The result is ~1.9 GB, most of it Chromium.
--build-arg WEBLINKED_REF=v0.7.1 pins a tag instead of tracking main.
What is actually verified
Measured inside this image on a 24-core host, at 1080p50:
- 50.1 ticks/sec, 0 dropped ticks, 289 µs clock lateness.
- 97% of ticks carried a fresh paint on a full-screen
requestAnimationFrameanimation; the rest repeated the previous frame, which is what SwiftShader costs on a pathological page. A dashboard is far lighter. - Real pages render correctly — fonts, gradients, web CSS, 0 console errors.
- 89 tests, 25,225 checks, 0 failures.
Not verified, and you should assume nothing:
- No NDI receiver has ever seen output from this image. No libndi, no test.
- OMT has never been tested against a receiver anywhere in the project.
- Audio rides along with the frames but has not been checked here.
frames_overwrittenclimbs roughly in step withframes_published, which under external pacing should stay near zero. It may be a counter that means something different from what its name suggests; it has not been chased down. See issues.
Licence
MIT, matching WebLinked itself. NDI® is a registered trademark of Vizrt NDI AB; this project neither includes nor distributes any part of the NDI SDK.
apps.marketingCta.appInstallTitle
apps.marketingCta.appInstallDescription
apps.detail.sections.requirements
apps.detail.sections.categories
apps.detail.sections.related
apps.detail.related.exploreCategories
apps.detail.related.exploreAllapps.detail.sections.links
apps.detail.sections.details
ghcr.io/stoatworks-labs/weblinked-docker:edgeapps.detail.sections.runtime
- apps.detail.details.webui
http://[IP]:7654/- apps.detail.details.network
host- apps.detail.details.shell
sh- apps.detail.details.privileged
- false
apps.detail.sections.configuration
The page to render. For Grafana, use a public dashboard link and add &kiosk to drop the surrounding UI — an offscreen browser cannot be typed into, so the page must need no login.
- apps.detail.config.target
- WEBLINKED_URL
- apps.detail.config.default
- https://example.com
- apps.detail.config.value
- https://example.com
Raster and exact frame rate, for example 1080p50, 720p59.94 or 1920x1080i25. Rates are exact rationals: 59.94 is 60000/1001, not 59.94.
- apps.detail.config.target
- WEBLINKED_FORMAT
- apps.detail.config.default
- 1080p50
- apps.detail.config.value
- 1080p50
The name this appears as on the network. Leave empty to disable the NDI output entirely.
- apps.detail.config.target
- WEBLINKED_NDI_NAME
- apps.detail.config.default
- WebLinked
- apps.detail.config.value
- WebLinked
A directory on the host containing libndi.so.6 from the NDI SDK for Linux, which you download and licence yourself. Mounted read-only. If this is empty the container still starts and the NDI output reports itself unavailable.
- apps.detail.config.target
- /opt/ndi/lib
- apps.detail.config.default
- /mnt/user/appdata/weblinked/ndi
- apps.detail.config.value
- /mnt/user/appdata/weblinked/ndi
Persists cookies and site storage across restarts. Two containers must never share one directory — Chromium locks it, and the second to start will fail.
- apps.detail.config.target
- /cache
- apps.detail.config.default
- /mnt/user/appdata/weblinked/cache
- apps.detail.config.value
- /mnt/user/appdata/weblinked/cache
Tells WebLinked to use the mounted cache directory. Clear this to keep cookies and storage in memory instead, discarded on restart.
- apps.detail.config.target
- WEBLINKED_CACHE
- apps.detail.config.default
- /cache
- apps.detail.config.value
- /cache
HTTP port for the control page and API. On host networking this binds the host directly, so it must not collide with anything else on this server.
- apps.detail.config.target
- WEBLINKED_PORT
- apps.detail.config.default
- 7654
- apps.detail.config.value
- 7654
UDP port the OSC receiver listens on, for Companion or a show-control system.
- apps.detail.config.target
- WEBLINKED_OSC_PORT
- apps.detail.config.default
- 7655
- apps.detail.config.value
- 7655
Which address the control API listens on. Host networking means the default, 0.0.0.0, is every interface this server has — including the one facing your LAN. Set this to your Tailscale address (100.x.y.z) to make it reachable only through the tailnet. Enabling this container's Tailscale toggle gives it its own address to use here.
- apps.detail.config.target
- WEBLINKED_BIND
- apps.detail.config.default
- 0.0.0.0
- apps.detail.config.value
- 0.0.0.0
If set, every HTTP request must carry this token. The API is otherwise unauthenticated, and POST /api/script runs arbitrary JavaScript in the page — set this even on a private network.
- apps.detail.config.target
- WEBLINKED_TOKEN
Set to true to send BGRA with the page's alpha channel intact, for a keyed graphic or an overlay. Leave false for a full-frame source such as a dashboard.
- apps.detail.config.target
- WEBLINKED_ALPHA
- apps.detail.config.default
- false
- apps.detail.config.value
- false
Set to true to ignore the page's audio entirely. A dashboard has none, so this costs nothing either way.
- apps.detail.config.target
- WEBLINKED_NO_AUDIO
- apps.detail.config.default
- false
- apps.detail.config.value
- false