From a083ba01793809a48b77d977279f32182cb3545d Mon Sep 17 00:00:00 2001 From: Rajan Bhattarai Date: Wed, 29 Jul 2026 21:20:24 +0545 Subject: [PATCH 1/8] fix(web): prune incidents for apps removed from config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An app dropped from the config no longer appears in Status(), so its open "down" incident never resolved — it lingered forever as an active incident (e.g. "Notesnook — down 1.4h" long after the app was removed). checkIncidents now prunes stale health tracking and incident history for apps no longer present, on each detection pass. Control-plane incidents (App == "") are kept. Co-Authored-By: Claude Opus 4.8 --- internal/web/server.go | 26 +++++++++++++++++++++++++ internal/web/server_test.go | 39 +++++++++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+) diff --git a/internal/web/server.go b/internal/web/server.go index 65e08ce..f421012 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -499,6 +499,14 @@ func (s *Server) checkIncidents() { "Docker is reachable again.\n\n" + now.Format(time.RFC1123) + link}) } } + present := make(map[string]bool, len(apps)) + for _, a := range apps { + present[a.Name] = true + } + // An app dropped from the config no longer appears in Status(); prune + // its stale health + any open/closed incidents so a removed app can't + // linger as a permanently-"down" incident (it never recovers to resolve). + s.pruneAbsent(present) for _, a := range apps { if a.Worker { continue @@ -534,6 +542,24 @@ func (s *Server) checkIncidents() { } } +// pruneAbsent drops health tracking and incident history for apps not in the +// present set (i.e. removed from the config). Control-plane incidents (App == "") +// are always kept. Must be called under s.mu. +func (s *Server) pruneAbsent(present map[string]bool) { + for name := range s.health { + if !present[name] { + delete(s.health, name) + } + } + kept := s.incidents[:0] + for _, in := range s.incidents { + if in.App == "" || present[in.App] { + kept = append(kept, in) + } + } + s.incidents = kept +} + // openIncident records a new open incident for app+kind, unless one is already // open for that app (dedup). Newest first, capped. func (s *Server) openIncident(app, kind, detail string, at time.Time) { diff --git a/internal/web/server_test.go b/internal/web/server_test.go index eef0223..62bdd87 100644 --- a/internal/web/server_test.go +++ b/internal/web/server_test.go @@ -343,6 +343,45 @@ func TestIncidentDetectionAndNotify(t *testing.T) { } } +func TestIncidentPrunedWhenAppRemoved(t *testing.T) { + f := &fakeController{} + s := NewServer(f, "") + + // Baseline healthy, then the app goes down → one open incident. + f.statuses = []runner.AppStatus{{Name: "gone", State: "running", HTTP: "200", Reachable: true}} + s.checkIncidents() + f.statuses = []runner.AppStatus{{Name: "gone", State: "exited"}} + s.checkIncidents() + + s.mu.Lock() + open := 0 + for _, in := range s.incidents { + if in.App == "gone" && in.Resolved.IsZero() { + open++ + } + } + s.mu.Unlock() + if open != 1 { + t.Fatalf("want 1 open incident for gone, got %d", open) + } + + // The app is removed from the config: Status() no longer lists it. A + // stale down incident must not linger (the notesnook-down-1.4h bug). + f.statuses = []runner.AppStatus{{Name: "other", State: "running", HTTP: "200", Reachable: true}} + s.checkIncidents() + + s.mu.Lock() + defer s.mu.Unlock() + for _, in := range s.incidents { + if in.App == "gone" { + t.Fatalf("incident for removed app should be pruned, still have %+v", in) + } + } + if _, ok := s.health["gone"]; ok { + t.Error("health entry for removed app should be pruned") + } +} + func TestIncidentsPageAndMarkRead(t *testing.T) { f := &fakeController{} s := NewServer(f, "") From 7bfb2d807685bcbb954809952565b96d69f0200b Mon Sep 17 00:00:00 2001 From: Rajan Bhattarai Date: Wed, 29 Jul 2026 21:25:10 +0545 Subject: [PATCH 2/8] feat(web): drag-and-drop reorder of app cards in grid mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Grid-mode app cards are now draggable. The new order persists to panel settings (Settings.Order) and list mode respects it too, so the two views never drift. groupApps sorts each category bucket by the saved order — unranked apps keep their config order, after the ranked ones. Reordering is constrained to within a category: a card can only be dropped in its own category's container, and because bucketing is by the config category, a name's position in Order can never move an app to a different category. New guarded POST /order handler persists the order; the client re-renders from the same server template. Co-Authored-By: Claude Opus 4.8 --- internal/web/server.go | 113 +++++++++++++++++++++++++++++++++--- internal/web/server_test.go | 100 ++++++++++++++++++++++++++++++- internal/web/settings.go | 19 ++++++ 3 files changed, 222 insertions(+), 10 deletions(-) diff --git a/internal/web/server.go b/internal/web/server.go index f421012..ca21130 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -144,18 +144,41 @@ type appGroup struct { } // groupApps buckets apps into Main apps / Utilities / Workers by category, -// preserving input order within each bucket and omitting empty buckets. An -// unknown or empty category falls back to Main apps. -func groupApps(apps []runner.AppStatus) []appGroup { +// omitting empty buckets. An unknown or empty category falls back to Main apps. +// Within each bucket apps are sorted by their position in order (the user's +// drag-and-drop ordering); apps not listed in order keep their input order and +// sort after the ordered ones. Because bucketing is by category, order can only +// rearrange apps within a category — never move one to another category. +func groupApps(apps []runner.AppStatus, order []string) []appGroup { + rank := make(map[string]int, len(order)) + for i, n := range order { + if _, ok := rank[n]; !ok { + rank[n] = i + } + } buckets := map[string][]runner.AppStatus{} for _, a := range apps { - buckets[groupTitle(a.Category)] = append(buckets[groupTitle(a.Category)], a) + t := groupTitle(a.Category) + buckets[t] = append(buckets[t], a) } var out []appGroup for _, title := range []string{"Main apps", "Utilities", "Workers"} { - if apps := buckets[title]; len(apps) > 0 { - out = append(out, appGroup{Title: title, Apps: apps}) + bucket := buckets[title] + if len(bucket) == 0 { + continue } + sort.SliceStable(bucket, func(i, j int) bool { + ri, oki := rank[bucket[i].Name] + rj, okj := rank[bucket[j].Name] + if oki && okj { + return ri < rj + } + if oki != okj { + return oki // a ranked app sorts before an unranked one + } + return false // both unranked: stable keeps input order + }) + out = append(out, appGroup{Title: title, Apps: bucket}) } return out } @@ -707,6 +730,7 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("POST /app/up", s.guard(s.handleAppAction("starting", s.ctrl.StartApp))) mux.HandleFunc("POST /app/down", s.guard(s.handleAppAction("stopping", s.ctrl.StopApp))) mux.HandleFunc("POST /app/featured", s.guard(s.handleToggleFeatured)) + mux.HandleFunc("POST /order", s.guard(s.handleReorder)) mux.HandleFunc("POST /add", s.guard(s.handleAdd)) mux.HandleFunc("POST /deploy", s.guard(s.handleDeploy)) mux.HandleFunc("POST /remove", s.guard(s.handleRemove)) @@ -860,6 +884,39 @@ func (s *Server) handleToggleFeatured(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/", http.StatusSeeOther) } +// handleReorder persists the user's drag-and-drop app ordering. Guarded — it +// mutates persisted settings. The body carries a comma-separated `order` of app +// names (grid-mode DOM order). Category grouping stays authoritative, so this +// only affects within-category order; it can never move an app to another +// category. Replies 204 (the client re-renders from its own state; no redirect). +func (s *Server) handleReorder(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, "bad form", http.StatusBadRequest) + return + } + var order []string + for _, n := range strings.Split(r.FormValue("order"), ",") { + if n = strings.TrimSpace(n); n != "" { + order = append(order, n) + } + } + s.mu.Lock() + cur := s.settings + cur.Order = order + cur = cur.Normalize() + s.settings = cur + store := s.store + s.mu.Unlock() + + if store != nil { + if err := store.Save(cur); err != nil { + http.Error(w, "save failed: "+err.Error(), http.StatusInternalServerError) + return + } + } + w.WriteHeader(http.StatusNoContent) +} + // handleSettingsPage renders the settings form in the panel shell. func (s *Server) handleSettingsPage(w http.ResponseWriter, _ *http.Request) { s.renderPage(w, "settings") @@ -1264,7 +1321,7 @@ func (s *Server) buildStatusView() statusView { apps := data.apps view.DockerOK = true view.Apps = apps - view.Groups = groupApps(apps) + view.Groups = groupApps(apps, view.Settings.Order) view.Total = len(apps) var used, capacity float64 for _, a := range apps { @@ -1447,7 +1504,7 @@ var statusTmpl = template.Must(template.New("status").Funcs(template.FuncMap{ roost control - + @@ -1688,6 +1745,9 @@ var statusTmpl = template.Must(template.New("status").Funcs(template.FuncMap{ .glist.grid .srv{border:1px solid var(--line);border-radius:14px;padding:18px;gap:14px;background:var(--panel2)} .glist.grid .grouphdr{padding-left:4px} .glist.grid .srv-top{flex-wrap:wrap;align-items:center} + .glist.grid .srv[draggable="true"]{cursor:grab} + .glist.grid .srv.dragging{opacity:.45;cursor:grabbing} + .glist.grid .srv.dragging *{pointer-events:none} .glist.grid .srv-idb{flex:1 1 55%} .glist.grid .srv-acts{flex-basis:100%;justify-content:flex-start;margin-top:2px} .srv-top{display:flex;align-items:flex-start;gap:12px} @@ -2385,6 +2445,8 @@ var statusTmpl = template.Must(template.New("status").Funcs(template.FuncMap{ function apply(v){ document.querySelectorAll(".glist").forEach(function(e){e.classList.toggle("grid",v==="grid")}); document.querySelectorAll("[data-view]").forEach(function(b){b.classList.toggle("active",b.dataset.view===v)}); + // Cards are only draggable in grid mode (reorder is a grid affordance). + document.querySelectorAll(".glist .srv").forEach(function(s){s.draggable=(v==="grid")}); } apply(localStorage.getItem(KEY)||((window.__roostCfg&&window.__roostCfg.view==="grid")?"grid":"list")); document.querySelectorAll("[data-view]").forEach(function(b){ @@ -2572,6 +2634,41 @@ var statusTmpl = template.Must(template.New("status").Funcs(template.FuncMap{ .then(function(){refresh(true);}) .catch(function(){}); },true); + // Drag-and-drop reorder (grid mode). A card can only be dropped within its own + // category container (.glist) — never into another category. On drop the new + // global DOM order POSTs to /order and is persisted, so list mode respects it + // too. Listeners are delegated on document, so they survive the live refresh. + var dragEl=null, srcList=null; + document.addEventListener("dragstart",function(e){ + var s=e.target.closest(".srv"); if(!s||!s.closest(".glist.grid"))return; + // Don't hijack a drag that starts on an interactive control (buttons/links). + if(e.target.closest("a,button,input,summary")){e.preventDefault();return;} + dragEl=s; srcList=s.closest(".glist"); s.classList.add("dragging"); + e.dataTransfer.effectAllowed="move"; + try{e.dataTransfer.setData("text/plain",s.dataset.app||"");}catch(_){} + }); + document.addEventListener("dragover",function(e){ + if(!dragEl)return; + var list=e.target.closest(".glist"); if(!list||list!==srcList)return; // within-category only + e.preventDefault(); + var over=e.target.closest(".srv"); + if(!over||over===dragEl)return; + var b=over.getBoundingClientRect(); + // Insert before the hovered card when the cursor is in its upper/left half. + var before=e.clientY Date: Thu, 30 Jul 2026 09:03:41 +0545 Subject: [PATCH 3/8] docs(web): document grid drag-and-drop reorder + incident auto-prune README + site now cover the two recent panel changes: drag-and-drop card reordering in grid view (persisted to panel.json, honoured in list, within a category only), and automatic pruning of incidents for apps removed from the config (so a deleted app can't linger as a permanently-open "down" incident). Co-Authored-By: Claude Opus 4.8 --- README.md | 9 +++++++-- site/index.html | 5 +++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 5587a68..4106876 100644 --- a/README.md +++ b/README.md @@ -392,7 +392,9 @@ it does: **share** buttons (copy / X / LinkedIn / Facebook) that post a one-line status summary. A background monitor re-checks every app on a configurable interval (**default 2 min**) and opens an incident — with details — even with no browser - open. Optional **email alerts** (SMTP; the password comes from + open. An app you've since **removed from the config** is pruned from incident + tracking on the next check, so a deleted app never lingers as a permanently-open + "down" incident. Optional **email alerts** (SMTP; the password comes from `$ROOST_SMTP_PASSWORD`, never config); the sidebar keeps a **Test alert** button. Click any app for a **detail drawer** — image, restarts, env **key names**, that app's own **incident history**, and a recent-log tail. The public @@ -405,7 +407,10 @@ it does: and **tech-stack label overrides** (`rails=Ruby on Rails`). - **Comfort** — a **Material Design 3** interface (tonal surfaces, ripples, elevated cards) in light / **dark**; search, **filter chips** with a friendly - empty state, **list / grid** views, a **⌘K command palette**, and fully + empty state, **list / grid** views with **drag-and-drop reordering** in grid + (grab a card and drop it into place — the order persists to `~/.roost/panel.json` + and is honoured in list view too; you can only reorder **within** a category, + never move an app to another one), a **⌘K command palette**, and fully mobile-responsive. **Exposing it.** Set the top-level `control_host:` in `config.yml` and roost diff --git a/site/index.html b/site/index.html index 9d9713c..86c09f4 100644 --- a/site/index.html +++ b/site/index.html @@ -296,8 +296,9 @@

