SSD Nodes Learn 🎉 VPS from $4.99/mo
Guides Matt ConnorBy Matt Connor

Self-host sandboxd: an AI app builder on a VPS

Run the sandboxd AI app builder on your own VPS: pinned install, model keys, HTTPS preview URLs, RAM and disk floors, and cleaning up stale sandboxes.

What sandboxd is, and what you get by running it yourself

To self-host sandboxd you need one Linux server with Docker and a domain name. You send a prompt, a coding agent builds a real application inside an isolated container, and that application comes up at its own preview URL. Prompt-to-app builders are the loudest hosted category of 2026, and sandboxd is the one that runs on your VPS, under the MIT licence, with the generated code sitting on your own disk.

The design is small on purpose. A Go control plane drives Docker, Traefik v3 routes every preview hostname, SQLite holds the state, and each app runs inside one container. There is no Kubernetes and no separate database server, which is why a 2 vCPU box can run it at all.

Four objects carry the whole model. An app is the durable project, holding its name, its git metadata and its secrets. A sandbox is the Docker container that app runs in, and an app points at one sandbox at a time. A workspace is the app's files, which live on the host and survive the container. A task is one prompt handed to the agent inside the sandbox. Stopping a sandbox frees memory and keeps the files. Destroying it throws the container away, and the app can boot a new one.

How is sandboxd different from Dify and OpenHands?

These three get confused because all of them run an LLM (large language model) on your server, but what they produce is different. Dify builds LLM applications: chat interfaces, retrieval pipelines, workflows that call a model every time someone uses them. The model is part of the finished product. OpenHands works on a repository you already have: you point it at your code and it reads files, runs commands and proposes changes. sandboxd starts from nothing. It scaffolds a project from a preset, builds it in a fresh container, and gives you a URL to look at. What comes out is an ordinary React or FastAPI application that needs no model to run.

So pick by what you want at the end. sandboxd is for starting from a sentence and keeping the code afterwards. The other two are for when the repository or the model-powered product already exists.

The other difference is age, and it is the one to weigh before you build anything real on top of it.

ChartGitHub stars and forks, read from the GitHub API on 4 August 2026
The data behind this chart
[
  {
    "tool": "sandboxd",
    "github_stars": "875",
    "forks": "50"
  },
  {
    "tool": "OpenHands",
    "github_stars": "83,091",
    "forks": "10,711"
  },
  {
    "tool": "Dify",
    "github_stars": "151,320",
    "forks": "23,886"
  }
]

sandboxd holds 875 stars against 83,091 for OpenHands and 151,320 for Dify. The repository was created on 3 June 2026, so it is two months old as of August 2026, while OpenHands dates from March 2024 and Dify from April 2023. Release v0.1.0 shipped on 6 June 2026 and v0.3.6 on 1 August 2026. The project calls itself beta and says 0.x releases can break compatibility. Read those numbers as dependency risk rather than as a verdict on quality: a two month old project has had two months of other people finding its bugs.

What the server needs, and what breaks when it is short

The project says 2 vCPU and 4 GB of RAM is enough to start. That is accurate for the control plane plus one small sandbox, and it is not enough for two people building at the same time. Budget the memory in parts. Traefik and the Go control plane are small. Each running sandbox holds a full Node or Python toolchain, and the peak is an npm install followed by a production build. Plan on 8 GB for a box that will keep a few apps alive, and treat swap as a safety net rather than capacity, because a build that swaps takes minutes instead of seconds.

When memory runs out you get two different failures, and they look nothing alike. Inside a sandbox, the container reaches the hard --memory ceiling that sandboxd sets and the kernel kills the largest process, so the build dies with no useful message from the agent. docker ps -a shows exit code 137 for that container and docker inspect on it reports "OOMKilled": true. A Node build that dies this way often prints JavaScript heap out of memory first.

The second failure happens on the host. sandboxd runs a pressure reaper that stops sandboxes when host memory runs low, so on a small box a sandbox can disappear while you are watching its preview. The files are safe and the next request to the preview URL wakes it, but a task that was running when the container stopped does not resume.

Disk is the quieter problem. Every app keeps its own workspace on the host, and a JavaScript project carries a node_modules tree in the hundreds of megabytes. Ten apps is several gigabytes of dependencies before you count the images. Start at 40 GB and watch it:

