A minimal PaaS-in-a-box. Submit a Git URL or upload a tarball, and Brimble will build it with Railpack, run the resulting container with Docker, and expose it through Caddy at a stable URL — with logs streaming live to the UI the entire way.
It is intentionally small: one backend service, one frontend page, one ingress, one database. Everything you need to play with the build/deploy loop and nothing you don't.
# bring up the entire stack
docker compose up --build
# open the UI
open http://localhost:5173On the very first run, the backend-builder container compiles the
Encore service into a Docker image (one-shot, ~1–2 min). Subsequent
docker compose up calls are fast: the image is reused.
When the UI is open, drop in a Git URL and watch the logs stream. There
is also a sample app at examples/hello-node you
can deploy by uploading the tarred-up directory:
tar -cf hello-node.tar -C examples/hello-node .
curl -X POST --data-binary @hello-node.tar http://localhost:4000/deployments/upload- ✅
docker compose upbrings up the whole thing. Postgres, Caddy, the Encore backend (built on first run), and the Vite frontend. - ✅ Live log streaming over WebSocket. The backend exposes a
streaming endpoint (
api.streamOut) at/deployments/:id/logs. Each build, container start, and container stdout line is published through an in-process bus the moment it happens, fanned out to every active subscriber, and persisted in Postgres so you can scroll back later. - ✅ Railpack does the building. No hand-written Dockerfiles for
user apps. The pipeline shells out to
railpack build <src> --name <tag>and streams its output into the log bus. - ✅ Caddy is the only ingress. Backend talks to Caddy's admin API
on
:2019to install/update/remove a route per deployment. The routes are rewritten so user apps see clean paths.
┌────────────────────────────────────────────┐
│ Browser │
│ http://localhost:5173 │
└─────────────┬──────────────────────────────┘
│ REST + WebSocket
▼
┌──────────────────────────────────────────────────────┐
│ backend (Encore.ts) │
│ - POST /deployments git URL │
│ - POST /deployments/upload tarball │
│ - GET /deployments list │
│ - GET /deployments/:id detail │
│ - GET /deployments/:id/builds history │
│ - POST /deployments/:id/redeploy rebuild │
│ - WS /deployments/:id/logs live logs │
│ │
│ pipeline: source → railpack → docker run → caddy │
└─────┬──────────────────┬─────────────────────┬───────┘
│ pg docker.sock caddy admin
▼ ▼ ▼
┌──────────┐ ┌────────────────┐ ┌─────────────────┐
│ postgres │ │ user containers │ │ caddy (:8080) │
└──────────┘ │ brimble-<id> │◀───│ /d/<id>/* → │
└────────────────┘ │ container:port│
└─────────────────┘
-
Encore.ts for the backend. It gives type-safe endpoints, native WebSocket streaming (
api.streamOut), automatic Postgres provisioning and migrations, and a generated client for the frontend. The trade is that "build a docker image" requires a one-shot init container with the host docker socket. That's encapsulated cleanly inbackend-builderso the user still only typesdocker compose up. -
Postgres (the bundled
encoredotdev/postgresimage) for state. Two tables:deploymentandbuild, plus adeployment_logtable that backs the persistent log buffer. Nothing here demands Postgres specifically — the schema is plain SQL and SQLite would work — but Encore's first-class Postgres support made it the path of least resistance. -
Railpack as the builder. Replaces handwritten Dockerfiles with source detection. Output is a normal OCI image we can
docker run. -
Caddy as the ingress. The admin API makes per-deployment routes trivial: a single
POST /id/<route-id>adds or replaces a route, no config files to edit and no reload.
The most interesting bit. Three layers, each does one job:
lib/cmd.ts— a thinspawnwrapper that line-buffers stdout/stderr and invokes a callback per line. Used by every shell-out (git, railpack, docker run, docker logs).lib/logbus.ts— an in-process pub/sub keyed by deployment id. Every published line is persisted todeployment_logand fanned out to every subscribed stream. New subscribers can ask to replay from aseqcursor for scroll-back.api.tslogsendpoint —api.streamOut. On connect: replay history from the requested seq, then live-tail. Replay and live tail are de-duplicated by seq so subscribers can't miss or double-see a line.
This is single-process by design. Scaling out would mean swapping the
in-memory bus for Postgres LISTEN/NOTIFY (or a real broker), which is
a one-file change.
backend/ # Encore.ts service
deployments/
api.ts # public HTTP / WS surface
pipeline.ts # build/deploy orchestration
store.ts # DB queries
db.ts # SQLDatabase definition
encore.service.ts
lib/
cmd.ts # streaming spawn()
config.ts # env-driven runtime config
logbus.ts # in-process log fan-out + persistence
source.ts # git clone / tar extract
railpack.ts # railpack CLI wrapper
docker.ts # docker CLI wrapper
caddy.ts # caddy admin API client
migrations/
1_init.up.sql
Dockerfile # tools layer on top of encore image
infra.json # Encore self-host config (postgres, etc.)
caddy/
Caddyfile # admin-only bootstrap
examples/
hello-node/ # smallest deployable app
docker-compose.yml # the whole stack
frontend/ # Vite + TanStack one-pager (separate)
Defaults are baked in. Override via env vars (or a .env next to
docker-compose.yml):
| Var | Default | Purpose |
|---|---|---|
BRIMBLE_PUBLIC_HOST |
localhost |
Hostname Caddy is reachable at |
BRIMBLE_PUBLIC_PORT |
8080 |
Public ingress port |
BRIMBLE_DOCKER_NETWORK |
brimble |
Docker network for spawned containers |
BRIMBLE_APP_PORT |
3000 |
PORT baked into user containers |
BRIMBLE_IMAGE_PREFIX |
brimble/ |
Image namespace |
POSTGRES_PASSWORD |
brimble |
Postgres password |
- Authentication. Right now everything is open. Encore's
authHandlerwould slot in cleanly. - Build cache reuse. Railpack supports BuildKit's cache mounts;
exposing a persistent
/root/.cachevolume to the backend container would cut rebuild times dramatically. - Zero-downtime redeploys. Today's
stopAndRemovethenrunContainercauses a ~1 s outage. Better: start the new container, wait for a health check, swap the Caddy upstream, then drop the old container. - Scoped deploy URLs. Path routing (
/d/<id>/) is the simplest thing that works but breaks user apps with absolute paths in HTML. Subdomain routing via wildcard DNS (<id>.localhost) is one PR. - Tests. The pipeline modules are mockable —
cmd,caddy, anddockerare the seams I'd cover first.
- The custom layered Dockerfile /
backend-buildersidecar dance. It exists only becauseencore build dockerneeds the docker daemon at build time. If we eject and own the runtime ourselves we can drop a good 60 lines of orchestration. Worth it the day Encore stops paying for itself. - Persisting log lines individually in Postgres. For long-running
containers the
deployment_logtable will grow. A chunked layout (or pushing them out to S3/MinIO) is the right answer above toy scale.
docker compose up is the supported path. If you want to iterate against
encore run directly, the backend shells out to a few tools that you need
on your PATH:
| Tool | Why | Install |
|---|---|---|
git |
clone source repos | apt install git / brew install git |
docker |
run / stop user containers | https://docs.docker.com/get-docker/ |
railpack |
build OCI images from source trees | curl -fsSL https://railpack.com/install.sh | bash |
You also need a Caddy instance with the admin API on :2019 if you want
the routing layer (e.g. caddy run --config caddy/Caddyfile --adapter caddyfile).
Without it the build/deploy will succeed up to the routing step.
# in one terminal
cd backend && encore run
# in another
cd frontend && bun install && bun run devencore run provisions Postgres for you. The backend will fail with a
clear "X not found on PATH" message if any of the above tools are
missing.
cd backend && bun test(Currently a thin smoke layer — see "Things I'd do with another weekend".)
See BRIMBLE_FEEDBACK.md for the deploy URL and
write-up.
Roughly one focused weekday. Most of it on the pipeline and the
docker-compose dance around encore build docker. The endpoint surface,
log bus, and Caddy integration came together quickly; the Dockerfile is
the part that took the most "huh, does this actually work?" iterations.