Practical guides

SSH port forwarding for apps on a cloud GPU

Reach Jupyter, vLLM, TensorBoard or any web app on a rented GPU through an SSH tunnel instead of an open port, with local and dynamic forwarding examples.

The whole technique is one command, ssh -N -L 8888:127.0.0.1:8888 user@ip, which makes port 8888 on your laptop reach port 8888 on the GPU VM through the SSH connection you already use to log in. The app on the VM never has to listen on anything but 127.0.0.1. That matters on DeployGPU, because the platform does not firewall VMs and Hyperstack VMs are created with inbound traffic open, so anything bound to 0.0.0.0 is on the internet.

The private links on app.deploygpu.ai do not replace this. They exist for the three app templates, open one port, work in a browser signed in to DeployGPU, and do not accept API keys or Bearer tokens, so a script cannot call them. A tunnel is the supported way to reach everything else, including the API of a model server.

The command ssh -N -L, piece by piece

Code example
ssh -N -L 8888:127.0.0.1:8888 ubuntu@YOUR_VM_IP -p 22

The OpenSSH manual, checked 1 October 2026, says -L forwards connections to a TCP port on your side to a given host and port on the remote side. The target is resolved on the VM, so the 127.0.0.1 in the middle is the VM's own loopback address and not your laptop's. -N means do not run a remote command, which is what you want when you only forward ports. The terminal then prints nothing and looks frozen. That is the tunnel working, and Ctrl-c ends it.

Take the user and port from the SSH command on your deployment, and see the SSH guide for keys and ~/.ssh/config. While the tunnel runs, open http://localhost:8888. The local port does not have to match: -L 9000:127.0.0.1:8888 publishes the VM's 8888 on your 9000 when something else already holds 8888.

ProviderSSH userPort
QuantaCloudubuntu22
Massed ComputeUbuntu, with a capital U22
Hyperstackubuntu22
Verdaroot22
Vast.ai, dashboard onlyrootThe proxy port shown on the deployment

Reach Jupyter on a template deployment

Each app template starts one container that publishes only the app's port, and only on the VM's 127.0.0.1. So the same command works with the template's port:

TemplatePort on the VMTunnel
PyTorch + Jupyter8888ssh -N -L 8888:127.0.0.1:8888 ubuntu@YOUR_VM_IP
ComfyUI8188ssh -N -L 8188:127.0.0.1:8188 ubuntu@YOUR_VM_IP
Open WebUI + Ollama8080ssh -N -L 8080:127.0.0.1:8080 ubuntu@YOUR_VM_IP

The Jupyter template has no token or password, because the private link's sign-in is its protection. Through a tunnel your SSH key is the protection instead, which is fine as long as you never republish that port on 0.0.0.0. The tunnel is what lets scripts and API clients talk to these apps, which the private link does not allow. The Open WebUI template page shows an API call through the 8080 tunnel.

Start Jupyter yourself on a plain GPU VM

On a plain VM you start the server, and the default already keeps it private. The Jupyter Server documentation says it runs at 127.0.0.1:8888 and is accessible only from localhost by default:

Code example
pip install jupyterlab
jupyter lab --no-browser --port 8888

Run both inside a virtual environment, created as the PyTorch install guide shows. The terminal prints a URL with a token. Open the tunnel from your laptop, then paste that URL into your browser. Run Jupyter inside tmux so it outlives your SSH session, as keeping jobs running after you disconnect shows. The same page of the Jupyter docs describes setting the server to listen on every interface for public use. On DeployGPU, leave that setting alone.

TensorBoard, vLLM and Gradio: ports and the flags that keep them private

AppDefault portWhat keeps it on 127.0.0.1
TensorBoard6006Since TensorBoard 2.0 the default is --host localhost. Never add --bind_all, which serves to the public network
vLLM8000Pass --host 127.0.0.1 to vllm serve, or publish with -p 127.0.0.1:8000:8000 in Docker. The vLLM docs list --port with a default of 8000 but do not state the default host
Gradio7860Defaults to 127.0.0.1. Setting GRADIO_SERVER_NAME to 0.0.0.0 opens it to every address

Sources, all checked 1 October 2026: the TensorBoard README, the vLLM serve reference and the Gradio environment variable guide. One command forwards several ports at once:

Code example
ssh -N -L 8888:127.0.0.1:8888 -L 6006:127.0.0.1:6006 -L 8000:127.0.0.1:8000 ubuntu@YOUR_VM_IP

Then curl http://localhost:8000/v1/models checks a vLLM server from your laptop, and the vLLM guide builds the rest around it.

Audit what is listening with ss

