Note: Rails API on AWS Lambda with Lamby

What's Lamby
Lamby is an AWS Lambda Web Adapter for Rack applications.
Lamby acts as a bridge (adapter) that translates AWS Lambda event data (which comes from API Gateway, ALB, etc.) into a Rack env hash. This makes it possible for Rails (or any Rack-based app) to handle requests as if it were running behind a traditional web server like Puma or Unicorn.
With Lamby on AWS Lambda, the web server is eliminated:
• AWS receives the HTTP request via API Gateway or ALB.
• Lambda is invoked with the HTTP event.
• Lamby takes that event, converts it into a Rack-compatible env hash.
• Your Rails app handles it via its Rack interface and returns a response.
• Lamby converts the Rack response back into the format expected by AWS Lambda and returns it.
This is event-based, not request-thread-based. Each Lambda invocation handles one HTTP request.

Why switch from Ruby on Jets to Lamby?
Ruby on Jets is a full-fledged serverless framework for Ruby, inspired by frameworks like Serverless.js and Rails. It brings its own DSL and conventions, somewhat similar to Rails, but not a direct drop-in for existing Rails apps. It includes support for controllers, jobs, tasks, and infrastructure as code (IAC) — an all-in-one framework.
I've been using Ruby on Jets for my Ruby Lambda app, but I've decided to migrate to Lamby because:
- Ruby on Jets made a significant architecture shift twice: v4 -> v5 and v5 -> v6
- It's started asking for a Jets account to set up any environments and charging for the number of CloudFormation stacks ($30 / stack / month) since v6.
- It comes with its own DSL, conventions, and dependencies, which are considered a bit overkill for my relatively simple Lambda app even though it takes care of all required environment setups with minimal effort.

Required development setup with Lamby
I'll use Rails + Puma for development and test. Even though sam local start-api
could be used to simulate the actual Lambda environment, I decided not to use it as I faced a lot of difficulties and issues with the SAM local setup because the Lambda app is quite simple so developing/testing it as a Rails app with Puma would be okay as I deploy it to the smoke-prod env before every production deployment.
Rails new
rails new lamby-demo --api \
--database=mysql \
--skip-test-unit \
--skip-active-storage \
--skip-action-cable \
--skip-bundle \
--skip-git
Update Gemfile
Update Gemfile as you want to use Puma for development/test and Lamby for production and include other necessary gems like RSpec.
source "https://rubygems.org"
gem "rails", "~> 8.0.2"
# Use mysql as the database for Active Record
gem "mysql2", "~> 0.5"
# Build JSON APIs with ease [https://github.com/rails/jbuilder]
# gem "jbuilder"
# Use Active Model has_secure_password [https://guides.rubyonrails.org/active_model_basics.html#securepassword]
# gem "bcrypt", "~> 3.1.7"
# Windows does not include zoneinfo files, so bundle the tzinfo-data gem
gem "tzinfo-data", platforms: %i[ windows jruby ]
# Reduces boot times through caching; required in config/boot.rb
gem "bootsnap", require: false
# Use Rack CORS for handling Cross-Origin Resource Sharing (CORS), making cross-origin Ajax possible
# gem "rack-cors"
group :production do
gem "lamby"
end
group :development, :test do
# Use Puma for development & test
gem "puma", ">= 5.0"
# See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem
gem "debug", platforms: %i[ mri windows ], require: "debug/prelude"
# Static analysis for security vulnerabilities [https://brakemanscanner.org/]
gem "brakeman", "~> 7.0.2", require: false
# Omakase Ruby styling [https://github.com/rails/rubocop-rails-omakase/]
gem "rubocop-rails-omakase", require: false
# RSpec for testing
gem "rspec-rails", "~> 7.1.1"
gem "factory_bot_rails", "~> 6.4.0"
gem "faker", "~> 3.5.1"
gem "dotenv"
end
group :test do
gem "database_cleaner-active_record", "~> 2.2.0"
end
Dockerization
FROM ruby:3.3.7
RUN apt-get update
RUN apt-get install -y vim unzip curl netcat-openbsd
ENV APP /usr/src/app
RUN mkdir $APP
WORKDIR $APP
# Environment + bundler config
ENV BUNDLE_IGNORE_CONFIG=1
ENV BUNDLE_PATH=./vendor/bundle
ENV BUNDLE_CACHE_PATH=./vendor/cache
ENV RAILS_SERVE_STATIC_FILES=1
COPY Gemfile* $APP/
RUN bundle install -j3
COPY . $APP/
CMD ["./bin/server-dev"]
services:
db:
image: mysql:8.0.20
platform: linux/x86_64
command: --default-authentication-plugin=mysql_native_password --sql_mode=""
environment:
MYSQL_ROOT_PASSWORD: password
ports:
- '3306:3306'
lamby-demo:
build:
context: .
dockerfile: Dockerfile.dev
volumes:
- .:/usr/src/app
ports:
- '8080:8080'
depends_on:
- db
stdin_open: true
tty: true
environment:
DB_USERNAME: root
DB_PASSWORD: password
DB_PORT: 3306
DB_HOST: db
RAILS_MAX_THREADS: 5
RAILS_ENV: development
bin/server-dev
#!/bin/bash -i
# Wait for MySQL
until nc -z -v -w30 $DB_HOST $DB_PORT; do
echo 'Waiting for MySQL...'
sleep 1
done
echo "MySQL is up and running!"
# If the container has been killed, there may be a stale pid file
# preventing rails from booting up
rm -f tmp/pids/server.pid
bundle exec rails server -p 8080 -b 0.0.0.0