docker system df
sudo du -sh /var/lib/sandboxed/workspaces

The default data directory is /var/lib/sandboxed, spelled with the extra e. Typing /var/lib/sandboxd gets you an empty directory and a confusing five minutes.

Install a pinned sandboxd release

Docker Engine with the Compose plugin, plus git, must be on the box first. Installing Docker on a VPS covers that side.

docker compose version
git --version

Both must print a version. docker: 'compose' is not a docker command means you have the old standalone docker-compose binary, and the installer expects the v2 plugin.

The installer is a shell script fetched over the network, so read it before running it, and pin the version.

curl -fsSL https://raw.githubusercontent.com/tastyeffectco/sandboxd/v0.3.6/install.sh -o install-sandboxd.sh
less install-sandboxd.sh
SANDBOXD_REF=v0.3.6 bash install-sandboxd.sh

SANDBOXD_REF is the git ref the installer checks out into $HOME/.sandboxd/src, and it defaults to main. Leaving it unset means your install is whatever was merged that morning, which matters on a project that shipped six releases in July 2026 alone. Pin it, then upgrade on purpose after reading the changelog.

The script clones the source, builds the images, starts the stack with docker compose up -d, and prints the console URL and an API token at the end. Save that token somewhere safe. It is the credential for an API that drives Docker as root.

curl http://127.0.0.1:9090/healthz

That prints ok when the control plane is up. If it prints nothing, the stack did not start: run docker compose ps from ~/.sandboxd/src to see which service is down, then docker compose logs sandboxd to see why.

Reaching the console on a remote box

The console is served through Traefik on HTTP_PORT, which is 80 by default, at the hostname http://console.localhost. Traefik routes on the hostname, so putting your server's IP address in the browser matches no rule and returns a 404. Until you set a real domain, forward the port and keep the hostname:

ssh -L 8080:127.0.0.1:80 you@your-vps

Then open http://console.localhost:8080 on your laptop. On Linux and macOS any name ending in .localhost resolves to 127.0.0.1, so the request goes down the tunnel with the right Host header. Set the console password on first visit.

Give the agent a model

Two coding agents ship in the base image: OpenCode and Claude Code. SANDBOXD_DEFAULT_AGENT decides which one runs a task that does not name one, and it defaults to opencode. With no key connected at all, tasks run on OpenCode Zen's keyless free models, so your first build costs nothing and you can test the whole loop before spending anything.

Connect your own key when you want a stronger model. Keys go to the control plane, never into the sandbox: they are stored encrypted under the data directory and injected on the wire by a credential proxy, so neither the agent nor the code it writes can read them.

export API=http://127.0.0.1:9090
export SANDBOXD_TOKEN=sk_...                       # printed by the installer
export AUTH="Authorization: Bearer $SANDBOXD_TOKEN"

curl -s -XPOST $API/v1/agents/claude-code/api-key -H "$AUTH" \
  -H 'content-type: application/json' \
  -d '{"api_key":"sk-ant-..."}'

The console does the same under Settings, AI Agents, including a guided OAuth flow if you want to use a Claude subscription instead of an API key. The default model per agent lives in the same panel, and a single task can override it.

Build one small app end to end

Create the app, boot its sandbox, then send a prompt. The ids come back as JSON, and the quickstart pulls them out with sed so you do not need jq installed.

APP=$(curl -s -XPOST $API/v1/apps -H "$AUTH" \
  -H 'content-type: application/json' \
  -d '{"name":"todo","runtime_preset":"react-vite"}' \
  | sed -E 's/.*"id":"([^"]+)".*/\1/')

SB=$(curl -s -XPOST $API/v1/apps/$APP/sandbox -H "$AUTH" \
  -H 'content-type: application/json' -d '{"ports":[3000]}' \
  | sed -E 's/.*"id":"([^"]+)".*/\1/')

echo "app=$APP sandbox=$SB"

Both variables must hold an id. An empty $SB means the sandbox never booted, and the usual cause is the base image still building or the host being out of memory. A 401 in place of an id means the bearer token is wrong.

curl -s -XPOST $API/v1/sandboxes/$SB/tasks -H "$AUTH" \
  -H 'content-type: application/json' \
  -d '{"prompt":"Add a todo list with a text input, an add button, and a delete button on each row. Keep the list in localStorage.","agent":"opencode"}'

