SSD Nodes Learn Hosting plans →
Guides Matt ConnorBy Matt Connor

NVIDIA OpenShell: sandbox agents on a VPS

Run Claude Code inside an NVIDIA OpenShell v0.1.2 sandbox on a Docker VPS. Read what the default policy blocks and which attacks it still lets through.

What NVIDIA OpenShell does on a VPS

NVIDIA OpenShell runs an AI agent such as Claude Code inside a sandbox on your VPS, and a policy decides which files and hosts the agent can reach. Your API keys stay outside the sandbox. On an ordinary KVM VPS you use the Docker backend, so each sandbox is a container with kernel filesystem rules and a network proxy wrapped around it. This guide pins OpenShell v0.1.2, tagged on 2026-09-28, and takes one Claude Code sandbox from install to a verified policy.

The threat model fits in two sentences. OpenShell makes it hard for an agent to send your data to a host you did not approve, and the agent never holds your raw API key. It does not stop an agent from misusing a host you did approve, so read the threat model section before you trust it with anything that matters.

What you need before you start

  • A KVM VPS on Ubuntu 24.04. Claude Code lists 4 GB of RAM as its minimum, and the sandbox gets its own memory limit, so treat 2 vCPUs and 4 GB as the floor.
  • A normal user with sudo. The OpenShell gateway runs as a systemd user service, so do not do this as root.
  • An API key from the Anthropic Console. The Claude Code provider reads ANTHROPIC_API_KEY, and a Pro or Max login is not an API key. The guide to Claude Code subscription login versus API key covers how the two are billed.
  • Docker Engine 28.0 or newer, or Podman 5.x with cgroups v2. Both requirements come from the v0.1.2 installation docs.

Which OpenShell backend fits a VPS?

The OpenShell README asks for "Docker, Podman, or host virtualization". Each local gateway uses one compute driver. When none is set, it tries Kubernetes, then Podman, then Docker. The microVM driver, vm, is never picked automatically, and on Linux it needs KVM.

On a VPS, KVM inside your guest means nested virtualization. Many providers do not expose it, so do not assume it. Check:

ls -l /dev/kvm
grep -cE 'vmx|svm' /proc/cpuinfo

ls: cannot access '/dev/kvm': No such file or directory and a count of 0 mean the CPU virtualization flags are not passed through to your guest. You cannot run the microVM driver, and Docker is the backend for you. If /dev/kvm exists, read how nested virtualization works on a VPS before you rely on it, because nested guests have their own performance and stability costs.

The choice changes the boundary. A Docker or Podman sandbox shares the host kernel. OpenShell narrows what the agent can ask that kernel to do, but a kernel bug is still a shared bug. A microVM sandbox has its own kernel and no network interface at all: every packet goes through the OpenShell supervisor on the host. This guide uses Docker, because that is what works on most VPS plans.

Check one more kernel feature. OpenShell protects its private /.openshell directory with a mandatory Landlock rule set, and it needs Landlock ABI v3, which arrived in Linux 6.2. Landlock is a Linux security module that lets a process restrict its own file access.

uname -r
cat /sys/kernel/security/lsm

Ubuntu 24.04 ships a 6.8 kernel. The lsm line must include landlock. If it does not, the kernel was booted without the Landlock module, and OpenShell sandboxes will not start on it.

Install Docker Engine

Docker's convenience script installs the current Docker Engine from Docker's own repository:

curl -fsSL https://get.docker.com -o get-docker.sh
sudo sh get-docker.sh
sudo usermod -aG docker "$USER"

Log out and log back in, so your session picks up the new group. Then check:

docker version
docker run --rm hello-world

docker version should show a Server section with a version of 28.0 or newer. hello-world should print Hello from Docker!. A permission denied while trying to connect to the Docker daemon socket error means your session does not have the docker group yet.

Be clear about what that group means. Membership in docker is equal to root on the host, because a member can start a container that mounts /. The OpenShell sandbox protects your server from the agent. It does not protect your server from the account that runs OpenShell.

Install OpenShell v0.1.2 from the release artifacts

The documented one-liner pipes install.sh from the main branch into sh. That script installs whatever release is newest on the day you run it, so two servers set up a week apart can differ. Pin the version and check the file instead:

VER=0.1.2
BASE="https://github.com/NVIDIA/OpenShell/releases/download/v${VER}"
curl -fLO "${BASE}/openshell_${VER}-1_amd64.deb"
curl -fLO "${BASE}/openshell-checksums-sha256.txt"
sha256sum --check --ignore-missing openshell-checksums-sha256.txt
sudo apt install -y "./openshell_${VER}-1_amd64.deb"

