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

Reach the DSH web UI on your VPS

The DSH web UI binds to 127.0.0.1:3080, so the URL your VPS prints will not open on your laptop. Three safe ways to reach it, and one you should avoid.

Why the DSH web UI will not open from your laptop

The DSH web UI binds to the loopback interface, so the URL your terminal printed only works on the machine that printed it. npx @deepseek-ai/dsh web reports http://127.0.0.1:3080, and 127.0.0.1 means "the computer reading this address" to whatever computer reads it. Your laptop reads it, looks at its own loopback interface, and finds nothing listening there. Do not change what DSH binds to. Build an authenticated path from your laptop to the VPS loopback address instead.

The loopback default is correct, and every method on this page keeps it. What sits behind port 3080 is an agent that runs shell commands on your server, and the web UI ships no login page. Loopback is the only access control DSH has here: to reach the socket, you must already have a shell on the box.

What DSH binds to, and how to check yours

Checked on 17 August 2026, the DeepSeek Harness README says npx @deepseek-ai/dsh web "starts the Web UI, served at http://127.0.0.1:3080 by default". The CLI reference in the same repository documents --port <num> as an override whose default is 3080, and --host <addr> as an override that "intentionally rejects 0.0.0.0". DSH is a developer preview, and the README warns in capitals that there will be compatibility-breaking changes. Confirm both values on your own install rather than trusting any page, including this one.

dsh --profile web --dump-config
ss -ltnp | grep 3080

--dump-config prints the composed configuration tree without booting the agent, so it shows the host and port that survive every patch layer. ss shows what is actually listening right now. A healthy line looks like this:

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

Read the address before the colon and nothing else. 127.0.0.1:3080 is loopback only, which is what you want. 0.0.0.0:3080 means every interface on the box, including the public one. If ss prints no line at all, DSH is not running, and no tunnel will help until it is. Getting to that point is covered in installing and running DeepSeek Harness on a VPS.

Flags apply to one run. To make a value stick, edit the profile patch layer instead. dsh --profile <name> boots the profile at $DSH_HOME/profiles/<name>, and the layers stack in this order: bundle patches, the profile's own cordis.patch.yml, the home-level $DSH_HOME/cordis.patch.yml, then any --patch overlay. The listen settings live under the @deepseek-ai/dsh-host-webserver plugin as host and port. Read your own composed tree with --dump-config before editing, because the web profile writes itself from a shipped template on first boot and the preview keeps changing that template's shape.

The second gate: the /api trust fence

Getting packets to port 3080 is half the problem. DSH has a second check, and it produces the confusing failure where the page loads, the layout appears, and then nothing works.

The @deepseek-ai/dsh-client-connection plugin holds a trustedHosts setting, documented as "Authorities this deployment serves beyond loopback: exact host:port, or port-less host matching any port". The fence refuses any /api request whose Host header is neither loopback nor listed there. The browser reports the refusal like this:

transport failure for /api/host.describe: HTTP 403

So any proxy you put in front of DSH must declare the name the browser types. The CLI takes --trusted-host <authority>, and it is repeatable:

dsh web --trusted-host dsh.example.com --trusted-host dsh.example.com:8443

The fence compares the Host header as a plain string, which is why both spellings appear above. dsh.example.com and dsh.example.com:8443 are two different authorities to it, and so are localhost:3080 and 127.0.0.1:3080. A 403 you cannot explain is almost always a spelling difference between your URL bar and your trusted list.

The fence is an origin check. It is not authentication. A browser is forbidden by the browser itself from setting a Host header, so the check does stop a page on some other site from driving your agent. Any client that is not a browser writes that header freely. Treat trustedHosts as a compatibility setting for proxies, and never as a security control.

Option 1: an SSH local forward

Reach for this first, because it adds nothing to the server at all.

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

-L opens a listener on your laptop and forwards each connection to 127.0.0.1:3080 as resolved on the VPS. -N means "run no remote command", so the session carries the forward and nothing else. Writing the local side as 127.0.0.1:3080 rather than a bare 3080 is deliberate: it pins the laptop-side listener to loopback, so a GatewayPorts setting in your SSH client config cannot quietly republish the agent to the network you are sitting on.

Now open http://localhost:3080 in your browser. Two things work for free here. The Host header is a loopback authority, so the /api fence passes with no configuration at all. And browsers treat http://localhost as a secure context, which matters because the DSH web app calls crypto.randomUUID() while booting, and browsers expose that function only over HTTPS or on a loopback origin.

Add -f so ssh moves itself to the background once the forward is up:

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

The honest trade-off. Nothing new listens on a public address and no firewall rule changes, so this is the smallest possible increase in exposure. Your whole access control is your SSH configuration, which makes key-only SSH with password login disabled a prerequisite rather than a nice extra. The cost is that the tunnel belongs to one client session. It dies when the laptop sleeps, you restart it by hand, and it gives you nothing on a phone.

Option 2: Tailscale serve on the VPS

Tailscale builds a private network between your own devices. Installing it on the VPS gives that machine a 100.x.y.z address that only your devices can route to.

Joining that network is not enough, and this is where people get stuck. DSH is not listening on the 100.x.y.z address, because it is listening on loopback. A browser pointed at http://100.x.y.z:3080 gets a connection refused, since no socket is bound to that address.

tailscale serve is the piece that connects the two. It runs on the VPS, accepts traffic from your private network, and proxies it to a local address:

curl -fsSL https://tailscale.com/install.sh | sh
sudo tailscale up
sudo tailscale serve --bg --https=443 localhost:3080
tailscale serve status

tailscale serve status prints the URL, in the form https://<machine>.<tailnet>.ts.net/. Start DSH with that name trusted, or /api returns 403 exactly as described above:

dsh web --trusted-host your-vps.your-tailnet.ts.net