The response carries a task id. GET /v1/sandboxes/$SB/tasks/<task id> returns its result, and the /events path on the same task is a live SSE (server sent events) stream of what the agent is doing. The console shows the same stream as a chat.

The app is then at http://s-<sandbox id>-3000.preview.localhost, where 3000 is the port you asked for. If the sandbox was asleep, the first request lands on Traefik's catch-all, sandboxd starts the container, waits for the port to answer, and serves a short warming page that refreshes into your app. A preview that never leaves that page means the process inside is not listening on the port declared in the app's sandbox.yaml.

Put the previews on a real domain with HTTPS

Every sandbox gets its own hostname, so one wildcard DNS record covers all of them. Point *.preview.yourdomain.com at the server's IP address with an A record. Then set the preview variables in .env in ~/.sandboxd/src:

PREVIEW_DOMAIN=yourdomain.com
PREVIEW_ENTRYPOINT=websecure
PREVIEW_TLS=true
SANDBOXD_API_AUTH_DISABLED=false

Traefik needs the matching half: enable the websecure entrypoint in traefik/traefik.yml and add a certificate resolver. Use the DNS-01 challenge, because one wildcard certificate then covers every preview hostname. With HTTP-01 each new sandbox would need its own issuance, and a busy afternoon of building walks straight into Let's Encrypt rate limits. Wildcard certificates through the DNS-01 challenge covers the DNS side of that.

cd ~/.sandboxd/src
docker compose up -d

Preview URLs become https://s-<id>-3000.preview.yourdomain.com. Open 80 and 443 on the firewall and leave 9090 closed to the world: see basic ufw firewall rules. Remember that anyone who can guess a preview hostname can load the app, so treat previews as public.

Where does the generated code land, and can you export it?

On the host, under the data directory. Each workspace is a plain directory at /var/lib/sandboxed/workspaces/<id>/, bind mounted into the container, and the app's files sit at /home/sandbox/workspace/app inside the sandbox. Control plane state is a single SQLite file at state/sandboxd.db, and encrypted agent credentials are in agent-auth/. Nothing is hidden inside a container layer, so a backup is a directory copy plus that database file. restic backups on a VPS handles both.

