Spunto Lite: the Build pillar, collapsed into one container you run yourself
I took the dev-environment half of Spunto — projects, disposable workers, VS Code in the browser, a persistent terminal — and packed it into a single self-hostable container that talks straight to your Docker socket. Here's what's in it, how it's wired, and the three things that were fiddlier than expected.
Mathieu
Founder, Spunto
Spunto is three pillars: Build (isolated dev environments), Ship (deploy Docker services), Run (watch them). That's a platform — organizations, roles, a fleet of nodes you connect, auth in front of all of it. Which is the right shape when several people and several machines are involved, and completely the wrong shape when what you actually want is "give me a clean container with an editor in it, on this laptop, now."
I kept wanting the second thing. Not a cloud account, not a node to register — just the Build pillar, on the machine already sitting in front of me. So I pulled it out into its own thing: Spunto Lite, a local dev-environment control plane in one container, talking directly to your own Docker socket. No multi-tenancy, no remote agents, no cloud in the loop.
docker run -d --name spunto-lite -p 80:80 \
-v /var/run/docker.sock:/var/run/docker.sock \
-v "$HOME/.ssh:/host-ssh:ro" \
-v spunto-lite-data:/app/data \
ghcr.io/spuntodotnet/spunto-lite:latestOpen http://localhost and it's there. Or clone the repo and docker compose up -d --build if you'd rather see it build. Three mounts, and each one is the whole story: the Docker socket is how it does anything at all, your ~/.ssh is how git push keeps working inside workers, and the named volume is where its SQLite file lives.
Two objects, same as the hosted platform. A project is a description of an environment: base image, devcontainer features, VS Code extensions, repos to clone, ports to forward, postCreate / postStart hooks. A worker is a disposable container built from that description, with code-server inside it so you get a real editor in a browser tab, plus a persistent terminal.
The parts that turn that from a toy into something I actually use daily:
- Starter templates — Next.js, Vite + React, Astro, FastAPI, Express, or a docker-compose project (that last one pulls in the
docker-in-dockerfeature, so you get a nested daemon). Each one scaffolds a working app in/workspaceand then runs it, so a fresh install goes from nothing to a live dev server without you writing a spec first. - Secrets, per project or per user, encrypted at rest and injected into the worker's environment.
- Your SSH identity, so commits and pushes from inside a worker are yours — and a per-project deploy key it can generate for you when a generic git remote needs one.
- Versioned project specs: editing a project bumps a version and pre-builds an image for it, so the next worker starts warm instead of re-running your
postCreatefrom scratch. - Forwarded ports on their own hostnames — your dev server shows up at
worker-<id>-3000.localhostwhile the editor stays atworker-<id>.localhost.
One Next.js app, served by a small custom Node server (server.ts) that does double duty: it reverse-proxies the worker subdomains and hosts the terminal WebSocket. dockerode talks to /var/run/docker.sock. SQLite (through Drizzle) holds projects, workers and secrets in a single file on a volume. That's the entire stack — no queue, no Redis, no Postgres, no agent process per machine.
The important consequence of going through the host's socket: workers are siblings, not children. They're plain containers on your daemon, so you can restart or upgrade the control plane without disturbing anything you had running — and if you ever want to poke at a worker with docker exec directly, nothing stops you.
Bind mounts don't mean what you think from inside a container. The control plane runs in Docker and asks the daemon to create workers, so every path in a worker's Binds is resolved by the daemon — on the host. A path that exists only inside the control plane's filesystem is meaningless to it: ask for it and you get an empty directory, silently. That's why your SSH keys take the long way around. The host ~/.ssh is mounted read-only into the control plane, which reads the key you selected and has the worker's setup script write it into ~/.ssh inside the worker, then generates a single ~/.ssh/config listing identities in a fixed order (your key first, the project deploy key second) so git-over-SSH doesn't fall through to the wrong one. More moving parts than a bind mount — but it's the only version that works from inside a container.
Isolating workers and proxying to them pull in opposite directions. Each worker gets its own bridge network, so two workers can't see each other's services — good default, and it means two projects can both bind port 3000 without a fight. But the control plane then can't reach them either, which breaks the whole point of worker-<id>.localhost. The fix is for the control plane to attach itself to each worker network as that worker comes up (and detach when it's torn down). It's a few lines, but it's the kind of thing you only discover when the first proxy request hangs.
*.localhost is free wildcard DNS, and it's the reason port 80 is the default. Chrome, Edge and Firefox all resolve anything under .localhost to 127.0.0.1 with no /etc/hosts editing and no dnsmasq — which is what makes per-worker subdomains work locally at all. The catch is that subdomain routing only feels free on the default port: run the control plane on :3900 and every worker URL grows a :3900, which is fine but noticeably less pleasant. You can override PORT; you just pay for it in every link.
Because the fifth time you type the same docker run with four mounts, a network, an env file and a postCreate script, you've written a control plane — just one that lives in your shell history and only works on your machine, in your head. Lite is that, made explicit: the spec is a record, the worker is reproducible from it, and the environment is a URL you can send to yourself on another device.
It also isn't trying to be DevPod or Codespaces. There's no provider abstraction and no cloud target — one machine, one daemon, a UI in front of it. That narrowness is what keeps it a single container instead of a toolchain.
Lite is one machine and one pillar. Once you want several machines, other people, deploys with real domains and TLS, or logs and stats over the whole thing, that's the hosted platform — same project-spec shape, so it's re-creating a project, not a migration.
The main thing I wanted was for the honest starting point to be free and local. Most environment tooling asks you to sign up before you can find out whether it fits how you work; this one asks for a docker run.
Repo's here: spuntodotnet/spunto-lite, MIT-licensed. Issues and PRs welcome.
Mathieu
Founder, SpuntoBuilding Spunto — self-hosted dev environments for engineers who want their compute to work for them, not against them.