Material 3, comfortable

A Material Design 3 dashboard in light and dark: reachability chips (live·200 vs 502), a dedicated incidents page with optional email alerts, a ⌘K command - palette, filter chips, and list or grid views. Mobile-responsive — no - front-end to maintain.

+ palette, filter chips, and list or grid views — drag cards to reorder + in grid (persisted, and honoured in list, within each category). + Mobile-responsive — no front-end to maintain.

Yours, gated

From 8317e4c230b13ee2910dd1a38e4686a30d151d26 Mon Sep 17 00:00:00 2001 From: Rajan Bhattarai Date: Thu, 30 Jul 2026 09:04:51 +0545 Subject: [PATCH 4/8] =?UTF-8?q?docs:=20add=20"Where=20to=20run=20it=20?= =?UTF-8?q?=E2=80=94=20laptop,=20server,=20or=20both"=20setup=20section?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Consolidates the run modes into one section after the quickstart: local (the default + roost enable), an always-on server (copy config/credentials, or the lighter remote: ssh option), and — new — running a dev laptop and a prod box at the same time. The two-machine case documents the one hard rule (one cloudflared per tunnel) and the pattern that makes it work: a separate tunnel + distinct hostnames per environment (e.g. everest vs everest-local), with a note that the two stacks keep isolated data on purpose. Co-Authored-By: Claude Opus 4.8 --- README.md | 49 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/README.md b/README.md index 4106876..8b483c9 100644 --- a/README.md +++ b/README.md @@ -345,6 +345,55 @@ Once published: `go install github.com/cdrrazan/roost/cmd/roost@latest`. --- +## 🏠 Where to run it — laptop, server, or both + +roost is a Go binary driving Docker, so it runs anywhere Docker does. Three shapes: + +**On your laptop (default).** Follow the quickstart, then `roost enable` to bring +the stack up at every login. Apps are live while the machine is awake — see +*the honest part* above. + +**On an always-on server.** Install roost + Docker on the box, copy +`~/.roost/config.yml` and `~/.roost/credentials` over, run `roost up`, then +`roost enable` (plus `loginctl enable-linger ` on a headless Linux box so +the units start with no interactive login). The tunnel is **outbound** — no ports +to open, and **no DNS change** when the box's IP changes; Cloudflare finds it by +the tunnel token. Lighter option: keep roost on your laptop and run only the +containers on the box with `remote: ssh://user@box` in `config.yml`. + +**Both at once — a dev laptop *and* a prod box.** Two connectors sharing **one** +tunnel split traffic between them (a request randomly hits whichever answers +first → intermittent 502s). To run both machines simultaneously, give each its +**own tunnel** and its **own hostnames**: + +```yaml +# Prod box — ~/.roost/config.yml +tunnel: + name: rserver +# apps resolve to everest.example.com +``` + +```yaml +# Dev laptop — ~/.roost/config.yml +tunnel: + name: rserver-local +# same apps, but everest-local.example.com +``` + +The two tunnels are independent, so **both stay live with zero conflict**: +`everest.example.com` is your always-on prod copy, `everest-local.example.com` +is the one you hack on. Point each hostname's DNS at its own tunnel (a wildcard +per suffix for prod; the `-local` names get their own records for the dev tunnel +— exact records win over a wildcard). The one rule: **one cloudflared per +tunnel**. + +> **Data does not cross between the two.** Each environment has its own Docker +> volumes (its own Postgres/MySQL), on purpose. If an app ships its own sync +> (e.g. a notes app with a sync server), point each client at whichever +> environment you want — roost keeps the two stacks isolated. + +--- + ## 🖥️ Web control panel — `roost web` `roost web` serves a small **dashboard** so you can run the whole fleet from a From 09270eb75cde1a2600565a646e5d4ee2483b2b94 Mon Sep 17 00:00:00 2001 From: Rajan Bhattarai Date: Thu, 30 Jul 2026 09:05:38 +0545 Subject: [PATCH 5/8] =?UTF-8?q?docs:=20FAQ=20=E2=80=94=20keeping=20roost?= =?UTF-8?q?=20in=20sync=20across=20laptop=20and=20box?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New entry covering how repo changes reach both installs: the binary locally via go install / build, the binary on the box via the deploy-web.yml auto-deploy on merge to main (build → scp → install → restart roost-web), and app source via roost deploy. Clarifies that only roost-web auto-restarts on the box — a stack change still needs roost up / roost deploy. Co-Authored-By: Claude Opus 4.8 --- README.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/README.md b/README.md index 8b483c9..228681f 100644 --- a/README.md +++ b/README.md @@ -764,6 +764,30 @@ the pull is fast-forward-only, a force-push or diverged branch surfaces as a failed deploy rather than a silent bad merge. +
+How do I keep roost itself in sync on my laptop and my box? + +Two moving parts — the roost **binary** and each app's **source**: + +- **The binary, locally.** After pulling this repo, `go install ./cmd/roost` + (installs to `~/go/bin`), or `go build -o roost ./cmd/roost && sudo install -m + 0755 roost /usr/local/bin/roost`. Restart `roost web` so the panel process picks + up the new binary. +- **The binary, on the box.** A merge to **`main`** triggers + [`.github/workflows/deploy-web.yml`](.github/workflows/deploy-web.yml): it builds + for the box's CPU arch, `scp`s the binary over a deploy key, installs it, and + restarts the `roost-web` systemd `--user` service. Nothing by hand — set the + `DEPLOY_SSH_KEY` / `DEPLOY_HOST` / `DEPLOY_USER` repo secrets once (see the + workflow header). So the loop is **commit to `develop` → PR → merge to `main`**, + and the box updates within a minute or two. +- **App source (not roost).** `roost deploy ` on the host does a `git pull + --ff-only` + rebuild of that one container — see the two entries above. + +Laptop and box are separate installs of the same tool; keeping them in step is +"`go install` here, merge-to-`main` there." Only `roost-web` is auto-restarted on +the box — a change to the running *stack* still needs a `roost up` / `roost deploy`. +
+
How do I keep it running after a reboot, with no one logged in? From 00546b0e73a1dae6627a031a58e6881822dafc97 Mon Sep 17 00:00:00 2001 From: Rajan Bhattarai Date: Thu, 30 Jul 2026 09:06:27 +0545 Subject: [PATCH 6/8] docs(configuration): note panel.json featured + order fields The Settings-page/panel.json section enumerated email, view, theme, mask, interval, and tech labels but not the click-set fields. Add featured pins (featured:) and the grid drag-and-drop card order (order:), both written by the panel rather than hand-edited. Co-Authored-By: Claude Opus 4.8 --- docs/configuration.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/configuration.md b/docs/configuration.md index 6cbc4d0..95d8bb8 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -62,7 +62,11 @@ apps: [...] # see below **tech-stack label overrides** (one `key=Label` per line, e.g. `rails=Ruby on Rails`). Saving rebuilds email delivery in place — no restart. `config.yml`'s `notify:` block still works as a fallback when the settings - page hasn't set an SMTP host. + page hasn't set an SMTP host. The panel also persists two things you set by + clicking, not typing: your **featured pins** (the star toggle, `featured:`) + and, in grid view, your **drag-and-drop card order** (`order:`, honoured in + list view too, reorderable within a category only). Both are written by the + panel — you don't edit `panel.json` by hand. - **Share status** — the Incidents page has copy / X / LinkedIn / Facebook buttons that post a one-line summary of the current status plus the `/status` link. From f8618f4d3c6262e70799221d1553c79e35c89a9f Mon Sep 17 00:00:00 2001 From: Rajan Bhattarai Date: Thu, 30 Jul 2026 09:31:31 +0545 Subject: [PATCH 7/8] docs: add developer & ops runbook (docs/runbook.md) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A command-first, copy-paste runbook — the "technical notes" for day-to-day work: git workflow + keeping a fork current, syncing the roost binary on the laptop (go install) and the box (deploy-web.yml on merge to main), adding apps (roost add / --repo / panel), updating from GitHub (roost deploy / Pull & redeploy / manual git pull + rebuild), the forked-app recipe (root Dockerfile, framework override, the Postgres role-on-existing-volume gotcha with the exact psql commands), common ops (rebuild/recreate/caddy reload/psql/disk), and the two-environment layout. Linked from the README Project section. Co-Authored-By: Claude Opus 4.8 --- README.md | 3 + docs/runbook.md | 165 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 168 insertions(+) create mode 100644 docs/runbook.md diff --git a/README.md b/README.md index 228681f..65280ec 100644 --- a/README.md +++ b/README.md @@ -807,6 +807,9 @@ is the supervisor. - **[Examples](examples/)** — runnable configs from minimal to every-knob, plus a [demo with fake data](examples/demo/config.yml) and an [`include` walkthrough](examples/includes/). +- **[Runbook](docs/runbook.md)** — copy-paste developer & ops commands: git + workflow, syncing the binary local + box, adding/updating apps (incl. a forked + app with its own Dockerfile + Postgres), and common ops. - **[Website](https://roost.app.rsynk.com)** — one-page overview ([source](site/)). - **[Ops scripts](scripts/)** — running a fleet on an always-on box: an encrypted backup (DB dumps + `age`-encrypted secrets → R2) and a one-shot bootstrap that diff --git a/docs/runbook.md b/docs/runbook.md new file mode 100644 index 0000000..03a23f7 --- /dev/null +++ b/docs/runbook.md @@ -0,0 +1,165 @@ +# roost runbook — developer & ops notes + +Copy-paste commands, grouped by task. `` = app name, `` = host dir, +`` = git URL, `` = FQDN. Superuser for Postgres is **`roost`**. + +- [Git workflow](#git-workflow) +- [Sync the roost binary (local + box)](#sync-the-roost-binary-local--box) +- [Add an app](#add-an-app) +- [Update an app from GitHub](#update-an-app-from-github) +- [Forked app: own Dockerfile + Postgres](#forked-app-own-dockerfile--postgres) +- [Common ops](#common-ops) +- [Two environments (laptop dev + box prod)](#two-environments-laptop-dev--box-prod) + +--- + +## Git workflow + +```bash +git switch -c feat/x develop # never commit to main +# ...edit; TDD: failing test first... +go test ./... && gofmt -l . && go vet ./... # must be clean +git commit -m "feat(x): ..." # conventional commits +git push -u origin feat/x # PR -> merge to develop; then PR develop -> main +``` + +Keep a fork current with upstream: + +```bash +git remote add upstream # once +git fetch upstream +git switch main && git merge --ff-only upstream/main +git push origin main +``` + +## Sync the roost binary (local + box) + +```bash +# --- local --- +git pull --ff-only +go install ./cmd/roost # -> ~/go/bin/roost +# or system-wide: +go build -o roost ./cmd/roost && sudo install -m 0755 roost /usr/local/bin/roost +# macOS: relaunch `roost web` to pick it up + +# --- box (automatic) --- +# merging to `main` triggers .github/workflows/deploy-web.yml: +# build for box arch -> scp -> install -> restart roost-web +# nothing to run by hand once DEPLOY_SSH_KEY / DEPLOY_HOST / DEPLOY_USER secrets are set. +``` + +## Add an app + +```bash +# detected framework (rails|next|django|flask|laravel|node|static) +roost add --domain +roost up + +# clone a GitHub repo — roost owns the checkout under ~/.roost/sources/ +roost add --repo --name --domain +roost up + +roost list # resolved apps + URLs +roost detect # framework + the signal that triggered it +``` + +Panel: **Add app** form takes a GitHub URL *or* a host path (not both), gated by +`roost doctor`. + +## Update an app from GitHub + +```bash +roost deploy # git pull --ff-only + rebuild + restart just +# panel: app menu -> "Pull & redeploy" == same thing + +# manually-cloned fork (own Dockerfile, NOT added with --repo): +git -C pull --ff-only +cd ~/.roost/build && docker compose -p roost up -d --build +docker exec roost-caddy-1 caddy reload --config /etc/caddy/Caddyfile +``` + +## Forked app: own Dockerfile + Postgres + +The `memos` / `joplin` pattern — a stack roost doesn't detect and/or its own build. + +```bash +# 1. source on host (shallow clone is fine on a box) +git clone --depth 1 + +# 2. root Dockerfile — roost only detects a file literally named "Dockerfile" +cp /Dockerfile.server /Dockerfile # if the real build file is elsewhere +# build must be self-contained (whole app in-image). If a repo .dockerignore +# excludes a package you need, add /Dockerfile..dockerignore. + +# 3. app entry -> ~/.roost/apps/.yml +# framework: node # override skips detection; root Dockerfile builds it +# port:

