Skip to content

Try Lens Agents on a VM with a public URL#

Install the full Lens Agents platform on one disposable cloud VM, in a single-node k0s cluster, and put it on a public HTTPS address. Then launch Prism Agent, Lens Agents' ready-made managed agent, and chat with it to confirm the install works. (Prefer your own machine? See Try Lens Agents locally by hand.)

Container-level isolation only

Sandboxes run as containers on the VM's kernel. You get the standard sandbox harness — privilege dropping, kernel-level network lockdown (nftables), and proxy-mediated egress — but not microVM isolation, which production deployments add with a Kata Containers or gVisor runtime class. Contact us to scope a production-grade environment.

What you'll end up with#

Ubuntu VM (24.04 or later)
├── caddy (systemd)        ← ports 80/443, Let's Encrypt certificates
└── k0scontroller (systemd, single node)
    └── lens-agents (Helm release)
        ├── platform       ← Lens Agents server, reached by Caddy on 127.0.0.1:30302
        ├── postgresql     ← bundled database
        └── lens-agents-sandbox namespace
            └── prism-agent
  • Platform admin UI on https://<vm-ip-with-dashes>.sslip.io, such as https://203-0-113-10.sslip.io
  • Prism chat UI on https://<slug>.<vm-ip-with-dashes>.sslip.io

Prerequisites#

  • An x86_64 VM running Ubuntu 24.04 or later (for example 26.04), with a static public IPv4 address. Size it at about 4 vCPU, 16 GB RAM, and 50 GB disk.
  • A Lens ID account. Sign up at app.k8slens.dev if you don't have one.
  • One inference-provider credential for managed inference. Step 4 has tabs for OpenRouter, AWS Bedrock, Azure, OpenAI, and LiteLLM.
  • Inbound cloud firewall rules (security group): TCP 22 and 443 from your own IP, and TCP 80 from anywhere, because Let's Encrypt validates from changing addresses.

The cloud firewall is the only protection

The Kubernetes API and node port 30302 listen on all of the VM's interfaces, including its public IP, so keep every port other than 22, 80, and 443 closed in the cloud firewall. A host firewall such as ufw does not protect node ports: kube-proxy redirects that traffic before ufw's rules see it.

Behind a corporate proxy, the address an IP-check site shows can differ from the one that reaches the VM. To find the address to allow on 443, run sudo tcpdump -ni any 'tcp dst port 80 and tcp[tcpflags] & tcp-syn != 0' on the VM and open http://<vm-ip> in your browser; the source address it prints is yours.

Step 1 — Set the address#

sslip.io is public DNS that resolves a name containing an IP address to that address, and every name below it too, so the platform and each sandbox port get a hostname with no DNS setup. Set DOMAIN on the VM from its static IP, written with dashes:

VM_IP=<your-static-ip>
export DOMAIN="${VM_IP//./-}.sslip.io"
getent hosts "$DOMAIN"

getent should print the VM's IP. Set DOMAIN again in any new SSH session.

Use the dashed form, not the dotted one (203.0.113.10.sslip.io). A corporate web filter blocked the dotted form in testing, and the dashed form has worked for both the browser and LNS Hub enrollment.

Choose the address now

Changing the address after install means starting over. If sslip.io is blocked on your network, see Troubleshooting before Step 4.

Step 2 — Install the tools#

SSH in and install kubectl and helm:

curl -LO "https://dl.k8s.io/release/$(curl -Ls https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl"
sudo install -m 0755 kubectl /usr/local/bin/kubectl && rm kubectl

curl -fsSL https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash

kubectl version --client && helm version

Step 3 — Create the cluster#

Install k0s as a single-node controller that also runs workloads, as a systemd service on the VM's own kernel, which has the nf_tables support sandboxes need:

curl -sSf https://get.k0s.sh | sudo sh
sudo k0s install controller --single
sudo k0s start

Rerun sudo k0s status until it reports the controller running, then write the admin kubeconfig for kubectl and helm:

mkdir -p ~/.kube
sudo k0s kubeconfig admin > ~/.kube/config && chmod 600 ~/.kube/config
kubectl get nodes

kubectl get nodes should show one Ready node; rerun it until it does.

The bundled database and sandbox volumes need a default StorageClass. If kubectl get storageclass lists none marked (default), install Rancher's local-path provisioner and make it the default:

kubectl apply -f https://raw.githubusercontent.com/rancher/local-path-provisioner/v0.0.31/deploy/local-path-storage.yaml
kubectl patch storageclass local-path \
  -p '{"metadata": {"annotations": {"storageclass.kubernetes.io/is-default-class": "true"}}}'
kubectl get storageclass

kubectl get storageclass should show local-path (default).

