NusapodDocsRequest a license

Control-Plane Setup

Overview

NUSAPOD runs on two types of hosts: one control-plane server and one or more GPU nodes. The control plane is the management hub — it runs K3s (Kubernetes), the GPU VRAM partitioning layer, the NUSAPOD backend, and the web console. GPU nodes join the control plane and expose their VRAM as schedulable resources; they do not need to be on the same physical machine as the control plane, only reachable over the network.

You can add GPU capacity at any time by repeating the node-install step on additional hosts. The control plane itself does not need a GPU.

Prerequisites

Check the Requirements page before running the installer — you will need a valid license token and the control plane must meet the OS and hardware minimums.

Prerequisites

Operating system: Ubuntu 22.04 / 24.04 or Debian 12 (tier 1); RHEL / Rocky 9 (best-effort). x86_64 or arm64.

Hardware minimum: 2 vCPU, 4 GB RAM, 20 GB disk. No GPU required on the control plane.

Access: root or sudo access on the host.

Network: a stable hostname or IP address that GPU nodes can reach on port 6443 (K3s API) and the backend port (80 / 443 via the built-in Traefik ingress). Outbound 443 for pulling packages and images.

No Docker requiredat runtime — NUSAPOD uses K3s's built-in containerd. The installer handles K3s, Helm, GPU VRAM partitioning, and kubectl automatically; curl is the only prerequisite.

Install (one command)

Run the following on your control-plane host. Replace <your-license-token> with the token from your license email:

bash
curl -sfL https://get.nusapod.app | sudo LICENSE=<your-license-token> bash

The installer:

  • Installs K3s (Kubernetes), Helm, and GPU VRAM partitioning.
  • Deploys the NUSAPOD backend and console via the licensed Helm chart.
  • Creates the enrollment secret used by GPU nodes to join.
  • Prints the GPU-node join command at the end — copy it for Step 6.

The installer is idempotent; re-running it with the same license upgrades in place.

Installer options

Options are passed as environment variables in front of the command (they must sit before bash so sudo forwards them). Only LICENSE is required.

  • LICENSE — your license token. Required; the installer verifies it before changing anything on the host.
  • PUBLIC_URL — the address operators and GPU nodes use to reach this server (e.g. http://192.168.1.27 or https://nusapod.example.com). It is embedded in the GPU-node join command and the model endpoints. Defaults to http://<auto-detected-ip>; set it explicitly if the host has more than one network interface.
  • ADMIN_PASSWORD — the console admin password. On a first install this seeds the admin; on a re-run it resetsthe existing admin's password. If omitted on a first install, a random password is generated and printed at the end.
  • ADMIN_USERNAME — the admin username (default superadmin).
  • NODE_IP — pin the IP the control plane advertises (K3s + join URL) when auto-detection would pick the wrong interface. Usually the same host as PUBLIC_URL.
  • SKIP_HAMI=1 — skip installing the GPU VRAM partitioning layer (advanced; only if you manage it yourself).
  • HAMI_VERSION — pin the GPU-partitioning chart version (default is the tested release; change only if directed by support).

Prerequisites

The admin password only takes effect as described above because the backend seeds the admin once. On a re-run without ADMIN_PASSWORD, your current password is kept — the installer will not print it. Forgot it? Re-run with ADMIN_PASSWORD=<new> to reset.

Example — pin the address and set the admin password on a fresh install:

bash
curl -sfL https://get.nusapod.app | \
  sudo PUBLIC_URL=http://192.168.1.27 ADMIN_PASSWORD='choose-a-strong-one' LICENSE=<token> bash

Expose the backend — DNS and TLS

The chart ships a Traefik ingress that serves the backend on the control-plane host at port 80 (and 443 if TLS is configured) out of the box — no manual port-forwarding needed. For internet-accessible deployments, point a DNS record at the server's IP and configure TLS (cert-manager with Let's Encrypt works with the built-in Traefik). On a private LAN, plain http:// is acceptable. Set the PUBLIC_URL environment variable to the reachable address before running the installer, or pass it directly:

bash
curl -sfL https://get.nusapod.app | sudo PUBLIC_URL=https://nusapod.example.com LICENSE=<token> bash

This URL is embedded in the GPU-node join command, so nodes can reach the backend to redeem their enrollment token.

Front it with a reverse proxy

