SSD Nodes Learn 🎉 VPS from $5.50/mo
Guides Matt ConnorBy Matt Connor

dsh web: http://127.0.0.1:3080 explained

dsh prints http://127.0.0.1:3080 because the Web UI binds to localhost only. Reach it safely with an SSH tunnel, and see why publishing 3080 is unsafe.

What dsh web: http://127.0.0.1:3080 means

When you start the DeepSeek Harness web profile on a VPS, it prints two lines and then waits:

dsh web: http://127.0.0.1:3080
Ready.

127.0.0.1 is the loopback address. It is the address a machine uses to talk to itself. A socket bound to 127.0.0.1 accepts connections from processes on that same machine, and from nowhere else. So that line tells you two things at once: where the Web UI is listening, and who is allowed to reach it. Only the machine dsh is running on.

That is why the URL does nothing when you paste it into the browser on your laptop. Your laptop's 127.0.0.1 is your laptop. The harness is listening on the VPS's 127.0.0.1, which is a different machine with a different loopback stack. Nothing is broken. You need to carry the connection across.

The official README states the default plainly: "The command starts the Web UI, served at http://127.0.0.1:3080 by default." The bind address comes from the webserver host plugin, @deepseek-ai/dsh-host-webserver, whose host key is documented as "Listen host; the two supported values are loopback and all-interfaces". Loopback is what you get unless you go and change it. If ports are new to you, how ports work on Linux covers the address-plus-port model this all rests on.

Why the Web UI binds to localhost only

dsh is an agent harness. The browser tab is a control surface for a process that runs shell commands, reads and writes files in the workspace directory you chose, and spends your model API key. Anyone who can load that page can do all of that as the user running dsh.

So port 3080 is not a read-only dashboard. Loading that page gives command execution on the server.

Open the Web UI and you land straight in the session list. There is no login prompt, because the developer preview ships no user accounts and no remote authentication. On loopback that is consistent: the operating system is the access control, and only local processes get through. Bind the same server to 0.0.0.0 on a VPS with a public IP and that same page answers the entire internet, with nothing in front of it. Automated scanners sweep uncommon ports continuously, so treat a published 3080 as found.

Do not open port 3080 in your firewall, and do not set the webserver host to 0.0.0.0 on a public VPS. That combination hands command execution on your server to whoever connects first.

The same reasoning applies to every agent runtime you put on a server, which is why running a coding agent safely on a VPS starts from the same rule: the agent's control port stays private, and something you trust carries you to it.

How do I open the dsh Web UI from my laptop?

There are three honest ways, and every one of them leaves the harness bound to loopback.

  • An SSH tunnel. Nothing new listens on the public interface, and you already have the credentials. This is the one to use.
  • A private overlay network, so the UI is reachable from your own devices and invisible to everyone else.
  • A reverse proxy that terminates TLS (transport layer security) and demands a password before it forwards anything.

The difference between them is what carries your browser to loopback. None of them should involve moving the harness off loopback.

Reach it with an SSH tunnel

Run this on your laptop, not on the VPS:

ssh -N -L 3080:127.0.0.1:3080 you@your-vps

Leave it running, then open http://127.0.0.1:3080 in your local browser. The Web UI loads.

The -L argument holds three fields separated by colons. The first is the port to open on your laptop. The second and third are the address and port to forward each connection to. The detail that matters: 127.0.0.1 in the middle field is resolved by the SSH server on the VPS, after your traffic has already arrived there. It means the VPS's loopback, not yours. That is precisely the address dsh printed, which is why the tunnel works when a direct browser connection does not.

-N tells SSH not to run a remote command, so you get a forwarder and no shell. For a background tunnel that fails loudly instead of quietly:

ssh -N -f -o ExitOnForwardFailure=yes -o ServerAliveInterval=30 -L 3080:127.0.0.1:3080 you@your-vps

-f drops it into the background after authentication. ExitOnForwardFailure=yes matters more than it looks: without it, SSH connects successfully even when the forward could not be set up, so you get a working session and a dead tunnel with no warning. ServerAliveInterval=30 sends a keepalive every 30 seconds so an idle tunnel survives NAT (network address translation) timeouts on café and hotel routers.

What you should see

On the VPS, confirm what is actually listening:

ss -ltnp | grep 3080

A healthy result names the loopback address:

