What melee cannot do
The honest list: what the process model rules out permanently, what is simply not built yet, and when you should use something else.
Read this before you commit an app to melee. Some of these are consequences of the design and will not change; others are gaps with work planned behind them. They are marked differently on purpose.
By design means it follows from compiling ahead of time and running one process per request. Not built means it is intended and missing.
The process model
By design — no background work in your app. No threads, no Thread.new, no Queue, no job runner. The
build step refuses Thread and Queue by name. A request child exits when the response is written, and
there is nothing left to hand work to. The replacement is a durable object with a timer — see
Background jobs and scheduled work.
Not built — nothing schedules fibers. The platform rule is “fibers, not threads”, and the intent is that the library’s I/O suspends the current fiber. Nothing implements that today, so in practice there is no concurrency inside a request at all.
By design — nothing in memory survives a request. Instance variables, class variables, memoised values,
an in-process cache, a connection pool: all per-request. Only constants computed at the top level survive
(they are built once in the warm process and inherited through fork).
No concurrency inside a request. Two outbound HTTP calls run one after the other. Ten feeds is ten sequential fetches.
By design — no shared state between requests except the database. There is no Rails.cache, no Redis,
no memcached, no shared memory. A cache is a table, or a durable object.
The dialect
By design. melee apps are compiled whole-program, so anything that decides what to call at run time is
out: eval, method_missing, define_method with a computed name, send with a computed name, Class.new,
ObjectSpace, binding. Dispatch with case.
Also: no gems, ever. The available requires are a fixed list (json, base64, digest,
securerandom, uri, net/http, openssl, set, csv, strscan, optparse, pathname, tmpdir,
forwardable), plus require_relative for your own files. Never stringio.
Smaller ones that bite: string literals are frozen; there is no Date class and no Time.parse; a rescue
clause must name its class in full rather than through a constant alias; a Proc fetched out of a Hash or
Array cannot be called; Exception#backtrace and caller return [].
The Ruby that compiles is the complete list, with a workaround for each.
And the build checks less than it looks like it does. An undefined method reached through an explicit
receiver — db.frist(...), "x".nope, Household.get("home").refrsh — compiles cleanly and raises
NoMethodError when the line runs. So do wrong arity and anything called on nil. A green melee check is
not a working app.
Missing from the app surface
| File uploads | Not built. Multipart bodies are not parsed at all — a file input’s fields arrive empty — and the request body is capped at 8 MB. |
| Object storage | Not built. HTTP has no put or delete and no request signing, so you cannot hand bytes to S3 either. Designed, with presigned URLs, not started. |
| Not built. Nothing sends mail. An HTTP API for a mail provider is the only route today. | |
| WebSockets, SSE, long polling | Not built. An app cannot open a listening socket, and a request is a short-lived process under a 30-second cap. |
| Streaming responses | Partly. A route can produce a streamed body, but it is collected and then sent rather than passed through as it arrives. |
later / every on objects | Not built. You re-arm the timer inside on_timer yourself. |
| Routing straight to a durable object | Not built; under design. Every entry point is a route today. |
A cache helper | Not built. Use setting, a table, or an object. |
| Middleware | Not built. before filters with a path prefix are what there is. |
| Sub-second timeouts | Not built, and blocked: timeout: is rounded up to whole seconds because the compiler truncates a Float at the FFI boundary, so 0.5 would reach net/http as 0, which it reads as no timeout. |
The numbers
Defaults an operator can change, but that your app lives inside:
| Requests at once, per app | 4 (slots) |
| Request timeout | 30 s, then the process group is killed and the caller gets a 504 |
| Request body | 8 MB |
| Memory per app | 128 MB (memory.max, root only) |
| Processes per app | 256 (pids.max) |
| Idle stop | 5 minutes with nothing in flight |
| Cold start after that | about 2 ms |
| Database writers | one at a time; a second waits 5 s and then raises |
| Durable object calls | one at a time, per app, across every object |
| Redirects followed | 5 |
| Static file cache | max-age=300, no fingerprinting |
Operating it
This is where melee is least finished.
- Not built — no TLS.
melee-serverspeaks plain HTTP on both its ports. TLS is something you put in front (Caddy is the intended answer). Until you do, the control-API token and everymelee envvalue cross the network in the clear. - Not built — no installable CLI.
meleefinds the standard library through a checkout of this repository. There is no package, nobrew install, and the compiler is a git submodule you build yourself. - Not built — no deployment recipe. No systemd unit, no guidance on where the home directory lives, no log rotation, no upgrade path for the server or the compiler pin.
- Not built — no backups. The database is a file on one machine’s local disk and nothing copies it anywhere.
- Not built — single machine. No replication, no failover, no placement, no custom domains. An app is on one host, and that host is a single point of failure.
- Not built — no rollback command and no way to delete an app. Deploying the previous source again is the rollback; removing an app means deleting directories on the server by hand.
- Not built — no metrics. Logs are what there is: no request counters, no latency histogram, no health
beyond
/v1/health. - By design for now — the build runs on the server, as the server’s user, unsandboxed, with the toolchain on its PATH. It compiles source you pushed. That is fine when you are the only person who can push and not fine otherwise.
Security, stated plainly
The sandbox around a running app is real: a uid per app, user and mount namespaces, a Landlock path allow-list, a seccomp allow-list of 85 syscalls, and cgroup limits. One app cannot read another’s database, environment or session secret, and cannot signal its processes.
What is not closed:
- An app can make outbound connections anywhere, including loopback and private addresses. If your app fetches a URL a visitor supplied, that is an SSRF and nothing stops it.
- The build step is not sandboxed.
- An app can enumerate the host’s processes (no PID namespace, no private
/proc), though it cannot read or signal them. - The control API is a TCP port on loopback with a bearer token; moving it to a UNIX socket is open work.
- A uid is never retired, so a deleted app’s id is not safely reusable.
The threat model and the full findings table live in docs/design/security.md in the repository. The short
version: melee is currently safe to run your own code on a machine you control, and is not yet ready to
accept apps from people you do not trust.
When to use something else
Use a normal server and Rails (or anything else) when:
- the app needs gems, or a library that only exists as a gem;
- it needs Postgres, a connection pool, or more than one writer;
- it needs background workers, real queues, or fan-out;
- it needs file uploads, or to serve large media;
- it needs to stay up when one machine does not;
- it holds data you cannot afford to lose and cannot back up yourself;
- it is going to keep growing.
melee is for the app that is small, mostly idle, and would otherwise not be worth deploying at all. Inside that shape it is genuinely good. Outside it, it will fight you.
See also
- How a request reaches your app — why most of the first section is the way it is.
- The Ruby that compiles.
- Operating melee.