Step 4 — Install the platform#

Save the encryption key and database password to files. A later upgrade or reinstall with a different key cannot read the existing encrypted data.

(umask 077; openssl rand -hex 32 > ~/lens-agents-encryption-key; openssl rand -hex 16 > ~/lens-agents-db-password)

Pick the tab for the provider whose credential you have, and replace <your-email> with the email address of your Lens ID.

Without --version, Helm installs the latest release. To pin one, add --version <x.y.z> from the release notes.

helm install lens-agents oci://ghcr.io/lensapp/lens-agents \
  --set branding.preset=mirantis \
  --set 'config.ownerEmails={<your-email>}' \
  --set postgresql.auth.password="$(cat ~/lens-agents-db-password)" \
  --set encryption.key="$(cat ~/lens-agents-encryption-key)" \
  --set config.publicUrl="https://${DOMAIN}" \
  --set sandboxIngress.host="${DOMAIN}" \
  --set sandbox.k8s.cpu=1 \
  --set inference.openrouter.token="<openrouter-api-key>" \
  --wait --timeout 10m
helm install lens-agents oci://ghcr.io/lensapp/lens-agents \
  --set branding.preset=mirantis \
  --set 'config.ownerEmails={<your-email>}' \
  --set postgresql.auth.password="$(cat ~/lens-agents-db-password)" \
  --set encryption.key="$(cat ~/lens-agents-encryption-key)" \
  --set config.publicUrl="https://${DOMAIN}" \
  --set sandboxIngress.host="${DOMAIN}" \
  --set sandbox.k8s.cpu=1 \
  --set inference.bedrock.token="<bedrock-api-key>" \
  --wait --timeout 10m
helm install lens-agents oci://ghcr.io/lensapp/lens-agents \
  --set branding.preset=mirantis \
  --set 'config.ownerEmails={<your-email>}' \
  --set postgresql.auth.password="$(cat ~/lens-agents-db-password)" \
  --set encryption.key="$(cat ~/lens-agents-encryption-key)" \
  --set config.publicUrl="https://${DOMAIN}" \
  --set sandboxIngress.host="${DOMAIN}" \
  --set sandbox.k8s.cpu=1 \
  --set inference.azure.baseUrl="https://<resource>.services.ai.azure.com" \
  --set inference.azure.token="<azure-api-key>" \
  --wait --timeout 10m

inference.azure.baseUrl is the Foundry resource root on the *.services.ai.azure.com host, with no surface suffix. See Microsoft Foundry.

helm install lens-agents oci://ghcr.io/lensapp/lens-agents \
  --set branding.preset=mirantis \
  --set 'config.ownerEmails={<your-email>}' \
  --set postgresql.auth.password="$(cat ~/lens-agents-db-password)" \
  --set encryption.key="$(cat ~/lens-agents-encryption-key)" \
  --set config.publicUrl="https://${DOMAIN}" \
  --set sandboxIngress.host="${DOMAIN}" \
  --set sandbox.k8s.cpu=1 \
  --set inference.openai.token="<openai-api-key>" \
  --wait --timeout 10m
helm install lens-agents oci://ghcr.io/lensapp/lens-agents \
  --set branding.preset=mirantis \
  --set 'config.ownerEmails={<your-email>}' \
  --set postgresql.auth.password="$(cat ~/lens-agents-db-password)" \
  --set encryption.key="$(cat ~/lens-agents-encryption-key)" \
  --set config.publicUrl="https://${DOMAIN}" \
  --set sandboxIngress.host="${DOMAIN}" \
  --set sandbox.k8s.cpu=1 \
  --set inference.litellm.baseUrl="https://<litellm-host>" \
  --set inference.litellm.token="<litellm-key>" \
  --wait --timeout 10m

inference.litellm.baseUrl is the https root of your LiteLLM proxy. The platform sets no model for LiteLLM, so Prism's model names must be ones your proxy serves; see Step 7. See LiteLLM.

What the shared flags do:

  • branding.preset=mirantis — the UI shows Mirantis Agent Platform. Leave it out for Lens Agents branding.
  • config.ownerEmails — the installation owners. On a public URL this matters: with no owners named, anyone with a Lens ID who opens the address can sign in and create an organization. See config.ownerEmails.
  • postgresql.auth.password — the bundled database password, which otherwise defaults to changeme. Set it before the first install; changing it later breaks the connection to the existing volume.
  • encryption.key — 64 hex characters, used for at-rest encryption.
  • config.publicUrl and sandboxIngress.host — the admin address, and the parent domain each sandbox port is published under.
  • sandbox.k8s.cpu=1 — raises the per-sandbox CPU ceiling from the chart's 500m to 1 CPU, because the sandbox form defaults to 1 CPU.