LISTEN 0  511  127.0.0.1:3080  0.0.0.0:*  users:(("node",pid=1042,fd=21))

If the local address column reads 0.0.0.0:3080 instead, the Web UI is on every interface, including the public one. Stop it and fix the bind before you do anything else. If ss prints the socket but leaves the users: field empty, run it with sudo, because the process name for a socket owned by another user is hidden otherwise.

When the tunnel refuses to start

SSH prints this and exits:

bind [127.0.0.1]:3080: Address already in use
channel_setup_fwd_listener_tcpip: cannot listen to port: 3080

This is about your laptop, not the server. Something local already holds port 3080, often an earlier tunnel you forgot. Pick a free local port instead:

ssh -N -L 3081:127.0.0.1:3080 you@your-vps

Only the first field changed, so you now browse to http://127.0.0.1:3081 while the harness keeps listening on 3080. The two numbers never have to match.

If the tunnel comes up but the browser reports a refused connection or an empty reply, the traffic crossed to the VPS and found nothing on the far end. Either dsh exited, or it bound a different port. Check with ss -ltnp | grep 3080 on the server.

One more thing bites people here. A foreground npx @deepseek-ai/dsh web dies when its shell closes, so the harness stops the moment you log out. Start it inside tmux or under a systemd user service, which is the same problem solved in keeping a coding agent running on a VPS. While you are working on the SSH side, hardening SSH on your VPS is worth doing first, because the tunnel makes your SSH login the only door to the agent.

Reach it over a private overlay network

An overlay network gives your VPS and your laptop addresses on a private network that only your devices join. Tailscale is the common choice, and its serve command fits this case exactly: tailscaled runs on the VPS and connects to localhost:3080 itself, so the harness stays on loopback and you change nothing about how dsh is configured.

tailscale serve --bg localhost:3080
tailscale serve status

The UI is then reachable at your machine's name inside your tailnet, over HTTPS, with no port open on the public interface. This needs HTTPS certificates enabled for your tailnet, otherwise serve has no certificate to present. To take it down again, repeat the command with off:

tailscale serve --https=443 off

Use serve, never funnel. Funnel publishes the same target to the public internet, which puts you back to an unauthenticated agent runtime on an open port. The two commands look almost identical and do opposite things, so read the difference between Tailscale Serve and Funnel before you type either. Tailscale as a private network covers the setup itself.

Reach it through a reverse proxy that checks a password

This is the option that genuinely publishes a port to the internet, so the authentication is the only thing standing between a stranger and command execution on your server. Choose it when several people need the UI and a tunnel each is impractical.

The harness stays on 127.0.0.1:3080. nginx runs on the same box, so it can reach loopback, and it listens on 443 with a certificate and a password file.

server {
    listen 443 ssl;
    server_name dsh.example.com;

    ssl_certificate     /etc/letsencrypt/live/dsh.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/dsh.example.com/privkey.pem;

    auth_basic           "dsh";
    auth_basic_user_file /etc/nginx/dsh.htpasswd;

    location / {
        proxy_pass http://127.0.0.1:3080;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_set_header Host $host;
        proxy_read_timeout 3600s;
        proxy_buffering off;
    }
}

Create the password file and reload:

sudo apt install -y apache2-utils
sudo htpasswd -c /etc/nginx/dsh.htpasswd you
sudo nginx -t && sudo systemctl reload nginx

nginx -t should print syntax is ok followed by test is successful. A reload with a broken file fails and leaves the running configuration in place, so read the error rather than restarting blindly.

Three of those proxy lines are not decoration. The Upgrade and Connection headers let the WebSocket handshake through, and without them the page loads but never updates. proxy_read_timeout 3600s replaces the 60 second default, which otherwise cuts a long agent run off mid-response and leaves the UI looking frozen. proxy_buffering off sends model output to the browser as it arrives instead of holding it until the response completes. An nginx reverse proxy config, line by line explains the rest, and choosing between nginx, Caddy and Traefik covers doing the same thing with automatic certificates.

Keep 3080 closed at the firewall no matter which proxy you pick, so the only path in is through the authenticated one. ufw firewall basics covers the rules. Basic authentication over TLS is a floor, not a finished security model: whoever holds that password holds a shell on your server. Prefer the tunnel when you can.

How do I change the port dsh web listens on?

--port belongs to the web application, not to the launcher. The CLI documentation gives the example directly:

dsh --profile web --port 8080

