(cover image generated with Google Gemini)
What / Why
As the title says, this is just an experiment with running "serverless" Ruby on Vercel, not a comparison with other app-hosting options (I don't even provide any metrics on costs/performance, etc.).
For the sake of brevity, what this post mentions barely scratches the surface of the platform, and I encourage you to explore further and judge by yourself.
Besides that, I thought it was an interesting challenge due to the really scarce documentation.
(I don't even remember how I stumbled upon Vercel's Ruby runtime existence... π€)
Bird's-eye overview of Vercel
Putting it simply (and risking it to read like an old tired salesman, or even an AI), Vercel is a cloud platform for building, deploying, and scaling modern web applications, plus all the "agentic" rigmarole.
One of its flagship concepts is "fluid compute", which I find quite "attractive" (though not super original/innovative).
Under the fluid compute model, the deployable units are called "Functions" (similar to AWS Lambdas), and their main benefit is supposed to be reducing cold-starts. The documentation describes it as: "Run server-side code without managing servers".
The platform is mostly focused on Next.js (a React framework created and maintained by them) and favors the JavaScript/TypeScript ecosystem (it welcomes Vue and other frontend frameworks), though it has support for Python and other languages like Go, Rust, and (surprisingly!) Ruby.
In fact, I'll be reproducing (and adapting) this very same example but using Ruby instead of Python + FastAPI, on the free tier.
It's worth mentioning, that if a language you like it's not officially supported, apparently you can go for a community-contributed runtime, or build it yourself by using the Runtime API .
Other benefits mentioned here (which I'm not going to cover in this post):
- Zero configuration out of the box
- Optimized concurrency
- Dynamic scaling
- Automatic cold start optimizations
- Cross-region and availability zone failover
- Error isolation
Fun (read "unfortunate") fact: shortly after I decided to sketch up this writing (months ago), the company was in the news spotlight due to a security breach)
Ruby runtime
It's in beta stage, so it's not recommended for production or anything serious. (Plus, there's no guarantee it will still be available in the future; a very low adoption may not justify the effort of keeping the lights on)
At the time of this writing (August 2026), only Ruby 3.3.x is supported (a bummer, considering that 4.0.x it's being out for a while now).
If you specify a higher version at Gemfile, the deployment fails with an error: "Found Gemfile with invalid Ruby version"
As for the dependencies, the documentation states there's support for:
- auto-installing gems specified in the
Gemfile - or, alternatively, they can be vendored with the command
bundler install --deployment(e.g. gems with native extensions).
(The latter, I didn't try.)
File hierarchy
File hierarchy feels a bit too-rigid to me, because all of your Ruby code must live under an api folder (which in turn may include subfolders) at your project's root. This implies that your URLs will start with /api (unless you mangle them somehow; see the appendix section at the end of this post for middleware).
api
βββ index.rb # Handles "/api"
βββ foo.rb # Handles "/api/foo"
βββ users
β βββ index.rb # Handles "/api/users"
β βββ bar.rb # Handles "/api/users/bar"
(!!) IMPORTANT (!!)
To save you from some headaches:
-
.gitignorehas no effect on Vercel - only.vercelignoredoes. Avendor/gitignored will still ship on CLI deploys. -
.vercelignoredoes not affectvercel build(command used to "compile" the project), onlyvercel deployuploads.
But wait... there's more.
A couple of "safety checkpoints" to avoid nasty gotchas: due to the way the runtime works, you need to define a vercel.json file with the following content:
{ "outputDirectory": "public" }
otherwise, any Ruby code files outside the top-level api folder, will be treated as plain-text, returning the source-code they contain as the response (not fun at all).
Also, public/ must stay non-empty; an empty output directory silently falls back to serving the whole repo.
Any dummy public/index.html file is ok (maybe you can redirect the browser to your API, or it could even be empty).
Request handlers
Basically, you have two different ways of implementing the request-handling code:
- A file defining a constant named
Handlerthat stores aprocwith|request, response|as block arguments:-
requestis an instance ofWEBrick::HTTPRequest -
responseis an instance ofWEBrick::HTTPResponse
-
- A file with a class named
Handlerthat inherits fromWEBrick::HTTPServlet::AbstractServlet
(You can mix and match: have some files using the first option, and others using the second one; as long as they define a Handler constant, they will do the trick)
Creating the project
To me, the simplest way to create a project it's importing an existing GitHub repo:
Exploring other more "programmatic" ways is left as an exercise to the reader :)
Gimme the codez!
(Do not perform code-review on these examples... they're just quick and dirty to have a proof-of concept π)
.ruby-version
3.3.12
vercel.json
{
"outputDirectory": "public"
}
.vercelignore
vendor/
bin/
.ruby-lsp/
.env*
Gemfile
source "https://rubygems.org"
gem "webrick"
api/weather/index.rb
# frozen_string_literal: true
require "open-uri"
require "json"
require "webrick"
# In a real-life scenario, these must live in environment variables.
GEO_API_DOMAIN = "geocoding-api.open-meteo.com"
GEO_API_SEARCH_PATH = "/v1/search"
WEATHER_API_HOST = "api.open-meteo.com"
WEATHER_API_PATH = "/v1/forecast"
HTTP_OK = WEBrick::HTTPStatus::OK.code
HTTP_BAD_REQUEST = WEBrick::HTTPStatus::BadRequest.code
HTTP_NOT_FOUND = WEBrick::HTTPStatus::NotFound.code
HTTP_BAD_GATEWAY = WEBrick::HTTPStatus::BadGateway.code
Handler = Proc.new do |request, response|
city = request.query["city"].to_s
units = request.query["units"].to_s
units = "metric" if units.empty?
response["Content-Type"] = "application/json"
if city.empty?
response.status = HTTP_BAD_REQUEST
response.body = JSON.dump({ error: "Missing required query parameter: city" })
next
end
begin
result = get_weather(city:, units:)
rescue ArgumentError => e
response.status = HTTP_BAD_REQUEST
response.body = JSON.dump({ error: e.message })
next
rescue StandardError => e
response.status = HTTP_BAD_GATEWAY
response.body = JSON.dump({ error: e.message })
next
end
if result.nil?
response.status = HTTP_NOT_FOUND
response.body = JSON.dump({ error: "No results for city: #{city}" })
else
response.status = HTTP_OK
response.body = JSON.dump(result)
end
end
# Get current weather for a city.
#
# @param city [String] City name (e.g., "London", "New York")
# @param units [String] Temperature units - "metric" (Celsius) or "imperial" (Fahrenheit)
#
# @return [Hash, nil] Weather data including temperature, humidity, wind speed, and location
# info, or nil when the city cannot be geocoded
def get_weather(city:, units: :metric)
units_sym = units.to_sym
raise ArgumentError, "Invalid units: #{units.inspect}" unless [:metric, :imperial].include?(units_sym)
geo_params = {
name: city,
count: 1,
language: :en,
format: :json
}
geo_url = URI::HTTPS.build(
host: GEO_API_DOMAIN,
path: GEO_API_SEARCH_PATH,
query: URI.encode_www_form(geo_params)
)
geo_response = URI.open(geo_url)
response_http_code = geo_response.status[0].to_i
raise StandardError, "Geocoding API request failed: #{geo_response.status}" if response_http_code != HTTP_OK
# The geocoding API omits "results" entirely when nothing matches.
location = JSON.parse(geo_response.read).fetch("results", []).first
return nil if location.nil?
weather_params = {
latitude: location["latitude"],
longitude: location["longitude"],
current: "temperature_2m,relative_humidity_2m,apparent_temperature,wind_speed_10m",
timezone: "auto"
}
if units_sym == :imperial
weather_params.merge!({
temperature_unit: "fahrenheit",
wind_speed_unit: "mph"
})
end
weather_url = URI::HTTPS.build(
host: WEATHER_API_HOST,
path: WEATHER_API_PATH,
query: URI.encode_www_form(weather_params)
)
weather_response = URI.open(weather_url)
response_http_code = weather_response.status[0].to_i
raise StandardError, "Weather API request failed: #{weather_response.status}" if response_http_code != HTTP_OK
weather_data = JSON.parse(weather_response.read)
{
city: location["name"],
country: location["country"],
latitude: location["latitude"],
longitude: location["longitude"],
units: units_sym,
current: weather_data["current"]
}
end
Running locally
First, make sure you install Vercel's CLI:
# Change the following command to your preferred way
npm i -g vercel
then, run the server:
# At your project's root:
vercel dev
and, finally, make your first local request (SPOILER ALERT: it won't work):
curl "http://localhost:3000/api/weather?city=london"
# The output:
# Runtime "ruby3.3" is not implemented
Oh, snap! what a showstopper... even when the vercel dev command succeeds, any request to the API endpoint fails with that disgusting error.
I didn't spend any time trying to debug that, and immediately resorted to Claude Code for finding an answer. And here's the thing:
- The local vercel "harness" doesn't emulate Vercel's routing/rewrites,
.env.localloading, or platform headers - only the function itself.
A poor-man's workaround (via a Claude Code generated script) allows you to do a bit of testing, but it still doesn't fully emulate Vercel rewrites, headers, etc.)
Put the following code in a bin/dev script, and use that in place of the vercel dev command:
#!/usr/bin/env ruby
# frozen_string_literal: true
# Local dev server for the Ruby functions in api/.
#
# `vercel dev` cannot run these: @vercel/ruby's dev server only handles Rack
# (.ru) entrypoints and returns null for .rb, so the CLI falls back to
# @vercel/fun, which registers no Ruby runtimes at all and fails with
# Runtime "ruby3.3" is not implemented
#
# This mounts the Handler proc the same way Vercel's runtime does in
# production (vc_init.rb: `server.mount_proc '/', Handler`), so request
# handling matches what a deployment sees. What it does NOT emulate is the
# Vercel layer around the function: routing/rewrites, .env.local loading,
# and platform headers.
#
# bin/dev
# curl "http://localhost:3000/api/weather?city=London&units=imperial"
require "webrick"
require_relative "../api/weather/index"
host = ENV.fetch("HOST", "127.0.0.1")
port = Integer(ENV.fetch("PORT", "3000"))
server = WEBrick::HTTPServer.new(BindAddress: host, Port: port)
server.mount_proc "/api/weather", Handler
trap("INT") { server.shutdown }
trap("TERM") { server.shutdown }
server.start
And now, execute the script:
./bin/dev
# [2026-08-01 21:05:02] INFO WEBrick 1.9.2 [2026-08-01 21:05:02] INFO ruby 3.3.1 (2024-04-23) [arm64-darwin25]
# [2026-08-01 21:05:02] INFO WEBrick::HTTPServer#start: pid=73221 port=3000
in another terminal:
curl "http://localhost:3000/api/weather?city=London&units=imperial"
# {"city":"London","country":"United Kingdom","latitude":51.50853,"longitude":-0.12574,"units":"imperial","current":{"time":"2026-08-02T01:00","interval":900,"temperature_2m":71.7,"relative_humidity_2m":34,"apparent_temperature":67.7,"wind_speed_10m":4.7}}
That looks good to me.
Deployment
You can install the official vercel CLI tool in a similar fashion to other platforms (you can see logs, deploy, etc.), but just pushing to the origin repo will trigger a deploy (like "good ol' Heroku"). And, at least in my tinkering, it was blazing fast.
Quoting verbatim the example I mentioned:
- Push the changes to your remote repository or run theΒ
vercelΒ cli command - Vercel will create a new preview deployment for your to test
- Merge to main branch to deploy to Production
Then, at your Vercel account's Deployments section you should see something like the following:
And you can test directly in your browser or using curl/wget/etc.:
Appendix
Middleware
Unless I overlooked something, if you want to implement a middleware, you cannot write it in Ruby... you must use JavaScript or TypeScript (a file named middleware.js or middleware.ts respectively)
This is a toy example:
// It requires the "vercel" package to be installed
import { next } from "@vercel/functions"
export default function middleware(request: Request) {
const url = new URL(request.url);
if (!url.pathname.startsWith("/api")) {
return new Response(null, {status: 418}) // I'm a teapot
}
return next()
}
Build - sneak peek under the hood
When you execute the vercel build command, a .vercel/output folder is generated with the build's output, and among the files, you can find a kind of WEBrick wrapper for each of your "functions":
βββ functions
βΒ Β βββ api
βΒ Β βββ weather
βΒ Β βββ index.func
βΒ Β βββ vc__handler__ruby.rb
βΒ Β βββ vc__utils__ruby.rb
An excerpt from from vc__handler__ruby.rb
require 'tmpdir'
require 'webrick'
require 'net/http'
require 'base64'
require 'json'
require_relative 'vc__utils__ruby'
$entrypoint = 'api/weather/index'
ENV['RAILS_ENV'] ||= 'production'
ENV['RACK_ENV'] ||= 'production'
ENV['RAILS_LOG_TO_STDOUT'] ||= '1'
$service_route_prefix = resolve_service_route_prefix
def rack_handler(httpMethod, path, body, headers, script_name = '')
require 'rack'
app, _ = Rack::Builder.parse_file($entrypoint)
server = Rack::MockRequest.new app
Closing thoughts
It is worth and fun trying out the Ruby runtime in order to learn something new, or prototype a hosted API in a "non-traditional" way, but I would not recommend it (at least for now) for anything else.
Side note (and this is just my personal view): I disliked Vercel panels and dashboards' UX, they felt a bit clumsy and hard to navigate; needs some love. That being said, I'm eager to see how it evolves in the future.



Top comments (0)