This removes the secure-context problem as well. Tailscale terminates real HTTPS with a certificate issued for that name, so crypto.randomUUID() is available and the UI boots in a phone browser. Nothing reaches the public internet, because serve publishes inside your private network only.

Do not substitute tailscale funnel here. It is the same command family aimed at the public internet, and it would put an agent with shell access on a hostname anyone can resolve. Read how serve keeps traffic inside your tailnet while funnel publishes it before typing either one. For the phone-shaped version of this whole setup, see reaching a self-hosted agent from your phone.

The trade-off is a dependency. Every device that needs the UI must join the network, and a coordination server you do not run decides who is on it. If that is unacceptable, a self-hosted Headscale control server speaks the same protocol on hardware you own.

Option 3: an authenticated TLS reverse proxy

Use this when the browser cannot join a private network, for example a machine you do not administer. You are publishing a hostname to the internet now, so the authentication has to be real and it has to come from the proxy. DSH supplies none.

TLS means transport layer security, the encryption behind https://. Point nginx at the loopback port and put a password in front. The map block belongs in the http context, so give it its own file under /etc/nginx/conf.d/:

map $http_upgrade $connection_upgrade {
    default upgrade;
    ''      close;
}

Then the site itself:

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 Host              $host;
        proxy_set_header Upgrade           $http_upgrade;
        proxy_set_header Connection        $connection_upgrade;
        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_read_timeout 3600s;
        proxy_buffering    off;
    }
}

Create the password file, test, 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 and test is successful. If it prints an error instead, nginx keeps serving the old configuration, so nothing is broken yet. Then start DSH with the public name trusted, because proxy_set_header Host $host forwards dsh.example.com and the fence rejects it otherwise:

dsh web --trusted-host dsh.example.com

Two directives there are load-bearing. The Upgrade and Connection headers keep the UI's long-lived connection open, and without them nginx closes it, so the interface reconnects in a loop while showing stale state. proxy_read_timeout 3600s stops nginx from cutting off an agent run that thinks for longer than the 60-second default. The reasoning behind the rest is in an nginx reverse proxy config explained directive by directive, and the choice of proxy itself in nginx compared against Caddy and Traefik.

Be clear about what basic auth buys you. It stops drive-by scanners, and it is far better than an open port. It is also one shared password in front of a shell, with no second factor and no way to revoke one person. Anyone who learns it can run commands as the user DSH runs as. If several people need access, put a real identity proxy in front instead, and read the safety rules for running a coding agent on a VPS before widening it further.

What happens if you bind to 0.0.0.0 and open the port

The obvious shortcut is to rebind and open the firewall. DSH blocks the first half. --host <addr> intentionally rejects 0.0.0.0, and the @deepseek-ai/dsh-host-webserver plugin documents host as accepting only 127.0.0.1 or 0.0.0.0, so there is one supported value and a second one the CLI refuses. Community plugins exist that patch the check out. They ship with warnings, and the warnings are accurate.

Here is what forcing it actually gives you. Port 3080 answers on your public IP address. There is no login page. The /api fence inspects a Host header that any non-browser client writes for itself, so listing your address in trustedHosts changes nothing for an attacker holding curl. What you have published is an agent that runs shell commands as your user, together with the credentials under $DSH_HOME, which is where DSH keeps profiles and API keys. That is remote code execution on your server with your provider bill attached. Scanners sweep the whole address space continuously, so an unlisted IP is not a control. Every secret the agent can read leaves with the shell.

Every method above exists so that you never have to do this. The SSH forward is the right default for one person on one laptop. Move to tailscale serve when a phone is involved or when you want a real certificate, and keep the public reverse proxy for the case where a browser you do not administer has to reach the UI, with real authentication in front of it.

FAQ

Why does http://127.0.0.1:3080 not open from my laptop?

Because 127.0.0.1 means "the machine reading this address". DSH prints it on the VPS, where it is correct. Your laptop reads the same string and looks at its own loopback interface, where nothing is listening. Check the server side with ss -ltnp | grep 3080 on the VPS. A line showing 127.0.0.1:3080 means DSH is running and bound to loopback on purpose. What you need is a tunnel or a proxy, not a different bind address.

Can I run dsh web with --host 0.0.0.0?

No. The repository CLI reference, checked on 17 August 2026, documents --host <addr> as an override that intentionally rejects 0.0.0.0. The reason is that DSH runs shell commands and ships no login page, so binding to every interface publishes an unauthenticated shell on your public IP address. Community patches remove the check. If you apply one, the firewall and the authentication that DSH does not provide become your responsibility.

Why do all /api calls return HTTP 403 behind my reverse proxy?

The /api trust fence refuses any request whose Host header is neither a loopback address nor listed in trustedHosts. Behind a proxy that header carries your public name, so the fence rejects it and the browser logs transport failure for /api/host.describe: HTTP 403. Start DSH with --trusted-host <your name> and match the spelling exactly, including the port when your URL carries one, because the comparison is a literal string match.

Why does the DSH UI load but never finish starting over plain HTTP?

The web app calls crypto.randomUUID() while booting, and browsers expose that function only in a secure context: HTTPS, or a loopback origin such as http://localhost. Served over plain HTTP from a bare IP address the function is undefined, so the calls depending on it throw and the interface never populates. Serving it over HTTPS with tailscale serve or a TLS reverse proxy removes the problem, and so does reaching it through an SSH forward on http://localhost.

Which method should a single developer use?

The SSH local forward. It adds nothing to the server, changes no firewall rule, and reuses the SSH key you already trust. Run ssh -f -N -L 127.0.0.1:3080:127.0.0.1:3080 you@your-vps, then open http://localhost:3080. Move to tailscale serve once you want the UI on a phone, or want access that survives the laptop going to sleep.