sha256sum should print openshell_0.1.2-1_amd64.deb: OK. Any other result means the download is damaged or is not the file NVIDIA published, so stop there. On an ARM VPS, use the _arm64.deb file. If you prefer the script, fetch it from the tag rather than from main, and pass the version: curl -LsSf https://raw.githubusercontent.com/NVIDIA/OpenShell/v0.1.2/install.sh | OPENSHELL_VERSION=v0.1.2 sh. That script picks the same .deb, checks it against the same checksum file, and starts the gateway for you.

Start the gateway and keep it running after you log out:

systemctl --user enable --now openshell-gateway
sudo loginctl enable-linger "$USER"
openshell status

The gateway listens on https://127.0.0.1:17670, on loopback only. openshell status should report that it reached the gateway. If it reports no gateway, register the local one with openshell gateway add https://127.0.0.1:17670 --local --name openshell and run openshell status again. Without enable-linger, systemd stops your user services when your last SSH session closes, so the gateway dies when you disconnect. For gateway logs, use journalctl --user -u openshell-gateway -f.

Or install the OpenShell snap

Canonical released an OpenShell snap in June 2026. In the Snap Store it is published under NVIDIA's verified account. As of October 2026, the latest/stable channel carries 0.1.2.

sudo snap install openshell
snap list openshell
sudo snap refresh --hold openshell

snap list prints the installed version and the channel it tracks. Write both down. Snaps refresh on their own, so --hold keeps you on the version you tested until you decide to move.

The snap differs from the .deb in ways that matter. It needs Docker Engine from Ubuntu or from Docker's repository, and the Docker snap does not work with it. Its gateway runs as a system service, not a user service, and it requires a client certificate. Give your user that certificate and register the gateway, exactly as the snap docs show:

d=~/snap/openshell/common/.local/state/openshell/tls
mkdir -p -m 700 "$d" "$d/client"
sudo install -o "$USER" -m 600 /var/snap/openshell/common/tls/ca.crt "$d/"
sudo install -o "$USER" -m 600 -t "$d/client" /var/snap/openshell/common/tls/client/tls.crt /var/snap/openshell/common/tls/client/tls.key
openshell gateway add https://127.0.0.1:17670 --local --name openshell
openshell status

Keep the client key private. Pick one channel only: the snap does not migrate an existing .deb install.

Why openshell sandbox create -- claude is not enough in v0.1.2

Older quickstarts show a one-line start: openshell sandbox create -- claude. In v0.1.2 that line has nothing to run. Without --from, the gateway uses its default image, nvcr.io/nvidia/base/ubuntu:24.04. The v0.1.2 docs describe it as a minimal Ubuntu Noble userspace that "does not include agent CLIs". Bare catalog names such as --from claude also stopped working before 0.1.0. The current documented form passes an image that already contains Claude Code: openshell sandbox create --from <image> -- claude. Run openshell sandbox create --help on your install to confirm the flags your build accepts.

So you build that image. The v0.1.2 bring-your-own-container example lists what a sandbox image needs: a standard Linux base, a non-root USER, a writable /sandbox workspace, and iproute2 for the network namespace.

Make a directory ~/claude-agent and put this Dockerfile in it:

FROM ubuntu:24.04