dsh web is an alias of --profile web, so dsh web --port 8080 is the same command. The launcher parses only its own flags and hands everything after them to the booted profile. Launcher flags therefore come first, and the first token the launcher does not recognise starts the application's arguments. Put --port after the profile, never before it.

Read the URL the command prints rather than assuming it, because that line reports the address the server actually bound. Then update the last field of your tunnel to match:

ssh -N -L 3080:127.0.0.1:8080 you@your-vps

For a permanent change, the port lives in the profile configuration instead of on the command line. The web and headless profiles auto-initialise on first use from shipped templates, under ~/.dsh. To see what is actually in effect after all the layers compose:

dsh --dump-config

The webserver plugin exposes exactly two keys, host and port. Setting port to 0 asks the operating system for a free port, documented as "zero requests an OS-assigned port". That guarantees you never hit a conflict, and it is a poor fit for a tunnel, because the number changes on every restart.

Why does dsh fail with address already in use?

Because another process already holds that address and port, so the kernel refuses the second bind. Node reports it like this:

Error: listen EADDRINUSE: address already in use 127.0.0.1:3080

Find the holder before you change anything:

sudo ss -ltnp | grep 3080

The users:(("node",pid=1042,fd=21)) field names the process and its PID. The usual answer is an earlier dsh that you thought had stopped, often still alive in a detached tmux window. Stop that one with kill 1042, or start the new instance on a different port. Note that 127.0.0.1:3080 and 0.0.0.0:3080 also collide with each other, because binding all interfaces already covers loopback.

Pin the version, because this is a developer preview

The README is blunt about it: DeepSeek Harness is in developer preview and iterating rapidly, and there will be compatibility-breaking changes.

npx @deepseek-ai/dsh web resolves to the newest published version every single time you run it. A server you have not touched for a week can start a different CLI on its next launch, with different flags. Pin the version so a restart is not an upgrade:

npx @deepseek-ai/dsh@0.1.0-rc.7 web

As of August 2026 the published package is version 0.1.0-rc.7. Check what a bare npx would pull before you accept it:

npm view @deepseek-ai/dsh version

Flags move between the launcher and the web application across preview releases. If --port stops behaving the way this guide describes, ask the application for its own flag list rather than guessing:

dsh --profile web --help

For the install itself, the workspace setup and the model key, see installing DeepSeek Harness on a VPS. For a shorter walkthrough of the access step alone, reaching the dsh Web UI on a VPS covers the tunnel without the reasoning.

FAQ

Why can I not open http://127.0.0.1:3080 in my laptop browser?

Because 127.0.0.1 means the machine you are typing on. The DeepSeek Harness Web UI is bound to the VPS's loopback address, so only processes on the VPS can connect to it. Your laptop has its own separate loopback, and nothing is listening on port 3080 there. Forward the port over SSH with ssh -N -L 3080:127.0.0.1:3080 you@your-vps, then load http://127.0.0.1:3080 locally. The middle field of the -L argument is resolved on the server side, which is what makes it point at the harness.

Is it safe to bind the dsh Web UI to 0.0.0.0 on a public VPS?

No. The Web UI is the control surface for an agent that runs shell commands and edits files as the user running dsh, and the developer preview presents no login screen at all. Binding to all interfaces on a public IP means anyone who reaches port 3080 gets command execution on your server. Keep the bind on 127.0.0.1, keep 3080 closed at the firewall, and use an SSH tunnel, a private overlay network, or a reverse proxy that requires a password.

How do I keep the dsh Web UI running after I close my SSH session?

A foreground npx @deepseek-ai/dsh web is a child of your login shell, so it is killed when that shell exits. Start it inside a tmux session and detach with Ctrl-b d, or run it as a systemd user service with lingering enabled. The tunnel and the harness are independent: you can drop and rebuild the SSH tunnel as often as you like without touching the running harness, as long as the harness itself has a parent that outlives your login.

Why does the dsh Web UI freeze part way through a long agent run behind nginx?

Because nginx's default proxy_read_timeout is 60 seconds, so it closes a connection that produces no data for a minute, which a long agent step easily does. Set proxy_read_timeout 3600s; in the location block. Add proxy_buffering off; so output streams to the browser as it arrives, and pass the Upgrade and Connection headers with proxy_http_version 1.1; so the WebSocket handshake succeeds. Without those headers the page loads but never receives an update.