To keep the token out of your shell history, use inference.<provider>.existingSecret instead; see An inference provider.

The platform sends product telemetry by default: usage events such as creating a project or a sandbox, carrying organization, project, and sandbox names and IDs and user IDs, to the Lens telemetry collector. It never sends prompts, model responses, or agent output. To turn it off, add --set telemetry.enabled=false. See Product telemetry.

Confirm the rollout:

kubectl rollout status deploy/lens-agents --timeout=5m
kubectl get pods

The lens-agents-* and lens-agents-postgresql-* pods should be Running.

Step 5 — Put it on HTTPS#

The platform serves the admin UI and every sandbox port on port 3002, and routes between them by hostname. Expose that port on the node with a NodePort service of your own, which a helm upgrade leaves alone:

kubectl apply -f - <<'EOF'
apiVersion: v1
kind: Service
metadata:
  name: lens-agents-public
spec:
  type: NodePort
  selector:
    app.kubernetes.io/name: lens-agents
    app.kubernetes.io/instance: lens-agents
  ports:
    - name: http
      port: 3002
      targetPort: http
      nodePort: 30302
EOF
curl -fsS http://127.0.0.1:30302/health && echo OK

Caddy terminates TLS on the VM and proxies to that port on 127.0.0.1. Install it from the official Caddy apt repository; the package runs it as the caddy systemd service with its configuration in /etc/caddy/Caddyfile:

sudo apt-get install -y debian-keyring debian-archive-keyring apt-transport-https curl
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' \
  | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' \
  | sudo tee /etc/apt/sources.list.d/caddy-stable.list
sudo apt-get update && sudo apt-get install -y caddy

Write the Caddyfile. Caddy issues a certificate for each sandbox host on its first visit, and asks its own ask endpoint first; without that check, anyone could make Caddy request a certificate for any name under your sslip.io address and use up its Let's Encrypt limits. The endpoint approves only the admin address and single-label hosts in the platform's <port-name>--<adjective>-<noun>-<NNNN> sandbox format, with each dot of the domain matched literally as [.]. It checks the shape of a name, not whether the sandbox exists, so treat the address as a short-lived trial:

sudo tee /etc/caddy/Caddyfile >/dev/null <<'EOF'
{
    on_demand_tls {
        ask http://127.0.0.1:9123/check
    }
}

http://127.0.0.1:9123 {
    bind 127.0.0.1
    @allowed expression `{query.domain} == "__DOMAIN__" || {query.domain}.matches("^([a-z0-9-]+--)?[a-z]+-[a-z]+-[0-9]{4}[.]__DOMAIN_RE__$")`
    respond @allowed 200
    respond 403
}

__DOMAIN__ {
    reverse_proxy 127.0.0.1:30302
}

*.__DOMAIN__ {
    tls {
        on_demand
    }
    reverse_proxy 127.0.0.1:30302
}
EOF
sudo sed -i -e "s/__DOMAIN_RE__/${DOMAIN//./[.]}/g" -e "s/__DOMAIN__/${DOMAIN}/g" \
  /etc/caddy/Caddyfile

Validate the file, load it, and check the platform answers over HTTPS. The check resolves the name to 127.0.0.1, because a request from the VM to its own public IP can be refused by a firewall that admits only your IP:

caddy validate --config /etc/caddy/Caddyfile && sudo systemctl reload caddy
curl -fsS --resolve "${DOMAIN}:443:127.0.0.1" "https://${DOMAIN}/health" && echo OK

Caddy needs a few seconds to obtain the certificate, so if the first curl fails, rerun it. If it keeps failing, sudo journalctl -u caddy -n 30 shows why.

Step 6 — Connect to LNS Hub and sign in#

  1. Read the setup code the chart generated:

    kubectl get secret lens-agents-hub-setup -o jsonpath='{.data.setupCode}' | base64 -d
    
  2. Open https://<vm-ip-with-dashes>.sslip.io, enter the code on the Connect to LNS Hub screen, and choose Continue to LNS Hub.

  3. Sign in to LNS Hub with your Lens ID, complete its Add fleet form (owner and name), and submit it. LNS Hub sends you back to the platform. If you leave LNS Hub without submitting, the platform stays on its setup screen.
  4. On the Mirantis Agent Platform sign-in screen, choose Continue to sign in, sign in with your Lens ID, and create an organization. Without branding.preset=mirantis, the platform goes straight to the Lens ID sign-in.

You create two organizations: the one in LNS Hub owns this installation, and the one in the platform is where your projects and agents live. See Hub enrollment for what enrollment sends.

Confirm the enrollment from the VM:

curl -fsS --resolve "${DOMAIN}:443:127.0.0.1" "https://${DOMAIN}/v1/installation"

