
VK Relay — Tunneling the Browser to a Local Agent Server
In Post 21, we deployed a hardened Kali workstation with VibeKanban running in local mode — SQLite database, file-based sessions, same filesystem as the coding agent. The self-hosted VK remote web UI at vk.cluster.derio.net shows issues and workspace metadata (synced via the remote API), but it can’t display the actual workspace content: repos, sessions, diffs, terminal output. That data lives on the local VK server at localhost:8081 inside the secure-agent-pod, and the browser has no way to reach it.
The VK architecture solves this with a relay server — the local server opens a persistent WebSocket tunnel to the relay, and the browser proxies API calls through that tunnel. The relay binary existed in the VK codebase (crates/relay-tunnel) but was never deployed in our self-hosted setup. This post fixes that.
Architecture
Browser --> vk.cluster.derio.net (Traefik)
|-- /v1/relay/* --> relay sidecar:8082 (JWT auth, no Authentik)
| <-> WSS tunnel (yamux multiplexed)
| <-> local VK server in secure-agent-pod:8081
'-- /* --> vk-remote:8081 (Authentik forward-auth + web UI + API)The relay runs as a sidecar container in the existing vk-remote pod. Same image, different entrypoint. It shares the PostgreSQL database and JWT secret with the main container — no new secrets, no new database, no new pod. The local VK server in the secure-agent-pod connects outbound to the relay via WebSocket, so there’s no inbound networking required to the agent pod.
Sidecar Deployment
The relay container uses the same ghcr.io/derio-net/vk-remote image as the main container, with a command override to run the relay binary:
# apps/vk-remote/manifests/deployment.yaml (excerpt)
- name: relay-server
image: ghcr.io/derio-net/vk-remote:edccfb1
command: ["/usr/local/bin/relay-server"]
ports:
- containerPort: 8082
protocol: TCP
env:
- name: RELAY_LISTEN_ADDR
value: "0.0.0.0:8082"
- name: VIBEKANBAN_REMOTE_JWT_SECRET
valueFrom:
secretKeyRef:
name: vk-remote-secrets
key: VIBEKANBAN_REMOTE_JWT_SECRET
- name: POSTGRES_PASSWORD
valueFrom:
secretKeyRef:
name: vk-remote-secrets
key: POSTGRES_PASSWORD
- name: SERVER_DATABASE_URL
value: "postgresql://remote:$(POSTGRES_PASSWORD)@postgres-vk:5432/remote?sslmode=disable"
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
cpu: 200m
memory: 128MiThe Service adds port 8082 alongside the existing 8081, and Traefik routes based on path prefix.
IngressRoute Split
The existing vk.cluster.derio.net IngressRoute becomes two rules. The relay rule must come first — Traefik evaluates rules in order, and the more specific PathPrefix match needs priority:
# apps/traefik/manifests/ingressroutes.yaml (excerpt)
routes:
- match: Host(`vk.cluster.derio.net`) && PathPrefix(`/v1/relay`)
kind: Rule
middlewares:
- name: ip-allowlist
- name: security-headers
services:
- name: vk-remote
namespace: agents
port: 8082
- match: Host(`vk.cluster.derio.net`)
kind: Rule
middlewares:
- name: ip-allowlist
- name: security-headers
services:
- name: vk-remote
namespace: agents
port: 8081The relay path deliberately skips Authentik forward-auth — the relay has its own JWT authentication at the application level. Adding forward-auth would break the WebSocket upgrade handshake since the relay client (the local VK server) authenticates with a JWT token, not a browser session cookie.
Local Server Configuration
The secure-agent-pod needs one new env var to tell the local VK server where the relay lives:
# apps/secure-agent-pod/manifests/deployment.yaml (excerpt)
- name: VK_SHARED_RELAY_API_BASE
value: "https://vk.cluster.derio.net"The local server reads this and connects via WebSocket to wss://vk.cluster.derio.net/v1/relay/connect. Registration uses the existing JWT token from VK_SHARED_API_BASE authentication. The relay_enabled config flag defaults to true, so no config file changes are needed.
Pairing
The relay requires a one-time cryptographic pairing between the browser and the local server using SPAKE2 key exchange:
- Port-forward the local VK server:
kubectl -n secure-agent-pod port-forward deploy/secure-agent-pod 8081:8081 - Open
http://localhost:8081in the browser, go to Settings > Relay Settings > “Generate pairing code” - Open
https://vk.cluster.derio.net, go to Settings > “Pair host”, enter the 6-digit code - SPAKE2 completes, browser stores Ed25519 signing keys in IndexedDB
After pairing, the port-forward is never needed again. The relay handles all communication going forward — unless the browser’s IndexedDB is cleared, in which case you re-pair.
Data Flow
Once paired, here’s what happens when you click into a workspace in the remote UI:
- Browser calls
workspacesApi.getRepos(workspaceId)on the remote UI - The web app routes this through
makeLocalApiRequest()intorequestLocalApiViaRelay() - Browser creates a relay session:
POST /v1/relay/create/{host_id} - Browser signs the request with its Ed25519 private key
- Request goes to the relay:
GET /v1/relay/h/{host_id}/s/{session_id}/api/workspaces/{id}/repos - Relay opens a yamux stream to the local VK server, proxies the HTTP request
- Local server queries SQLite, returns the response
- Response flows back through the relay to the browser
The yamux multiplexing means multiple API calls share a single WebSocket connection. After the first successful relay request, the browser attempts a WebRTC P2P upgrade for lower latency — but the relay remains the reliable fallback.
What Changed
| File | Change |
|---|---|
apps/vk-remote/manifests/deployment.yaml | Added relay-server sidecar container + relay port on Service |
apps/traefik/manifests/ingressroutes.yaml | Split VK route: /v1/relay/* to port 8082, everything else to 8081 |
apps/secure-agent-pod/manifests/deployment.yaml | Added VK_SHARED_RELAY_API_BASE env var |
Three files, one sidecar, zero new secrets. The relay reuses everything that was already deployed for the VK remote server.
References
- VibeKanban — the agent orchestration tool
- yamux — multiplexed stream protocol over a single connection
- SPAKE2 — password-authenticated key exchange
- Post 21: Secure Agent Pod — the hardened workstation this relay connects to
- Post 24: In-Cluster Ingress — the Traefik IngressRoute setup this extends