Unofficial Docker image for DeepSeek Harness (dsh),
built from the official source code — not from the npm package.
GitHub Actions clones upstream, runs the real monorepo build (pnpm install && pnpm run build)
and publishes a multi-arch image to GHCR (linux/amd64 + linux/arm64).
ghcr.io/medyma/dshdocker:latest
Upstream deliberately restricts the Web GUI:
| Location | Rule |
|---|---|
dsh-web-app/startup.js |
rejects --host 0.0.0.0 outright ("would expose remote code execution to the network") |
dsh-host-webserver config |
host: z.union([z.const("127.0.0.1"), z.const("0.0.0.0")]) — only those two values are legal |
Since 0.0.0.0 is rejected at startup and every other value fails the schema,
127.0.0.1 is the only bindable host. A container port mapping (-p) DNATs to the
container's eth0, not to loopback — so this image runs a tiny TCP forwarder
(dsh-forward.js) inside the container:
0.0.0.0:3080 ──forward──▶ 127.0.0.1:30801 (dsh web)
▲
└── docker -p 3080:3080
The forwarder is a raw TCP relay: it does not rewrite any header, so DSH's Host/Origin fence and its authority-bound cookie keep working on the original authority.
DSH_TRUSTED_HOSTSis mandatory for any non-localhost access. DSH auto-trusts LAN IP literals only when bound to0.0.0.0— which is impossible — so LAN IPs are not trusted implicitly. Declare whatever you browse with.- You need a one-time
?token=visit to obtain the session cookie (see below).
docker run -d --name dsh \
--restart unless-stopped \
-p 3080:3080 \
-v dsh-home:/home/node/.dsh \
-v "$PWD/workspace:/workspace" \
-e DSH_TRUSTED_HOSTS="192.168.1.10,dsh.example.com" \
ghcr.io/medyma/dshdocker:latestThen see Getting access below — a bare http://host:3080 will return
401 unauthorized until you complete the token exchange.
Edit DSH_TRUSTED_HOSTS in docker-compose.yml, then:
docker compose up -dDSH Web has two gates, both enforced on /api:
| Gate | Failure | Cause | Fix |
|---|---|---|---|
| Host/Origin trust fence | 403 forbidden | the authority you browse with is not in trustedHosts |
add it to DSH_TRUSTED_HOSTS |
| Browser-session auth | 401 unauthorized | no session cookie | visit /?token=… once |
Flow:
# 1. take the launch token from the container log
docker logs dsh 2>&1 | grep 'dsh web:'
# dsh web: http://127.0.0.1:30801/?token=XXXXXX
# 2. exchange it for a cookie — use YOUR authority, not 127.0.0.1
# http://192.168.1.10:3080/?token=XXXXXX
# or through a tunnel:
# https://dsh.example.com/?token=XXXXXXThat returns 303 → / plus a Set-Cookie, after which the UI loads normally.
Notes:
- The cookie is bound to the authority (host:port). Changing host or port requires the token exchange again.
- The launch token is regenerated on every process start — after
docker restartyou need a fresh token. sec-fetch-site: cross-siteis rejected: open the URL directly, don't embed it in an iframe or navigate from another site.
Add the IP/host you browse with:
-e DSH_TRUSTED_HOSTS="192.168.2.1" # port-less: matches any port (recommended)
-e DSH_TRUSTED_HOSTS="192.168.2.1:3080" # or pin an exact authority- Include the public domain in
DSH_TRUSTED_HOSTS(port-less is easiest, e.g.dsh.example.com). - Preserve the original
Hostheader in your proxy:frp— preserved by default ✅- nginx —
proxy_set_header Host $host;
- Enable WebSocket upgrade — the RPC bridge uses WS; without it the page loads but hangs.
- Then complete the
?token=exchange on the public URL.
If your proxy rewrites
Hostto127.0.0.1:30801, the fence passes as loopback, but the browser'sOriginwill no longer match — declare the real authority instead.
DSH gates the settings document on loopback (dsh-client-ui-settings/lib/client.js:1345):
const persistence = ctx.remote.$host.isLoopback ? "host" : "memory";isLoopback is computed from the browser's address-bar hostname and only accepts
localhost / 127.x.x.x / [::1] (dsh-client-connection/lib/client.js:6344). On a
memory page the document never loads, so the Models tab reports
settings are unavailable in this browser.
| Address | Chat | Settings (Models) |
|---|---|---|
http://127.0.0.1:3080 |
✅ | ✅ |
http://192.168.x.x:3080 |
✅ | ❌ |
https://<tunnel domain> |
✅ | ❌ |
This is a deliberate upstream safeguard (settings hold API keys). Three ways:
A. SSH local forward so the browser is loopback (recommended)
# on your own machine, keep this window open
ssh -N -L 3080:127.0.0.1:3080 root@<router-ip>
# read the token
docker logs dsh 2>&1 | sed -n 's/.*[?&]token=\([A-Za-z0-9_-]*\).*/\1/p' | tail -n1
# open in the browser (127.0.0.1 is required)
# http://127.0.0.1:3080/?token=<TOKEN>The config lands in the container's DSH_HOME, so afterwards you can go back to the
tunnel domain for normal use.
B. Write settings.yaml directly (no browser)
Template: examples/settings.deepseek.yaml (public DeepSeek API).
To point at your own OpenAI / Anthropic-compatible service, add an llm-pi-ai provider:
llm-pi-ai:
providers:
myprovider:
displayName: My Provider
apiKeyEnv: MY_PROVIDER_API_KEY # the env var that carries the key
api: anthropic-messages # or openai-chat-completions
baseURL: https://api.example.com/v1
models:
- id: my-model
name: my-model
contextWindow: 128000
maxTokens: 8192
agent-default-model:
provider: myprovider
model: my-modelInstall it into the volume and restart:
MP=$(docker volume inspect dsh-home --format '{{.Mountpoint}}')
cp examples/settings.deepseek.yaml "$MP/settings.yaml" # or your own file
chown 1000:1000 "$MP/settings.yaml"
docker restart dshPass the key as an environment variable (named by the provider's apiKeyEnv):
-e DEEPSEEK_API_KEY=sk-xxxxxxxx
-e MY_PROVIDER_API_KEY=xxxxxxxxC. Lift the restriction: DSH_ALLOW_REMOTE_SETTINGS=1
This image ships a switch that, at container start, rewrites the two client-side loopback checks to be unconditionally true — so the Models and Plugins settings pages work from a LAN IP or a tunnel domain too.
docker rm -f dsh
docker run -d --name dsh --restart unless-stopped --network host \
-v dsh-home:/home/node/.dsh -v "$PWD/workspace:/workspace" \
-e DSH_TRUSTED_HOSTS="192.168.2.1,dsh.example.com" \
-e DSH_ALLOW_REMOTE_SETTINGS=1 \
-e DEEPSEEK_API_KEY=sk-xxxxxxxx \
ghcr.io/medyma/dshdocker:latest
⚠️ Security trade-off: this removes an upstream protection. The settings page holds your API keys, so once enabled anyone who can reach that address can read and change them. Real risk on a public tunnel domain — judge for yourself.The patch is applied at runtime to the client plugin files (
ui-settings/ui-settings-generallib/client.js); the image itself is unchanged, and turning the switch off restores upstream behaviour. Hard-refresh the browser (Ctrl+Shift+R) afterwards to drop the cached plugin bundle.
| Variable | Default | Description |
|---|---|---|
DSH_PORT |
3080 |
Exposed / forwarder listen port inside the container |
DSH_WEB_INTERNAL_PORT |
30801 |
Internal loopback port the dsh web server binds |
DSH_TRUSTED_HOSTS |
(empty) | Comma-separated authorities accepted by the /api fence. Required for any non-localhost access. Port-less entries match any port. |
DSH_ALLOW_REMOTE_SETTINGS |
0 |
=1 lifts the loopback-only settings gate (see C above). |
DSH_BIN |
/src/apps/cli/lib/bin.js |
CLI entry produced by the source build |
DSH_HOME |
/home/node/.dsh |
DSH data root (credentials, settings, sessions, profiles) |
DSH_TELEMETRY_DISABLED |
1 |
Disable telemetry |
There is deliberately no
DSH_HOST: DSH cannot bind anything but loopback.
| Path | Purpose |
|---|---|
/home/node/.dsh |
Persist this. Credentials, settings, sessions, profiles. |
/workspace |
Working directory DSH reads/writes and runs commands in |
The container runs as the base image's
nodeuser (UID/GID 1000). For bind mounts:sudo chown -R 1000:1000 ./data ./workspace
Any arguments are forwarded straight to the CLI (the web server and forwarder are skipped):
docker run --rm -v dsh-home:/home/node/.dsh \
ghcr.io/medyma/dshdocker:latest --profile headless "summarize /workspace/notes.txt"| Tag | Meaning |
|---|---|
latest |
Newest upstream master built by this repo |
0.1.5-rc.2 |
Upstream package.json version at build time |
sha-<short> |
Upstream commit that was built (most precise — use this to pin) |
| Arg | Default | Description |
|---|---|---|
DSH_REPO |
https://github.com/deepseek-ai/deepseek-harness.git |
Upstream repo (point at a mirror if needed) |
DSH_REF |
master |
Branch, tag, or commit SHA |
DSH_BUILD_SCRIPT |
build |
pnpm script; build:official matches upstream's release profile |
NODE_VERSION |
24 |
Node base image (upstream CI uses 24) |
PNPM_VERSION |
11.7.0 |
pnpm version via corepack |
docker build -t dshdocker .
docker build --build-arg DSH_REF=<commit-sha> -t dshdocker .
docker buildx build --platform linux/arm64 -t dshdocker .The build is heavy: build:native-system (needs musl-gcc) → build:lib (tsc, 4 GB heap) →
build:web.
| Workflow | Purpose |
|---|---|
docker.yml |
resolve (shallow-clone upstream, read version, compute tags) → build (ubuntu-24.04 for amd64, ubuntu-24.04-arm for arm64, pushed by digest) → merge (one multi-arch manifest) |
desktop-win.yml |
build the unsigned Windows x64 desktop app from upstream source |
ghcr-prune.yml |
weekly cleanup of untagged GHCR package versions |
The sha-<short> “already exists, skip” check in docker.yml only applies to scheduled runs;
every push rebuilds (intentional — a Dockerfile change should rebuild), which means a one-line docs
change also rebuilds the whole multi-arch image. Add a paths filter to the push trigger to avoid that.
Why GHCR lists so many untagged versions: each build pushes several manifests — 2 platform images,
2 provenance attestations (buildx's default; they embed timestamps/run ids, so they differ every build),
per-arch indexes, and finally the tagged multi-arch index. GHCR counts every manifest as a version,
so of those ~30+ entries only 1 carries tags (latest/sha-<short>/<version> all point at the same
index) — there is only one actual image, and docker pull always gets the newest.
ghcr-prune.yml cleans the untagged ones weekly. It must not simply keep the newest N by time: a
platform image manifest is a reproducible build artifact whose digest is stable across builds, so its
created_at is old and it would sort into the delete range while the tagged multi-arch index still
references it — that breaks linux/amd64 in :latest with MANIFEST_UNKNOWN. Instead it computes a
protection set (tagged versions plus the keep range, and every digest those reference as an index)
and only deletes versions that are untagged and unprotected. If it cannot obtain a registry token it
fails outright and deletes nothing.
All actions are pinned to majors whose action.yml declares runs.using: node24 — no Node 20
deprecation warnings (ghcr-prune.yml uses no actions, just the gh CLI, so it is Node-agnostic too).
A daily schedule (cron: "0 2 * * *", UTC = 10:00 Beijing) does:
- shallow-clone upstream
master - read its commit SHA and
package.jsonversion - skip if the image for that SHA already exists (no wasted builds)
- otherwise build amd64 + arm64 and push
So a new upstream release lands in your registry by ~10:00 the next day. All you run:
docker pull ghcr.io/medyma/dshdocker:latest
docker rm -f dsh && docker run -d --name dsh --restart unless-stopped \
--network host \
-v dsh-home:/home/node/.dsh \
-v "$PWD/workspace:/workspace" \
-e DSH_TRUSTED_HOSTS="192.168.2.1,dsh.example.com" \
-e DSH_ALLOW_REMOTE_SETTINGS=1 \
-e DEEPSEEK_API_KEY=sk-xxxxxxxx \
ghcr.io/medyma/dshdocker:latestThe
dsh-homevolume persists, so model config, sessions and credentials survive.
GitHub → Actions → Build & Push DSH Image (from source) → Run workflow
| Input | Meaning |
|---|---|
ref |
Upstream ref. Empty = latest master; or v0.1.5-rc.3, or a commit SHA |
force |
Ignore the "image already exists" check (manual runs always build anyway) |
workflow_dispatchis never subject to the skip logic — it always builds.
Set ref to an upstream tag; the build produces:
| Tag | Use |
|---|---|
0.1.5-rc.3 |
upstream version |
sha-<upstream-commit> |
most precise — preferred for production |
latest |
also updated |
# production: pin the exact tag so `latest` can't drift under you
ghcr.io/medyma/dshdocker:sha-<upstream-commit>List existing tags: GitHub → Packages → dshdocker.
git clone https://github.com/MedyMa/DshDocker.git && cd DshDocker
docker build -t dshdocker . # latest master
docker build --build-arg DSH_REF=v0.1.5-rc.3 -t dshdocker . # specific version
docker buildx build --platform linux/arm64 -t dshdocker . # cross-build arm64Ship it to the router:
docker save dshdocker | gzip > dsh.tgz
scp dsh.tgz root@192.168.2.1:/tmp/
# on the router
gunzip -c /tmp/dsh.tgz | docker load
⚠️ The upstream build is heavy (tsc with a 4 GB heap + frontend + native addons), roughly 5–10 minutes on x86. Never build on the router.
# latest version published on npm
curl -s https://registry.npmjs.org/@deepseek-ai/dsh/latest | head -c 200
# the version your container runs
docker exec dsh node -e "console.log(require('/src/package.json').version)"Then look at GitHub → Actions for a recent automated build.
Actions → open the failed run → check the red job's log. Three usual causes:
| Symptom | Cause | Fix |
|---|---|---|
pnpm install fails |
upstream changed the lockfile/deps | usually transient upstream — Re-run jobs later |
pnpm run build fails |
upstream changed the build script | open an issue with the error; the Dockerfile needs updating |
| ref not found | upstream renamed a branch | pass the right branch via ref |
Re-run: Actions → that run → Re-run jobs (top right).
| Situation | What you do |
|---|---|
| Upstream releases (normal) | nothing — next-day auto build, then docker pull + recreate the container |
| Want it now | Actions → Run workflow (ref empty) |
| Need a fixed version | set ref, use the sha-xxx tag in your container |
| Avoid GitHub | local docker build + docker save/load |
This repo also builds the Electron desktop app for Windows x64 from upstream source.
Download: GitHub → Releases → desktop-v<version> (or the run's Artifacts, kept 30 days).
| Target | Hosted runner | Reason |
|---|---|---|
| Windows x64 (unsigned) | ✅ | upstream only exposes --unsigned for win-x64 |
| Windows, signed | ❌ | needs a GlobalSign EV certificate and a SafeNet USB token (physical) — self-hosted only |
| macOS | ❌ | upstream requires an Apple signing identity + Team ID + notarization; impossible without a developer account |
Always 64-bit: the installer is ...-win-x64.exe (a 32-bit build would be ia32), and upstream's
SUPPORTED_TARGETS has no 32-bit entry. The workflow additionally enforces a binary-level gate
(verify-win-artifacts.mjs): the PE header of the main executable and the bundled node.exe must be
AMD64 (0x8664), or the build fails (the gate has its own 8-case regression test).
The installer shell is a 32-bit PE — NSIS has no 64-bit implementation; that says nothing about the installed app. The bundled
fastlist-0.3.0-x86.exeandwin10-arm64/OpenConsole.exeare pnpm's and node-pty's own multi-arch payloads, so the gate only warns about them.
Installing: SmartScreen blocks the unsigned package (“More info → Run anyway”), and it carries no
auto-update config. Manual run: Actions → Build DSH Desktop (Windows, unsigned) → Run workflow
(force ignores the release-exists check). Daily at UTC 02:30.
MIT. DeepSeek Harness itself is licensed by its own authors.