Architecture
Grove is a Cargo workspace. A single long-running daemon binds the privileged ports (DNS 53, HTTP 80, HTTPS 443) and supervises runtimes and services. The CLI and GUI are thin clients that drive the daemon over a local Unix-socket JSON-RPC.
┌───────────┐ ┌───────────┐
│ grove-cli│ │ grove-gui│ (Tauri 2 + Svelte 5)
└─────┬─────┘ └─────┬─────┘
│ JSON-RPC (grove-ipc) over Unix socket
└───────────┬────────┘
┌─────▼──────┐
│ grove-daemon│ binds 53/80/443, serves IPC
└─────┬──────┘
┌───────────────┬──────────┼───────────┬───────────────┐
┌────▼────┐ ┌─────▼────┐ ┌───▼────┐ ┌───▼─────┐ ┌────▼─────┐
│grove-dns│ │grove-proxy│ │grove- │ │grove- │ │grove-os │
│ (*.test)│ │ + FastCGI │ │runtime │ │services │ │resolver/ │
└─────────┘ └───────────┘ │PHP/Node│ │DB/Redis │ │trust/svc │
└────────┘ │+ mail │ └──────────┘
└─────────┘
┌───────────┐
│ grove-core│ config, site registry, drivers (pure)
└───────────┘
Crates
| Crate | Responsibility |
|---|---|
grove-core |
Site registry, driver detection, TOML config, paths — and the primitives the privileged parts need: securefs (mode-at-open, O_NOFOLLOW), ownership (handing files to the user a sudo command acts for), checksum (SHA-256 verification), redact (stripping secrets out of logs). No port binding, no supervision. |
grove-ipc |
JSON-RPC protocol types + newline-delimited transport, and the client used by CLI/GUI. |
grove-tls |
Root CA generation, NameConstraints-scoped to the configured TLD, + on-demand leaf issuance and renewal (rcgen/rustls). |
grove-dns |
Embedded authoritative resolver for *.<tld> (hickory). |
grove-proxy |
HTTP/HTTPS listeners, per-driver dispatch, SNI cert resolution, and a minimal FastCGI client. |
grove-runtime |
PHP version management + lazy FPM pools; Node version management; project scaffolding; the bundled toolchain (Composer, Laravel installer) exposed by grove path. |
grove-services |
Bundled service manager (PostgreSQL/MySQL/Redis) + the SMTP mail-catcher + cross-dialect database conversion + point-in-time database snapshots. |
grove-tunnel |
Native public tunnels: grove share client + the self-hostable grove-tunnel server (yamux + hyper). |
grove-license |
Offline Ed25519 verification of Grove Pro/Teams license keys against a baked-in public key. |
grove-secrets |
End-to-end encrypted team secrets (age/X25519): identities, EnvSecrets, SecretStore (file mock + HTTP), SecretsClient, and the local recipient/version pins that keep the backend from choosing who can decrypt. |
grove-os |
Platform integration: resolver setup, trust store, OS service install, elevation checks. |
grove-daemon |
The long-running process: boots listeners, supervises runtimes/services, serves IPC. |
grove-cli |
clap frontend (binary grove). |
grove-gui |
Tauri 2 + Svelte 5 desktop app + macOS menu-bar icon. Hosts the Pro database client (reuses the e-db engine). |
Request flow
- A browser requests
https://myapp.test. - The OS resolver (configured by
grove-os) sends*.testtogrove-dns, which answers loopback. - The request hits
grove-proxyon 443. SNI selects/issues a leaf cert from the local CA. TheHostheader is matched against the site registry. - The site's driver decides handling: PHP → FastCGI to a lazily-started FPM pool for the site's PHP version; static → serve files; proxy → forward to the upstream dev server.
Protocols and upgrades
Both listeners speak HTTP/1.1 and HTTP/2, chosen per connection — by ALPN on
the TLS listener, by preface sniffing on the plain one. h2 requests carry the
host as :authority rather than a Host header; the handler reads either and
puts a Host into the header map so FastCGI's HTTP_HOST, the proxy driver
and the timeline all see it.
A request that asks to switch protocols (Upgrade: websocket with
Connection: upgrade) on a proxy-driver site bypasses the pooled upstream
client, which cannot hand a connection back: it gets a dedicated HTTP/1.1
connection to the upstream, and if that answers 101, both sides' upgraded
streams are copied bidirectionally until either closes. That is what lets Vite
HMR on a proxy site, Next/Nuxt dev servers and Reverb over wss:// work.
A secured site reached over plain HTTP is redirected to its HTTPS origin
(301 for GET/HEAD, 308 otherwise), with the configured HTTPS port in the
Location.
Bodies are streamed, not buffered
Neither direction is held whole in memory:
- Responses are forwarded as they arrive. Grove returns the headers as soon
as PHP flushes them and turns each FastCGI record into an HTTP chunk, so
Server-Sent Events and other long-lived streams work, and a large download
costs no memory. PHP sets no
Content-Lengthon a streamed response, so hyper selectsTransfer-Encoding: chunked. - Request bodies are streamed into
STDINwhen the length is known. Because CGI must be toldCONTENT_LENGTHbefore the body, a chunked request — which declares no length — is measured first: kept in memory up to 1 MiB, and spilled to a private0600spool file beyond that, removed as soon as the body is dropped. Bodies beyond 2 GiB are refused with413. - Reads and writes on the FastCGI connection proceed concurrently. Writing a whole body before reading any response would deadlock on a large upload that PHP rejects early.
The request timeline stores up to 1 MiB of a request body and flags the entry as
truncated beyond that, so grove replay and the curl / .http / Pest export see
smaller bodies in full.
What the PHP drivers serve
For a PHP site, an existing file under the document root is served directly (so
built assets under /build/ do not go through PHP), with two rules:
.phpand.phtmlfiles are executed, never served. Handing them back as text would disclose source, and WordPress addresses scripts directly —wp-login.php,wp-admin/*.php. The extension is matched case-insensitively, since a case-insensitive filesystem resolves/INDEX.PHPto the same file.- Dot-prefixed paths are refused with 404. A plain PHP project's document
root is the project root, so
/.envwould otherwise be readable..well-known/is exempt so ACME HTTP-01 challenges keep working.
Anything else falls through to the site's front controller, which receives the
request path as PATH_INFO.
Static files carry Accept-Ranges: bytes and answer single-range requests with
206, are gzipped when the client accepts it and the type compresses (with a
distinct ETag for that representation), and fall back to the root
index.html only for extension-less paths from clients that accept HTML — a
missing asset is a 404 that names the file, not the SPA shell as text/html.
Every error Grove generates itself — no site for the host, an upstream that refused the connection, no PHP for the version, a request too large — is an HTML page that states the situation and, where Grove knows it, the command that fixes it.
What is not repeated per request
A local dev proxy is asked the same questions over and over — the same assets on every reload, the same hostname on every connection — so the request path is built to answer them cheaply the second time:
- Upstream connections are pooled. One shared
hyperclient serves the proxy driver and replay. A client is the connection pool, so constructing one per request meant a fresh TCP handshake per request — and a Vite dev server saw one connection per asset instead of a few kept alive. - Static responses carry a validator. An
ETagis derived from the file's size and mtime, which astatalready provides, so no extra read is needed;If-None-Matchis answered with304.Cache-Control: no-cachemeans the browser always asks — an edit is never served stale — but an unchanged asset costs a round trip rather than a re-transfer. Files over 256 KiB stream from disk instead of being read into memory whole. - DNS answers are cacheable (
TTL 300). The answer is always loopback and never changes; aTTL 0forbade caching, which put the system resolver — and on macOSmDNSResponder— in the path to the first byte of every connection. - Filesystem checks are async. The existence checks on the PHP and static paths run on every request; issued as blocking syscalls from the request task they would stall a runtime worker on a slow volume, such as a network share or a Docker bind mount. Starting an FPM pool — which forks php-fpm and waits for its socket — runs on the blocking pool for the same reason.
Staying up
The daemon serves every site on the machine, so a failure in one request must not be able to end the process:
- Panics unwind. The release profile deliberately does not set
panic = "abort": with it, one panic anywhere takes down DNS, TLS and every site at once. Unwinding keeps the failure inside the tokio task that caused it. - The accept loop backs off (5 ms to 1 s) instead of retrying immediately.
Out of file descriptors,
acceptfails instantly and forever, so a bare retry is a busy loop that burns a core and never recovers. - Silent connections are bounded. A TLS handshake has 10 s and the request headers 30 s; without a deadline, a peer that connects and says nothing holds a task and a descriptor indefinitely.
Trust boundaries
Grove's shape used to follow from one fact: a part of it ran as root, because binding 53/80/443 needs root, and everything it supervised ran as you. That line is where the interesting failure modes lived, so it was drawn explicitly.
The line has moved. The service manager binds the ports and hands the
descriptors over, so the daemon runs as you from its first instruction. What
still needs root is one-off and visible: writing the unit, /etc/resolver, and
adding the CA to the system trust store — all of them steps in
sudo grove install or sudo grove ca trust, none of them a running process.
The privileged ports need not be bound by Grove
Binding a port below 1024 needs root; serving on one does not. launchd and
systemd will bind the port themselves, while they are root, and hand the
already-listening descriptor to the process they start. grove install writes
that into the unit — a Sockets dictionary in the launchd plist, a companion
grove.socket unit under systemd — naming the HTTP, HTTPS and DNS ports the
config asks for, with both halves of DNS, since a response too large for a
datagram is retried over TCP.
At startup the daemon asks for each port before binding it (grove_core::activation).
What it gets is an owned socket; what it does with it is exactly what it does
with one it bound. grove doctor reports which sockets arrived, so the
difference is visible rather than inferred:
✓ privileges http_port=80, elevated=false, sockets from the service manager: tcp/53, tcp/80, tcp/443, udp/53
Nothing requires the handover. Grove updates itself without rewriting the
unit, so a daemon that insisted on delivered sockets would break every install
the moment it shipped; an unmatched port is bound by the daemon as before. That
also means the two halves can be adopted independently, and that running
sudo grove install is the visible, deliberate step that switches a machine
over.
Nothing the daemon starts was ever root, and now neither is the daemon
The unit carries UserName (launchd) or User= (systemd), so the daemon is
the login user. Children — PHP-FPM pools, PostgreSQL, MySQL, Redis, grove dev
servers, Composer, the Laravel installer — simply inherit that.
There used to be a privilege-dropping layer here: every spawn went through
setgroups, setgid and setuid in a pre_exec hook, so a root daemon never
exec'd a binary out of a tree the user could write. 1.9.0 deleted it, and made
the daemon refuse to start as root instead. Without the drop, a root daemon
would exec PHP-FPM and the databases out of that tree, so not starting is the
only safe answer, and sudo grove install is the one-command fix. What remains
is grove_core::ownership: sudo commands create files as root and hand them
to the user who will read them.
The CA private key belongs to the user now
It used to be root:root 0600, which kept it away from code running as you.
The daemon signs leaf certificates, and the daemon is you, so the key is yours:
0600, owned by the run user.
That is a trade and worth stating plainly. Code running as you can read the key
and mint a certificate this machine will believe. Two things bound it: the CA
carries a NameConstraints extension limiting it to the configured TLD, which
every TLS client enforces and the grove-tls tests pin; and those names resolve
to loopback, so the key is worth nothing elsewhere. Against that, the same code
could previously reach a root daemon through the IPC socket, whose requests
include installing runtimes and services. Removing root removes that surface
outright, which is the larger half.
The IPC socket is the authorization boundary
Every privileged operation the daemon can perform is reachable through
run/groved.sock, which makes the socket — not any individual command — the
thing that has to be right:
- it is chowned to the run user and set to
0o660, so mode alone excludes other local users; - the daemon reads the peer's credentials (
SO_PEERCRED/LOCAL_PEERCRED) before it reads the request, so an unauthorized caller's bytes are never parsed; - an unauthorized peer gets an error response rather than a dropped connection, because a hangup is indistinguishable from a crashed daemon and sends people debugging the wrong thing.
Permitted: root (it can already do everything the daemon can), the daemon's own
uid, the configured run user, and the owner of $GROVE_HOME. Both of the last
two are needed, and neither alone would do: the owner covers a daemon someone
started by hand in their own tree, but after sudo grove install the owner is
root — so trusting only the owner would lock the user out of the daemon that
exists to serve them.
Two trees, two owners
| Tree | Owner | Holds |
|---|---|---|
$GROVE_HOME |
you (sudo grove install hands it over) |
config, runtimes, services, certs, sockets |
~/.grove |
you | identity, secret pins, cpx.phar, PATH shims |
The split still matters, but less sharply than it did. Anything recording
your trust decisions stays in ~/.grove, and nothing privileged reads from
there. $GROVE_HOME used to be the dangerous half: a tree you could write that
a root daemon executed out of, which is why php-builds.json — a JSON file
naming the php-fpm binary — was as privileged as root itself. The daemon that
reads it is now you, so writing that file buys an attacker nothing they did not
already have.
securefs and the grove doctor check that fails on a world-writable
$GROVE_HOME stay, because the tree still decides which binaries run.
Replacing a binary is the obvious attack; naming a different one is the cheaper
one. What changed is who runs them: another user on the machine, not the login
user becoming root.
Files are created with the mode they need
grove-core::securefs passes the mode to open(2) rather than chmod-ing
afterwards, so there is no window — however short — in which a freshly written
private key is world-readable. It also opens with O_NOFOLLOW and refuses a
symlink at the destination, so a planted link cannot redirect a privileged write
into a file of the attacker's choosing.
What is not trusted from the wire
- Downloads are verified before they are executed — SHA-256, against a document from the same publisher. The exceptions, and what the check does and does not prove, are in SECURITY.md.
- Forwarded headers are honoured only from loopback.
X-Forwarded-ProtoandX-Grove-Sitedecide how a request is routed and whether it looks like HTTPS to the app, so off-loopback they are ignored rather than believed. - A client-supplied
Proxy:header never becomes a CGI variable — that is httpoxy (CVE-2016-5385): asHTTP_PROXYit would redirect the application's outbound HTTP through an attacker's host. - The tunnel server refuses to start open.
grove-tunnelis the one component meant to face the public internet, so it requires--tokenor an explicit--allow-anonymous; it rejects reserved subdomains, overwritesX-Forwarded-Forwith the real peer, and puts a timeout on header reads.
Beyond native sites
- Docker / OrbStack —
grove-daemonpolls the Docker socket and merges running containers into the site registry asproxysites (label- or compose-based). They get the same trusted HTTPS + dashboard, and can be started/stopped over IPC. See DOCKER.md. - Public tunnels —
grove share(ingrove-tunnel) proxies a local*.testsite — native or container-backed — to a public tunnel server over a yamux-multiplexed connection. See TUNNEL.md. - Xdebug — when enabled, FPM pools are respawned with
-dXdebug INI overrides (trigger mode). See DEBUGGING.md. - Toolchain on PATH —
grove pathwrites read-only shims that resolve each project's pinnedphp/node/composerversion andexecit. Runtimes are provisioned by the (root) daemon (ProvisionToolchain) into the sharedruntimes/dir, so the user-run shims never need write access. The shims themselves live in~/.grove/bin(user-owned, added to PATH). - Database client — the GUI's Database panel reuses the
e-dbengine to browse/edit databases, auto-discovering connections from each site's.env. Free tier is read-only; editing + schema inspection are gated behind an active Pro license (client-side, since it's a local feature). See DATABASE.md. - Database snapshots —
grove dbdumps/restores the bundled MySQL / PostgreSQL via their own client tools, indexed undersnapshots/. - Reproducible environments —
grove upreads a project's committedgrove.toml(grove-core::ProjectFile) and orchestrates the existing daemon operations (link, isolate, node pin, service install/start, dev start) so a fresh clone comes up identically in one command. - Request timeline — the proxy handler records every request (method, path,
status, duration) into a bounded in-memory ring buffer in
grove-core(RequestLog), shared with the daemon sogrove requestsand the GUI panel can read it. Framework-agnostic; nothing is persisted to disk. What leaves the daemon is redacted —Authorization,Cookie,Set-Cookie, API-key headers, and query/body fields whose name looks like a credential are replaced with[redacted], sogrove request --as curl, the.http/Pest exports, the MCP tools and webhook payloads are all safe to paste. Replay is the deliberate exception: it reads the unredacted copy that never left the process, so a replayed request still authenticates. - Local HTTPS —
grove-tlskeeps the root CA that was generated on first run and trusted once, and signs per-site leaves from it on demand. The certificate it reports is the one on disk, which is the one the OS trust store was pointed at. The CA carries an X.509NameConstraintsextension permitting only.<tld>, so a machine that trusts it cannot be served a valid certificate forgoogle.comeven if the CA key leaks — the TLD it was constrained to is recorded inca-meta.json, and changingtldtherefore requiresgrove ca rotate. Leaves last 397 days and are re-issued once they are within 30 days of expiry, so a long-lived site does not silently serve an expired certificate.
Licensing & Teams (Grove Pro)
The free core is never gated; Pro/Teams features sit behind an entitlement.
- License keys are Ed25519-signed by the store (elyracode.com) and verified
offline by
grove-licenseagainst a baked-in public key.grove license activatestores the key at$GROVE_HOME/license.key(written by the root daemon); the daemon exposesrequire_pro/require_teamsgates. - Team secrets (
grove secret) are encrypted client-side (grove-secrets, age/X25519) to the current members' public keys.HttpStoretalks to the hosted, zero-knowledge backend, which stores only ciphertext + public keys and independently verifies the license + enforces seats (real enforcement is server-side, so the open client is safe to inspect). Your member identity lives at~/.grove/identity. See PRO.md.
Zero external dependencies
DNS, the reverse proxy, FastCGI and TLS are built into the Rust core (no
dnsmasq, nginx or OpenSSL). PHP, Node and the databases are downloaded as
self-contained binaries into $GROVE_HOME. The only host requirement for
scaffolding Redis from source / new Laravel projects is a C toolchain and
network access, which dev machines already have.
State on disk
Everything lives under one base directory ($GROVE_HOME, or the platform
default such as ~/Library/Application Support/Grove):
config.toml declarative source of truth
certs/ root CA + ca-meta.json + issued leaf certs (incl. certs/dev for Vite HTTPS)
runtimes/ PHP/Node builds, FPM configs, php-builds.json, composer.phar
services/ bundled DB/cache binaries + data dirs + state.json
snapshots/ database snapshots (SQL dumps) + index.json
logs/ per-service logs
run/ daemon IPC socket (groved.sock), pidfile, FPM/service sockets
ca-meta.json records the TLD the CA was name-constrained to; it is what lets
Grove notice that tld changed and tell you to grove ca rotate rather than
issue leaves the CA is not permitted to sign.
A second, user-owned tree holds the things root should have no part in — see Trust boundaries:
~/.grove/identity your age/X25519 secret-sync key pair (0600)
~/.grove/secrets/ per-project recipient + version pins
~/.grove/cpx.phar the Composer Package Executor
~/.grove/bin/ PATH shims written by `grove path`