- Dockerfile 67.8%
- Shell 32.2%
## What Replace **Lens** (k8slens, proprietary) with **[Freelens](https://github.com/freelensapp/freelens)** in the `coder-xfce-vnc` variant. Freelens is the MIT-licensed community fork of OpenLens. It's installed from Freelens's official APT repo and set up the same way Lens was: a wrapper that adds the flags needed in the pod, plus a desktop-entry override. ## Changes - **`Dockerfile`**: the Lens block is replaced by a Freelens block. - The signing key's fingerprint `50B1 BA0A AF45 14AE 56F9 9AC4 8B2A 569C D2EE ECC1` is checked before the key is trusted, the same check the Claude Desktop block does. - `test -x "$(readlink -f /usr/bin/freelens)"` checks that the `update-alternatives` entry point exists. - The override `freelens.desktop` is generated with `sed` from the packaged entry: `Exec` points to `/usr/local/bin/freelens` (upstream's own flags are kept), `Categories=Development;`. Both changes are checked with `grep`. - `COPY` now ships the Freelens wrapper, and the comments that mentioned Lens elsewhere are updated. - **`scripts/lens-desktop-wrapper.sh` → `scripts/freelens-wrapper.sh`**: same wrapper (`ELECTRON_DISABLE_SANDBOX=1` + `--disable-dev-shm-usage`), now pointing at `/usr/bin/freelens`. - **`scripts/coder-init-desktop.sh`**: I removed the code that cleaned up stale user-local Lens menu entries. It only ran when the Lens wrapper existed, so with Lens gone it could never run again. Its note on why entries are renamed to `.bak` now sits on the Claude Desktop cleanup code. - **`README.md`**: Lens → Freelens in the app list, the Electron section (the heading anchor changed, and every link to it is updated), the `coder-init-desktop` docs, and Version Information. A `~/.config/Freelens/` row is added to the persisted-state table. ## One design point worth reviewing: where the repo is defined The Freelens repo is **not** set up like the Azul/Mozilla/Claude repos (`/usr/share/keyrings/*` + a `.list` file). **The freelens deb installs its own `/etc/apt/keyrings/freelens.asc` and `/etc/apt/sources.list.d/freelens.sources` as conffiles.** If the repo were defined anywhere else, apt would see the same source twice with two different `Signed-By` paths. That fails every later `apt-get update`, including the Claude Desktop step right after it and any `sudo apt update` in a workspace. I checked this in a pod: ``` E: Conflicting values set for option Signed-By regarding source https://github.com/freelensapp/freelens/releases/latest/download/ ./: /usr/share/keyrings/freelens-archive-keyring.asc != /etc/apt/keyrings/freelens.asc E: The list of sources could not be read. ``` So the block writes the key and a deb822 source file at exactly those paths. The source file is byte-identical to the one the package ships. `--force-confdef --force-confold` means that if the key bundled in the deb ever differs from the fingerprint-checked one, the build keeps ours instead of stopping at a conffile prompt. The repo is served from GitHub's `releases/latest` download URL, so every build installs the current Freelens release (currently **1.10.3**) with no manual version bump. That's the same always-latest approach as Toolbox and Nimbalyst. APT still checks the signed `Release` file. ## Verification I pulled the `RUN` step out of the Dockerfile with a script and ran it in an `ubuntu:24.04` pod on the cluster: | Check | Result | |---|---| | Key fingerprint check | pass | | `apt-get install freelens` | pass, `1.10.3` | | `/usr/bin/freelens` alternative | → `/opt/Freelens/freelens` | | All 3 conffiles: dpkg md5 vs on-disk | match (no `.dpkg-dist` / `.dpkg-old` files) | | Override `Exec` / `Categories` checks | pass | | `apt-get update` after install | clean, no `W:`/`E:` lines | | Negative control: second definition with a different `Signed-By` | fails as shown above | `shellcheck` passes on `freelens-wrapper.sh` and `coder-init-desktop.sh`. The branch push also starts the `docker-dev` Kaniko build. **Not verified:** actually running Freelens inside the VNC session, which needs a real desktop. It's an Electron app with the same two failure modes the wrapper already handles for Lens and Claude Desktop, so I expect it to work, but that's inference. Worth a smoke test once the image is built. ## Notes - **Existing workspaces:** Freelens keeps its state in `~/.config/Freelens/`, not Lens's `~/.config/Lens/`, so clusters added in Lens won't show up automatically. `~/.kube/config` is read as usual. The old `~/.config/Lens/` directory stays on the PVC untouched. - The Freelens signing key expires **2028-01-16**. If upstream extends it, the fingerprint stays the same and the next build picks it up, since the key is downloaded fresh each time. If upstream switches to a new key, the fingerprint check fails the build loudly, and the pin needs a deliberate update. - During install, the package's postinst prints `unshare: unshare failed: Operation not permitted` and then sets `chrome-sandbox` setuid. That's harmless here: `ELECTRON_DISABLE_SANDBOX=1` still applies, as with Claude Desktop. Closes #21 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01XPajGyTH2js52mY5WSJoNA Reviewed-on: #23 Reviewed-by: Guillaume "B.B." Van Hemmen <guillaumehemmen@noreply.git.van-hemmen.com> |
||
|---|---|---|
| .forgejo/workflows | ||
| asset | ||
| scripts | ||
| CODE_OF_CONDUCT.md | ||
| CONTRIBUTING.md | ||
| Dockerfile | ||
| LICENSE | ||
| README.md | ||
Sindri
A flexible, multi-stage Docker image providing minimal to complete development environments. Choose the variant that fits your needs: from a lightweight base image to a full-featured development workspace.
Package mirror notice: all published images use the France Ubuntu mirror (
http://fr.archive.ubuntu.com/ubuntu) in place ofarchive.ubuntu.comandsecurity.ubuntu.com, includingapt-getrun inside a container or a derived image. Packages are still signature-checked against Ubuntu's archive key, but security updates can reach the mirror a few hours later. See Ubuntu Package Mirror to use another mirror or restore the stock sources.
Image Variants
Sindri provides three image variants built from a common base:
🔹 ci-* - CI/CD Ready
Lightweight image with Node.js LTS for continuous integration pipelines.
Features:
- Ubuntu 24.04 base with essential system utilities
- Core utilities (curl, wget, git, jq, nano, etc.)
- Network tools (ssh, rsync, netcat, etc.)
- Compression tools (zip, tar, gzip, xz, brotli, etc.)
- Build essentials (make, autoconf, pkg-config, etc.)
- Python 3 with
pip,pipx, andvenv(python3 -m venvworks out of the box) - Azul Zulu JDK, configurable at build time
- Firebase CLI (
firebase-tools, standalone binary) — pairs with the JDK forfirebase deployand the Firebase Emulator Suite - Node.js LTS (via NodeSource)
- npm and yarn
- Timezone configured to Europe/Paris
- Working directory set to
/workspaces
Use case: Ideal for GitHub Actions, Forgejo Actions, GitLab CI, or any CI/CD pipeline requiring Node.js or the Firebase CLI / Emulator Suite.
Available as: git.van-hemmen.com/actions/sindri:ci-latest, git.van-hemmen.com/actions/sindri:ci-26.8.1
docker pull git.van-hemmen.com/actions/sindri:ci-latest
docker run -it git.van-hemmen.com/actions/sindri:ci-latest
🔹 coder-* - Full Development Environment
Complete dev workspace with the coder user; per-user runtime is provisioned at workspace start by
scripts/coder-init.sh so the same image works whether the PVC mounts at
/workspaces or at /home/coder.
Features:
- Everything from the base layer
- Non-root
coderuser with passwordless sudo coderuser created with UID/GID1000by default- Default project directory set to
/home/coder/Projects scripts/coder-init.shprovisions, at workspace start: customized bash prompt and environment, NVM with Node.js 24 ( configurable), Yarn, and a global gitignore from toptal.comupdate-k8s-toolsonPATH— installs/updateskubectl,helm, andtalosctl(latest stable) into~/.local/bin; binaries are fetched at runtime, so re-run it any time to upgrade (pin withKUBECTL_VERSION=… HELM_VERSION=… TALOS_VERSION=… update-k8s-tools)- Ready for VS Code Remote Containers, Coder, or similar
Use case: Full-featured development environment for remote coding, devcontainers, or local development.
Available as: git.van-hemmen.com/actions/sindri:coder-latest, git.van-hemmen.com/actions/sindri:coder-26.8.1
docker pull git.van-hemmen.com/actions/sindri:coder-latest
docker run -it git.van-hemmen.com/actions/sindri:coder-latest
🔹 coder-xfce-vnc-* - Web Desktop Development Environment
Desktop-enabled workspace based on the coder variant, with XFCE and noVNC.
Features:
- Everything from the
codervariant - XFCE desktop environment
- TigerVNC server (loopback only) + websockify on port
6080 - noVNC web client with a path-aware redirect (works under Coder's path-based app proxy without wildcard DNS — see Web access patterns below)
- Pre-installed GUI applications:
- Firefox — from Mozilla's official APT repo (not the Ubuntu Snap stub)
- JetBrains Toolbox — installed to
/opt/jetbrains-toolbox, symlinked into/usr/local/bin, with a system-wide XDG menu entry pre-shipped - Freelens — the free and open-source Kubernetes IDE (MIT-licensed community fork of OpenLens), installed in this variant from the official Freelens APT repo, so each image build picks up the latest release; launches out of the box through a wrapper that applies the flags Electron apps need in an unprivileged pod — see Electron & Chromium-based apps below
- Claude Desktop — Anthropic's AI assistant desktop app (Chat, Cowork, and Code tabs), installed from Anthropic's official APT repo; wrapper-routed like Freelens — see Electron & Chromium-based apps below. Linux support is an Anthropic-labelled beta: Computer Use and dictation aren't available yet
- Nimbalyst — a visual workspace for building with Codex, Claude Code, and more. Upstream ships only a Linux
AppImage; each image build pulls the latest release and pre-extracts it to
/opt/nimbalyst(FUSE can't self-mount it in an unprivileged pod), then routes it through a wrapper like Freelens/Claude — see Electron & Chromium-based apps below
- Per-user desktop bootstrap via
scripts/coder-init-desktop.sh(heals the XFCE preferred-browser setting when the PVC carries stale state) - Runtime env vars exposed for tuning:
DISPLAY,VNC_PORT(5901),NOVNC_PORT(6080),VNC_GEOMETRY(1920x1080),VNC_DEPTH(24),VNC_PASSWORD(empty by default)
Use case: Remote desktop development environment for Coder workspaces, browser-based desktop access, or any tooling that needs a real graphical session (JetBrains IDEs running locally, Electron tools, browser automation, etc.).
Available as: git.van-hemmen.com/actions/sindri:coder-xfce-vnc-latest,
git.van-hemmen.com/actions/sindri:coder-xfce-vnc-26.8.1
docker pull git.van-hemmen.com/actions/sindri:coder-xfce-vnc-latest
docker run -it -p 6080:6080 git.van-hemmen.com/actions/sindri:coder-xfce-vnc-latest
# Open http://localhost:6080/ — the index page auto-redirects to the noVNC client.
Usage
Quick Start
# Pull and run the CI variant
docker pull git.van-hemmen.com/actions/sindri:ci-latest
docker run -it git.van-hemmen.com/actions/sindri:ci-latest
# Pull and run the coder variant
docker pull git.van-hemmen.com/actions/sindri:coder-latest
docker run -it git.van-hemmen.com/actions/sindri:coder-latest
# Pull and run the XFCE/noVNC coder variant
docker pull git.van-hemmen.com/actions/sindri:coder-xfce-vnc-latest
docker run -it -p 6080:6080 git.van-hemmen.com/actions/sindri:coder-xfce-vnc-latest
Building with Custom Arguments
The image supports build arguments:
docker build --target coder \
--build-arg ARG_TZ="America/New_York" \
--build-arg AZUL_JAVA_MAJOR=21 \
-t sindri:coder-custom .
Available arguments:
UBUNTU_VERSION: Ubuntu base image version (default:24.04)UBUNTU_MIRROR: Ubuntu APT mirror used by every variant (default:http://fr.archive.ubuntu.com/ubuntu; empty keeps the stock sources). Requires Ubuntu 24.04 or later unless empty — see Ubuntu Package MirrorARG_TZ: Timezone (default:Europe/Paris)AZUL_JAVA_MAJOR: Azul Zulu Java major version (default:21)USER_NAME: Workspace user name (default:coder)USER_UID: Workspace user UID (default:1000)USER_GID: Workspace user GID (default:1000)PROJECTS_DIR: Default project directory for thecodervariants (default:/home/coder/Projects)
Node.js version and global-gitignore URL are no longer build-time arguments. They are configured at workspace start via environment variables consumed by
scripts/coder-init.sh.
Using with Forgejo/GitHub Actions
Use the CI variant in your workflow:
jobs:
build:
runs-on: ubuntu-latest
container: git.van-hemmen.com/actions/sindri:ci-latest
steps:
- uses: actions/checkout@v4
- run: npm install
- run: npm test
Using with VS Code Remote Containers
Create .devcontainer/devcontainer.json:
{
"name": "Sindri Development Environment",
"build": {
"dockerfile": "../Dockerfile",
"target": "coder"
},
"remoteUser": "coder",
"workspaceFolder": "/home/coder/Projects"
}
Or reference the remote image:
{
"name": "Sindri Development Environment",
"image": "git.van-hemmen.com/actions/sindri:coder-latest",
"remoteUser": "coder",
"workspaceFolder": "/home/coder/Projects"
}
Extending the Image
Build on top of any variant:
FROM git.van-hemmen.com/actions/sindri:ci-latest
# Add your custom tools
RUN apt-get update && apt-get install -y \
postgresql-client \
redis-tools \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /myapp
apt-get in a derived image uses the image's Ubuntu package mirror (France by default). To use
the stock Ubuntu sources instead, restore them before installing anything:
FROM git.van-hemmen.com/actions/sindri:ci-latest
RUN cp /usr/share/sindri/ubuntu.sources.orig /etc/apt/sources.list.d/ubuntu.sources
The coder variants end as the coder user, so switch to USER root for this step and back to USER coder afterwards.
Ubuntu Package Mirror
Sindri images replace Ubuntu's default package sources with a single mirror, set by the UBUNTU_MIRROR build argument.
The published images use the France country mirror, http://fr.archive.ubuntu.com/ubuntu. This applies to both the
regular archive (noble, noble-updates, noble-backports) and security updates (noble-security), in every
variant. It covers every apt-get during the build, in running containers and workspaces, and in images built FROM
Sindri.
Why: on 2026-09-11, Canonical's main archive (archive.ubuntu.com and security.ubuntu.com) went down. Every
apt-get stalled on retries, and a desktop-image build slowed from about 10 minutes to 48. Country mirrors run on
separate hosts and kept working, so the builds don't depend on Canonical's main servers being up. The France mirror
was chosen because the project's images are built in France.
What it means for you:
- Integrity is unchanged. APT checks every package list against Ubuntu's archive signing key, so a mirror cannot serve altered packages.
- Security updates can arrive later. Country mirrors sync from Canonical periodically, so a fix can reach
security.ubuntu.coma few hours before this mirror. - Speed depends on your location. Outside France, a nearer mirror or the stock sources may be faster.
- Plain HTTP, as in Ubuntu's stock sources; the signatures above provide the integrity.
Check which mirror an image uses
# From the image metadata
docker inspect --format '{{ index .Config.Labels "com.van-hemmen.sindri.ubuntu-mirror" }}' \
git.van-hemmen.com/actions/sindri:coder-latest
# From inside a container or workspace
grep '^URIs:' /etc/apt/sources.list.d/ubuntu.sources
Build with another mirror, or with the stock sources
# Another Ubuntu mirror (any country mirror, or your own proxy/cache)
docker build --target coder --build-arg UBUNTU_MIRROR=http://de.archive.ubuntu.com/ubuntu -t sindri:coder .
# Stock Ubuntu sources: an empty value turns the rewrite off entirely
docker build --target coder --build-arg UBUNTU_MIRROR= -t sindri:coder .
With an empty value, the image keeps Ubuntu's original sources file exactly as shipped, and no backup is created.
UBUNTU_MIRROR rewrites the deb822 /etc/apt/sources.list.d/ubuntu.sources that Ubuntu 24.04 and later use. With an
older UBUNTU_VERSION, the build stops with a message unless you also pass --build-arg UBUNTU_MIRROR=.
Restore the stock sources in an existing image
When a mirror is set, the image keeps Ubuntu's original file at /usr/share/sindri/ubuntu.sources.orig.
-
Derived image: copy it back before installing anything, as shown in Extending the Image:
RUN cp /usr/share/sindri/ubuntu.sources.orig /etc/apt/sources.list.d/ubuntu.sources -
Running container or workspace:
sudo cp /usr/share/sindri/ubuntu.sources.orig /etc/apt/sources.list.d/ubuntu.sources sudo apt-get update/etclives in the image, not on the PVC, so a Coder workspace goes back to the mirror at every restart. To make the change stick, run thecpfrom the template'sstartup_script, next tocoder-init.
Workspace Initialization (coder-init)
The coder-* variants no longer bake the per-user home directory contents into image layers, so the same image can be
deployed in either of the two Coder workspace modes:
- Devcontainer mode — the PVC mounts at
/workspaces, leaving$HOME(/home/coder) intact. - Plain Kubernetes pod mode — the PVC mounts at
/home/coderand shadows anything baked into image layers.
To populate $HOME consistently in both modes, the script scripts/coder-init.sh (shipped in the image as
/usr/local/bin/coder-init) performs the per-user setup at workspace start. It is idempotent and safe to re-run.
What it does (as the workspace user):
- Seeds
~/.bashrcfrom/etc/skel/.bashrcwhen the home directory is empty (fresh PVC). - Appends a managed block to
~/.bashrcwith the NVM init lines and the customizedPS1. - Downloads a global gitignore template and points
git config --global core.excludesfileat it. - Installs NVM in
~/.nvm(if missing). - Installs the configured Node.js major version via NVM, marks it as
default, and installs Yarn globally.
Configuration (environment variables):
| Variable | Default | Description |
|---|---|---|
NODE_MAJOR |
24 |
Node.js major version installed via NVM. |
NVM_VERSION |
0.40.1 |
NVM installer version. |
GITIGNORE_URL |
https://www.toptal.com/developers/gitignore/api/linux,jetbrains+all,visualstudio,visualstudiocode |
Global gitignore template URL. |
Usage from a Coder template (in the workspace's startup_script):
# Use the defaults baked in the script
coder-init
# Or pin a Node.js version for this workspace
NODE_MAJOR=22 coder-init
Usage from a devcontainer, in .devcontainer/devcontainer.json:
{
"image": "git.van-hemmen.com/actions/sindri:coder-latest",
"remoteUser": "coder",
"workspaceFolder": "/home/coder/Projects",
"postCreateCommand": "coder-init"
}
The script refuses to run as root.
Desktop Bootstrap (coder-init-desktop)
Companion to coder-init, shipped only in the coder-xfce-vnc variant at /usr/local/bin/coder-init-desktop. Same
contract: run as the workspace user, once per workspace start, idempotent. Refuses to run as root.
It covers per-user setup that doesn't make sense on the headless coder variant — currently:
- Heals XFCE's preferred web browser when
~/.config/xfce4/helpers.rcis missing or points at a binary that no longer exists (common after a PVC carries over from an older image). WritesWebBrowser=firefoxonly when the existing value is invalid; never trampling a user who has deliberately chosen something else. - Parks a stale user-local Claude Desktop entry (renamed to
.bakwith a log line) when it launches the package binary directly and would therefore shadow the image's wrapper-routed entry forever (XDG_DATA_HOMEoutranksXDG_DATA_DIRS) — typically left by a manual install from before the image shipped Claude Desktop, or carried on a long-lived PVC across the move from the community repack to Anthropic's official package (see Electron & Chromium-based apps). All three known entry names are checked (claude-desktop.desktop,claude-desktop-unofficial.desktop,com.anthropic.Claude.desktop). Entries whoseExecis an absolute path to either package's launcher or bundled Electron binary are renamed to.bak; wrapper-routed or custom entries are left alone — including Anthropic's ownExec=claude-desktop, whose bare command already resolves to the wrapper onPATH. The.bakbreadcrumb makes recovery a one-rename affair if the workspace is ever rolled back to a pre-wrapper image.
Usage from a Coder template — guard with command -v so the same template stays compatible with the headless
coder variant:
/usr/local/bin/coder-init
if command -v coder-init-desktop >/dev/null 2>&1; then
/usr/local/bin/coder-init-desktop
fi
if command -v start-xfce-vnc >/dev/null 2>&1; then
nohup /usr/local/bin/start-xfce-vnc >/tmp/xfce-vnc.log 2>&1 &
fi
start-xfce-vnc runs tigervncserver + websockify and exec's into websockify, so it must be backgrounded from
startup_script. It is also idempotent — re-invoking when port 6080 is already bound short-circuits with a friendly
message rather than tearing the live session down.
Web Access Patterns
The coder-xfce-vnc variant is designed to work behind Coder's path-based app proxy (subdomain = false), which is
the only option on a Coder OSS deployment without wildcard DNS. Two subtleties this image already handles:
-
/returns a redirect, not 404. The Ubuntunovncpackage shipsvnc.htmlbut noindex.html. We install a tiny JS-basedindex.htmlat/usr/share/novnc/index.htmlthat readswindow.location.pathname, strips the filename, and redirects tovnc.html?autoconnect=1&resize=remote&path=<dir>/websockify. This meanscoder_app.url = "http://localhost:6080"(no path component) is sufficient — Coder lands the browser at the slug root, the script bounces it to the real entry point, and relative asset paths resolve correctly. -
The noVNC WebSocket goes under the slug, not to origin root. noVNC's
pathsetting defaults to the literal string"websockify", not derived fromwindow.location— so under any path-based proxy it would otherwise openwss://<host>/websockify(origin root) and miss the prefix entirely. The redirect script computes the correct path from the current URL and passes it via the?path=query parameter.
If your Coder deployment has a Traefik / nginx / Envoy reverse proxy in front of it, make sure it forwards WebSocket
Upgrade: headers unaltered — that's a deployment concern, not an image concern.
Software Rendering & Desktop Performance
The image targets non-privileged Kubernetes pods with no GPU and no /dev/dri, so all rendering goes through Mesa's
llvmpipe software rasterizer. The image is configured for this from the start:
LIBGL_ALWAYS_SOFTWARE=1is set as anENVin the stage. Mesa skips the always-failing DRI probe and goes straight to llvmpipe. This shaves a noticeable beat off the startup of every GL app (browsers, IDEs, Chromium-based UIs).libgbm1is installed. Without it, Chromium/JCEF apps log "cannot create linux GL context" and fall through a slow error path. Installing it is the single biggest fix for the GL warning seen in JetBrains IDEs and Toolbox launches.mesa-utilsshipsglxinfoso you can verify post-launch:glxinfo -B # OpenGL renderer string: llvmpipe (LLVM 20.x.x, 256 bits)
Electron & Chromium-based apps (Freelens, Claude Desktop, VS Code, …)
Out of the box, Electron apps die instantly in an unprivileged Kubernetes pod running the RuntimeDefault seccomp
profile — the hardened setup this image targets (Talos enables seccompDefault, as do PSS-restricted policies and
many managed clusters; stock upstream kubelets default to Unconfined, where only cause 2 below applies). Lens (the
Kubernetes IDE this image originally shipped) crashed at startup with Failed to move to new namespace … Operation not permitted, and any other Electron app (Freelens, Claude Desktop, VS Code, Slack, …) fails identically. Two independent root causes were isolated, and
the image handles both:
-
The Chromium sandbox cannot work in such a pod — at all. Chromium's primary (layer-1) sandbox creates user/PID namespaces, which the pod's
RuntimeDefaultseccomp profile forbids for unprivileged processes. Chromium's fallback, the setuid-rootchrome-sandboxhelper, is equally dead: the pod's capability bounding set excludesCAP_SYS_ADMIN, and setuid root cannot re-gain a capability the bounding set dropped. Since no pod-safe configuration can make the sandbox functional, the image setsELECTRON_DISABLE_SANDBOX=1stage-wide (equivalent to--no-sandbox; honoured by every Electron app, propagated into the XFCE session and therefore into menu-launched apps). Isolation is provided by the container/pod boundary instead. Note that pure Chrome/Chromium browsers ignore this env var and would need an explicit--no-sandbox. -
The default 64 MiB
/dev/shmof a Kubernetes pod is too small for Chromium at desktop resolutions. Chromium allocates its inter-process shared-memory transport buffers in/dev/shm; at 1920×1080 with software rendering it overruns 64 MiB. The symptom is delayed and misleading: the window opens, then renderers crash (reason: 'crashed', exitCode: 4— blank/reloading UI) or the whole app aborts withGPU process isn't usable. Goodbye.(which looks GPU-related but is shm exhaustion).--disable-dev-shm-usagemoves those buffers to/tmp, trading a sliver of IPC speed for stability.
What the image ships for Freelens and Claude Desktop: every launch path of both apps is routed through a wrapper
applying both fixes — /usr/local/bin/freelens and /usr/local/bin/claude-desktop (shadowing the packages'
/usr/bin launchers on PATH; the Freelens wrapper targets the deb's update-alternatives entry point so package
upgrades can't strand it) and desktop-entry overrides in /usr/local/share/applications/ (shadowing the packages'
entries: XDG_DATA_DIRS resolves /usr/local/share first). The overrides are generated at image-build time from the
entries the packages installed — Exec is rewritten (to the wrapper) and Categories is normalised to Development
(so both land in the XFCE Development menu rather than upstream's Network / Utility), every other key stays in sync with
upstream, and the build fails loudly if a package's entry changes shape. Terminal launches, XFCE menu launches, the
Claude entry's New chat / New Claude Code session actions, and freelens:// / claude:// URL activations all get
the flags.
The Claude wrapper carries the whole load on its own: Anthropic's deb symlinks /usr/bin/claude-desktop straight to
the bundled Electron binary, so unlike the community repack the image used previously there is no packaged shell
launcher doing any environment setup behind it. The deb does ship a setuid-root chrome-sandbox helper and an
AppArmor userns profile, but neither applies in an unprivileged pod (allowPrivilegeEscalation=false makes setuid a
no-op, and loading AppArmor policy needs privileges the pod lacks) — ELECTRON_DISABLE_SANDBOX=1 remains the fix.
Nimbalyst is the same story with an AppImage twist. Upstream ships only a Linux AppImage, which would self-mount via
FUSE — impossible here (FUSE's mount needs the same CAP_SYS_ADMIN the pod drops). So the image extracts it at
build time (--appimage-extract, which uses the runtime's built-in squashfs unpacker — no FUSE, no /dev/fuse) to
/opt/nimbalyst and ships /usr/local/bin/nimbalyst, a wrapper that adds both Electron fixes plus APPDIR=/opt/nimbalyst
(an extracted bundle has no AppImage runtime to set it, and pre-0.36 AppRuns misdetected the AppDir when the first
argument was a flag). The releases/latest download URL means every build picks up the current Nimbalyst version
automatically — which also means an upstream change to the bundle layout surfaces as a build failure; the install step
asserts the paths it relies on and prints the bundle root when one goes missing.
For other Electron apps (a user-installed VS Code, Slack, … — ordinary Electron apps behave exactly as described
here): the sandbox half is already handled globally by the image env. If the app's UI goes blank or its renderer
crash-loops under load, add --disable-dev-shm-usage to its launcher the same way the Freelens and Claude Desktop
wrappers do. Alternatively, solve the shm half for every app at once at the pod level by mounting a larger /dev/shm
in the Coder template / pod spec:
volumes:
- name: dshm
emptyDir:
medium: Memory
sizeLimit: 1Gi
containers:
- volumeMounts:
- name: dshm
mountPath: /dev/shm
(--disable-gpu turned out to be unnecessary for Electron apps once the shm issue is fixed — the GPU process falls
back to SwiftShader software rendering on its own. The earlier GPU process isn't usable fatals were shm exhaustion,
not GPU init.)
JetBrains Toolbox (2.x) specifics
Toolbox 2.x is a Compose Multiplatform + Skia application (not Chromium / not JCEF, despite earlier versions). A few non-obvious implications:
--disable-gpuis a no-op for Toolbox 2.x — it's a Chromium flag and Toolbox no longer uses Chromium. We pass it in the shipped.desktopentry anyway as a defensive hint for downstream tooling that scans Exec lines; it doesn't hurt and is silently ignored by Toolbox itself.- No CLI option disables the animated gradient background. The only switch is the in-UI toggle: ☰ Settings → "Use
background effects" (introduced in Toolbox 2.4). The setting persists to
~/.local/share/JetBrains/Toolbox/.settings.json(PVC-backed; survives workspace rerolls). The exact JSON key is undocumented. SKIKO_RENDER_API=SOFTWAREis an environment variable that can help. It tells Skiko to skip the OpenGL backend and rasterize Skia directly to CPU, removing one indirection (Skiko → OpenGL → llvmpipe → CPUbecomesSkiko → CPU). It is not set by default because results vary; try setting it for Toolbox if the UI feels sluggish:env SKIKO_RENDER_API=SOFTWARE /opt/jetbrains-toolbox/bin/jetbrains-toolbox- For each JetBrains IDE installed via Toolbox, after first launch open Help → Edit Custom VM Options and add:
The first uses Java2D's hand-tuned software path instead of routing Java2D through OpenGL→llvmpipe. The second stops JCEF (the Chromium engine the IDEs do still embed for the welcome screen, Markdown preview, AI Assistant, etc.) from compositing on a non-existent GPU. Both settings persist per-IDE in the PVC.-Dsun.java2d.opengl=false -Dide.browser.jcef.gpu.disable=true
State that persists across workspace rerolls
The PVC mounts at /home/coder, so the following are preserved automatically:
| App | State location |
|---|---|
| Firefox | ~/.mozilla/firefox/ (profiles, add-ons, bookmarks, passwords) |
| JetBrains Toolbox | ~/.local/share/JetBrains/Toolbox/, ~/.config/JetBrains/ |
| Freelens | ~/.config/Freelens/ (cluster list, preferences) |
| Claude Desktop | ~/.config/Claude/ (login, settings, MCP config) |
| Nimbalyst | ~/.config/@nimbalyst/ (login, settings, sessions, MCP config) |
| XFCE | ~/.config/xfce4/ (panel layout, preferred apps, etc.) |
| TigerVNC | ~/.vnc/ (xstartup, password if set, session logs) |
Building Locally
# Build CI variant
docker build --target ci -t git.van-hemmen.com/actions/sindri:ci-dev .
# Build coder variant
docker build --target coder -t git.van-hemmen.com/actions/sindri:coder-dev .
# Build XFCE/noVNC coder variant
docker build --target coder-xfce-vnc -t git.van-hemmen.com/actions/sindri:coder-xfce-vnc-dev .
# Build coder variant with a custom Java version
docker build --target coder \
--build-arg AZUL_JAVA_MAJOR=17 \
-t git.van-hemmen.com/actions/sindri:coder-java17 .
CI/CD Workflows
Forgejo Actions workflows automate multi-target builds:
- docker-dev.yaml: Builds all targets on branch commits
- docker-tag.yaml: Builds and publishes versioned releases on tags
The workflows use the KANIKO_TARGET variable to build each variant:
strategy:
matrix:
target: [ ci, coder, coder-xfce-vnc ]
env:
KANIKO_TARGET: ${{ matrix.target }}
Images are pushed to Forgejo's in-cluster Service (app-http-service.forgejo.svc.cluster.local:3000) over plain
HTTP, not to git.van-hemmen.com. The runners and Forgejo run in the same cluster, and the public ingress cuts any
single layer upload that takes longer than about a minute, which the coder-xfce-vnc layers exceed. This uses the
kaniko wrapper's REGISTRY_HOST and REGISTRY_INSECURE settings.
Forgejo stores images by owner and name, so they are still pulled as git.van-hemmen.com/actions/sindri:<tag>.
Choosing the Right Variant
| Use Case | Recommended Variant |
|---|---|
| CI/CD pipeline with Node.js | ci-* |
| GitHub/Forgejo Actions | ci-* |
| Custom base for your own image | ci-* |
| VS Code Remote Containers | coder-* |
| Coder.com workspace | coder-* |
| GitHub Codespaces | coder-* |
| Local Node.js development | coder-* |
| Browser-based Linux desktop workspace | coder-xfce-vnc-* |
| Graphical tools in a remote workspace | coder-xfce-vnc-* |
| JetBrains IDEs running inside the workspace (not Gateway) | coder-xfce-vnc-* |
| Firefox in a remote workspace | coder-xfce-vnc-* |
Version Information
- Base OS: Ubuntu 24.04 LTS
- Ubuntu package mirror:
http://fr.archive.ubuntu.com/ubuntufor all Ubuntu sources, security included (build argumentUBUNTU_MIRROR; stock file kept at/usr/share/sindri/ubuntu.sources.orig). See Ubuntu Package Mirror - Python: Ubuntu's
python3(3.12 on 24.04) withpip,pipx, andvenv, installed in the base layer (available in all variants) - Java: Azul Zulu JDK, configurable at build time, default
21 - Firebase CLI:
firebase-toolsstandalone binary, installed in the base layer (available in all variants) - Node.js: LTS in the
civariant; installed via NVM at workspace start in thecodervariants byscripts/coder-init.sh(default Node24) - Default Timezone: Europe/Paris, configurable
- Coder User:
coder, UID/GID1000by default - Coder Project Directory:
/home/coder/Projects - noVNC Port:
6080(websockify, all interfaces) in thecoder-xfce-vncvariant - VNC Port:
5901(loopback only) in thecoder-xfce-vncvariant - Default Desktop Resolution:
1920x1080× 24-bit (override withVNC_GEOMETRY/VNC_DEPTH) - GUI Apps (xfce-vnc variant): Firefox (Mozilla deb), JetBrains Toolbox (
/opt/jetbrains-toolbox), Freelens (official Freelens APT repo, launched via the/usr/local/bin/freelenswrapper), Claude Desktop (Anthropic's official APT repo, launched via the/usr/local/bin/claude-desktopwrapper)
License
Apache License 2.0 - See LICENSE file for details.
Contributing
See CONTRIBUTING.md for guidelines on how to contribute to this project.
Code of Conduct
This project follows a Code of Conduct. Please read CODE_OF_CONDUCT.md before contributing.
