How a request reaches your app
From the browser to your route block and back: one supervisor, one warm process per app, and one short-lived child process per request.
You do not need this page to write an app. You need it when you are wondering why an instance variable did not survive, why there is no background thread, or why the app took two milliseconds to answer the first request and a fifth of a millisecond after that.
The pieces
flowchart LR
browser["browser"] -- "HTTP" --> front
subgraph host["one Linux host"]
front["melee-server<br/>terminates HTTP, routes by Host:"]
warm["warm process<br/>your binary, sandboxed<br/>one per app"]
child["request child<br/>one request, then exit"]
worker["worker process<br/>durable objects and timers"]
data[("data/<br/>SQLite")]
front -- "a frame on a free slot" --> warm
warm -- "fork" --> child
child --> data
child -- "Call / Result" --> worker
worker --> data
end
cli["melee CLI<br/>on your machine"] -- "push: a tarball" --> front
Two programs make up the platform: melee-server, which runs on the host and owns every port, and melee,
the command-line tool you run on your own machine. Your app is a third program — a binary of its own — and
the melee library is compiled into it rather than running as a service.
Deploying
melee push tars up your directory and sends it to the server’s control API. The server does not trust
anything in the tarball except the Ruby, the templates, the migrations and the files in public/; a build/
directory is refused outright, so an uploaded executable can never become the app.
flowchart TD
A["melee push<br/>tar.gz of the app directory"] --> B["new release directory"]
B --> C["compile views/*.erb to Ruby<br/>strict locals become keyword arguments"]
C --> D["check the Ruby: syntax, render calls against declared locals,<br/>migrations against a scratch database"]
D --> E["spin build<br/>Spinel compiles your app + the melee library + SQLite<br/>into one native binary"]
E -- ok --> F["swap the 'current' symlink<br/>stop the old warm process"]
E -- error --> X["422 with file:line: message<br/>nothing is deployed, the old release keeps serving"]
Two things about this are unusual and worth holding on to.
The compile is whole-program. Spinel reads your app, the library and everything they call together and infers types across the whole thing, which is why a rebuild is a few seconds rather than instant. It is also why the dialect is restricted: a program that can invent method names at run time cannot be compiled this way. It catches less than you might hope — see what a green build does not prove.
A failed build deploys nothing. The previous release keeps serving, and the diagnostics come back to your
terminal as file:line: message, mapped back to the .erb you wrote rather than the Ruby it was compiled
into.
Serving
When a request arrives for an app whose process is not running, the server starts it. That is the warm
process: your binary, sandboxed, sitting in a loop doing nothing. It is not handling requests. Its only job
is to be already initialised — classes defined, constants built, templates compiled in — so that answering a
request costs a fork rather than a program start.
sequenceDiagram
participant B as browser
participant S as melee-server
participant W as warm process
participant C as request child
B->>S: GET /notes Host: notes.example
S->>S: Host → the app "notes"; read the body (≤ 8 MB)
alt nothing running
S->>W: start the binary, sandboxed, with N sockets on fds 3..3+N
end
S->>S: take a free slot
S->>W: a Request frame on that slot
W->>C: fork
C->>C: your before filters, your route block, your template
C->>S: a Response frame, then exit(0)
S->>B: the HTTP response
fork is the Unix call that makes a copy of a process. The copy — the child — starts life with everything
the parent had: the compiled templates, the open sockets, the sandbox. Copying is cheap because the kernel
does not actually copy the memory, it just marks it to be copied if either side writes to it. The fork
itself measured about 0.09 ms in isolation on Linux; a whole warm request through the real server — HTTP,
frames, fork, router, session — is 0.21–0.27 ms.
The child handles exactly one request and exits. This is the single most important thing on this page. It is why:
params,session,requestandlogcan be top-level methods with nothing passed around — there is one request in the process, so “the current request” is unambiguous;- an instance variable, a class variable or a memoised constant set during a request is gone afterwards;
- a slow request cannot poison anything, and a crashed request cannot corrupt the next one;
- there is nowhere to run a background thread, because the process you would start it in is about to exit.
The warm process itself stays alive between requests, but your code does not run in it. It only selects on its sockets, forks, and reaps the children that finish.
Slots, and how much runs at once
The server hands the warm process a fixed number of socket pairs at start-up — four by default. A socket
pair is just two connected file descriptors, like a pipe that works in both directions; the server holds one
end and the app holds the other. One in-flight request occupies one pair, so the number of pairs is the app’s
concurrency limit. Requests beyond that queue briefly, and then get a 503 with Retry-After: 1 if the
queue is also full.
When things go wrong
| What happens | What the caller sees |
|---|---|
| The child crashes, or the sandbox kills it for a forbidden syscall | 502, within about a second |
| The request takes longer than 30 seconds | 504, and the whole process group is killed |
| Every slot is busy and more than eight requests are already queued on one | 503 with Retry-After: 1 |
fork fails because the app is at its process cap (pids.max, 256) | 503 |
| Nobody has asked for 5 minutes | The process is stopped; the next request restarts it in about 2 ms |
The timeouts and the slot count are the operator’s flags, not the app’s; see Running melee-server.
The sandbox
All of this is set up once, around the warm process, before your code runs — so every request child inherits
it through fork. On macOS none of it exists, which is why melee dev is a development tool and the real
thing is Linux.
flowchart TB
s["melee-server"] -- "clear the environment, then fork" --> w
subgraph w["between fork and exec, in the child"]
direction TB
a["a uid and gid of this app's own"]
b["user and mount namespaces"]
c["Landlock: an allow-list of paths<br/>read its own release, write its own data directory"]
d["seccomp: an allow-list of 85 syscalls<br/>anything else kills the process"]
a --> b --> c --> d
end
w -- exec --> run["your binary"]
s -- "then, from outside:<br/>put the pid in a cgroup<br/>memory.max, cpu.weight, pids.max" --> run
run -- fork --> child["request children inherit all of it"]
Four of those are applied by the child itself between fork and exec, so they are in place before a single
line of your code runs, and every request child inherits them. The environment is cleared by the server
before the fork, and the cgroup is applied by the server afterwards, by writing the new process id into it.
In one line each:
- A uid of its own. Every app gets its own Unix user id from a range the operator sets. That is what file
permissions, signals and
/procare built on, so it keeps one app out of another’s processes. This one is conditional: it needs the server to be running as root with--app-uid-range. Without that every app runs as the server’s own user, and the server says so loudly at startup. - Namespaces give the process its own view of users and of the filesystem mount table, so it is not looking at the host’s.
- Landlock is a Linux kernel feature that fences a process into a list of paths. melee grants read on the system libraries and this app’s own release, read-write on this app’s data directory, and nothing else — so even a path the uid would be allowed to read is refused.
- seccomp filters system calls. melee allows 85 — the ones a Ruby program doing files, sockets and memory
needs — and kills the process on anything else.
listenis not among them, so an app cannot turn a socket into a listener and cannot open a port of its own. (acceptis allowed, but only because the worker answers on a UNIX socket that melee-server binds and hands over; the app never creates one.) - cgroups are the kernel’s resource accounting. Each app gets a memory ceiling (128 MB by default), a CPU share, and a cap on how many processes it may have.
The two credential mechanisms overlap on purpose: the uid covers processes and signals, Landlock covers files, and getting at another tenant’s data means defeating both.
Where things are kept
<home>/apps/<name>/
releases/<id>/source/ the push
releases/<id>/bin/<name> the binary
current -> releases/<id> swapped atomically on deploy
data/ your SQLite databases and anything else you write
env the values melee env set, delivered as environment variables
<home>/logs/<name>.ndjson one JSON object per line
There is no database in the server. The filesystem is the registry, and data/ is the only place an app can
write.
The worker, for things that outlive a request
A request child cannot hold state and cannot run in the background. So melee runs a second long-lived process per app — the worker — in the same sandbox, and durable objects live there.
flowchart LR
child["request child"] -- "Call over data/worker.sock" --> worker["worker process"]
worker -- "Result" --> child
worker --> objdb[("one SQLite file per object")]
worker -- "my next timer is at T" --> server["melee-server"]
server -. "starts the worker at T, even if nothing is being requested" .-> worker
Household.get("home").refresh in a route is a call across that socket: the arguments go over as JSON, the
method runs in the worker, and the return value comes back. Handles are generated at build time from your
class, so the set of callable methods is fixed — though a misspelling still raises at run time rather than at
build time, like any undefined method. The worker runs one call at a time.
Because the worker tells the server when its next timer is due, an app with a timer wakes up on its own schedule without anyone visiting it. That is the whole of melee’s job and cron story — see Background jobs and scheduled work.
How fast, in numbers
Measured on the Linux development VM, sandboxed:
| Warm request, through HTTP, frames, fork, router and session | 0.21–0.27 ms |
| First request after a deploy or an idle stop | 1.6–2.6 ms |
| Throughput, 500 requests at 50 concurrent | 7,700 requests/s |
| Warm process resident memory, after that load | 4.8 MB |
| A whole deploy through the control API (unpack, templates, compile, activate) | 10 s |
spin build alone, after a Ruby change | about 4 s |
Next
- If you know Rails or Sinatra — the same material as a translation table.
- The tutorial — write one.
- What melee cannot do — what the process model costs you.