@martijn-on-fhir/docker-desktop-mx
v1.34.0
Published
A desktop client for Docker in WSL, without Docker Desktop: containers, images, volumes, networks, compose and logs.
Maintainers
Readme
Docker Client MX
A desktop client for a Docker daemon: the everyday work of Docker Desktop (containers, images, volumes, networks, compose and logs) without Docker Desktop itself. It was made for Docker running in WSL on Windows, but it talks to any daemon you can reach.
The app does not manage the engine. The daemon already runs somewhere (a WSL distro, a Linux host, Colima, Docker Desktop); Docker Client MX is only the client.

Features
- Dashboard: engine status, CPU/memory/network charts, disk usage, and a prune dialog.
- Containers: grouped by compose project; start, stop, restart, pause and remove, also in bulk. The detail page has live logs, inspect, stats, a terminal, a file browser (download and upload by drag and drop) and a one-click diagnosis you can paste into an issue.
- Images: pull with per-layer progress, run a container from an image, check the registry for newer versions, and recreate compose containers after an update.
- Volumes and networks: list, remove and prune; back up a volume to a
.tarand restore it. - Compose: projects as a group, with
upanddown. - Logs: the logs of all running containers merged in one view, with a level filter.
- Terminal: a root shell on the Docker host itself, next to the per-container shells.
- Claude (optional): with your own Claude API key, a Claude tab on every container and on the host terminal that reads logs and inspect data and suggests fixes or commands.
- Docker contexts: switch between them from the status bar.
- Command palette:
Ctrl+K(Cmd+Kon macOS) jumps to any page, container, image, volume or network. - Background: the app keeps running in the system tray and sends a notification when a container crashes or becomes unhealthy. It can start at login.
- Updates: the Windows installer version updates itself.
- Languages and themes: English (the default) and Dutch, chosen under Settings. Technical terms stay in English. Light and dark theme, toggled in the header.
Requirements
- A running Docker daemon. The app finds it the same way the
dockerCLI does:DOCKER_HOSTfirst, then the current docker context, then the platform default (npipe:////./pipe/docker_engineon Windows,/var/run/docker.sockelsewhere). - The
dockerCLI with the compose plugin, only for composeupanddown. Everything else goes through the Engine API directly. - A Claude API key, only for the Claude tab.
Docker in WSL, without Docker Desktop
With Docker Engine installed inside a WSL distro, make the daemon listen on localhost as well
as on its socket. In the distro, edit /etc/docker/daemon.json:
{ "hosts": ["unix:///var/run/docker.sock", "tcp://127.0.0.1:2375"] }On systemd distros, also remove the -H fd:// flag from the service, because it conflicts
with hosts. Then restart Docker. On Windows, point a context at it:
docker context create wsl --docker host=tcp://localhost:2375
docker context use wslPort 2375 has no authentication, so bind it to 127.0.0.1 only, as above. If the distro is not
running, the app says so and offers to start it.
A Docker host over SSH
A context with an ssh:// endpoint works too, like it does for the docker CLI. Add one under
Settings → Servers (which also tests the connection and can trust a new host key after you
compare its fingerprint), or with the CLI:
docker context create server --docker host=ssh://[email protected]The app logs in and runs docker system dial-stdio on the server, so nothing has to listen on
TCP there. What it needs:
- The server in
~/.ssh/known_hosts. The app never accepts an unknown host key by itself: trust it under Settings → Servers after comparing the fingerprint, or connect once withssh user@host. A changed host key is refused; that one you check withssh-keygen -R. - A key without a passphrase prompt: the ssh agent (
SSH_AUTH_SOCK, or the OpenSSH agent service on Windows), or~/.ssh/id_ed25519,id_ecdsaorid_rsawithout a passphrase.~/.ssh/configis not read, so put the user name in the address. - The
dockerCLI on the server, and the user in itsdockergroup.
Installing
- Windows installer: download
Docker-Client-MX-Setup.exefrom the latest release on Codeberg. It is not code-signed, so SmartScreen asks for confirmation the first time. - npm:
npx @martijn-on-fhir/docker-desktop-mx, ornpm install -g @martijn-on-fhir/docker-desktop-mxand thendocker-desktop-mx. The first start downloads Electron (about 100 MB). Starting at login and self-updating only work with the installer. - From source: see Development.
Using Claude
Open Settings → Claude and paste an API key from the Claude Console. The key is stored encrypted on this computer and is readable only by your user account. Each question costs a little API usage, billed to your own account.
The same screen picks the model: Opus 5 (the default) reasons best about a stubborn problem, Sonnet 5 and Haiku 4.5 answer faster and cost less.
Claude can only read: container logs, events, stats, inspect data, compose projects and, on the host terminal, what the terminal shows. Secrets such as passwords and tokens are masked before anything is sent. Claude never acts on its own. It can propose an action, such as a restart, which runs only when you click it. It can also propose a command, which goes into the terminal prompt; you decide whether to press Enter.
Development
Built with Angular 22 (standalone, zoneless, signals) in the renderer, Electron 44 as the shell, and dockerode in the main process. Needs Node 20 or later and a running daemon.
npm install
npm startThe original design notes are in docs/plan.md, and how releases work is in
RELEASING.md. Both are in Dutch.
Scripts
| Command | What it does |
| -------------------------- | ------------------------------------------------------------ |
| npm start | builds the app and starts it, the way the installer does |
| npm run dev | the same, with ng serve behind it so the renderer reloads |
| npm run build | production build of the renderer |
| npm run electron:compile | compiles electron/ and bundles the preload |
| npm run electron:build | both builds, ready to run with electron . |
| npm run electron:package | builds everything and makes an installer with electron-builder |
| npm run lint | ESLint over TypeScript, the configs and the Angular templates |
| npm run lint:fix | the same, with autofix |
| npm test | renderer unit tests (Vitest + jsdom) |
| npm run test:electron | main process unit tests (Vitest, node) |
| npm run smoke | starts the built app and checks the whole chain end to end |
| npm run release | semantic-release; runs in CI, not by hand |
| npm run release:installer | builds the Windows installer and attaches it to the release |
The app only runs inside Electron. In a plain browser window.electron does not exist and the
shell fails straight away, which is why there is no script that serves only the renderer.
Layout
electron/ main process (TypeScript, compiled to CommonJS)
api.ts the contract between main/preload and the renderer
channels.ts IPC channel names, without imports
ipc.ts the only place where channels are attached to ipcMain
preload.ts contextBridge, the renderer's only window into main
main.ts window, tray, notifications and app lifecycle
docker-client.ts daemon connection and readable errors
i18n.ts language of the texts main produces
*.ts one file per feature (containers, images, logs, exec, compose, ...)
*.spec.ts unit tests, kept out of dist-electron
src/
styles.css Tailwind 4 and the theme tokens
app/core/ stores and services without UI of their own
app/shell/ header, sidebar, status bar, table, dialogs, toasts, command palette
app/pages/ one folder per route
app/i18n/ the dictionaries: en/ is the source, nl/ follows the same shape
testing/ the fake window.electron for renderer tests
scripts/smoke.js starts the built app against a real daemon
.forgejo/workflows/ CI on every branch, release on main
eslint-rules/ two custom formatting rulesAdding a feature that needs main
Type it in electron/api.ts, attach the channel in electron/ipc.ts (validate the input with
zod there), and add the wrapper in electron/preload.ts. Angular picks up the type through
src/electron.d.ts (window.electron).
Translations
Every visible text in the renderer comes from src/app/i18n/. Add a key to the English file in
en/, and the compiler requires the same key in nl/. A text that includes a number or a name
is a function, so each language can handle plurals its own way. Texts made in the main process
use t({ en: '…', nl: '…' }) from electron/i18n.ts. The tests run in Dutch
(src/testing/setup.ts, electron/test-setup.ts). Each area also has tests that switch to
English.
Theme
The tokens in src/styles.css are a --color-ink-* ramp, where a lower number means more
contrast with the page, plus accent, ok, warn, danger and panel.
:root[data-theme='dark'] overrides the same names, so every Tailwind utility follows the
theme without template changes. src/main.ts sets the theme before Angular starts, which
avoids a light flash with a dark theme.
Filters on the list pages and the log view toggles are remembered (app/core/preferences.ts).
Search fields deliberately are not: a search term still active after a restart looks as if
half your containers have gone.
Code style
The lint config enforces the house style:
- lines up to 200 characters, and always braces;
- blank lines around control statements, and one after the
{of a function declaration; - parameters on one line as long as it fits within 150 characters.
Two custom rules for this live in eslint-rules/. There is deliberately no Prettier,
because it conflicts with these rules. angular-eslint also lints the .html templates.
Commits follow conventional commits, which commitlint checks through
.husky/commit-msg. The messages decide the version number: every push to main releases
automatically.
Security
The window runs with contextIsolation: true, nodeIntegration: false and sandbox: true.
The renderer has no Node access and no socket. Everything goes through contextBridge in
electron/preload.ts, which never passes ipcRenderer itself along. Otherwise the renderer
could call any channel instead of only the actions we meant to expose.
src/index.html sets a Content-Security-Policy that allows only the app's own scripts. Two
consequences that fail silently if you forget them:
- The preload must be one self-contained file. A sandboxed preload cannot load local
modules, so
electron:compilebundles it with esbuild. inlineCriticalis off in the production build. It adds an inlineonloadhandler to the stylesheet, which the CSP blocks, and then part of the CSS is never applied.
npm run smoke guards both.
License
MIT, see LICENSE.