RUN apt-get update \
 && apt-get install -y --no-install-recommends ca-certificates curl git iproute2 \
 && install -d -m 0755 /etc/apt/keyrings \
 && curl -fsSL https://downloads.claude.ai/keys/claude-code.asc -o /etc/apt/keyrings/claude-code.asc \
 && echo "deb [signed-by=/etc/apt/keyrings/claude-code.asc] https://downloads.claude.ai/claude-code/apt/stable stable main" > /etc/apt/sources.list.d/claude-code.list \
 && apt-get update \
 && apt-get install -y --no-install-recommends claude-code \
 && rm -rf /var/lib/apt/lists/*

RUN install -d -o ubuntu -g ubuntu /sandbox
USER ubuntu
WORKDIR /sandbox

The ubuntu:24.04 image already has an ubuntu user with UID 1000, so the image does not run as root. Docker-backed OpenShell rejects an image whose user is root unless your policy sets a non-root user. Claude Code comes from Anthropic's signed apt repository on the stable channel. Package installs do not update themselves, so the binary in the image stays the one you built.

Build it and look inside:

cd ~/claude-agent
docker build -t claude-agent:0.1 .
docker run --rm claude-agent:0.1 sh -c 'id; command -v claude; readlink -f "$(command -v claude)"; claude --version'

You should see uid=1000(ubuntu), the path to claude, its resolved path, and a version line such as 2.1.211 (Claude Code). Write down both paths. They matter in the next step.

Give the agent a provider, not a key

An OpenShell provider holds a credential on the gateway. A provider profile says which hosts that credential may go to and which programs may send traffic there. The v0.1.2 repository ships an example profile for Claude Code. OpenShell does not load it on its own, so download the copy from the tag and read it:

curl -fLO https://raw.githubusercontent.com/NVIDIA/OpenShell/v0.1.2/providers/claude-code.yaml
cat claude-code.yaml

The profile grants three endpoints, all on port 443: api.anthropic.com for the model, plus statsig.anthropic.com and sentry.io for Claude Code's telemetry and error reports. Delete the last two entries if you do not want that traffic to leave the sandbox. The key goes out only as the x-api-key header. The binaries line lists /usr/bin/claude and /usr/local/bin/claude. If either path from your docker run check is missing from that list, add it. A profile whose binaries match nothing still shows up in the catalog, but the credential is never injected and the traffic is denied.

Lint the file, import it, and create the provider:

openshell profile lint -f claude-code.yaml
openshell profile import -f claude-code.yaml
read -rs ANTHROPIC_API_KEY && export ANTHROPIC_API_KEY
openshell provider create --name claude --type claude-code --from-existing
unset ANTHROPIC_API_KEY
openshell provider list

read -rs waits for you to paste the key and press Enter. It echoes nothing, so the key stays out of your screen and your shell history. --from-existing copies ANTHROPIC_API_KEY from your shell into the gateway's credential store. openshell provider list should show a provider named claude with the credential key name and no value. OpenShell never prints credential values.

Do not pass the key with --env ANTHROPIC_API_KEY=... instead. The agent can read a plain environment variable directly, which defeats the point. OpenShell prints a warning when an --env name looks like a credential. The wider habit of keeping secrets out of an AI agent's reach starts here.

Write a policy for Claude Code's home directory

The built-in default policy grants read-write access to the workspace, /tmp and /dev/null, and read-only access to system paths. Claude Code also writes its settings and session state to ~/.claude and ~/.claude.json, which is /home/ubuntu in this image. Under the default policy that directory is not writable. Save this as ~/claude-agent/policy.yaml:

version: 1

filesystem_policy:
  include_workdir: true
  read_only: [/bin, /usr, /lib, /proc, /dev/urandom, /etc, /var/log]
  read_write: [/tmp, /dev/null, /home/ubuntu]

include_workdir: true keeps /sandbox read-write. The read_only list is the default policy's list, unchanged. The only addition is /home/ubuntu. There is no network_policies section, on purpose. With no network rules of its own, the sandbox can reach only what the attached provider adds.

Create the Claude Code sandbox

cd ~/claude-agent
openshell sandbox create --name claude-box --from claude-agent:0.1 --provider claude --policy ./policy.yaml --cpu 2 --memory 4Gi -- claude

The trailing claude is the sandbox's main process. OpenShell starts it once and attaches your terminal. Claude Code finds ANTHROPIC_API_KEY set and asks you once to approve it. Approve it: the value Claude Code sees is a placeholder, not your key. --cpu and --memory become Docker runtime limits on the container.

To leave Claude Code running, press Ctrl-P and then Ctrl-Q. Reattach later with openshell sandbox connect claude-box, which replays up to 1 MiB of recent output.

To give it a project, open a second SSH session, upload the project, and later copy the result back:

openshell sandbox upload claude-box ./myapp
openshell sandbox download claude-box myapp ./myapp-out

The upload lands in the sandbox workspace as /sandbox/myapp. Inside a Git repository it skips files that .gitignore excludes, which usually keeps .env files out. The download refuses any path outside the workspace, so the agent cannot hand you /etc/passwd by accident.

Walk through the default policy, then the one you got

Print both views of the policy:

openshell policy get claude-box --base
openshell policy get claude-box --full

--base is what you supplied, plus the paths OpenShell added. --full is the effective policy, which adds the rules the claude provider contributes. Read them in four parts.

Filesystem. Landlock enforces these paths in the kernel. A path outside both lists is closed to the agent. Because the effective policy now has a network rule, OpenShell adds baseline paths for shared libraries, DNS and CA certificates, such as /app when it exists. It never widens a path you already listed. The agent also gets read-only access to OpenShell's CA certificates under /run/openshell-supervisor-ca, and it can never reach /.openshell.

Network. The default policy has no network rules, so all outbound traffic is denied. The sandbox runs in its own network namespace, and every connection goes through a proxy on the host side of a veth pair at 10.200.0.1. Your effective policy allows api.anthropic.com and the two telemetry hosts, and only for the binaries in the profile. A process that is not claude, such as curl or git, cannot reach even api.anthropic.com.

Process. The agent runs as UID 1000, not root, with no_new_privs set, so setuid binaries cannot raise its privileges. A seccomp filter blocks system calls a coding agent has no use for, including mount, ptrace, bpf, kernel module loading, and unshare with a new user namespace. Core dumps are off. The CPU and memory limits come from your flags. A process-count limit is a gateway setting, sandbox_pids_limit under [openshell.drivers.docker] in ~/.config/openshell/gateway.toml.

Credentials. The agent's environment holds an opaque placeholder where the key would be. The proxy swaps in the real key only when two checks pass: network policy allows the calling binary and destination, and the credential's binding includes the request's host, port and path. Send the placeholder anywhere else and the proxy answers HTTP 403 with the reason credential_endpoint_mismatch.

Filesystem, Landlock and process settings are fixed when the sandbox starts. To change them, delete the sandbox and create it again. Network rules can change while it runs.

Check the boundary yourself

Do not take the policy file's word for it. Run these checks from the host:

openshell sandbox exec -n claude-box -- printenv ANTHROPIC_API_KEY
openshell sandbox exec -n claude-box -- curl -sS -m 10 https://example.com
openshell logs claude-box --since 5m

The first command prints a placeholder token. If it prints a value that starts with sk-ant-, the key reached the sandbox as a plain variable, and the provider is not doing its job. The second command fails, because no rule lets curl reach example.com. The logs show that connection with action=deny. For a live view of status and denials together, run openshell term.

Grant more access without opening everything

Claude Code will hit walls. It may want a package registry or an API. When the agent reaches a host the policy does not allow, OpenShell denies the request and drafts a narrow rule for it. List the drafts, put the chunk ID of the one you are deciding on in CHUNK_ID, and approve or reject it:

openshell rule get claude-box --status pending
openshell rule approve claude-box --chunk-id "$CHUNK_ID"
openshell rule reject claude-box --chunk-id "$CHUNK_ID" --reason "Not needed for this task."

Approved rules reload into the running sandbox without a restart. To add a rule yourself, name the binary and the endpoint:

openshell policy update claude-box --rule-name github_readonly --binary /usr/bin/curl --add-endpoint api.github.com:443:read-only:rest:enforce --wait
openshell policy list claude-box

Each approval is the moment you widen the sandbox. Approve the narrowest rule that does the job. A read-only rule for one host is a small change. A read-write rule for a host where you also attach a token is a new way out for your data, as the next section explains.

What OpenShell stops, and what it does not

OpenShell is built for a specific job. It limits where data can go, and it keeps raw keys away from the agent. Within that job it is strong. An injected instruction that says "post this repository to paste.example" fails, because nothing allows that host. An agent that tries to read a file outside its lists gets nothing back. An agent that reads its own environment finds a placeholder, not a key it could copy to another service.

It does not stop misuse of an approved endpoint. Every allowed host is a channel out. The model API itself receives your code, which is the point of using it. Attach a GitHub token with write access, and an injected instruction can push your code to a public gist or a new repository if the token's scopes allow it, through the exact api.github.com rule you approved. The proxy sees a valid request to an allowed host with a correctly bound credential. Nothing about it looks wrong.

It does not stop prompt injection that stays inside the allowed scope. A malicious README can still tell the agent to delete the workspace or plant a backdoor in code you will later merge. Both happen inside /sandbox, which the agent is allowed to write. How prompt injection attacks coding agents covers what those attacks look like and why human review is the control that catches the second kind.

So OpenShell is one layer. Keep the others. Review every change before it leaves the sandbox. Keep Claude Code's own permission prompts on, and read what auto mode lets Claude Code do without asking before you turn them off. Treat the sandbox as disposable: delete it and start fresh rather than keeping one alive for weeks, the same discipline as running coding agents in a disposable VM. For the server around it, such as SSH, the firewall and a separate user account, follow the guide to running Claude Code safely on a VPS.

Failure modes, with the strings you will see

The sandbox stays in Provisioning. OpenShell checks the effective policy and provider configuration before it starts the workload. A rejection shows as a ConfigurationInvalid condition, and openshell term shows Invalid config in the NOTES column. Read the reason with openshell sandbox get claude-box -o json, then fix the policy or provider. You have 300 seconds from the last change. After that the sandbox moves to Error with ProvisioningTimedOut. Once the fix is in, start it again with openshell sandbox start claude-box.

Ready=False with SupervisorNotConnected. The container is up, but the OpenShell supervisor inside it has not connected to the gateway yet. This is normal for a few seconds, and after a gateway restart. Wait for Ready before you connect.

Claude Code cannot reach the API, and the logs show a deny for api.anthropic.com. The binary paths in the profile do not match the claude binary in your image. Export the live profile with openshell profile export claude-code -o yaml > claude-code-live.yaml, fix the binaries line, and apply it with openshell profile update claude-code -f claude-code-live.yaml. Keep the exported resource_version field, because the update rejects a stale one.

HTTP 403 with credential_endpoint_mismatch. Something sent the placeholder to a host outside the profile's endpoints. The proxy blocked the request before it added the key. If the host is legitimate, give it its own provider profile. Do not widen the Claude Code profile to cover it.

The image is rejected for running as root. Docker-backed sandboxes refuse a root image unless the policy sets a non-root run_as_user. Keep the USER ubuntu line in the Dockerfile.

The sandbox ends in Error with MainProcessFailed. Claude Code exited with a nonzero status, and openshell logs claude-box shows why. A main process that exits with status 0 leaves the sandbox in Completed instead. In both cases openshell sandbox start claude-box runs claude again.

Every command says it cannot reach the gateway. Check systemctl --user status openshell-gateway and read journalctl --user -u openshell-gateway -n 50. If the service stopped when you logged out, you skipped loginctl enable-linger.

Charmed OpenShell is for fleets, not one VPS

Canonical announced Charmed OpenShell as an alpha on 2026-09-28. It uses Juju, Canonical's operator tool, to run the OpenShell gateway on Canonical Kubernetes on a MicroCloud, with Charmed PostgreSQL holding state and each sandbox created as an LXD instance. It handles deployment and lifecycle for an organization with many agents and many users. It does not need a GPU, although its LXD driver can pass one through when the hardware has one. For a single VPS it is far more than you need, and an alpha is not a base to build on yet. The .deb or the snap above is the right tool for one server.

Clean up and upgrade

openshell sandbox delete claude-box
openshell provider delete claude

Deleting a sandbox stops its processes and purges the credentials injected into it. Deleting the provider removes the stored key from the gateway. To upgrade, read the release notes for the new tag first. Then repeat the install with a new VER, or run sudo snap refresh --unhold openshell followed by sudo snap refresh openshell. Afterwards, run openshell policy get on a fresh sandbox, because policy defaults are part of what a release can change.

FAQ

Does NVIDIA OpenShell need an NVIDIA GPU?

No. OpenShell sandboxes run on an ordinary CPU-only Linux VPS with Docker or Podman. A GPU is an option you request with --gpu when the host has one and the workload needs it. Claude Code sends its inference to Anthropic's API, so a sandbox running Claude Code needs no GPU at all.

Can I run OpenShell on a VPS without nested virtualization?

Yes. Use the Docker or Podman backend, which needs no KVM inside your guest. Only the microVM driver needs host virtualization, and the gateway never selects it automatically. Check with ls -l /dev/kvm. If the device is missing, stay with Docker.

Does OpenShell stop prompt injection?

No. It limits the damage. An injected instruction cannot send data to a host the policy does not allow, and it cannot read your raw API key. It can still misuse any host you approved, and it can still change code inside the workspace. Review the agent's changes before you merge them.

Why does openshell sandbox create -- claude not start Claude Code in v0.1.2?

Without --from, the sandbox uses nvcr.io/nvidia/base/ubuntu:24.04, which contains no agent CLIs. Catalog names such as --from claude were removed before 0.1.0. Build an image with Claude Code installed, then run openshell sandbox create --from <your-image> --provider <name> -- claude.

Can I use my Claude Pro or Max subscription inside an OpenShell sandbox?

Not through the documented provider. The claude-code provider profile reads ANTHROPIC_API_KEY or CLAUDE_API_KEY, and OpenShell's docs say this must be an API key from the Anthropic Console, not a subscription token. API usage is billed per token, separately from any subscription.

#openshell#nvidia#agent-sandbox#claude-code#security