By default the control plane is reached at http://<server-ip>. To put it behind a domain with TLS, run a reverse proxy (nginx, Caddy, or a Cloudflare Tunnel) in front of the built-in Traefik ingress on port 80. Two rules matter:

  • Install with PUBLIC_URL set to the public URL (e.g. https://nusapod.example.com). The ingress and the GPU-node join command are built from it.
  • The proxy must forward the original Host header. When PUBLIC_URL is a domain the ingress matches on host — a wrong or missing Host yields a 404 from Traefik.

The console and backend share one origin (the console serves /; the backend serves /v1, /healthz, /install.sh, and the model endpoints under /m/), so the proxy only needs a single rule to /.

nginx — note streaming must stay unbuffered for the live log endpoint (Server-Sent Events):

nginx
server {
  listen 443 ssl;
  server_name nusapod.example.com;
  ssl_certificate     /etc/letsencrypt/live/nusapod.example.com/fullchain.pem;
  ssl_certificate_key /etc/letsencrypt/live/nusapod.example.com/privkey.pem;

  location / {
    proxy_pass http://<server-ip>:80;
    proxy_set_header Host $host;            # required — ingress matches on host
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_http_version 1.1;
    proxy_buffering off;                    # live logs (SSE) must stream
    proxy_read_timeout 3600s;
    client_max_body_size 0;
  }
}

Caddy — automatic TLS, preserves the Host header by default:

Caddyfile
nusapod.example.com {
  reverse_proxy http://<server-ip>:80 {
    flush_interval -1   # stream SSE (live logs) without buffering
  }
}

Cloudflare Tunnel — no public IP or open ports needed; run cloudflared on the control-plane host and point the tunnel at the local Traefik:

bash
# ~/.cloudflared/config.yml
tunnel: <tunnel-id>
credentials-file: /root/.cloudflared/<tunnel-id>.json
ingress:
  - hostname: nusapod.example.com
    service: http://localhost:80
    originRequest:
      httpHostHeader: nusapod.example.com   # keep the ingress host match
  - service: http_status:404

Prerequisites

GPU nodes reach the K3s API on port 6443 directly (not through the reverse proxy). Keep 6443 reachable from your GPU hosts — the proxy only fronts the HTTP console/backend on port 80.

Verify

After the installer finishes, confirm the backend is running:

bash
kubectl -n nusapod get pods

You should see the backend pod in Running state. Open a browser to the control-plane host — the web console should be reachable on port 80 (or your configured domain).

Troubleshooting

Backend pod not Ready / crash-looping: check the logs and the license state — enforcement is fail-closed on a missing auth secret:

bash
kubectl -n nusapod logs deploy/nusapod-backend --tail=50
kubectl -n nusapod get pods

Confirm the license is activeafter install — an unreadable public key silently disables enforcement, and this is where you'd catch it:

bash
curl -s http://<control-plane>/v1/license
# expect "status":"ok" and gpu_used <= max_gpus

Dashboard sign-in fails (500 / blank page): confirm the console pod is running and reaching the backend, then check both logs:

bash
kubectl -n nusapod get pods -l app=nusapod-console
kubectl -n nusapod logs deploy/nusapod-console --tail=50
kubectl -n nusapod logs deploy/nusapod-backend  --tail=50

If the backend rejects the credentials, they're wrong — re-run the installer with ADMIN_PASSWORD=<new> to reset the admin password (see Installer options).

A GPU node is Ready but shows no GPUs / VRAM:the node's default containerd runtime is likely still runc, or the GPU partitioning layer hasn't registered it yet. Check the default-runtime step on the node, and:

bash
kubectl -n kube-system get pods | grep -i hami   # partitioning pods Running?
kubectl get nodes --show-labels | grep gpu=on    # node labelled?

Telemetry shows 0% / “telemetry unavailable”: the metrics stack needs the nvidia RuntimeClass on the cluster. The installer creates it, but on older setups confirm it exists and the exporter is running:

bash
kubectl get runtimeclass nvidia
kubectl -n nusapod get pods -l app=dcgm-exporter

A provisioned pod is stuck Pending:no GPU has enough free VRAM for the request, the target GPU is cordoned (maintenance), or its node is offline. The pod's events show which:

bash
kubectl -n nusapod describe pod <pod-name> | tail -20

ImagePullBackOff on the backend or console: the licensed image pull is gated by your license. Re-run the installer to refresh the registry credentials; confirm the license is not expired or suspended in the license dashboard.

Add GPU nodes

The installer prints a join command at the end. Copy it and run it on each GPU host — see the GPU node install guide for prerequisites (NVIDIA driver, network requirements) before running the command.

Get a license

NUSAPOD is distributed under a commercial license. To obtain a license token, contact us.