Skip to main content
A full, visual Firefox you click around in from your own browser — lscr.io/linuxserver/firefox, streamed over KasmVNC/Selkies. The interactive counterpart to Browserless: Browserless is headless and built for agents, Firefox is for the times you want eyes and hands on a real session. Runs on i3 (debian-1) as a compose-only service, and is off by default.

Why not Neko?

Neko is the more popular dedicated “virtual browser,” but it streams over WebRTC and wants a UDP port range. The homelab’s only ingress is the Cloudflare tunnel → Traefik, which is HTTP/TCP. linuxserver/firefox streams over plain HTTP+WS, so it routes through Traefik and the tunnel with no special plumbing — the right fit for this topology.

Why compose-only

Needs shm_size (Firefox crashes on Docker’s default 64 MB /dev/shm) and security_opt: seccomp:unconfined, neither of which swarm’s deploy: block can express — same exception bucket as Jellyfin and the downloads stack. It still joins the swarm overlay and routes via Traefik with service-level labels.

Endpoint

WhatURL
Visual browserhttps://firefox.augustin.ai
Gated by HTTP basic auth (the container’s built-in CUSTOM_USER / PASSWORD, set in firefox/.env). Enforced on the container’s 3001/https endpoint, which Traefik reaches via the insecure@file transport.

Lifecycle — start and stop on demand

This is a desktop-class workload with no restart:. Bring it up when you want it, shut it down when you’re done; the idle cost is the whole reason it isn’t always-on.
cd ~/apps/firefox
set -a; source .env; set +a

docker compose up -d      # start a session
docker compose stop       # stop (keeps container + browsing profile)
docker compose down       # remove container (profile in ./config persists)
docker compose logs -f    # logs
The browsing profile, history, and extensions persist in firefox/config/, so a stopped-and-restarted session picks up where you left off.

Stream tuning

Wayland/pixelflux mode otherwise makes the remote resolution follow your browser window — on a 5K display that’s a 4080×2608 @ 60fps software-encoded stream, which pegs the CPU and tends to wedge on reconnect (“waiting for stream”). The compose pins it instead:
EnvValueEffect
SELKIES_FRAMERATE30fixed 30fps
SELKIES_IS_MANUAL_RESOLUTION_MODEtrueignore the client window size
SELKIES_MANUAL_WIDTH / _HEIGHT1920 / 1080force 1080p
These are read directly by the selkies server, not the ls.io shell scripts (which only honor the X11-path SELKIES_MANUAL_*). Confirm they took with docker logs firefox | grep "Stream settings"Res: 1920x1080 | FPS: 30.0.

A note on idle cost

The framerate/resolution cap only governs the encoder, which is idle when no one’s connected. The selkies server process still polls the display and burns ~20% of a core even with zero clients. There’s no env knob for that — the only real way to spend nothing when idle is to stop the container (above). Don’t leave it running as a background convenience; a software-streamed desktop is never free.

Reconnect: “waiting for stream”

When you disconnect, Selkies cleanly stops the pipeline (good for idle cost). If you return to a stale tab and it hangs on “waiting for stream,” the cause is Selkies’ service worker, not the server — the worker caches the app and holds a dead connection, and it survives a hard-refresh. Every server hop has been verified to upgrade WebSockets correctly (container nginx, Traefik, and the full Cloudflare tunnel all return 101), so don’t chase the backend. Fixes, fastest first:
  • Incognito/private window — skips the service worker entirely; use it to confirm the worker is the culprit.
  • Normal browser: DevTools → Application → Service Workers → Unregister, then Clear site data for firefox.augustin.ai, and reopen the tab.
To verify the WS path itself is healthy (it almost always is):
set -a; source firefox/.env; set +a
curl -s --http1.1 -o /dev/null -w '%{http_code}\n' \
  -u "$CUSTOM_USER:$PASSWORD" \
  -H 'Connection: Upgrade' -H 'Upgrade: websocket' \
  -H 'Sec-WebSocket-Version: 13' -H 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==' \
  https://firefox.augustin.ai/websocket    # expect 101 (use --http1.1; HTTP/2 returns a misleading 426)