Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

6. Deploying it

melee check compiles it, melee push ships it, and melee env is where the secret goes.

Everything so far ran under CRuby. The real thing is a native binary, and this is the step that finds out whether your Ruby survives the compiler.

Compile it here first

melee check
melee build: /path/to/notes/build/bin/notes (2002 KB) in 3.4 s

That is the whole app — your code, the melee library and SQLite — in a two-megabyte executable. Nothing was sent anywhere.

If it does not compile you get one line per problem, with the file and line:

views/broken.erb:2: unexpected end-of-input, assuming it is closing the parent top level context
db/migrations/003_bad.sql:1: near "TABEL": syntax error
app.rb:5: views/index.erb does not declare a local `nots`
app.rb:12: unsupported eval of a runtime string is not supported by AOT compilation (define the code statically)

Template errors are mapped back to the .erb you wrote, not the Ruby it was compiled into.

What melee check does not catch

This is the part to take seriously. A green build is not a working app.

db.qeury("SELECT 1")            # compiles. 500 at run time.
Stats.get("visits").hitt        # compiles. 500 at run time.
render :index, notes: n         # arity and nil are not checked either

An undefined method with an explicit receiver compiles and raises NoMethodError when the line runs. So do wrong arity and calling anything on nil. What is reliably caught is the shape of the program: templates, migrations, forbidden constructs like eval, unknown requires, and calls to a helper you never defined.

So: click through the app under melee dev before pushing, and read When something breaks.

Push it

melee.toml says where:

name = "notes"
server = "http://127.0.0.1:7070"
url = "http://notes.localhost:8080"

server has to be https://, or http:// to a loopback address (127.0.0.1, ::1, localhost) as above — anywhere else over plain http:// would send the token below in the clear, and melee refuses it.

The control API needs a token. melee looks for it in MELEE_TOKEN, or at a token_file path named in melee.toml — which has to be outside the app directory, because melee push uploads everything in it.

melee push
using control API at http://127.0.0.1:7070
pushing notes (3 KB) to http://127.0.0.1:7070
deployed notes release 1789257455384-0000 (2002 KB binary) in 3.7s
http://notes.localhost:8080

(That 3.7 s is a local server on a development machine with a warm compiler cache. A push from a laptop to a Linux server measured 10.8 s, and the first build on a fresh machine takes about half a minute while SQLite is compiled.)

Three kilobytes went up — the source, not the binary. The server compiled it, wrote a new release directory, swapped the current symlink and stopped the old warm process. The next request starts the new one.

A failed build answers with the same diagnostics, deploys nothing, and leaves the previous release serving.

The secret

melee env NOTES_SECRET letmein
set NOTES_SECRET for notes; the app will restart on its next request

It arrives in the app as ENV["NOTES_SECRET"]. This is the only place secrets belong: not in app.rb, not in melee.toml, not in the tarball.

Watch it work

curl -s -o /dev/null -w "%{http_code} in %{time_total}s\n" https://notes.example/
200 in 0.315581s     # cold: the server had to start the process
200 in 0.002725s     # warm
200 in 0.001870s

The first request after an idle stop pays for starting the binary. After that it is a fork. On the Linux test machine, sandboxed, that is 0.2 ms warm and 1.6–2.6 ms cold.

The other commands

melee logs             # the last 50 lines
melee logs -f          # follow
melee apps             # what is deployed on this server
melee objects          # the durable objects and their pending timers
melee restart          # stop the warm process; the next request starts it again
melee open             # print and open the URL

melee objects is how you check the timer you wrote in step 5 is actually scheduled:

CLASS                ID                               CREATED        LAST CALL      TIMER
Stats                visits                           0s ago         0s ago         in 23h

And the log shows the object being called, one line per event:

23:57:39 call method=hit ms=0.16 object=Stats/visits ok=true
23:57:39 call method=today ms=0.04 object=Stats/visits ok=true

When it breaks in production

There is no error page and no backtrace. A visitor gets:

Something went wrong

and the log gets the line that matters:

undefined method 'qeury' for an instance of Melee::DB class=NoMethodError method=GET path=/ route=GET /

Class, message, method, path and route — no line number, because the compiled binary has no backtrace. That is why the log line and the route name have to be enough, and why log.info with fields is worth writing as you go. When something breaks is the whole subject.

The whole thing

For reference, the finished app.rb — 45 lines, and the only Ruby file besides lib/stats.rb:

# frozen_string_literal: true
require_relative "lib/stats"

title "Notes"

before "/admin" do
  redirect "/login" unless session[:admin]
end

get "/" do
  Stats.get("visits").hit
  render :index, notes: open_notes, views: Stats.get("visits").today
end

get "/login" do
  render :login, error: nil
end

post "/login" do
  if secure_equal?(params.fetch(:secret), ENV["NOTES_SECRET"].to_s)
    session[:admin] = "1"
    redirect "/admin"
  else
    status 401
    render :login, error: "That secret is wrong."
  end
end

get "/admin" do
  render :admin, notes: db.query("SELECT id, text, done FROM notes ORDER BY done, id DESC")
end

post "/admin/notes" do
  text = params.fetch(:text).strip
  halt 400, "A note needs some text" if text.empty?
  db.run "INSERT INTO notes (text, created_at) VALUES (?, ?)", text, Time.now.to_i
  redirect "/admin"
end

post "/admin/notes/:id/done" do
  db.run "UPDATE notes SET done = 1 - done WHERE id = ?", params.fetch(:id).to_i
  redirect "/admin"
end

def open_notes = db.query("SELECT id, text FROM notes WHERE done = 0 ORDER BY id DESC")

The rest is lib/stats.rb, five templates, three .sql files and melee.toml.

Done

You have written and deployed an app with routes, templates, a database, a login, and an object that does work on a schedule with nobody watching.

Where to go next: