Skip to content
85 changes: 83 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <user>` 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
Expand Down Expand Up @@ -392,7 +441,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
Expand All @@ -405,7 +456,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
Expand Down Expand Up @@ -710,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.
</details>

<details>
<summary><b>How do I keep roost itself in sync on my laptop and my box?</b></summary>

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 <app>` 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`.
</details>

<details>
<summary><b>How do I keep it running after a reboot, with no one logged in?</b></summary>

Expand All @@ -729,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
Expand Down
6 changes: 5 additions & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
165 changes: 165 additions & 0 deletions docs/runbook.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
# roost runbook β€” developer & ops notes

Copy-paste commands, grouped by task. `<app>` = app name, `<path>` = host dir,
`<url>` = git URL, `<host>` = 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 <upstream-url> # 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 <path> --domain <host>
roost up

# clone a GitHub repo β€” roost owns the checkout under ~/.roost/sources/<name>
roost add --repo <url> --name <app> --domain <host>
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 <app> # git pull --ff-only + rebuild + restart just <app>
# panel: app menu -> "Pull & redeploy" == same thing

# manually-cloned fork (own Dockerfile, NOT added with --repo):
git -C <path> pull --ff-only
cd ~/.roost/build && docker compose -p roost up -d --build <app>
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 <url> <path>

# 2. root Dockerfile β€” roost only detects a file literally named "Dockerfile"
cp <path>/Dockerfile.server <path>/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 <path>/Dockerfile.<name>.dockerignore.

# 3. app entry -> ~/.roost/apps/<app>.yml
# framework: node # override skips detection; root Dockerfile builds it
# port: <p> # app's listen port (must bind 0.0.0.0)
# database: postgres
# migrate: false # app self-migrates on boot
# env: <app's OWN db vars pointing at roost Postgres, e.g. POSTGRES_* / *_DSN>
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 '<app>' ~/.roost/build/postgres-init.sql
docker exec roost-postgres-1 psql -U roost -c "CREATE ROLE <app> LOGIN CREATEDB PASSWORD 'rp_<derived>';"
docker exec roost-postgres-1 psql -U roost -c 'CREATE DATABASE "<app>" OWNER <app>;'
# password is deterministic: rp_ + sha256("roost-pg:<app>")[: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 <app>
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 [<app>] -f
roost start <app> ; roost stop <app> ; roost restart <app>

# rebuild ONE app's image (env/Dockerfile change)
cd ~/.roost/build && docker compose -p roost up -d --build <app>

# recreate ONE app WITHOUT rebuild (env-only change)
cd ~/.roost/build && docker compose -p roost up -d <app>

# 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 <app> -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@<box-ip> # 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).
Loading
Loading