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:
curl -sfL https://get.nusapod.app | sudo LICENSE=<your-license-token> bashThe 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.27orhttps://nusapod.example.com). It is embedded in the GPU-node join command and the model endpoints. Defaults tohttp://<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 (defaultsuperadmin).NODE_IP— pin the IP the control plane advertises (K3s + join URL) when auto-detection would pick the wrong interface. Usually the same host asPUBLIC_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:
curl -sfL https://get.nusapod.app | \
sudo PUBLIC_URL=http://192.168.1.27 ADMIN_PASSWORD='choose-a-strong-one' LICENSE=<token> bashExpose 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:
curl -sfL https://get.nusapod.app | sudo PUBLIC_URL=https://nusapod.example.com LICENSE=<token> bashThis 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_URLset 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
Hostheader. WhenPUBLIC_URLis a domain the ingress matches on host — a wrong or missingHostyields 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):
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:
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:
# ~/.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:404Prerequisites
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:
kubectl -n nusapod get podsYou 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:
kubectl -n nusapod logs deploy/nusapod-backend --tail=50
kubectl -n nusapod get podsConfirm the license is activeafter install — an unreadable public key silently disables enforcement, and this is where you'd catch it:
curl -s http://<control-plane>/v1/license
# expect "status":"ok" and gpu_used <= max_gpusDashboard sign-in fails (500 / blank page): confirm the console pod is running and reaching the backend, then check both logs:
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=50If 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:
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:
kubectl get runtimeclass nvidia
kubectl -n nusapod get pods -l app=dcgm-exporterA 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:
kubectl -n nusapod describe pod <pod-name> | tail -20ImagePullBackOff 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.
