Pre-alphaSpinel 2026.09.12MIT

Rails-shaped web apps, compiled to one native binary.

CyberTrain is a Rails-shaped web application framework written natively for Spinel, matz's ahead-of-time Ruby compiler. You keep the vocabulary (resources, before_action, validates, form_with, scaffolds) while cybertrain build compiles controllers, models, views and the framework itself into a single native binary with no Rack, no runtime gems and no Ruby interpreter in the artifact.

Quick start

terminal — any directory, then blog/shell
gem install cybertrain
cybertrain new blog          # installs Spinel 2026.09.12 the first time (a few minutes)
cd blog
cybertrain g scaffold article title:string body:text
cybertrain db migrate
cybertrain server            # http://127.0.0.1:3000
cybertrain build             # production: dist/blog, dist/public/, dist/storage/, dist/tmp/

Needs Ruby 3.2+ for the cybertrain command only (applications never run on CRuby), plus git, make, curl, a C compiler and the SQLite 3 headers: before you start. Run the build with cd dist && ./blog migrate && ./blog and CYBERTRAIN_SECRET_KEY_BASE set.

  • ServerHTTP/1.1, no Rack
  • RuntimeNo gems, no interpreter
  • DatabaseSQLite via Spinel FFI
  • CILinux and macOS

Who it is for, and what it is not

For Rails developers who want a single native binary, and who are comfortable with a young project and a Ruby subset.

  • Not Roundhouse

    Roundhouse transpiles existing Rails apps to Spinel. CyberTrain is a native framework you write directly against, not a compatibility layer.

  • Not Rails- or Rack-compatible

    No Rack compatibility and no gems at runtime: only Spinel's own spin-index packages that compile under its Ruby subset.

  • Not production-ready

    CyberTrain is pre-alpha. The example blog is exercised by CI on Linux and macOS.

The code you write

Rails vocabulary, with Spinel’s rules written down.

Controllers, models, routes and views read like a Rails app. Where an ahead-of-time compiler forces a difference, it is small, explicit and documented.

  • Routes

    Nested resources blocks take the mapper explicitly: articles.resources :comments.

  • Controllers

    new is written new_action: a method named new would shadow Klass.new(ctx).

  • Views

    In development, templates are read from disk, so editing a view needs no rebuild; cybertrain build embeds them in the binary. They are a closed grammar, not arbitrary Ruby.

  • Models

    class Article declares validations; columns, finders and associations are generated from db/schema.rb.

config/routes.rbruby
Cybertrain::Routes.draw do
  root "articles#index"

  # A nested block takes the mapper explicitly (Spinel's instance_eval
  # trampoline only rewires `self` for the outermost block): `articles.resources`.
  resources :articles do |articles|
    articles.resources :comments, only: [:create, :destroy]
  end
end
app/controllers/articles_controller.rbruby
# app/controllers/articles_controller.rb (index/show/edit are as plain as
# Rails' own scaffold; update mirrors create)
class ArticlesController < ApplicationController
  before_action :set_article, only: [:show, :edit, :update, :destroy]

  def new_action   # a `new` method would shadow ArticlesController.new
    @article = Article.new
  end

  def create
    @article = Article.new(article_params)
    if @article.save
      flash[:notice] = "Article was successfully created."
      redirect_to article_path(@article), status: :see_other
    else
      render :new, status: :unprocessable_entity
    end
  end

  private

  def set_article
    @article = Article.find(params[:id])
  end

  def article_params
    params.require(:article).permit(:title, :body)
  end
end
app/views/comments/_comment.html.erberb
<%# locals: (comment:) %>
<div class="comment">
  <p>
    <strong>Commenter:</strong>
    <%= comment.commenter %>
  </p>

  <p>
    <strong>Comment:</strong>
    <%= comment.body %>
  </p>

  <%= button_to "Destroy comment", [@article, comment], method: :delete %>
</div>
app/models/article.rbruby
# app/models/article.rb -- only the first string field gets a default
# presence validation; add anything past that yourself.
class Article
  validates :title, presence: true
  validates :body, presence: true, length: { minimum: 10 }
end

What works today