# app's listen port (must bind 0.0.0.0) +# database: postgres +# migrate: false # app self-migrates on boot +# env: +roost generate + +# 4. Postgres role: auto-created ONLY on a fresh volume. +# Existing volume (any prior app) => create by hand with roost's exact line: +grep -A1 '' ~/.roost/build/postgres-init.sql +docker exec roost-postgres-1 psql -U roost -c "CREATE ROLE LOGIN CREATEDB PASSWORD 'rp_';" +docker exec roost-postgres-1 psql -U roost -c 'CREATE DATABASE "" OWNER ;' +# password is deterministic: rp_ + sha256("roost-pg:")[:24] +# -> copy it from postgres-init.sql so it matches DATABASE_URL + your env: + +# 5. build + start + route +cd ~/.roost/build && docker compose -p roost up -d --build +docker exec roost-caddy-1 caddy reload --config /etc/caddy/Caddyfile +``` + +Static front-end SPA (server URL set in-app, not baked): serve its `dist/` as a +`framework: static` app at its own host — no port, no db. If it calls the backend +cross-origin, the backend must send CORS for the SPA's origin. + +## Common ops + +```bash +# stack +roost up ; roost down ; roost status ; roost logs [] -f +roost start ; roost stop ; roost restart + +# rebuild ONE app's image (env/Dockerfile change) +cd ~/.roost/build && docker compose -p roost up -d --build + +# recreate ONE app WITHOUT rebuild (env-only change) +cd ~/.roost/build && docker compose -p roost up -d + +# caddy reload after a route change +docker exec roost-caddy-1 caddy reload --config /etc/caddy/Caddyfile + +# Postgres (superuser = roost) +docker exec roost-postgres-1 psql -U roost -tc "SELECT rolname FROM pg_roles;" +docker exec roost-postgres-1 psql -U roost -d -c '\dt' + +# panel: a category: change only shows after a restart (categories read at startup) +systemctl --user restart roost-web # Linux box + +# DNS / tunnel for the standard (wildcard) case +roost tunnel setup # tunnel + all DNS records via API + +# disk (box) +df -h / ; docker system df ; docker builder prune -f +``` + +## Two environments (laptop dev + box prod) + +Run both at once — **separate tunnel + hostnames per machine**, never two +connectors on one tunnel. + +```text +box ~/.roost/config.yml : tunnel.name rserver apps -> app.example.com +mac ~/.roost/config.yml : tunnel.name rserver-local apps -> app-local.example.com +``` + +```bash +ssh -i ~/.ssh/oracle-roost ubuntu@ # reach the box +``` + +Rule: **one cloudflared per tunnel**. Each env has isolated Docker volumes (its +own Postgres/MySQL) — data does not cross; use an app's own sync if you need it. +See [README → Where to run it](../README.md#-where-to-run-it--laptop-server-or-both). From 0a07d858ba78c6dd7daa4edb95781260f5c92716 Mon Sep 17 00:00:00 2001 From: Rajan Bhattarai Date: Thu, 30 Jul 2026 09:36:17 +0545 Subject: [PATCH 8/8] docs(site): link the developer & ops runbook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add docs/runbook.md to the site — in the resources line under Commands and in the footer nav — alongside the config reference and examples. Co-Authored-By: Claude Opus 4.8 --- site/index.html | 2 ++ 1 file changed, 2 insertions(+) diff --git a/site/index.html b/site/index.html index 86c09f4..aa336fe 100644 --- a/site/index.html +++ b/site/index.html @@ -398,6 +398,7 @@

Command reference

Full schema and hostname rules: configuration reference · + copy-paste commands: developer & ops runbook · runnable configs: examples, including a fully-populated demo.

@@ -598,6 +599,7 @@

Rajan Bhattarai

GitHub Examples Config reference + Runbook Roadmap Contributing Security