iTranslated by AI
Deploying a Rails Application to Vercel with ISR
After going through "Investigating ISR Support in Nuxt3" and "Building a Custom Runtime for Serverless Functions", I've reached a point where I mostly understand Vercel. To take things further, I experimented with running ISR on Rails.
Conditions
- Run on Vercel's Serverless Function ruby27 runtime (equivalent to AWS Lambda).
a. Building everything with a Custom Runtime seemed like a lot of work, so I won't consider it. - Use Build Output API (v3) for On-Demand Incremental Static Regeneration.
a. I want to verify if this is possible with things other than JavaScript frameworks. - Since it is a Rails application, I want to implement MVC using Views and AR.
- Use PlanetScale's MySQL-compatible server for the database.
a. I confirmed that Aurora Serverless is also usable, but I heard that the provider handles connection pooling issues on their end, so I want to try it out.
Base Source Code
Actually, there is a minimal Rails snippet within the Ruby runtime development code. I will modify this.
From there, I did the following:
- Updated to version 7.
- Added Propshaft.
- Added database settings for mysql2.
Final Gemfile:
source "https://rubygems.org"
ruby "~> 2.7.x"
git_source(:github) { |repo| "https://github.com/#{repo}.git" }
gem 'rails', '~> 7.0.3.1'
gem "propshaft", "~> 0.6.4"
gem "mysql2", "~> 0.5.4"
Lambda Handler
Create an entry point so that the Rails application can run on Lambda. The process is as follows:
- Start 'config.ru' as a Rack application.
- Extract request information from the payload passed by the Vercel runtime.
- Convert it into a request object.
- Get the response from the Rack application and return it as the result.
require 'rack'
require 'base64'
require 'json'
$entrypoint = 'config.ru'
ENV['RAILS_ENV'] ||= 'production'
ENV['RAILS_LOG_TO_STDOUT'] ||= '1'
def rack_handler(httpMethod, path, body, headers)
app, _ = Rack::Builder.parse_file($entrypoint)
server = Rack::MockRequest.new app
env = headers.transform_keys { |k| k.split('-').join('_').prepend('HTTP_').upcase }
res = server.request(httpMethod, path, env.merge({ :input => body }))
{
:statusCode => res.status,
:headers => res.original_headers,
:body => res.body,
}
end
def main(event:, context:)
payload = JSON.parse(event['body'])
path = payload['path']
headers = payload['headers']
httpMethod = payload['method']
encoding = payload['encoding']
body = payload['body']
if (not body.nil? and not body.empty?) and (not encoding.nil? and encoding == 'base64')
body = Base64.decode64(body)
end
return rack_handler(httpMethod, path, body, headers)
end
Requests for assets that are not handled by the Rails application can be managed through Vercel's routing settings, so they are not included here.
Rails Initialization Settings
require_relative 'boot'
# Load only what's necessary
# require "rails/all"
require "action_controller/railtie"
require "action_view/railtie"
require "active_record/railtie"
Bundler.require(*Rails.groups)
module App
class Application < Rails::Application
config.load_defaults 7.0
end
end
Rails.application.configure do
config.cache_classes = true
config.eager_load = true
config.consider_all_requests_local = false
config.action_controller.perform_caching = true
config.public_file_server.enabled = ENV['RAILS_SERVE_STATIC_FILES'].present?
config.log_level = :debug
config.log_tags = [ :request_id ]
config.i18n.fallbacks = true
config.active_support.deprecation = :notify
config.log_formatter = ::Logger::Formatter.new
if ENV["RAILS_LOG_TO_STDOUT"].present?
logger = ActiveSupport::Logger.new(STDOUT)
logger.formatter = config.log_formatter
config.logger = ActiveSupport::TaggedLogging.new(logger)
end
end
I think these are mostly defaults. Loading action_mailer or active_storage here would cause an application startup error.
Building the mysql2 gem
'An error occurred while installing mysql2 (0.5.4), and Bundler cannot continue.\n' +
Even if you add mysql2 to your Gemfile, it cannot be installed in the Vercel environment. This is because the Lambda environment lacks the libmysql headers and libraries, so you need to prepare them yourself and upload them along with your application.
Someone has published a gem called customink/mysql2-lambda which includes pre-compiled static libraries, and replacing the original with that might solve the problem. However, I decided to build it myself to better understand the platform.
FROM public.ecr.aws/lambda/ruby:2.7
WORKDIR ${LAMBDA_TASK_ROOT}
ENV RAILS_ENV=production
RUN yum -y install gcc make ruby-devel mysql-devel
ENTRYPOINT []
Prepare a Dockerfile like this and cross-compile for an x86 Amazon Linux environment.
#!/usr/bin/env bash
set -e -o pipefail
bundle config set path 'vendor/bundle'
bundle config set without 'development'
bundle config set build.mysql2 "--with-mysql-dir=${PWD}/lib/mysql"
mkdir -p ./lib/mysql/lib
cp -af /usr/lib64/mysql/* ./lib/mysql/lib
cp -af /usr/include/mysql ./lib/mysql/include
bundle install --redownload
Place lib/mysql directly under the directory to be uploaded and put the files to be linked there. Configure it so that these are used as compilation options during bundle install. Also, ensure the Bundler installation path is mounted and written out from the host side.
$ docker build -t railson .
$ docker run -v $PWD:/var/task -it railson build.sh
Confirm that the installed mysql2 gem is linked to the local library:
$ docker run -v $PWD:/var/task -it railson ldd vendor/bundle/ruby/2.7.0/gems/mysql2-0.5.4/lib/mysql2/mysql2.so
libruby.so.2.7 => /var/lang/lib/libruby.so.2.7 (0x0000004001c3e000)
libmysqlclient.so.18 => /var/task/lib/mysql/lib/libmysqlclient.so.18 (0x000000400216c000)
# ...
DB Connection Settings
As per the documentation, set the CA location for SSL connections to the path within the Lambda environment. Load the DATABASE_URL by storing it in Vercel's Secrets.
default: &default
timeout: 5000
development:
<<: *default
test:
<<: *default
production:
<<: *default
# mysql2://myuser:mypassword@myhost.us-east-2.psdb.cloud/mydb?ssl_mode=verify_identity&sslca=/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem
url: <%= ENV['DATABASE_URL'] %>
Converting to Build Output API (v3) format
Since the current setup alone is insufficient for deploying to Vercel, we need to add the configuration and directory structure required by the Build Output API v3 specifications.
Directory structure
Place the Rails application into a Serverless Function named index.func. This will be executed at the root / when deployed to Vercel.
$ tree .vercel/output -L 3
.vercel/output
├── config.json
└── functions
├── index.func
│ ├── .vc-config.json
│ ├── Gemfile
│ ├── Gemfile.lock
│ ├── Rakefile
│ ├── app/
│ ├── app.rb
│ ├── config/
│ ├── config.ru
│ ├── db/
│ ├── lib/
│ └── vendor/bundle
└── index.prerender-config.json
While it's possible to handle everything with a single endpoint, it feels more natural to split them into separate functions since the prerender-config.json settings used for ISR are applied per function.
When splitting, you would create separate functions for each endpoint, such as articles.func and settings.func (similar to standard FaaS architectures). However, it might be a bit tricky since features like Lambda layers are not available.
The following files are related to the Build Output API:
{
"version": 3
}
{
"handler": "app.main",
"runtime": "ruby2.7"
}
{
"expiration": 180,
"bypassToken": "mybypassToken",
"allowQuery": []
}
Since there is a 50MB limit on the source code uploaded for the index.func directory, you'll likely need to meticulously exclude files using .vercelignore or eventually implement per-function rebundling.
Implementing Views and DB Loading
I kept it simple as follows:
- Include the rendering time and an image in the CDN for verifying ISR.
- Output the content loaded from the DB into the HTML.
Rails.application.routes.draw do
get '/index' => 'articles#index'
nd
require 'net/https'
class ArticlesController < ApplicationController
def index
@articles = Article.all
resp = Net::HTTP.get_response(URI.parse('https://picsum.photos/400'))
@image_url_full = resp.header['location']
@now = Time.now
end
end
<h1>ISR Demo</h1>
<div>
<div>
<img src="<%= @image_url_full %>" alt="ISR">
</div>
<div>
<p>Rendered: <%= @now %></p>
</div>
<div>
<% @articles.each do |article| %>
<p>
<%= article.title %>
</p>
<% end %>
</div>
</div>
Deploying the Preview Version
Verify by uploading the contents of the .vercel/ directory built locally as is. Note that prebuilt cannot be deployed to production.
$ vercel deploy --prebuilt

Deploying the Production Version
Once verified with the preview version, apply it to production. However, if you change it as follows, vercel build will run on Vercel's build server, and the local .vercel/ directory will not be reflected.
$ vercel deploy --prod
In Build your own web framework – Vercel, they discuss dynamically generating the output directory using Node.js during build time. While you could do the same, I was looking for a simpler method and noticed that pushing the output directory directly to the GitHub repository allows it to be reflected in the production version.
In other words, a rough approach would look like this:
$ rsync -av ./.vercel/output/ ../prod-repo/.vercel/output/
# git push in prod-repo
However, this is likely a method intended for CI builds of SSG software and is probably not the standard approach.
Verification
Check the actually deployed site.
- Since the database is located in us-east, the Function was also set to us-east.
- Every 180 seconds, results cached at the edge are returned asynchronously.
You can confirm this with x-vercel-cache: HIT.
curl -v https://railson.vercel.app/ -s
# ...
< content-length: 393
< x-vercel-cache: HIT
< age: 149
< server: Vercel
By adding the bypassToken to the header, you can retrieve the latest results.
$ curl -v https://railson.vercel.app/ -s -H "x-prerender-revalidate: $MYBYPASSTOKEN"
# ...
< content-length: 393
< x-vercel-cache: REVALIDATED
< age: 0
< server: Vercel
Discussion