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
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.
| Provider | SSH user | Port |
|---|---|---|
| QuantaCloud | ubuntu | 22 |
| Massed Compute | Ubuntu, with a capital U | 22 |
| Hyperstack | ubuntu | 22 |
| Verda | root | 22 |
| Vast.ai, dashboard only | root | The 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:
| Template | Port on the VM | Tunnel |
|---|---|---|
| PyTorch + Jupyter | 8888 | ssh -N -L 8888:127.0.0.1:8888 ubuntu@YOUR_VM_IP |
| ComfyUI | 8188 | ssh -N -L 8188:127.0.0.1:8188 ubuntu@YOUR_VM_IP |
| Open WebUI + Ollama | 8080 | ssh -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:
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
| App | Default port | What keeps it on 127.0.0.1 |
|---|---|---|
| TensorBoard | 6006 | Since TensorBoard 2.0 the default is --host localhost. Never add --bind_all, which serves to the public network |
| vLLM | 8000 | Pass --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 |
| Gradio | 7860 | Defaults 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:
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
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:
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:
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:
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
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 see | Cause | Fix |
|---|---|---|
channel 2: open failed: connect failed: Connection refused, with the channel number varying | Nothing listens on that port on the VM | Start the app, or run sudo ss -ltnp on the VM to find its real port |
bind [127.0.0.1]:8888: Address already in use | Something on your laptop already holds the local port | Choose another local port, as in -L 9000:127.0.0.1:8888 |
Permission denied (publickey) | Wrong user or key | Permission denied (publickey) |
Connection refused on the SSH connection itself | SSH is not up yet, or the VM is gone | Connection refused |
| The tunnel freezes after a quiet period | A router dropped the idle connection | ServerAliveInterval, 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.