Skip to content
VK Relay — Tunneling the Browser to a Local Agent Server
VK Relay — Tunneling the Browser to a Local Agent Server

VK Relay — Tunneling the Browser to a Local Agent Server

In the secure-agent-pod post, 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 VibeKanbanThe task board Frank dispatches agent work through — the queue between a written plan's phases and the agents that execute them. remote web UI at vk.cluster.derio.net shows issues and workspace metadata, but it cannot display the actual workspace content: repos, sessions, diffs, terminal output. That data lives on the local VK server at localhost:8081 inside secure-agent-pod, and the browser has no way to reach it.

VK’s 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.

Architecture

    flowchart TD
  subgraph Browser[Operator Browser]
    UI[VK Remote UI]
    IDB[IndexedDB<br/>Ed25519 keys]
  end
  subgraph Relay[vk-remote pod — agents namespace]
    MAIN[vk-remote — port 8081<br/>Authentik forward-auth]
    RELAY[relay-server — port 8082<br/>JWT auth, no forward-auth]
  end
  subgraph Agent[secure-agent-pod — gpu-1]
    VK[vk-local — port 8081<br/>SQLite workspaces]
  end
  subgraph Traefik[traefik-system]
    TR[Traefik]
  end

  UI -->|/v1/relay/*| TR
  TR --> RELAY
  RELAY <-->|WSS + yamux| VK
  UI -->|/*| TR
  TR -->|Authentik forward-auth| MAIN

  UI <-->|SPAKE2 pairing| VK
  

The relay runs as a sidecar container in the existing vk-remote pod — same image, different entrypoint. It shares the PostgreSQL database and JSON Web TokenA signed, self-describing token carrying claims — who you are, what you may do, when it expires. Readable by anyone holding it, so the expiry and signature are the only things protecting it. secret with the main container: no new secrets, no new database, no new pod.

Sidecar Deployment

The relay uses the same ghcr.io/derio-net/vk-remote image with a command override:

# 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
  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: SERVER_DATABASE_URL
      value: "postgresql://remote:$(POSTGRES_PASSWORD)@postgres-vk:5432/remote?sslmode=disable"

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:

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
      - name: authentik-forwardauth
    services:
      - name: vk-remote
        namespace: agents
        port: 8081

The relay path deliberately skips Authentik forward-auth — the relay has its own JWT authentication. Adding forward-auth would break the WebSocket upgrade handshake since the relay client authenticates with a JWT token, not a browser session cookie.

Local Server Configuration

The secure-agent-pod needs one new env var:

- 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.

Pairing

The relay requires a one-time cryptographic pairing using Simple Password-Authenticated Key ExchangeA protocol that turns a short shared code into a strong mutual key without exposing the code to eavesdroppers. The enrolment codes are single-use — "please sign in again" usually means it was already spent. key exchange:

  1. Port-forward local VK server: kubectl port-forward deploy/secure-agent-pod 8081:8081
  2. Open http://localhost:8081, go to Settings > Relay Settings > “Generate pairing code”
  3. Open https://vk.cluster.derio.net, Settings > “Pair host”, enter the 6-digit code
  4. SPAKE2 completes; browser stores Ed25519 signing keys in IndexedDB

After pairing, the port-forward is never needed again. If the browser’s IndexedDB is cleared, re-pair.

Data Flow

Once paired, clicking into a workspace in the remote UI triggers:

  1. Browser calls workspacesApi.getRepos(workspaceId) on the remote UI
  2. Routes through makeLocalApiRequest()requestLocalApiViaRelay()
  3. Browser creates relay session: POST /v1/relay/create/{host_id}
  4. Browser signs the request with its Ed25519 private key
  5. Relay opens a yamux stream to the local VK server, proxies the HTTP request
  6. Local server queries SQLite, returns the response
  7. Response flows back through relay to browser

yamux multiplexing means multiple API calls share a single WebSocket connection.

Missteps

What HappenedWhy It Was WrongHow We Fixed ItCommit
Relay path blocked by Authentik forward-auth — WebSocket upgrade handshake failed; browser showed “Relay client connected but unresponsive”Relay uses JWT auth, not session cookies; forward-auth intercepts WebSocket upgradeSkipped authentik-forwardauth middleware on the relay IngressRoute path
Initial attempt: separate relay pod — new Deployment, Service, secretsUnnecessary complexity; already have PostgreSQL and JWT secret in vk-remote podSwitched to sidecar container in existing vk-remote Deployment
Traefik rule order matters — relay path never matched, all traffic went to main vk-remoteLess specific Host() rule evaluated before Host() && PathPrefix()Placed relay IngressRoute rule first in the routes list

Recovery Path

SymptomCauseFix
Relay client shows “connected but unresponsive”Authentik forward-auth blocking WebSocketVerify relay IngressRoute skips authentik-forwardauth middleware
Pairing dialog never completesPort-forward not active or wrong portVerify kubectl port-forward is running and pointing at port 8081
Browser lost relay access after VK remote restartIndexedDB cleared or browser storage resetRe-pair: generate new code from local VK server
API calls return 401 from relayJWT token expired or relay not sharing vk-remote JWT secretVerify VIBEKANBAN_REMOTE_JWT_SECRET env var on relay container

References

  • VibeKanban — agent orchestration tool
  • yamux — multiplexed stream protocol
  • SPAKE2 — password-authenticated key exchange

Next: VK Remote — Self-Hosting the Kanban Backend Before the Cloud Dies