← All notes

Run DeepSeek Harness as an AppVM, step by step

DeepSeek Harness is a brand-new open source AI agent, moving fast and still in developer preview. Give it a machine of its own instead of your laptop, then reach it from any browser, including a phone or a tablet.

Update, 2026-08-23. Now covers dsh 0.1.1-rc.2, adds a prebuilt image so you can skip the build entirely, and trims this walkthrough down to the steps.

DeepSeek Harness, dsh, is DeepSeek’s open source AI agent. Every release so far is a release candidate, and its own README warns of compatibility-breaking changes — a good argument for giving it a machine of its own rather than the laptop with everything else on it.

This runs it as a Virtainer AppVM: a real machine with its own kernel, its own disk, and its own address, reachable from any browser on your network. There are two ways to get the image, and then one way to run it.

What you get

browser ──► <vm-ip>:3080  nginx  ──► 127.0.0.1:3081  dsh web

dsh binds loopback and refuses to bind anything else, so nginx sits in front of it and re-presents every request as loopback. It rewrites Host and Origin, injects a crypto.randomUUID polyfill that a plain HTTP address does not provide, and unlocks the Settings panel, which the client disables unless the browser’s own address is loopback. All three are properties of dsh, not of AppVM.

This relaxes checks dsh puts up on purpose, for anyone who can reach the VM. It suits a home or lab network. Put a real gate in front of it anywhere else.

Option 1: Pull the prebuilt image

The Virtainer team publishes this image, built from exactly the Dockerfile in option 2 and refreshed as dsh releases land. The version in the tag is the dsh version inside it.

quay.io/virtainer/dsh-lan:0.1.1-rc.2

In App images, point the source at that reference rather than pasting a Dockerfile, then skip to Create the AppVM.

Option 2: Build it yourself

Worth it when you want to change something in the file, pin a different dsh, or watch every layer come from your own host.

Open App images and click Build. Name the template deepseek-harness, leave the source on Dockerfile, and paste the file below. The build context stays empty, which is why the configuration arrives through heredocs rather than COPY.

The Build image dialog: a Template ID field, a Dockerfile source tab, an inline Dockerfile text area, an optional build context upload, and a note that RUN steps execute on this host.

FROM docker.io/library/node:24-trixie-slim

RUN apt-get update \
 && apt-get install -y --no-install-recommends \
      ca-certificates curl iproute2 procps nginx \
 && rm -rf /var/lib/apt/lists/*

RUN npm install -g @deepseek-ai/dsh@0.1.1-rc.2 \
 && npm cache clean --force \
 && dsh --version \
 && node -e "require('$(npm root -g)/@deepseek-ai/dsh/node_modules/node-pty')"

RUN rm -f /etc/nginx/sites-enabled/default

COPY <<'CONF' /etc/nginx/conf.d/dsh.conf
map $http_upgrade $dsh_connection {
    default upgrade;
    ''      close;
}

server {
    listen 3080;
    server_name _;

    access_log off;
    error_log  /dev/stderr warn;

    client_max_body_size 0;

    location / {
        proxy_pass http://127.0.0.1:3081;

        proxy_set_header Host           127.0.0.1:3081;
        proxy_set_header Origin         http://127.0.0.1:3081;
        proxy_set_header Sec-Fetch-Site "";
        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;

        proxy_set_header Accept-Encoding "";
        sub_filter_types text/javascript application/javascript;
        sub_filter_once on;
        sub_filter '<head>' '<head><script>(function(){var c=window.crypto;if(!c||typeof c.randomUUID==="function")return;function h(n){return n.toString(16).padStart(2,"0")}function u(){var b=c.getRandomValues(new Uint8Array(16));b[6]=(b[6]&15)|64;b[8]=(b[8]&63)|128;var s="";for(var i=0;i<16;i++){s+=h(b[i]);if(i===3||i===5||i===7||i===9)s+="-"}return s}try{c.randomUUID=u}catch(e){}if(typeof c.randomUUID!=="function"){try{Object.defineProperty(Crypto.prototype,"randomUUID",{value:u,configurable:true,writable:true})}catch(e){}}})();</script>';
        sub_filter 'isLoopbackHostname(pageLocation.hostname)' 'true';

        proxy_http_version 1.1;
        proxy_set_header Upgrade    $http_upgrade;
        proxy_set_header Connection $dsh_connection;

        proxy_buffering     off;
        proxy_cache         off;
        proxy_read_timeout  3600s;
        proxy_send_timeout  3600s;
    }
}
CONF

COPY <<'SH' /usr/local/bin/dsh-with-proxy
#!/bin/sh
set -eu

DSH_PORT="${DSH_PORT:-3081}"
RESTART_DELAY="${RESTART_DELAY:-5}"

proxy() {
    trap 'exit 0' TERM INT
    while :; do
        rc=0
        nginx -g 'daemon off;' || rc=$?
        echo "[proxy] nginx exited ($rc); restarting in ${RESTART_DELAY}s" >&2
        sleep "$RESTART_DELAY" || exit 0
    done
}

proxy &

exec dsh web --port "$DSH_PORT" "$@"
SH

RUN chmod 0755 /usr/local/bin/dsh-with-proxy \
 && nginx -t

ENV DSH_HOME=/root/.dsh
RUN mkdir -p "$DSH_HOME" /workspace
WORKDIR /workspace

EXPOSE 3080

HEALTHCHECK --interval=30s --timeout=10s --start-period=120s --retries=3 \
  CMD curl -fsS http://127.0.0.1:3080/ >/dev/null || exit 1

CMD ["/usr/local/bin/dsh-with-proxy"]

Click Build. Those RUN steps execute on your host, using its network and its privileges. The image reaches Ready at about 600 MiB.

Pinning 0.1.1-rc.2 is deliberate. When a project warns you it will break compatibility, a version in the file is how today’s working setup still works tomorrow.

The reasoning behind the rest of the file is in Writing a Dockerfile for an AppVM.

Create the AppVM

Go to Instances, click New AppVM, and pick the image. Four vCPUs, 4096 MB and 16 GiB of disk suit this workload; the form opens on smaller defaults.

The Create AppVM form: a template dropdown, vCPU, memory and disk fields, a Keepalive shell toggle, a restart policy selector, and a summary panel showing resources, disk, name and DHCP network.

Two settings are worth setting deliberately. Leave Keepalive shell off — it is for shell-only images, and this is a service image. Set Restart policy to on-failure, which is what connects the HEALTHCHECK to real recovery.

Storage and Network stay as they are: no extra volumes, DHCP on your VM network. Click Create AppVM.

The instance reaches healthy in under a minute and the list shows the address it took from DHCP. That address belongs to the VM, not to the host, so open it on port 3080 from whatever is nearest — a laptop, a tablet, a phone. The agent is not running on any of them.

Check it works

Open the instance’s terminal and you land in a root shell on the guest:

# ss -tlnp | grep -E '3080|3081'
LISTEN  0.0.0.0:3080     users:(("nginx",pid=144))
LISTEN  127.0.0.1:3081   users:(("node",pid=141))

# curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:3080/
200

nginx holds the network socket, dsh sits on loopback behind it, and a request through the whole chain returns 200.

Now open the URL in a browser, set a model API key under Settings, choose a workspace, and start a session.

KEEP READING
A running machine can be snapshotted nowSep 20After Apple, Docker agrees that AI agents belong in their own machineAug 16