dexterdev8

writing / / 5 min / infra · agents

The pipeline was longer than the app

Every small thing I built needed a deploy pipeline before anyone else could look at it. SpaceDock is what I built so that step stops existing.


I write a lot of small things. A scraper that summarises a feed, a dashboard for one number I care about, a toy that exists to settle an argument. Most of them take an evening.

Then comes the part that takes longer than the thing itself: a Dockerfile, a registry, a CI workflow, the same four secrets pasted into another provider's settings page, a host, a DNS record. None of it is hard. It is just long, it is the same every time, and all of it has to happen before anyone but me can look at what I made.

So most of them never got shown to anyone. The setup cost more than the idea was worth, and the idea quietly expired in a folder.

What I actually wanted #

Three things, none of them clever. A URL I can paste into a chat now, not after a pipeline goes green. No build step that I have to keep working six months from now. And once it is up, it stays reachable without me thinking about it again.

That is the entire brief. SpaceDock is what came out of it.

Nine tools, no pipeline #

I write most of these things with an agent now, so deploying should be something the agent can do too, rather than a handover where I go and click things. SpaceDock has one interface: tool calls. Nine of them.

deploy takes a directory and comes back with a URL, or with the errors. It is synchronous, so there is nothing to poll, no build log to tail, and no dashboard anyone has to be told to check. The whole loop fits on one line:

deploy → read status → crashed? read errors + log_tail → fix → deploy

A deploy that crashes never gets traffic, so the previous version keeps serving. That is the part that makes it safe to run unattended: a wrong guess costs a deploy, not the URL.

The tool I underestimated is screenshot — the rendered page, plus console errors and failed requests. A 200 is not a working page, and that gap is precisely what a status code cannot tell you.

Nothing to build #

There is no build step because there is no image to push. Files are injected into a shared Bun image that is already running, so TypeScript and JavaScript run as written.

A compiled binary gets in through the same door: the runner honours scripts.start, so Rust and Go run as shipped.

{ "scripts": { "start": "./server" } }  // the built binary sits beside it

Three things have to line up: the binary is built for linux/x86-64, a package.json names it, and it is actually in the bundle — that last one is the one everybody gitignores. Strip it, too. A stripped Rust server measures 227 KB where the same thing in Go, symbols left in, is 4.6 MB of the 50 MB budget.

Asleep is not off #

The last thing I wanted was for it to stay up, and it does — with one honest asterisk. The URL is up all the time. The machine is not.

An app with no requests for fifteen minutes is snapshotted and its container stops. The next request wakes it, and the proxy holds that request open for about a second instead of failing it. Waking is not redeploying: the bundle and the installed dependencies are still on disk, so it goes straight to starting the process. A held-open connection counts as activity, so a stream is never slept out from underneath you.

Sleeping costs a file on disk rather than a machine. That is the only reason five apps can be free and stay free, which is the difference between a free plan and a trial.

Two things to know before you put anything on it. The URL is the only access control — the suffix is unguessable, but there is no password, so anyone with the link has the app. And it is Bun or a binary you compiled yourself, on 256 MB and half a CPU, with no WebSockets. It was built for the thing you want to show someone this evening, not for the thing someone else depends on.

The docs are written for the agent rather than for you, which is the point. Setup is one paste, and there is a Claude Code plugin if that is where you work.