The Rails slice you reach for first, written natively for Spinel.

  • Native HTTP server

    Plain-Ruby HTTP/1.1 server with one green thread per connection and typed requests and responses. No Rack layer; put nginx or Caddy in front for TLS.

    TCPServer#accept

  • Routing and controllers

    resources, nested routes, before_action, after_action and rescue_from, generated into plain Ruby at build time. Strong params and flash-then-redirect work as in Rails.

    config/routes.rb

  • Sessions, flash, CSRF

    HMAC-signed cookie sessions, flash messages and CSRF protection are on by default. Forms carry the token automatically; forged posts get a 403.

    _cybertrain_session

  • ERB-flavoured templates

    Views look like Rails ERB with layouts, partials, form_with and link_to. In development they are read from disk, so edits need no rebuild. cybertrain build embeds them.

    app/views/**/*.html.erb

  • SQLite models

    Validations, callbacks and associations derived from foreign keys. Talks to SQLite through Spinel's FFI with a connection pool; every query is bound.

    ffi_func · ffi_lib

  • Migrations and scaffolds

    cybertrain generate scaffold writes migration, model, controller and five views. cybertrain db migrate generates, applies migrations, rewrites db/schema.rb and generates again.

    db/migrate/*.rb

  • Live dev loop

    cybertrain server watches your code, rebuilds in the background, then re-execs the new binary on the same port and PID. A failed build keeps the old server running.

    rebuild → stop → execv

  • Self-testing

    Write tests with Cybertrain::Test and drive the whole app in-process with a cookie-jar client. cybertrain spin test compiles each test and diffs its output.

    test/<name>.rb.expected

Framework and CLI

One name, two parts.

gem install cybertrain installs a command, not the framework. The framework is a Spinel package that your app depends on and compiles in. The gem gives you the cybertrain command, which creates the app, installs Spinel and runs Spinel’s tools for you.

Spinel package

The framework

The library your app is built on.

What is in it

The HTTP server, routing and controllers, sessions and CSRF protection, the template interpreter, SQLite models and migrations, and the code generator behind spin run gen.

How you get it

You don’t install it. Your app’s spin.toml names the release tag v0.2.1 of this repository, and spin lock fetches it into spin’s cache and pins the commit in spin.lock. Nothing is copied into your app.

Where it runs

Inside your app. Spinel compiles it together with your code ahead of time; it never runs on CRuby.

In dist/blog

Compiled inIt is the part of CyberTrain that ships.

RubyGem

The cybertrain command

The tool that creates, builds and runs your app.

What is in it

The cybertrain command only: new, generate scaffold, db migrate, server, build, spin, setup and doctor. Neither the framework nor Spinel is in the gem.

How you get it

gem install cybertrain. On first use it builds the pinned Spinel release, 2026.09.12, into ~/.cybertrain, unless that release is already on your PATH.

Where it runs

On your machine, under CRuby 3.2 or newer. It calls Spinel’s spin tool for you, so you don’t have to.

In dist/blog

Not shippedThe binary runs without Ruby, the gem or Spinel.

Both are released from the same tag, so their versions match: the 0.2.1 gem writes apps that depend on framework v0.2.1. To move an app to a new release, update the gem, point spin.toml at the new tag, then run cybertrain spin lock and cybertrain spin run gen.

How it works

The metaprogramming happens once, at build time.

Spinel compiles ahead of time and has no eval. So where Rails reaches for method_missing and define_method at runtime, spin run gen writes plain Ruby you can read and commit, and only the views are interpreted.

The CyberTrain pipelinedb/schema.rb, config/routes.rb, app/ and app/views/ feed the build. spin run gen writes plain, committed Ruby into gen/. cybertrain build embeds app/views/ and compiles gen/ and the framework into one native binary, dist/blog, which serves HTTP/1.1 and talks to SQLite through Spinel's FFI. In development the binary reads app/views/ from disk instead, and a return path watches the sources: watch, rebuild, stop, execv.YOU WRITEBUILD TIME · spinRUN TIMEdb/schema.rbtablesconfig/routes.rbroutesapp/controllers, modelsspin run gen1gen/plain Ruby, committedmodelsroutes · URL helpersview_assignsrun_callbackmigrations · viewsmanifest2cybertrain builddist/blogone native binaryframeworkcontrollers · modelstemplate interpreterembedded viewsHTTP/1.1 serverSQLite via Spinel FFI4app/views/dev: read from diskembedded in dist/blog35dev: watch → rebuild → stop → execvdevelopment only · a failed build keeps the old server running The CyberTrain pipelinedb/schema.rb, config/routes.rb, app/ and app/views/ feed the build. spin run gen writes plain, committed Ruby into gen/. cybertrain build embeds app/views/ and compiles gen/ and the framework into one native binary, dist/blog, which serves HTTP/1.1 and talks to SQLite through Spinel's FFI. In development the binary reads app/views/ from disk instead, and a return path watches the sources: watch, rebuild, stop, execv.YOU WRITEdb/schema.rbtablesconfig/routes.rbroutesapp/controllers, modelsBUILD TIMEspin run gen1gen/plain Ruby, committedmodelsroutes · URL helpersview_assignsrun_callbackmigrations · viewsmanifest2cybertrain buildRUN TIMEdist/blogone native binaryframeworkcontrollers · modelstemplate interpreterembedded viewsHTTP/1.1 serverSQLite via Spinel FFI4app/views/dev: read from diskembedded in dist/blog3dev: watch → rebuild → stop → execv5development only · a failed buildkeeps the old server running
build stepdev only: read from disk / dev loopthe binaryNumbers match the steps below.
  1. Generate at build time

    spin run gen executes db/schema.rb and config/routes.rb to obtain table and route data, and does a lexical scan (regexes, not a parser) of app/controllers and app/models for @ivar = assignments, callback names and zero-argument defs. It writes plain, readable Ruby into gen/, including gen/views.rb, the table of embedded views (empty unless building for deployment). cybertrain db migrate, server and build run it for you.

  2. Commit gen/

    It is checked in, not gitignored, because spin build and spin test have no hook to generate first. CI re-runs spin run gen and fails on a diff, so stale generated code cannot merge. cybertrain spin run gen -- --check reports stale files.

  3. Interpret the views

    app/views/**/*.html.erb are parsed at request time and walked by a tree-walking interpreter. In development the engine reads app/views/ from disk and re-parses a template when it changes, so view edits need no rebuild. cybertrain build embeds every template in the binary (spin run gen -- --embed-views); a production binary renders only from that table and parses each template once. Spinel has no eval, so this is a closed grammar, not real Ruby.

  4. Serve with green threads, store in SQLite

    A plain-Ruby HTTP/1.1 server runs one Spinel green thread per connection. Models talk to SQLite through ffi_func/ffi_lib with a 4-connection pool (WAL, a 5-second busy handler, foreign keys on). SPINEL_WORKERS defaults to 1.

  5. Rebuild in development

    cybertrain server runs spin run gen, then spin run blog (your app's name). In development the binary polls app/**/*.rb, config/**/*.rb, db/schema.rb and gen/**/*.rb every half second (views and gen/views.rb excluded). A change runs spin run gen && spin build blog in the background; success sets a restart flag (the one an external kill -HUP sets), and the server stops listening, drains open requests and execvs the new binary on the same port and PID. A failed build keeps serving the old binary and shows the compiler output as a banner. Rebuilds are skipped while cybertrain build holds tmp/cybertrain-build.lock. None of this loads in production.

terminal — blog/shell
cybertrain spin run gen   # reads db/schema.rb + config/routes.rb, scans app/, writes gen/
cybertrain build          # dist/blog with app/views embedded, plus dist/public/, dist/storage/, dist/tmp/

Rails vs CyberTrain

Same vocabulary. Different rules, written down.

An ahead-of-time compiler changes a few habits. These are the ones you meet first.

Differences between Rails and CyberTrain (condensed)
TopicRailsCyberTrain
01Consolerails consoleNo console — Spinel has no eval
02ReloadingEdit code, the running app picks it upRuby needs a rebuild; cybertrain server in development does this for you (rebuild, stop, execv)
03The new actiondef newdef new_action (new would shadow Klass.new(ctx))
04Callbacksbefore_action { do_thing } (implicit self)before_action { |c| c.do_thing } — no instance_exec on a stored block, so callbacks take the controller/record explicitly
05Nested routesresources :posts do resources :comments endresources :posts do |posts| posts.resources :comments end — nested blocks take the mapper explicitly
06SessionsSession holds any marshalled objectSession values are Strings only, HMAC-signed cookie
07DatabaseMany database adaptersSQLite only, via FFI
08Testsminitest / RSpecCybertrain::Test — reflection-based runners can't work ahead-of-time

Performance

On one core, 2.6 to 12.2 times the requests of Rails.

examples/blog against the same blog written in Rails 8.1, with the same views, routes, models and SQLite schema. Both run in production mode, pinned to the same CPU cores of one machine, under the same load from wrk. A second core helps Rails more: Puma adds a whole process, while CyberTrain’s second worker thread adds 9 to 58%, so on two cores the lead is 2.0 to 5.5 times.

Requests per second with 16 connectionsCyberTrainRails 8.1
  • GET /articlesthe list, 30 articles

    One core

    CyberTrain: 2,641

    Rails: 468

    5.6×vs Rails

    Two cores

    CyberTrain: 3,289

    Rails: 1,036

    3.2×vs Rails

  • GET /articles/1an article with 10 comments and 12 forms

    One core

    CyberTrain: 2,450

    Rails: 201

    12.2×vs Rails

    Two cores

    CyberTrain: 2,662

    Rails: 486

    5.5×vs Rails

  • POST /articles/2/commentsa validated insert and a redirect

    One core

    CyberTrain: 1,507

    Rails: 390

    3.9×vs Rails

    Two cores

    CyberTrain: 2,379

    Rails: 870

    2.7×vs Rails

  • GET /style.cssa static file from public/

    One core

    CyberTrain: 13,300

    Rails: 5,127

    2.6×vs Rails

    Two cores

    CyberTrain: 20,589

    Rails: 10,311

    2.0×vs Rails

Memory after the load runs (lower is better)CyberTrainRails 8.1
  • System mallocthe default allocator

    One core

    CyberTrain: 10.6 MB

    Rails: 125.9 MB

    11.9×less

    Two cores

    CyberTrain: 12.0 MB

    Rails: 331.4 MB

    27.6×less

  • jemallocpreloaded into both

    One core

    CyberTrain: 13.3 MB

    Rails: 119.9 MB

    9.0×less

    Two cores

    CyberTrain: 15.1 MB

    Rails: 313.2 MB

    20.7×less

Resident memory (RSS, from /proc) summed over all of the server’s processes, read after all the load runs. Rails runs one Puma process with 3 threads on one core and two workers on two. RSS counts a page that Puma’s workers share once per worker; by PSS, which splits shared pages between processes, Rails on two cores takes 249.1 MB (222.5 MB with jemalloc) against CyberTrain’s 10.2 MB (12.3 MB).

  • Memory under load

    10.6 MB

    Rails: 126 MB, one Puma process with 3 threads.

  • Start to first response

    0.02 s

    Rails: 1.3 s.

  • What the server needs

    2.5 MB

    One binary. Rails: 109 MB of Ruby, 69 gems and the app.

  • The price: a build

    3 min

    to compile the binary with cybertrain build. Rails runs its source as it is.

Medians of three 10-second runs after a warm-up, on a cloud VM with 4 vCPUs, with wrk on the two cores the server does not use: CyberTrain 0.2.1 built with Spinel 2026.09.12, against Rails 8.1.4 on Ruby 3.4.6 with YJIT and Puma 8.0 (one process with 3 threads on one core, two on two). The bars in a row share one scale. One request at a time, the article page takes 0.4 ms against 4.7 ms. With jemalloc in both (Rails’ production image preloads it; for CyberTrain it is one line in spin.toml), one core gives 2.8 to 14.6 times. Part of the gap is work Rails does by default and CyberTrain does not: it encrypts the session cookie and signs a CSRF token for each form. The method, every number and how to run it again: docs/benchmark.md.

Before you start

What you need on your machine.

Ruby
3.2+ with RubyGems, for the cybertrain command only. Applications never run on CRuby.
Spinel
2026.09.12, untested against other versions. The first command that needs it builds it into ~/.cybertrain (a few minutes, once); a spinel of that release already on PATH is used as is.
Toolchain
cc and make (Spinel compiles every program to C), plus git and curl to build Spinel. SQLite 3 headers and library: libsqlite3-dev on Debian/Ubuntu, included with Xcode command line tools on macOS. OpenSSL headers are optional.
Status
Pre-alpha, version 0.2.1, MIT licensed. The example blog is exercised by CI on Linux and macOS.

cybertrain doctor checks these tools and headers and the Spinel install, and prints the install command for your platform when one is missing. cybertrain setup installs Spinel on request and prints the PATH line for running spin yourself; cybertrain spin ARGS... runs it without one.