Code example
sudo ss -ltnp

The ss manual defines -l as listening sockets only, -t as TCP, -n as numeric ports and -p as the owning processes. In the Local Address column, 127.0.0.1:8888 can only be reached from the VM, while 0.0.0.0:8888, *:8888 and [::]:8888 listen on every interface. SSH on port 22 is expected on all of them. Anything else on a wildcard address is public until you restart it with the flags above.

A firewall helps for what is left, but not for Docker. Docker's documentation says traffic to a published container port is diverted before it reaches the ufw rules, so publish container ports with -p 127.0.0.1:PORT:PORT. For everything outside Docker, allow SSH before you turn ufw on, or you lock yourself out:

Code example
sudo ufw allow OpenSSH
sudo ufw enable

Keep the tunnel up: keepalives, ssh config and autossh

Two options make a tunnel behave. -o ServerAliveInterval=30 makes ssh ask the server for a response after 30 quiet seconds, which stops routers from dropping an idle tunnel. -o ExitOnForwardFailure=yes makes ssh quit when it cannot bind a port. Without it, ssh prints the bind error and keeps running, which looks like a tunnel that forwards nothing.

For tunnels you open every session, put them in ~/.ssh/config. The ssh_config manual defines LocalForward with the local port first and host:hostport second:

Code example
Host gpu-tunnel
    HostName YOUR_VM_IP
    User ubuntu
    LocalForward 8888 127.0.0.1:8888
    LocalForward 6006 127.0.0.1:6006
    ServerAliveInterval 30
    ExitOnForwardFailure yes

Then ssh -N gpu-tunnel opens both. Add -f to send ssh to the background once the connection is made. autossh restarts a tunnel that dies. The autossh manual says -M 0 turns off its monitoring port so it only restarts ssh when ssh exits, which pairs with the keepalive options so that ssh exits when the link dies:

Code example
autossh -M 0 -N -o "ServerAliveInterval 30" -o "ServerAliveCountMax 3" \
  -L 8888:127.0.0.1:8888 ubuntu@YOUR_VM_IP

Install autossh on your own machine, not on the VM. A relaunched VM has a new IP address, so a saved tunnel must be pointed at it, and a reused address can trigger Host key verification failed. VS Code does the same job from its Ports view, and the VS Code Remote-SSH guide covers it.

Dynamic forwarding with -D when one port is not enough

Code example
ssh -N -D 1080 ubuntu@YOUR_VM_IP
curl --socks5-hostname localhost:1080 http://127.0.0.1:8000/v1/models

The OpenSSH manual describes -D as local dynamic application-level forwarding that speaks SOCKS4 and SOCKS5, with the destination decided by the application and connected from the remote machine. So 127.0.0.1 in the curl line is the VM's loopback, and one proxy port can reach any service the VM can reach. curl's --socks5-hostname lets the proxy resolve the name. For a single app I still use -L, because every client supports it without proxy settings.

When the tunnel does not work

What you seeCauseFix
channel 2: open failed: connect failed: Connection refused, with the channel number varyingNothing listens on that port on the VMStart the app, or run sudo ss -ltnp on the VM to find its real port
bind [127.0.0.1]:8888: Address already in useSomething on your laptop already holds the local portChoose another local port, as in -L 9000:127.0.0.1:8888
Permission denied (publickey)Wrong user or keyPermission denied (publickey)
Connection refused on the SSH connection itselfSSH is not up yet, or the VM is goneConnection refused
The tunnel freezes after a quiet periodA router dropped the idle connectionServerAliveInterval, or autossh

Frequently asked questions

Can I use SSH port forwarding with the app templates?

Yes. Each template publishes its app port on the VM's 127.0.0.1: 8888 for Jupyter, 8188 for ComfyUI and 8080 for Open WebUI. A tunnel to that port gives scripts and API clients access that the private app link does not.

Why not just open the port on the VM?

DeployGPU does not firewall VMs, and Hyperstack VMs are created with inbound traffic open. Anything bound to 0.0.0.0 is reachable from the internet, while a tunnel needs no open port at all.

Do I need a separate tunnel for each port?

No. Repeat -L in one ssh command, list several LocalForward lines in your ssh config, or use -D for a SOCKS proxy that reaches any port on the VM.

Does the tunnel survive stopping and relaunching the instance?

No. A relaunch gives you a new machine with a new IP address, so open a new tunnel to the new address.

Can an OpenAI client call my model through the private app link?

No. The private link accepts only a signed-in browser session, not API keys or Bearer tokens. Run the server on 127.0.0.1, open a tunnel to its port and point the client at localhost.