The response shows "enrolled": true and "connection": "connected". Rerun it if connection still reads connecting.

Step 7 — Launch Prism#

To launch Prism in the admin UI:

  1. Project. A new organization has no projects. Choose New Project and create one.
  2. Template. In the project, choose Create Sandbox, then Prism Agent. The New Prism Agent sandbox dialog holds every choice on one page. The template sets the image, command, /data volume, and a private web port.
  3. Name and size. Enter a Name, and keep Size at M (1 CPU, 2Gi).
  4. Connect services. Leave Slack and Microsoft Teams off.
  5. Managed inference. Leave Managed inference on and choose Change beside the provider list. Under Providers, untick Azure unless you installed with an Azure key (Prism uses it for voice), and set Primary provider to the provider from Step 4. On LiteLLM, Prism asks the proxy for claude-opus-5-5 for chat and claude-haiku-5-5 for classification; if your proxy serves other names, open Advanced access and add LITELLM_MODEL_ID and LITELLM_CLASSIFICATION_MODEL_ID under Environment Variables, set to model names it serves. Leave Common sets under Network unselected, so model calls go through managed inference.
  6. Create. Choose Create. The dialog stays open through Provisioning, Configuring, and Ready; then choose Open the web UI, which opens https://<slug>.<vm-ip-with-dashes>.sslip.io. A private port asks for a platform sign-in first. See Exposed ports.
  7. Check it. Send Prism a message. A reply confirms the install works end to end: the sandbox, its policy, managed inference, and your provider credential.

Next steps: control what agents may reach with Policies, review what they did in the Audit trail, and stop an organization's network access at once with Emergency halt.

Upgrade#

Read the release notes for the versions in between first, then upgrade:

helm upgrade lens-agents oci://ghcr.io/lensapp/lens-agents \
  --reset-then-reuse-values --wait

Without --version, this moves to the latest release; add --version <x.y.z> to stop at an earlier one. --reset-then-reuse-values keeps the values you set, including the encryption key and database password, and takes the chart default for any value the new chart adds.

Troubleshooting#

Symptom Cause and fix
The browser times out on https://<vm-ip-with-dashes>.sslip.io Port 443 is not open to your address, or Caddy is not running. Check the firewall rule (see the tcpdump tip in Prerequisites) and systemctl status caddy.
sslip.io address doesn't load (corporate web filter) A corporate web filter blocks sslip.io. Check that DOMAIN uses dashes, not dots. If sslip.io is still blocked, set DOMAIN to a domain you control (an A record plus a wildcard A record, both pointing at the VM) before Step 4. If you have already installed, run the k0s commands in Clean up, then repeat from Step 3.
Certificate errors Caddy could not get a certificate. sudo journalctl -u caddy -n 30 shows why: usually port 80 not open to anywhere, or a Let's Encrypt rate limit, which sslip.io names may share with other sslip.io users. Caddy requests one certificate for the admin address and one for each sandbox port someone opens.
502 Bad Gateway Caddy cannot reach the platform. curl -fsS http://127.0.0.1:30302/health on the VM must answer, and kubectl get endpointslices -l kubernetes.io/service-name=lens-agents-public must list the platform pod.
The platform stays on its setup screen The LNS Hub Add fleet form was not submitted. Start again from Step 6.
Creating the sandbox fails with cpu 1 exceeds the maximum allowed (500m) The install did not set sandbox.k8s.cpu=1. Choose Size Custom and set CPU to 0.5, or run the upgrade above with --set sandbox.k8s.cpu=1 and --version set to the chart version you have.
Prism UI loads but the agent doesn't answer No inference credential, the wrong Primary provider, a ticked provider with no key, or, on LiteLLM, a proxy that doesn't serve the model names Prism asks for (set LITELLM_MODEL_ID and LITELLM_CLASSIFICATION_MODEL_ID, Step 7). See Models.
Worked, then stopped answering The VM's public IP changed, and the sslip.io address depends on that IP, which is why the prerequisites ask for a static IP. For a trial, the simplest fix is a fresh install on a VM with a static IP.

Useful logs:

kubectl logs deploy/lens-agents --tail=100
kubectl get pods -n lens-agents-sandbox

Clean up#

sudo apt-get remove -y caddy
sudo k0s stop
sudo k0s reset

Then close ports 80 and 443 and terminate the VM.

Ready for production?

This trial is deliberately minimal: container-level isolation, one Caddy service in place of an ingress, a bundled database, and a single inference credential passed at install. Before a production install, read Install on Kubernetes — Database, Ingress, and Sandbox runtime — then SSO, Product telemetry, and the Security model. Contact us and we'll help you size and stand up a production-grade cluster.