sudo ls /var/lib/sandboxed/workspaces
sudo du -sh /var/lib/sandboxed/workspaces/*

Git export is built in rather than bolted on. The API exposes status and diff for reading, then commit and push:

curl -s $API/v1/apps/$APP/git/status -H "$AUTH"

curl -s -XPOST $API/v1/apps/$APP/git/commit -H "$AUTH" \
  -H 'content-type: application/json' \
  -d '{"message":"todo list, first pass"}'

curl -s -XPOST $API/v1/apps/$APP/git/push -H "$AUTH" \
  -H 'content-type: application/json' -d '{"branch":"main"}'

A private remote needs a personal access token, set once in the console under Settings, Git credentials. It is stored encrypted and stays out of the sandbox, so the agent cannot read it or push with it behind your back. Push early and often. Until you do, the workspace directory is the only copy of the code, and DELETE /v1/apps/<id> removes it without a second chance.

What does a build cost in model tokens?

sandboxd does not meter your spend, so the number that matters is in your provider's console. The free OpenCode Zen models cost nothing and are slower and weaker than a paid model, which shows up as more rounds of correction on anything past a toy app.

The shape of the bill follows how an agent loop works. Each turn resends the context it needs, so cost tracks the number of turns, not the number of apps. One prompt that lands is cheap. Fifteen rounds of "now fix the spacing" against a project with fifty files is not, because the file contents ride along each time. Input and output tokens are priced differently, and what a coding agent costs per session gives the realistic range. Set a hard spend limit at the provider before you hand the keys to a loop that runs unattended.

Cleaning up stale sandboxes

The idle reaper stops any sandbox that has been idle past SANDBOXD_IDLE_THRESHOLD_SECONDS, which defaults to 2100 seconds, or 35 minutes. That returns the RAM and keeps the files, and the next request to the preview URL wakes the container. Lower it on a small box, because 35 minutes of idle containers is 35 minutes of memory you cannot use.

Stopping is not deleting, and this is where disks fill up quietly. A stopped sandbox still owns its workspace and its container. Removing the sandbox but keeping the app is a DELETE on the sandbox, which takes the container and the workspace with it. Removing the app takes everything, permanently.

curl -s -XPOST $API/v1/sandboxes/$SB/stop -H "$AUTH"     # frees RAM, keeps files
curl -s -XDELETE $API/v1/sandboxes/$SB -H "$AUTH"        # container and workspace gone
curl -s -XDELETE $API/v1/apps/$APP -H "$AUTH"            # app and everything under it

After a few weeks of experiments, docker system df will show more reclaimable image space than you expect, because every app that pulled its own toolchain left layers behind. docker image prune clears the dangling ones. Check GET /v1/apps first, since an image still referenced by a sleeping sandbox is not garbage.

What the container boundary does and does not give you

Each sandbox runs as an unprivileged user with a read only root filesystem, all Linux capabilities dropped, no-new-privileges set, a memory ceiling and a process limit. The project is honest about the limit of that: a shared kernel Linux container is a strong isolation boundary and a weak security boundary. A kernel bug is a host compromise.

Two facts deserve action. Network egress from a sandbox is open in the self-hosted build, so generated code can reach the internet, your local network and cloud metadata endpoints. An nftables egress subsystem exists in the source but is compiled off in the portable Docker Compose build, which means the limits have to come from your host firewall. And the control plane API is effectively host root, because it drives the Docker socket. It binds to 127.0.0.1:9090 by default, SANDBOXD_API_AUTH_DISABLED must stay false, and it should never be published to the internet.

If you plan to let other people send prompts to your box, that model is too weak on its own. The project points at gVisor with SANDBOXD_RUNTIME=runsc, which puts a userspace kernel between the sandbox and the host and costs roughly 1.7 to 4 times slower syscall heavy work. The stronger answer is one machine per tenant, which is the same argument as running coding agents in a disposable VM.

Should you build on a two month old project?

For a personal build box, yes, with the obvious precautions: pin SANDBOXD_REF, back up /var/lib/sandboxed, and push every app you care about to a git remote. For anything a customer touches, wait for 1.0 or budget for breakage, because the maintainers say plainly that 0.x can change under you. The maintainers also sell a managed install at 79 dollars a month as of August 2026, which is worth knowing when you are judging whether the project has a reason to keep existing.

The reason the risk is tolerable is the output. sandboxd produces an ordinary application in an ordinary git repository, so if the project stalls you keep the code and lose only the wrapper. That is a much better position than a hosted builder that owns your project. For the wider view of what earns a place on your server this year, see what is worth self-hosting in 2026.

FAQ

What are the minimum server specs for sandboxd?

The project states 2 vCPU and 4 GB of RAM is enough to start, which covers the control plane, Traefik and one small sandbox. Use 8 GB and 40 GB of disk if you want several apps alive at once, because each running sandbox holds a full Node or Python toolchain and each workspace keeps its own dependency tree on disk. When the host runs short, sandboxd's pressure reaper stops sandboxes to free memory, and a build that exceeds its container's memory ceiling is killed by the kernel: docker ps -a shows exit code 137 for it.

How is sandboxd different from Dify or OpenHands?

They produce different artifacts. Dify builds applications that call a model at runtime, such as chat interfaces and retrieval pipelines. OpenHands edits a repository you already have, running commands and proposing changes to existing code. sandboxd scaffolds a brand new project from a prompt, builds it inside its own container, and serves it at a preview URL, and the result is an ordinary web application that does not need a model to run.

Where does the code the agent writes actually live?

On the host filesystem, not inside a container image. Each app gets a directory at /var/lib/sandboxed/workspaces/<id>/ which is bind mounted into its sandbox, and the files appear at /home/sandbox/workspace/app inside. Control plane state is a single SQLite file under state/ in the same data directory. You can commit and push to a git remote from the console's Git tab or through the /v1/apps/<id>/git/commit and /git/push endpoints, and a token for private remotes is stored encrypted by the control plane rather than handed to the sandbox.

Is it safe to expose sandboxd to the internet?

Expose the preview URLs and the console, never the control plane API. That API drives Docker on the host, so it is equivalent to root, and it binds to 127.0.0.1:9090 by default for that reason. Sandboxes also have open network egress in the self-hosted build, meaning code the agent writes can reach your local network and cloud metadata endpoints, so add host firewall rules if the box has neighbours worth protecting. For prompts from people you do not trust, run one host per tenant rather than relying on the container boundary.

#sandboxd#ai-agents#self-hosted#app-builder#docker