02 / Optional plugin
Holodex
One Go binary on a rented server. It accepts signed source from Vela, builds it, and runs it in a container with hard resource caps and no internet — then deletes everything once a day.
novaoc/holodex ↗Optional. Vela runs perfectly well without this — she still researches, writes, charts, and publishes GitHub repositories. Holodex only answers the question “can I click it?”. Add it when you want verified builds to end in a live URL instead of a clone command; skip it and /request simply finishes at the repository. Nothing else in the harness depends on it.
Adding it to your Vela
Holodex needs a box you already own, with Docker and a domain pointed at it. It runs Docker on the host, so it mounts the socket — give it a machine you are willing to let build arbitrary code.
# on the server
make linux
docker network create --internal holodeck-net
scp holodex-linux-amd64 root@<server>:/srv/holodeck/bin/holodex
docker run -d --name holodex --restart unless-stopped \
-v /var/run/docker.sock:/var/run/docker.sock \
-v /srv/holodeck:/srv/holodeck \
-e HOLODEX_TOKEN=<deploy bearer> \
-e HOLODEX_BUILD_SECRET=<shared HMAC secret> \
-e HOLODEX_DOMAIN=demo.example.com \
<image> /srv/holodeck/bin/holodex
Then point a wildcard *.demo.example.com at the box through a reverse proxy that asks /api/tls-check before minting certificates, and tell Vela about it in her env file:
VELA_SANDBOX_URL=https://api.example.com
VELA_SANDBOX_TOKEN=<same as HOLODEX_TOKEN>
VELA_SANDBOX_SECRET=<same as HOLODEX_BUILD_SECRET>
Restart Vela and the deploy tools appear in her belt automatically. The build secret is what makes a deploy provably hers, so it lives on her machine and on the server — nowhere else, and never in a repository.
Shape of the program
Holodex is a plain net/http server with no framework and no database. State is the filesystem: each application is a directory holding its source and a meta.json. It orchestrates the host's Docker daemon by shelling out to the CLI, which is why it runs with the Docker socket mounted — and why its blast-radius discipline is written into the code rather than assumed.
POST /api/deploy inline files → {url, slug, expires}
POST /api/verify signed .tar.gz → {receipt, logs, …}
POST /api/deploy/archive signed .tar.gz + receipt → {url, slug, …}
GET /api/apps
DELETE /api/apps/{slug}
GET /api/tls-check (no auth — the ingress asks before minting a cert)
GET / routes <slug>.<domain> to the right app
Provenance
A bearer token proves the caller has a credential. It does not prove the bytes came from Vela's build pipeline, so every deploy also carries an HMAC-SHA256 signature made with a shared build secret. The signature covers canonical metadata and the raw body, so a captured archive cannot be re-submitted under a different name, port, or Dockerfile.
mac := hmac.New(sha256.New, s.buildKey)
io.WriteString(mac, p.canonical()) // holodex-archive-v1\nverify\n…
io.Copy(io.MultiWriter(f, mac, digest), limited)
The stream is hashed while it is written to disk, so the file, the signature, and the digest are computed in one pass over the body — the bytes are never read twice, and there is no window in which the file on disk differs from what was signed.
X-Holodex-* and the current canonical prefix, or with the legacy family and its own prefix. The two never mix — a legacy-prefix signature presented as the current family is refused, which is asserted by a test.
Verify before deploy
Verification builds the repository's Docker test target in a disposable workspace, then builds the final deployable image (normally reusing every expensive layer). Passing returns a receipt: an HMAC over a timestamp, the archive's SHA-256 digest, and the target.
func makeVerifyReceipt(key []byte, digest, target string, now time.Time) string {
payload := fmt.Sprintf("v1:%d:%s:%s", now.Unix(), digest, target)
…
}
Deployment recomputes the digest of the archive it just received and refuses unless the receipt matches it, was issued for the test target, and is under an hour old. Source that never passed its tests cannot be deployed, and bytes cannot be swapped after passing.
Treating archives as hostile
An uploaded tarball is untrusted input. The extractor strips the single synthetic top-level directory that GitHub adds, then refuses anything that could escape or smuggle host references: parent-directory traversal, absolute paths, symlinks and other special files, .git data, oversized expansion, and excessive file counts. Every destination path is re-checked against the workspace root after joining.
full := filepath.Join(dst, filepath.FromSlash(rel))
if !strings.HasPrefix(full, filepath.Clean(dst)+string(os.PathSeparator)) {
return total, files, fmt.Errorf("archive path escapes workspace: %q", h.Name)
}
Container lockdown
Building and running a demo means executing code, on a box that also runs unrelated production containers. Each application gets:
--cap-drop ALLand--security-opt no-new-privileges- hard
--memory,--cpus, and--pids-limitcaps - an internal Docker network with no internet egress at runtime — dependencies are fetched at build time only
- a cap on how many container apps may run at once
Equally important is what Holodex will touch. Removal is guarded by name prefix, so a bug cannot reach a neighbouring container:
func (s *server) stopContainer(name string) {
if name == "" || !owned(name, "holodex-app-", "holodeck-app-") {
return
}
…
}
Both prefixes are accepted so previews created before the rename stay removable — a cleanup path that silently stops recognising old resources leaks them forever.
Rails previews
When the source looks like a Rails application, the preview gets more than a container: its own PostgreSQL sidecar, its own volume, and freshly generated secrets that exist only for that demo. Nothing is read from, or written to, the application's public repository.
The preview environment deliberately contains no Stripe keys — not even fake-looking ones. A commerce demo runs the application's own local test-checkout simulator: no API call, no card data, no money. Real Stripe requires a self-hoster's own credentials on a host with egress.
RAILS_ENV=production
VELA_HOLODEX_PREVIEW=1
SECRET_KEY_BASE=… # generated per preview
DB_HOST=holodex-db-<slug>
APP_HOST=<slug>.demo.holode.xyz
SMTP_ADDRESS=holodex # only when the relay is configured
APP_HOST is injected so confirmation and password-reset links point at the live preview instead of a template placeholder.
The mail relay
Previews have no internet egress, so they cannot reach a mail provider — and handing each disposable app the provider's credentials would be worse. Instead Holodex runs a small SMTP listener on the internal network. Applications connect to it unauthenticated; Holodex rewrites the sender headers to its own verified identity and relays upstream over STARTTLS with credentials the app never sees.
It accepts one recipient per message, caps message size, and enforces a whole-deck daily delivery quota. Rewriting both From and Reply-To is what stops a preview from sending mail that appears to come from someone else.
Slots and the daily wipe
A fixed number of container apps run at once. When the deck is full, the least-recently-used app that has been idle past a threshold is put to sleep — container and image removed, files and URL kept, with a page explaining what happened. If every app saw traffic recently, the new deploy is refused instead of evicting someone mid-demo.
Everything is deleted at the daily wipe, and a periodic sweep clears anything older than 25 hours as a backstop in case the machine was down at the wipe hour. The repository is the permanent copy; the deck is not storage.
Ingress and TLS
Holodex sits behind Caddy and is the single ingress for *.demo.holode.xyz. Certificates are minted on demand, and Caddy asks Holodex before issuing one — /api/tls-check answers only for the base domain and for slugs that actually exist on disk, so the deck cannot be used to mint certificates for arbitrary hostnames.
Where the code lives
| File | Responsibility |
|---|---|
main.go | Server, routing and reverse proxy, build and run, slots, wipe, Docker helpers, blast-radius guards |
archive.go | Signed archive intake, canonical HMAC, receipts, safe tar extraction, verify and deploy handlers |
mailrelay.go | Internal SMTP listener, sender rewriting, upstream STARTTLS delivery, daily quota |
Under two thousand lines total, with no framework. The security posture is stated plainly at the top of main.go, including its own residual risk: a container is a limit, not a perfect boundary, and build steps run arbitrary code. This is a hobby demo deck sized accordingly — not a multi-tenant platform.