Run dsh headless on a VPS with systemd
Run dsh, the DeepSeek Harness, as a systemd service on a VPS: dedicated user, pinned version, Restart rules, journalctl logs, and an SSH tunnel to the UI.
Run dsh headless on a VPS, not in a terminal
Running dsh headless on a VPS is one systemd unit file plus a dedicated user to own it. dsh is the command line launcher for DeepSeek Harness, DeepSeek's agent runtime, published under the MIT licence in developer preview in August 2026. The quickstart tells you to type npx @deepseek-ai/dsh web, which is correct, and which also dies the moment you close your SSH (secure shell) session.
A unit file fixes four things at once. The service comes back after a reboot. Its output goes to the journal instead of scrolling past you. It runs as an account that is not root. And the version it runs is the version you picked, which matters more here than usual, because upstream says so in capital letters:
DeepSeek Harness is currently in developer preview and is iterating rapidly. THERE WILL BE COMPATIBILITY-BREAKING CHANGES.
This guide assumes dsh already works for you by hand. If it does not, start with installing DeepSeek Harness on a VPS and come back once npx @deepseek-ai/dsh web serves a page.
Node first, because npm will not warn you
node -vUbuntu 24.04's own package is Node 18 (18.19.1 as of August 2026), which is old for a package published this year. @deepseek-ai/dsh publishes no engines field, so npm prints no EBADENGINE warning when your Node is too old. The failure arrives at run time instead, as a syntax error or a missing built-in, which is a much worse place to find it. Install a current long term support (LTS) release from NodeSource:
curl -fsSL https://deb.nodesource.com/setup_22.x -o /tmp/nodesource_setup.sh
less /tmp/nodesource_setup.sh
sudo -E bash /tmp/nodesource_setup.sh
sudo apt install -y nodejs
node -vnode -v should now print a v22 version. The less line is there because piping a remote script straight into bash runs code you never read.
Prove it runs before you write a unit
npx @deepseek-ai/dsh@0.1.0-rc.7 webLeave that running. From a second SSH session:
curl -fsS http://127.0.0.1:3080/ -o /dev/null && echo upup means the web profile is listening on loopback, which is where it binds by default. curl: (7) Failed to connect to 127.0.0.1 port 3080: Connection refused means it is not, and the first terminal is telling you why. Stop the manual run with Ctrl+C before you go further: a unit that tries to bind a port something else already holds fails with Error: listen EADDRINUSE: address already in use 127.0.0.1:3080.
0.1.0-rc.7 was the published version on 18 August 2026. Check what is current with npm view @deepseek-ai/dsh version, then pin whatever you decide to run.
Install the version you pinned, globally
npx is the wrong tool inside a unit file. It resolves the package version when the process starts, so a restart three months from now can bring up a different build of a preview-stage agent with no change on your side. It also needs the npm registry reachable at boot, which turns a working machine into a failed unit on the day the registry is slow. Install once, at a version you wrote down:
sudo npm install -g @deepseek-ai/dsh@0.1.0-rc.7
command -v dsh
npm ls -g --depth=0 @deepseek-ai/dshcommand -v dsh prints /usr/bin/dsh when npm came from NodeSource, and /usr/local/bin/dsh when it came from Ubuntu's own package. Use the path it actually printed in the unit file. npm ls -g prints the exact version, which is the answer you want in six weeks when behaviour changes and you cannot remember what you installed.
A user that owns the service and nothing else
The agent runs shell commands. That is its job. Running it as root makes every tool call a root tool call, so give it its own account with no login shell.
sudo useradd --system --create-home --home-dir /var/lib/dsh --shell /usr/sbin/nologin dsh
sudo install -d -o dsh -g dsh -m 750 /var/lib/dsh/harness /var/lib/dsh/workspace
id dsh/var/lib/dsh/harness becomes DSH_HOME, the directory dsh keeps profiles in. A profile is a named stack of plugin bundles with your own patch layer on top, and the web and headless profiles build themselves from shipped templates the first time you boot them. That first boot writes files and may fetch bundles, so do it by hand where you can watch it.
sudo -u dsh env HOME=/var/lib/dsh DSH_HOME=/var/lib/dsh/harness /usr/bin/dsh --profile webSet HOME explicitly rather than trusting what sudo does with it, because whether sudo rewrites HOME for a non-login command depends on the set_home setting in /etc/sudoers. Get it wrong and the first run drops cache directories into your home directory owned by dsh, and the service later cannot find its own state. Stop it with Ctrl+C once the curl check returns up.
The unit file
Write /etc/systemd/system/dsh.service:
[Unit]
Description=DeepSeek Harness (dsh) web profile
Documentation=https://github.com/deepseek-ai/deepseek-harness
After=network-online.target
Wants=network-online.target
StartLimitIntervalSec=300
StartLimitBurst=5
[Service]
Type=exec
User=dsh
Group=dsh
WorkingDirectory=/var/lib/dsh/workspace
Environment=HOME=/var/lib/dsh
Environment=DSH_HOME=/var/lib/dsh/harness
ExecStart=/usr/bin/dsh --profile web
Restart=on-failure
RestartSec=5s
TimeoutStopSec=30s
SyslogIdentifier=dsh
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=full
ProtectHome=true
[Install]
WantedBy=multi-user.targetExecStart= takes the absolute path you got from command -v dsh. systemd will search a fixed path list for a bare command name, but that list is not your shell's PATH, so an absolute path removes the guess.
WorkingDirectory= is where relative paths resolve, and it is where a tool call that runs ls with no arguments starts. Point it at the workspace you hand the agent. If the directory is missing or the service user cannot enter it, the unit fails with status=200/CHDIR before dsh runs at all.
ProtectHome=true hides /home and /root from the process. That is safe here because everything the service touches lives under /var/lib/dsh. Point the workspace at a path under /home and the agent will report that the directory does not exist, which is confusing until you remember this line. ProtectSystem=full makes /usr, /boot and /etc read-only, which the service never needs to write.
Going further is tempting and usually wrong. ProtectSystem=strict makes the whole filesystem read-only apart from the kernel pseudo-filesystems, so the first tool call that writes a file fails with EROFS: read-only file system. If you want that level, add ReadWritePaths=/var/lib/dsh in the same edit.
Which Type= belongs here
Type=exec, because dsh stays in the foreground and never forks. What that buys you over the default is a real error message. With Type=simple, systemd calls the start successful as soon as it has forked, before it knows whether the binary even exists, so systemctl start dsh returns cleanly and the failure shows up only in the journal. With Type=exec, systemd waits for execve() to succeed, so a typo in ExecStart= fails the command you just typed, where you will see it.
The two wrong answers both hang. Type=forking tells systemd to wait for a parent process to exit, and dsh never exits, so the start blocks until TimeoutStartSec expires (90 seconds by default) and then reports Job for dsh.service failed because a timeout was exceeded. Type=notify waits for a READY=1 message over sd_notify, and a Node process that never sends one stalls the same way. The full comparison of systemd service types covers the rest, including when wiring up notify is worth the effort.
Restart rules that fail loudly
Restart=on-failure restarts on a non-zero exit or a fatal signal, and leaves the unit alone after a clean exit. That is the behaviour you want from a preview build. If dsh ever exits 0 because it read a config it did not like, the unit stops and stays stopped, and systemctl status dsh shows inactive (dead) where you can see it. Restart=always turns that same event into a restart loop that looks healthy from a distance.
The rate limit is the part people leave out. systemd's defaults are five starts inside ten seconds, and with RestartSec=5s you never reach five starts inside a ten second window, so a unit that crashes on startup restarts forever and only the journal knows. StartLimitIntervalSec=300 with StartLimitBurst=5 means five failures in five minutes is enough: systemd gives up and parks the unit in failed, logging Start request repeated too quickly. Clear that state with sudo systemctl reset-failed dsh once you have fixed the cause. Both settings belong in [Unit], not [Service], and systemd ignores them silently in the wrong section.
Start it, then check it
sudo systemctl daemon-reload
sudo systemctl enable --now dsh
systemctl status dshenable --now does two jobs. enable is what brings the service back after a reboot, and --now starts it in this boot. A bare systemctl start is gone after the next reboot, and kernel updates mean reboots.
systemctl status dsh should show Active: active (running), a Main PID, and a Memory: line. Then confirm where it is listening:
sudo ss -lntp | grep 3080You want 127.0.0.1:3080. If you see 0.0.0.0:3080, something has patched the bind address and your agent is on the public internet. The process name in that output is node, not dsh, because the dsh binary is a Node script, so pgrep -x dsh finds nothing. Use systemctl show -p MainPID dsh instead.
Then reboot once. A service that has never survived a reboot is not a service yet.
sudo rebootReconnect and run systemctl is-active dsh. It prints active.
Reading the logs with journalctl
Everything dsh writes to stdout and stderr lands in the journal under the unit name.
journalctl -u dsh -f
journalctl -u dsh -n 200 --no-pager
journalctl -u dsh --since "10 min ago" -p err-f follows new lines, -n shows the last N, -p err filters by priority. SyslogIdentifier=dsh in the unit is why those lines are tagged dsh rather than node, which matters the first time you read journal output that is not filtered by unit.
Check the journal survives reboots before you need it to:
journalctl -u dsh -b -1If that prints Specifying boot ID or boot offset has no effect, no persistent journal was found, the journal lives in /run and every reboot throws it away. Create the directory and restart the daemon:
sudo mkdir -p /var/log/journal
sudo systemctl restart systemd-journaldReach the UI over an SSH tunnel, not a public port
dsh serves the web UI (user interface) on 127.0.0.1:3080 and refuses to serve it anywhere else. Ask for --host 0.0.0.0 and it stops with this:
error: --host 0.0.0.0 is intentionally not supported yet for safety: it would expose remote code execution to the network; use 127.0.0.1 insteadThat is not a limitation to work around. The web API (application programming interface) drives the agent, and the agent runs shell commands, so a reachable port is a shell on your VPS for whoever finds it. The maintainers give remote authentication being unbuilt as the reason the bind is fixed to loopback. Forward the port from your own machine instead:
ssh -N -L 3080:127.0.0.1:3080 you@203.0.113.10-L 3080:127.0.0.1:3080 opens port 3080 on your laptop and sends anything arriving there to 127.0.0.1:3080 as resolved on the VPS. -N means run no remote command, so the session only holds the tunnel open. Leave it running and open http://127.0.0.1:3080/ in your browser. That is where you enter the DeepSeek API key, under Settings then Models, and where you pick the workspace directory. Point the workspace at /var/lib/dsh/workspace, the directory the service user owns, or the agent's file tools fail with EACCES: permission denied.
If port 3080 is busy on your laptop, ssh says so:
bind [127.0.0.1]:3080: Address already in use
channel_setup_fwd_listener_tcpip: cannot listen to port: 3080Pick a different local port with ssh -N -L 3081:127.0.0.1:3080 you@203.0.113.10, then browse to http://127.0.0.1:3081/. Save the typing in ~/.ssh/config on your own machine:
Host dsh-vps
HostName 203.0.113.10
User you
LocalForward 3080 127.0.0.1:3080After that, ssh -N dsh-vps is the whole command. This tunnel is now the only door to your agent, so the SSH daemon is the thing protecting it: keys only, no password authentication, and the rest of hardening SSH on your VPS applies with more force than usual.
The key does not belong in the unit file. Environment= values are printed by systemctl show dsh -p Environment, which any user on the box can run. If a plugin you install needs a key in the environment, put it in /etc/dsh.env with mode 600 owned by root and reference it with EnvironmentFile=/etc/dsh.env. systemd reads that file as root at exec time, and systemctl show does not print its contents.
What it costs to run
Inference happens at DeepSeek's API, not on your VPS. Your box pays for the Node process, the UI it serves, and every command the agent decides to run. The first two are steady and small. The third is bounded by nothing in this unit file.
Measure the floor on your own box instead of trusting a figure from someone else's:
systemctl show dsh -p MemoryCurrent
systemd-cgtop -1 --depth 2MemoryCurrent is in bytes. Watch it while the agent is working, not while it idles.
Tool calls are children of the service, so they land in the same control group and count against the same limits. An agent that runs npm install or a test suite inside the workspace can use more memory than the harness itself by a wide margin. On a 1 GB VPS that is where things break: the kernel picks a process and kills it, and journalctl -k | grep -i "out of memory" shows the Out of memory: Killed process line naming whatever it chose. That process is often not the one that caused the problem.
The fix is a limit you set on purpose. MemoryMax= and CPUQuota= in the [Service] section keep the damage inside the unit, so a runaway build is killed instead of the whole box locking up. Capping memory and CPU with systemd covers the numbers and the failure behaviour. Disk grows too, from session history under DSH_HOME and from whatever the agent writes in the workspace, so put du -sh /var/lib/dsh in whatever you already use to watch disk.
If what you want is an interactive agent you attach to and detach from, a service is the wrong shape, and running an agent in a persistent tmux session fits better. Run dsh as a unit when you want it always up and reachable through a tunnel.
Failure modes and the strings you will see
status=203/EXEC. systemd could not run the file, and logs Failed to locate executable /usr/local/bin/dsh: No such file or directory. The path in ExecStart= does not match what command -v dsh printed. This is the failure Type=exec reports at systemctl start time instead of hiding.
status=217/USER. The account in User= does not exist. Confirm with id dsh.
status=200/CHDIR. WorkingDirectory= is missing, or the service user cannot enter it. sudo -u dsh ls /var/lib/dsh/workspace reproduces it directly.
Error: listen EADDRINUSE: address already in use 127.0.0.1:3080. Something already holds the port, usually the npx run left open in another terminal. sudo ss -lntp | grep 3080 names the process.
EACCES: permission denied followed by a path. Ownership under /var/lib/dsh is wrong, normally because a first run happened as root or with the wrong HOME. sudo chown -R dsh:dsh /var/lib/dsh fixes it.
Start request repeated too quickly. The unit hit the start rate limit and gave up. The real error is in the lines above it. Run sudo systemctl reset-failed dsh before trying again.
Unit is active (running) but the browser shows nothing. Run the check on the VPS: if curl -fsS http://127.0.0.1:3080/ -o /dev/null && echo up prints up there, the service is healthy and the problem is in the port forward.
Upgrading on purpose
Pinning means an upgrade is something you do, not something that happens to you. Read the release notes first, because upstream's own warning about compatibility-breaking changes is the entire reason for the pin. Back up the state directory, then swap the version:
sudo systemctl stop dsh
sudo tar czf /root/dsh-home-$(date +%F).tgz -C /var/lib/dsh harness
sudo npm install -g @deepseek-ai/dsh@0.1.0-rc.7
sudo systemctl start dsh
journalctl -u dsh -n 50 --no-pagerRolling back is the same npm install -g with the old version, plus restoring that tarball, which only works if you took it. A preview-stage agent runtime is exactly the software where an upgrade rewrites a config format underneath you.
FAQ
Why does dsh stop when I close my SSH session?
Because npx @deepseek-ai/dsh web is a foreground process owned by your login session, so it is torn down when the session ends. A systemd unit is owned by the init system instead, which is why it keeps running after you disconnect and starts again after a reboot. sudo systemctl enable --now dsh is the pair of steps that gives you both: enable for the reboot, --now for this boot.
Should I use Type=simple or Type=exec for dsh?
Type=exec. dsh runs in the foreground and never forks, so both work, but Type=exec makes systemd wait for execve() to succeed before it calls the start successful. A wrong path in ExecStart= then fails systemctl start with status=203/EXEC in front of you. With Type=simple the same mistake returns success and hides in the journal. Type=forking and Type=notify are both wrong here, and both hang until TimeoutStartSec expires after 90 seconds.
How do I open the dsh web UI from my laptop?
Forward the port over SSH: ssh -N -L 3080:127.0.0.1:3080 you@your-vps, then open http://127.0.0.1:3080/ in your browser. Do not try to bind the service to a public address. dsh rejects --host 0.0.0.0 with error: --host 0.0.0.0 is intentionally not supported yet for safety: it would expose remote code execution to the network; use 127.0.0.1 instead, because the web API can make the agent run shell commands and there is no remote authentication in front of it.
Can I run dsh as root to keep permissions simple?
No. The harness exists to run commands and write files, so whatever privileges the service has are privileges the agent has. Create a system account with useradd --system --shell /usr/sbin/nologin dsh, own /var/lib/dsh with it, and add NoNewPrivileges=true to the unit. If you hit EACCES: permission denied afterwards, the usual cause is an earlier run as root leaving root-owned files behind, and sudo chown -R dsh:dsh /var/lib/dsh clears it.
Which version of dsh should I pin in the unit?
Whatever npm view @deepseek-ai/dsh version reports when you set the service up, installed with npm install -g @deepseek-ai/dsh@<that version> and recorded somewhere you will find it. 0.1.0-rc.7 was current on 18 August 2026. The point is not the number, it is that npx with no version resolves the package at start time, so an unattended restart can silently move you onto a build with a different config format.