- assets/: replace the white-background avatar with a transparent 512px PNG (brand blue #4d6bfe, tight-cropped) plus a 128px header variant. Same path, so the Unraid template icon keeps working. - CI: every image tag in the registry now gets a v<tag> git tag on main and a Gitea release carrying digest, dsh/pnpm/base versions, deploy notes and the 0.1.2+ launch-token caveat. Idempotent, so it backfills the three existing tags on the next run. - README rewritten to match OpenPXE/Discord-Authenticator: centered brand header, badges, contents, why/highlights, quick start, Unraid template, image contract, versions & upgrade procedure (0.1.5 session-format V3 has no downgrade), plugins, security model, pipeline, layout. - LICENSE: MIT for the build tooling. Co-Authored-By: Claude Fable 5 <[email protected]>
19 KiB
DeepSeek Harness — trusted container build
Auditable, immutable container images of DeepSeek Harness (dsh), built from the official npm release and served from this Gitea.
Upstream ships no container image. Community images ship someone else's build.
This repo turns the official release into an image whose every byte you can trace — and keeps it that way on a schedule.
dsh is DeepSeek's open-source agent harness: a browser UI that drives an AI agent
with shell access over a workspace. It is distributed only as the npm package
@deepseek-ai/dsh, so anyone who wants it in a container either trusts a third-party
image or builds their own. This repository is the "build your own" option, done
properly: the upstream source is mirrored here, the image installs the exact pinned
official release on a slim Node base, and CI re-verifies and republishes on a cadence —
without ever changing what a running deployment executes.
Status. The deployed version is pinned to
0.1.1-rc.2. Newer upstream releases are built and published automatically as their own tags (currently up to0.1.5-rc.1) but nothing moves until the template is edited by hand — see Versions and upgrades for why, and for what changed in0.1.2+before you jump.
Contents
- Why this image
- Highlights
- Quick start
- Unraid
- Image contract
- Versions and upgrades
- Plugins
- Security model
- How it stays current
- Validating a third-party image
- Repository layout
- License
Why this image
Three things went wrong with the alternatives, and this repo is shaped by each:
- A community image is an unverifiable build. The obvious candidate,
runzhliu/deepseek-harness, turned out to be honest — this repo's forensic workflow found itsdshcode byte-identical to the official release — but that took a filesystem-level diff to establish, and a 1.6 GB image bundling a Chromium desktop was never going to be re-audited on every update. Here the provenance is a chain you can follow: upstream tag → upstream npm release → this Dockerfile → this registry, with the Dockerfile in plain sight and no upstream code modified. :latestsilently upgraded a live deployment. A routine container restart pulled a moving tag and jumped from0.1.1-rc.2to the0.1.2line, which added mandatory launch-token auth and swapped the API gateway — breaking remote access and a plugin in one go. So this registry publishes immutable version tags only. There is no:latest, and CI is incapable of re-pushing an existing tag.- Upstream refuses to bind
0.0.0.0from the CLI, on purpose (the web UI can execute code). The image doesn't patch that out; it uses upstream's own supported opt-in — a--patchlauncher layer — so the safety intent survives and exposure is decided entirely by which ports you publish.
Highlights
Provenance & integrity
- Official release, pinned exactly.
npm install -g @deepseek-ai/dsh@<version>fromregistry.npmjs.org, nothing else. The version is a build argument with a known-good default. - Immutable tags.
<version>for each upstream release,<version>-r<N>for a toolchain refresh of the same release. A tag's digest never changes. - Smoke-tested before push. Every build must report the intended
dshandpnpmversions and boot the web server with the baked patch and answer HTTP on its non-loopback interface — so a release the patch no longer applies to fails in CI, loudly, not on your NAS. - Toolchain freshness. CI compares the newest build of the pinned version against the
current pnpm release and the current
node:22-bookworm-slimdigest (wherenpmitself comes from); if either moved, it publishes a-r<N>revision with the patched toolchain.
Runtime hygiene
- Non-root, volume-confined. Runs as
node(UID/GID 1000). Everything writable — dsh state, plugin installs, pnpm's store and cache — lives inside the/home/node/.dshvolume. Verified: a full plugin install writes zero bytes to the rootfs. - Headless by contract.
--no-openis baked intoCMD; a server never launches a browser (on0.1.2+an in-container browser could burn the one-time launch token). - Plugin manager works in-container. pnpm is present on
PATH, sodsh plugin --profile web add|update|removeruns viadocker exec.
Operations
- Upstream mirror. The
masterbranch and everydsh-v*tag track GitHub exactly, synced every six hours. - Releases. Every image tag gets a matching
v<tag>release with its digest, toolchain versions, and deployment notes — generated by CI. - Forensic validation workflow. A manual job that diffs any third-party
deepseek-harnessimage on the host against the trusted build without executing it.
Quick start
mkdir -p dsh/state dsh/workspace && sudo chown -R 1000:1000 dsh
docker run -d --name dsh -p 3080:3080 \
-v "$PWD/dsh/state:/home/node/.dsh" \
-v "$PWD/dsh/workspace:/workspace" \
gitea.milesward.dev/mward4/deepseek-harness:0.1.1-rc.2 \
web --patch /opt/deepseek-harness/web.cordis.patch.yml --no-open --trusted-host $(hostname)
open http://$(hostname):3080
Two things in that command are load-bearing. The volumes must be owned by UID 1000 or
dsh fails with EACCES on first start. And --trusted-host must name every hostname
or IP you will open the UI at (port-less entries match any port) — without it, anything
but a loopback Host header gets HTTP 403 on /api. That's upstream's
DNS-rebinding/CSRF fence doing its job, not a broken container.
The image is linux/amd64, built on the Unraid host that runs it.
Unraid
First run (required): the container runs as UID 1000. From the Unraid terminal:
mkdir -p /mnt/user/appdata/deepseek-harness/workspace
chown -R 1000:1000 /mnt/user/appdata/deepseek-harness
Then add the container from the template below (Docker tab → Add Container → paste, or
drop it in /boot/config/plugins/dockerMan/templates-user/). Two fields are the whole
operating contract:
- Repository is an explicit version tag. Never
:latest— it doesn't exist here. - Post Arguments replace the image command entirely. Always start with
web --patch /opt/deepseek-harness/web.cordis.patch.yml --no-open, then add one--trusted-hostper name you'll browse from.
<?xml version="1.0"?>
<Container version="2">
<Name>DeepSeek-Harness</Name>
<Repository>gitea.milesward.dev/mward4/deepseek-harness:0.1.1-rc.2</Repository>
<Registry>https://gitea.milesward.dev/mward4/deepseek-harness</Registry>
<Network>bridge</Network>
<Shell>sh</Shell>
<Privileged>false</Privileged>
<Project>https://github.com/deepseek-ai/deepseek-harness</Project>
<Support>https://gitea.milesward.dev/mward4/deepseek-harness/issues</Support>
<Overview>
DeepSeek Harness (dsh) - browser-based agent runtime from DeepSeek AI, packaged from the official npm release (built and served from this Gitea; non-root).
FIRST RUN (required): the container runs as UID 1000. Before starting, from the Unraid terminal:
mkdir -p /mnt/user/appdata/deepseek-harness/workspace
chown -R 1000:1000 /mnt/user/appdata/deepseek-harness
Otherwise dsh fails with EACCES on /home/node/.dsh.
SECURITY - READ BEFORE EXPOSING: the dsh Web UI has no authentication or TLS (before 0.1.2), and its web API can execute code on this container. Anything that can reach the port can run commands. Keep it on a trusted LAN, or put an authenticated reverse proxy (e.g. Cloudflare Access, Authelia) in front. Never port-forward it directly to the internet.
POST ARGUMENTS REPLACE THE IMAGE COMMAND ENTIRELY. Always include the patch flag and --no-open, then your trusted hosts:
web --patch /opt/deepseek-harness/web.cordis.patch.yml --no-open --trusted-host YOUR-HOST [--trusted-host YOUR-HOST2]
Without a matching --trusted-host, non-loopback requests get HTTP 403 on /api - that is upstream's DNS-rebinding/CSRF fence working as intended.
The Settings/credentials pages are additionally restricted to loopback pages by upstream design; edit them via SSH tunnel (ssh -L 3080:127.0.0.1:3080 root@unraid-ip, then browse http://127.0.0.1:3080). The @goodandready/dsh-lanmode community plugin can lift that restriction on dsh 0.1.1 (it is NOT compatible with 0.1.2+, and it is third-party code that disables an upstream security fence - review it before trusting it with your credentials).
VERSIONS: this registry publishes immutable version tags only - NO :latest. dsh is a developer preview with breaking changes between releases (0.1.2 adds mandatory launch-token auth: token in docker logs, rotates every restart; 0.1.5 migrates session logs to a format that cannot be downgraded). Upgrade by editing this tag deliberately, after backing up appdata. Tags like 0.1.1-rc.2-rN are the same dsh version rebuilt with updated pnpm/base-image security patches.
Your DeepSeek API key can be set below (stored in the container env) or entered later in Settings once running.
</Overview>
<Category>AI: Tools:</Category>
<WebUI>http://[IP]:[PORT:3080]</WebUI>
<TemplateURL/>
<Icon>https://gitea.milesward.dev/mward4/deepseek-harness/raw/branch/main/assets/deepseek-logo.png</Icon>
<ExtraParams/>
<PostArgs>web --patch /opt/deepseek-harness/web.cordis.patch.yml --no-open</PostArgs>
<Config Name="WebUI Port" Target="3080" Default="3080" Mode="tcp" Description="dsh Web UI. Reachable by everything on your LAN - see the security note in the Overview." Type="Port" Display="always" Required="true" Mask="false">3080</Config>
<Config Name="DeepSeek API Key" Target="DEEPSEEK_API_KEY" Default="" Description="Optional. Official DeepSeek API key (sk-...). Can also be configured later in the dsh Settings UI." Type="Variable" Display="always" Required="false" Mask="true"></Config>
<Config Name="DSH Home" Target="/home/node/.dsh" Default="/mnt/user/appdata/deepseek-harness" Mode="rw" Description="Persistent dsh state: settings, credentials, sessions, plugins. Must be owned by UID/GID 1000 (see Overview)." Type="Path" Display="always" Required="true" Mask="false">/mnt/user/appdata/deepseek-harness</Config>
<Config Name="Workspace" Target="/workspace" Default="/mnt/user/appdata/deepseek-harness/workspace" Mode="rw" Description="The agent's working directory. Only mount data you are willing to let the agent read and modify. Must be writable by UID 1000." Type="Path" Display="always" Required="true" Mask="false">/mnt/user/appdata/deepseek-harness/workspace</Config>
</Container>
Behind a Cloudflare Tunnel, add the public hostname as a --trusted-host and have the
tunnel rewrite Host to that name (and strip Origin) so the fence sees a name it
trusts.
Image contract
| Base | node:22-bookworm-slim + git + ca-certificates |
| Installed | @deepseek-ai/dsh@<version> and pnpm@<version> as npm globals (exact versions recorded as image labels) |
| User | node — UID/GID 1000 |
ENTRYPOINT |
["dsh"] |
CMD |
["web", "--patch", "/opt/deepseek-harness/web.cordis.patch.yml", "--no-open"] |
| Port | 3080 |
/home/node/.dsh |
DSH_HOME — settings, credentials, sessions, plugins, pnpm store/cache. Mount this. |
/workspace |
The agent's working directory (WORKDIR). Mount only what the agent may read and modify. |
/opt/deepseek-harness/web.cordis.patch.yml |
The baked bind patch: webserver.host: 0.0.0.0, port: 3080 |
| Labels | dev.milesward.dsh-version, dev.milesward.pnpm-version, dev.milesward.base-digest — what CI's freshness check reads |
Two rules about the command line that are easy to get wrong:
--patchmust come immediately afterweb. It's a launcher flag; placed after the web command's own flags it fails withunknown option '--patch'.- Nothing deployment-specific is baked in. No
--trusted-host, no keys, no hostnames. Those are yours to supply.
Versions and upgrades
| Tag | dsh | Role |
|---|---|---|
0.1.1-rc.2 |
0.1.1-rc.2 | Pinned / deployed. No launch-token auth; dsh-lanmode compatible. |
0.1.2-rc.1 |
0.1.2-rc.1 | First launch-token release; Remote gateway replaces the ApiProxy. |
0.1.5-rc.1 |
0.1.5-rc.1 | Current upstream latest. Session format V3 — no downgrade once migrated. |
The full list, with digests and notes, is on the Releases page — one release per image tag, published by CI.
dsh is a developer preview and upstream says so: expect breaking changes between
releases. An upgrade here is always three deliberate steps:
- Back up the state volume (
/mnt/user/appdata/deepseek-harness). From0.1.5upstream migrates session logs to a new format that cannot be read by older versions. - Reconcile plugins first.
@goodandready/dsh-lanmode≤ 0.6.8 breaks the web client on0.1.2+(web boot: … waiting for services: remote.*). Remove or update it before changing the tag:docker exec DeepSeek-Harness dsh plugin --profile web remove @goodandready/dsh-lanmode - Edit the Repository tag in the template and apply.
What changes in 0.1.2+: launch-token auth
From 0.1.2 the Web UI is gated by a one-time launch token:
- The tokened URL is printed at startup —
docker logs DeepSeek-Harness. - The token rotates on every container restart.
- Opening the URL exchanges it for a ~30-day cookie bound to the exact origin you used. Do the exchange on the hostname you actually intend to browse from (your tunnel domain, not the LAN IP), or you'll be doing it twice.
- Requests without token or cookie get
dsh web authentication required.
Plugins
Plugins live in the state volume (/home/node/.dsh/profiles/web), never in the image, so
image rebuilds and version bumps don't touch them. Manage them with dsh's own tooling,
which works because pnpm ships in the image:
docker exec DeepSeek-Harness dsh plugin --profile web add <package>
docker exec DeepSeek-Harness dsh plugin --profile web update <package>
docker exec DeepSeek-Harness dsh plugin --profile web remove <package>
On the third-party plugin question: @goodandready/dsh-lanmode exists because upstream
restricts the Settings/credentials pages to loopback origins, and it removes that
restriction. A quick source audit found nothing hostile, but it is unaffiliated code
whose purpose is disabling a security fence in front of your API keys. The
fence-preserving alternative is an SSH tunnel:
ssh -L 3080:127.0.0.1:3080 root@<unraid> then http://127.0.0.1:3080.
Security model
Be clear about what this thing is: a web page that runs shell commands on the container as the agent. Everything below follows from that.
- Reachability is the perimeter. Before
0.1.2there is no authentication; from0.1.2there is a launch token, but no TLS. Keep the port on a trusted LAN or behind an authenticated proxy (Cloudflare Access, Authelia). Never port-forward it to the internet. - The trust fence is a feature.
/apirefuses anyHostthat isn't loopback or listed in--trusted-host— that is upstream's DNS-rebinding and CSRF protection. Configure it; don't work around it. - Mount deliberately.
/workspaceis what the agent can read and modify. The state volume holds credentials — back it up and keep it out of anything world-readable. - The container is unprivileged. Non-root, no capabilities, nothing writable outside the mounted volume. Upstream's own safety notice still applies: dsh has not been security-audited, and its sandboxing does not guarantee isolation.
How it stays current
sync-upstream.yml runs on the Gitea Actions
runners every six hours (17 */6 * * *) and on any change to the build inputs:
- Mirror — force-syncs upstream
masterand everydsh-v*tag into this repo. - New releases — if npm's
latestfor@deepseek-ai/dshhas no tag in the registry, builds it, smoke-tests it, pushes it. - Freshness — reads the toolchain labels off the newest build of the pinned version
and compares them to the current pnpm release and
node:22-bookworm-slimdigest. If either moved, publishes<version>-r<run>with the updated toolchain. - Releases — every image tag without a
v<tag>release gets one, pointing at themaincommit that built it, with digest, toolchain, and deployment notes.
Nothing in this pipeline can change an existing tag or publish latest. Adopting any
new build — release or revision — is a template edit.
Validating a third-party image
validate-suspect-image.yml is a manual
job for the question this repo started with: is that image someone handed me actually
the official code? Given an image already present on the runner host's daemon, it
docker creates (never runs) both it and the trusted build, exports both filesystems,
and reports: config/entrypoint/labels, whether any containers use it, a byte-level diff of
dsh's own code against the official release, dependency-tree drift, and red flags —
patched-out 0.0.0.0 refusal, startup hooks, unexpected network callouts.
Run 2026-09-02 against runzhliu/deepseek-harness:0.1.1-rc.2: dsh byte-identical to
the official release, upstream safety check intact, no tampering.
Repository layout
| Branch / path | Contents |
|---|---|
master |
Exact mirror of upstream master plus all dsh-v* tags. Never edited here. |
main |
This branch — the build. |
Dockerfile |
The image. ARG DSH_VERSION is the pinned default. |
web.cordis.patch.yml |
The 0.0.0.0 bind patch, applied via --patch. |
.gitea/workflows/ |
sync-upstream.yml (mirror · build · freshness · releases), validate-suspect-image.yml (forensics). |
assets/ |
Logo (transparent PNG, also the Unraid template icon). |
Release tags on main are v<image tag>; upstream's mirrored tags are dsh-v<version>.
License
The build tooling in this repository is MIT. DeepSeek Harness itself is MIT-licensed by DeepSeek AI; the DeepSeek name and logo belong to them and are used here to identify what the image contains.
