roost.

Every app on your laptop, live on your own domain.

One config file with paths and hostnames. roost generates the Dockerfiles, Compose stack, Caddy routing, and Cloudflare Tunnel — and creates every DNS record via API. No files added to your repos, ever. Run it on your laptop or a small always-on box, and drive the whole fleet from a browser.

The whole configuration

You supply where each app lives and what hostname it answers on. Framework, port, database, start command, and memory cap are inferred — every one overridable, none required.

# ~/.roost/config.yml — minimal
domain: rajan.dev
apps:
  - ~/code/blog       # → blog.rajan.dev
  - ~/code/shop       # → shop.rajan.dev
# …or with every knob
control_host: control.rajan.dev  # route this host to the web panel
tunnel:
  name: roost
  access:
    emails: [rajan@rajan.dev]
include: apps/*.yml   # split a big fleet across files
apps:
  - path: ~/code/trackaru
    domain: trackaru.com   # bare apex, own zone
    category: main         # panel grouping: main | utility
    profile: extras       # start on demand
    seed: true            # migrate + seed on up (once)
    migrate: false       # image self-migrates at boot
    redis: true           # shared redis + REDIS_URL (auto-detected)
    build_env:            # build-time env
      SKIP_ENV_VALIDATION: "1"
  - path: ~/code/trackaru   # a background worker over the same code
    worker: true          # no domain, no route
    command: bundle exec sidekiq

How it works

One tunnel, one wildcard DNS record per suffix, host-header routing inside. That architecture is why adding an app is a purely local change with zero API calls.

DetectGemfile? package.json? manage.py? — framework, port, DB
GenerateDockerfiles, compose.yml, Caddyfile → ~/.roost/build
Rundocker compose, staggered starts, memory caps
RouteCaddy matches the Host header → app container
Exposecloudflared + wildcard DNS, TLS at the edge

What gets inferred from a bare path

Signal in the folderFrameworkPortStart command
Gemfile + config/application.rbrails3000puma, bound to 0.0.0.0
Gemfile + config.ru + sinatrasinatra4567rackup
package.json with nextnext3000npm run start
package.json with vitestatic80built, served by Caddy
package.json with expressnode3000npm run start
manage.py + requirements/pyprojectdjango8000gunicorn
index.html, no manifeststatic80served by Caddy

Also inferred: runtime version, database need (one shared MySQL/Postgres, one database per app), and memory caps. Detection is explainable — roost detect prints the exact signal — and never guesses silently.

Run the whole fleet from a browser

roost web serves a control panel — start, stop, add, and remove apps and watch health at a glance. It runs as a host process outside the stack, so stopping the apps can never take down the thing that restarts them. Set control_host: to route a hostname through the tunnel, put Cloudflare Access in front, and reach it from anywhere.

See everything

Apps grouped into Main / Utilities / Workers, each with a live status pill and a colour-coded memory bar. A needs attention strip surfaces anything that isn't running, over metric cards for running, memory, and stopped.

Control anything

Start or stop the whole stack, or a single app. Add one from a path — the panel runs roost doctor first, then builds and starts just that app, streaming each step. Remove drops it and keeps it for one-click re-add; shared databases are never touched.

Comfortable

Search, a status filter, list or grid views, light and dark mode, and it's mobile-responsive. The dashboard you'd expect — generated for you, no front-end to maintain.

Yours, gated

Loopback (127.0.0.1:4600) by default, with a --token bearer check on the actions. Behind Cloudflare Access it's a private control plane for your fleet — the Add form builds real code, so it never faces the open internet.

60-second quickstart

Prerequisites: Docker, and a domain on Cloudflare. You create one API token — roost automates every other Cloudflare step, including all DNS records. There is no "add this CNAME in your dashboard" step.

$ brew install cdrrazan/tap/roost
$ roost init          # picks your domain from your live zone list
$ roost auth login    # paste the API token (init links the page + scopes)
$ roost doctor        # every failure comes with a specific fix
$ roost tunnel setup  # tunnel + ALL DNS records via API
$ roost up            # build, start, route — live on HTTPS
$ roost enable        # come back up at every login

Everyday usage

A day in the life after setup: check what's running, tail a log, park heavy apps in a profile, and let the doctor explain anything odd.

Status & logs

$ roost status
APP STATE HEALTH MEMORY URL
blog running healthy 212MiB / 512MiB https://blog.rajan.dev
shop running healthy 198MiB / 512MiB https://shop.rajan.dev
trackaru running healthy 301MiB / 768MiB https://trackaru.com
 
$ roost logs blog -f
blog | Puma starting in single mode...
blog | * Listening on http://0.0.0.0:3000

Add, remove, profiles

$ roost add ~/code/api --domain api.rajan.dev
added ~/code/api to ~/.roost/config.yml
$ roost up
 
$ roost up --profile extras
# heavy apps only start when you ask
 
$ roost remove api
removed api from ~/.roost/config.yml

When something's off, the doctor says exactly what and how to fix it

$ roost doctor
ok docker: installed and running
ok config: ~/.roost/config.yml parses; 6 app(s) resolve to hostnames
warn zone:app.pending.dev: zone pending.dev is pending: nameservers haven't propagated yet
fix: wait for propagation (usually under 24h); records can be created meanwhile
FAIL ssl-depth: app1.demo.rajan.dev is 2 levels below zone rajan.dev; free Universal SSL
covers only ONE subdomain level, so browsers will get an opaque TLS error
fix: (1) flatten to app1.rajan.dev (free, recommended), (2) use a dedicated
one-level domain, or (3) enable Advanced Certificate Manager on rajan.dev

Command reference

CommandWhat it does
roost initinteractive setup; picks your domain from your live Cloudflare zone list, scans a folder for apps, writes explicit hostnames
roost add / removeedit the app list — your config comments are preserved
roost list / detectresolved apps and URLs; detection with the signal that triggered it
roost generatewrite ~/.roost/build/* without starting anything
roost up [--profile] [--reseed] [--no-seed] / downstart the whole stack (staggered, memory-capped), migrate + seed DB apps (--no-seed skips seeding for a clean start) / stop it
roost start / stop / restartbring one app up, take it down, or bounce it — without touching the rest of the stack
roost status / logsstate, health, memory vs cap, public URL; tail one app's logs
roost doctorpreflight everything — Docker, token scopes, zones, DNS shadowing, SSL depth — with a fix per failure
roost tunnel setupcreate the tunnel + every DNS record via API; idempotent, refuses to clobber records it didn't create
roost tunnel accessapply the Cloudflare Access policy across every suffix
roost auth loginverify and store the API token (~/.roost/credentials, 0600)
roost enable / disablerun at login via launchd (macOS) / systemd --user (Linux); fully removable

Full schema and hostname rules: configuration reference · runnable configs: examples, including a fully-populated demo.

Built-in guardrails

The failure modes that cost hours are encoded, tested, and checked by roost doctor.

No Rails redirect loops

Cloudflare terminates TLS and forwards plain HTTP; roost sets RAILS_ASSUME_SSL so force_ssl apps don't redirect forever.

The SSL depth trap

Free Universal SSL covers one subdomain level. Doctor flags hostnames nested deeper — before you hit an unexplainable browser TLS error.

No shadowed wildcards

An exact DNS record silently beats a wildcard. roost pre-flights every record and names the collision and both ways out.

No leaked databases

Databases and apps never publish host ports; everything rides the internal network. One shared MySQL/Postgres, one database per app.

Access before exposure

Hostnames leak via Certificate Transparency within hours. Set access.emails and every suffix gets an auth wall before first up.

Laptop-friendly

Memory caps per app, single-worker Rails defaults, staggered starts so six apps don't spike your CPU at once.

Multi-database Rails

A per-app database user so apps that connect as their own username and use Solid Cache/Queue/Cable (sibling <app>_* databases) just work.

Never a stale route

After (re)starting containers Caddy is reloaded and cloudflared refreshed on new routes — no empty responses from a dead upstream, no 404 on a just-added zone.

DB ready on up

Database apps are migrated every up; add seed: true (or a command) and they seed once — tracked in state.json, --reseed to repeat, --no-seed to skip for a clean start. No manual db:prepare after a fresh start.

How it compares

Label-driven tools automate tunnel routing for containers you already maintain. roost starts one step earlier: from source folders.

roostDockFlare / TunnelDockCoolify
Inputa list of source foldersrunning containers + labelsgit repos, web UI
Dockerfiles & Composegenerated for youyou write thembuildpacks / you
DNS strategyone wildcard per suffixper-hostname API recordsyour server's DNS
Adding an applocal change onlylabels + API callsUI / git push
Runs onyour laptopyour Docker hosta server you operate

FAQ — lifecycle & your data

Every database runs in the shared MySQL/Postgres container, and its files live in a named Docker volume (roost-mysql-data) — decoupled from the containers. Rebuilding images or recreating containers never touches it; only deleting the volume does.

If I stop Docker or close the laptop, do my apps go down?

Yes — the engine stops, so apps, Caddy, MySQL, and cloudflared all stop and every hostname goes unreachable. Your data is safe on disk. When Docker restarts, containers carry restart: unless-stopped and come back automatically if they were running when it stopped. Enable Docker Desktop's Start on login for hands-off recovery — roost has no daemon; Docker's restart policy is the supervisor.

Does roost down then up drop my databases?

No. down removes the containers, not the named volume; up recreates the containers and reattaches the same volume. Every database, user, and row is intact — no migrate, no re-seed. Same for an image rebuild after you change app code.

Which Docker cleanups are safe, and which wipe my data?

ActionDatabases
roost down / up / image rebuildkept
docker system prune -fkept — named volumes untouched
docker compose down -vdropped
docker volume rm roost-mysql-datadropped
docker system prune --volumes / --all --volumesdropped
Docker Desktop → Troubleshoot → Clean / Purge dataeverything gone

To reclaim space without losing data, use docker system prune -f — it keeps named volumes. Clean / Purge data is the nuclear option: it wipes the entire Docker VM (all containers, images, and volumes), so every database and all demo data is gone and the next roost up rebuilds every image, recreates empty databases from mysql-init.sql, and needs a fresh migrate + re-seed. Back the volume up first if you want it reversible.

FAQ — running it for real

Can I control the whole stack from a browser?

Yes — roost web serves a control panel: a live status table with whole-stack Start apps / Stop apps buttons and per-row Start / Stop for each app. It runs as a host process outside the stack, so stopping the apps can't take down the thing that starts them back up. Per-app actions are name-checked against your config, so the panel can never toggle an infra container. It binds 127.0.0.1:4600 by default — expose it only behind Cloudflare Access (control_host: routes a hostname to it), with --token as defense-in-depth. An unprotected panel lets anyone who reaches it stop and start your stack.

Can I add or remove apps from the panel?

Yes. An Add an app form takes a host path (+ optional hostname); it runs roost doctor first and, only if preflight passes, edits the config, regenerates, and builds + starts just the new app — the Processing pane streams each step. Per-row Remove (with a free disk option to drop the image) stops the container, removes it from the config, and lists it under Removed apps for one-click re-add. Shared DB volumes are never touched, so data survives. The Add form builds any path you give it — keep the panel behind Cloudflare Access.

Does clicking "Stop apps" take the panel down too?

No. Stop stops only the app containers; Caddy and cloudflared — the proxy and tunnel — keep running, so the panel stays reachable and can bring the apps back. Start resumes the stopped containers rather than rebuilding, so a full stack returns in seconds. Only roost down in the terminal removes the whole stack, tunnel included.

Can I run roost on an always-on server, not my laptop?

Yes — nothing ties it to a laptop. It's a Go binary driving Docker, so it runs on any always-on VPS or cloud VM if you want the apps up 24/7. To move: install roost + Docker, copy ~/.roost/config.yml and credentials, restore your DB dumps, then roost up. The tunnel is outbound — no ports to open. Run cloudflared from one machine at a time; two connectors on the same token split traffic.

How do I ship a code change to a running app?

Push to the repo, then run roost deploy <app> on the host. It does a fast-forward-only git pull on that app's clone and rebuilds + restarts only that container, leaving the rest up. Diverged clone? The deploy fails loudly instead of silently merging. roost still only reads your repo — never writes to it.

Can I auto-deploy on a push to GitHub?

Yes — roost deploy is built to run from CI over SSH. A GitHub Actions workflow on push: branches: [main] SSHes to the box with a deploy key and runs roost deploy <app>. Fast-forward-only pull means a force-push or diverged branch surfaces as a failed deploy, not a bad merge.

How do I keep it running after a reboot, with no one logged in?

roost enable installs a boot unit — launchd on macOS, systemd --user on Linux — that runs roost up at login. On a headless Linux box, also run loginctl enable-linger <user> so units start at boot without a login. Docker must start on boot too (default on a server install); its restart: unless-stopped policy is the supervisor — roost has no daemon.

The honest part

On a laptop, this is not always-on hosting.

Your apps roost while the laptop is open and leave when it closes. Lid shut — apps down; lid open — cloudflared reconnects in seconds. For demos, previews, and personal projects, that's the whole point — and it says so plainly. Want 24/7? The same config runs unchanged on a small always-on box: copy config.yml and credentials, restore your database dumps, roost up, and drive the fleet from the web panel. Same tool, your call on where it lives.

Developer

Rajan Bhattarai

Creator & maintainer of roost — Ruby and Go developer building tools that keep side projects fun. roost exists because putting ten local apps online should be one config file, not ten YAML rituals.