Deployment with GitHub Actions
Overview
Dockerfile
# Start with the official AWS Lambda base image for Ruby 3.3
FROM public.ecr.aws/lambda/ruby:3.3-x86_64
ARG RAILS_MASTER_KEY
ARG ENVIRONMENT
ENV RAILS_MASTER_KEY=$RAILS_MASTER_KEY
ENV RAILS_ENV=production
ENV ENVIRONMENT=$ENVIRONMENT
# Install build tools & dev libraries for psych (YAML) + mysql2
# XXX 'mariadb-devel' doesn't exist on Amazon Linux 2023.
# Use mariadb-connector-c-devel instead.
RUN dnf install -y \
gcc gcc-c++ make \
libyaml-devel \
mariadb-connector-c-devel \
&& dnf clean all
# Set the working directory to /var/task (already the default in AWS base images)
WORKDIR /var/task
# Bundler config to vendor gems inside the image
ENV BUNDLE_IGNORE_CONFIG=1
ENV BUNDLE_PATH=./vendor/bundle
ENV BUNDLE_CACHE_PATH=./vendor/cache
ENV BUNDLE_WITHOUT=development:test
ENV BUNDLE_DEPLOYMENT=true
ENV BUNDLE_FROZEN=true
# Copy Gemfiles and install gems
COPY Gemfile Gemfile.lock ./
RUN bundle install --jobs=3
# Copy the rest of your application code
COPY . .
ENV BOOTSNAP_CACHE_DIR=/var/task/tmp/cache
RUN bundle exec bootsnap precompile --gemfile . \
&& bundle exec ruby config/environment.rb
# The AWS Lambda base images come with the Runtime Interface Client (RIC) and entrypoint.
CMD ["app.handler"]
template.yaml
The template YAML for the AWS SAM deploy command:
AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: Rails + Lamby
Globals:
Function:
Timeout: 30
Architectures:
- x86_64
Environment:
Variables:
DATABASE_URL: !Ref DatabaseUrl
AWS_ECR_IMAGE_URI: !Ref AwsEcrImageUri
Parameters:
DatabaseUrl:
Type: String
AwsEcrImageUri:
Type: String
Resources:
RailsAppFunction:
Type: AWS::Serverless::Function
Properties:
AutoPublishAlias: live
FunctionUrlConfig:
AuthType: NONE
DeploymentPreference:
Type: AllAtOnce
MemorySize: 1792
PackageType: Image
ImageUri: !Ref AwsEcrImageUri
Events:
HttpApi:
Type: HttpApi
Policies:
- Statement:
- Effect: Allow
Action:
- logs:CreateLogGroup
- logs:CreateLogStream
- logs:PutLogEvents
- cloudwatch:PutMetricData
Resource: "*"
bin/deploy
The deploy command for the app:
#!/usr/bin/env bash
set -euo pipefail
STACK_NAME="lamby-demo-${ENVIRONMENT}"
REGION="${AWS_REGION}"
echo "Installing SAM CLI..."
curl -sSL -o sam-install.zip https://github.com/aws/aws-sam-cli/releases/latest/download/aws-sam-cli-linux-x86_64.zip && \
unzip -q sam-install.zip -d sam-installation && \
./sam-installation/install && \
rm -rf sam-install.zip sam-installation
sam --version
echo "Deploying..."
# Create a temp file to store the output
DEPLOY_LOG=$(mktemp)
sam deploy \
--template-file template.yaml \
--stack-name "$STACK_NAME" \
--region "$REGION" \
--capabilities CAPABILITY_IAM \
--no-fail-on-empty-changeset \
--on-failure ROLLBACK \
--image-repositories RailsAppFunction=${AWS_ECR_REGISTRY}/lamby-demo \
--parameter-overrides \
Environment="$ENVIRONMENT" \
DatabaseUrl="${DATABASE_URL}" \
AwsEcrImageUri="${AWS_ECR_IMAGE_URI}" \
2>&1 | tee "$DEPLOY_LOG"
# Grab the last line of the deploy log
LAST_LINE=$(tail -n 1 "$DEPLOY_LOG")
EXPECTED="Successfully created/updated stack - $STACK_NAME in $REGION"
if grep -q "$EXPECTED" "$DEPLOY_LOG"; then
echo "Deployment succeeded."
else
echo "Deployment failed or incomplete."
exit 1
fi
.github/workflows/deploy.yml
The deploy action on GitHub Actions supporting smoke-prod
and production
:
name: Deploy
on:
workflow_dispatch:
inputs:
environment:
description: 'Deploy to:'
required: true
default: 'smoke-prod'
type: choice
options:
- smoke-prod
- production
jobs:
deploy:
runs-on: ubuntu-22.04
env:
ENVIRONMENT: ${{ github.event.inputs.environment }}
RAILS_MASTER_KEY: ${{ secrets.RAILS_MASTER_KEY }}
DATABASE_URL: ${{ secrets.DATABASE_URL }}
AWS_REGION: us-east-1
AWS_ECR_REGISTRY: ${{ secrets.AWS_ECR_REGISTRY }}
IMAGE_TAG: ${{ github.sha }}
steps:
- name: Echo selected deployment environment
run: echo "${{ env.ENVIRONMENT }} deployment is initiated."
- name: Set AWS ECR image URI
run: echo "AWS_ECR_IMAGE_URI=${AWS_ECR_REGISTRY}/lamby-demo:${IMAGE_TAG}" >> $GITHUB_ENV
- name: Checkout code
uses: actions/checkout@v4
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: ${{ env.AWS_REGION }}
- name: Log in to Amazon ECR
run: |
aws ecr get-login-password --region $AWS_REGION \
| docker login --username AWS --password-stdin $AWS_ECR_REGISTRY
- name: Build Docker image
run: |
docker build --build-arg RAILS_MASTER_KEY=$RAILS_MASTER_KEY --build-arg ENVIRONMENT=$ENVIRONMENT -t lamby-demo . && \
docker tag lamby-demo "$AWS_ECR_IMAGE_URI"
- name: Check if image already exists in ECR
id: ecr-image-check
run: |
set -e
if aws ecr describe-images \
--repository-name lamby-demo \
--image-ids imageTag=$IMAGE_TAG \
--region $AWS_REGION > /dev/null 2>&1; then
echo "Image ${IMAGE_TAG} already exists in ECR"
echo "exists=true" >> $GITHUB_OUTPUT
else
echo "Image ${IMAGE_TAG} does not exist in ECR"
echo "exists=false" >> $GITHUB_OUTPUT
fi
- name: Push Docker Image to ECR
if: steps.ecr-image-check.outputs.exists == 'false'
run: docker push "$AWS_ECR_IMAGE_URI"
- name: Deploy via Docker container
run: |
timeout 5m docker run --rm \
--entrypoint "/bin/bash" \
-e DATABASE_URL \
-e AWS_ECR_IMAGE_URI \
-e AWS_ACCESS_KEY_ID \
-e AWS_SECRET_ACCESS_KEY \
-e AWS_REGION \
-e AWS_ECR_REGISTRY \
lamby-demo ./bin/deploy

How to enable the ColdStart metrics
if Rails.env.production?
Lamby::Config.configure do |config|
config.cold_start_metrics = true
config.metrics_app_name = "lamby-demo-#{ENV['ENVIRONMENT']}"
puts "[Lamby] Cold start metrics enabled for #{config.metrics_app_name}"
end
end

Switch from Ruby on Jets to Lamby without downtime
It's possible to switch the production domain from Jets' API to Lamby's API without downtime, as long as both API Gateways use the same custom domain name configuration.
You just need to go to the custom domain name on the API Gateway dashboard and update the bath path mapping from one to the other.
As a result:
- The CloudFront distribution (used under the hood by API Gateway custom domains) stays the same.
- Your DNS record (the CNAME in Route 53) does not change.
- The request routing to Lamby's API happens within milliseconds and has no downtime.