Series: 3-part tutorial on integrating Jitsi Meet video calling into a Rails 8 application, from iframe embedding to self-hosted production deployment.
What you'll build: A complete video conferencing system — virtual classrooms, staff meetings, real-time call notifications — embedded directly in your Rails app with JWT authentication, custom branding, and ActionCable-powered alerts.
Who This Is For
- Rails developers who want to add video calling to an existing app
- Schools, clinics, or businesses that need private video infrastructure
- Anyone tired of paying per-seat for Zoom/Teams when open-source exists
Tech Stack
- Rails 8.0 with Hotwire (Turbo + Stimulus)
- Jitsi Meet (open-source video conferencing)
- ActionCable with SolidCable (database-backed, no Redis)
- JWT for authentication and moderator control
- Hetzner Cloud for the Jitsi server ($5/month)
The Architecture
┌─────────────────────────────┐ ┌─────────────────────────┐
│ Your Rails App │ │ Jitsi Server │
│ (lvhs.ng) │ │ (jitsi.lvhs.ng) │
│ │ │ │
│ ┌───────────────────────┐ │ JWT │ ┌───────────────────┐ │
│ │ StaffMeeting model │──┼────────►│ │ Prosody (XMPP) │ │
│ │ VirtualClass model │ │ token │ │ Jicofo (Focus) │ │
│ │ JitsiTokenService │ │ │ │ JVB (Video Bridge)│ │
│ └───────────────────────┘ │ │ └───────────────────┘ │
│ │ │ │
│ ┌───────────────────────┐ │ iframe │ ┌───────────────────┐ │
│ │ Stimulus controller │──┼────────►│ │ Jitsi Meet Web UI │ │
│ │ (jitsi_controller.js) │ │ embed │ │ (WebRTC) │ │
│ └───────────────────────┘ │ │ └───────────────────┘ │
│ │ │ │
│ ┌───────────────────────┐ │ │ │
│ │ ActionCable channels │ │ │ │
│ │ (real-time alerts) │ │ │ │
│ └───────────────────────┘ │ │ │
└─────────────────────────────┘ └─────────────────────────┘
Why two servers? Jitsi runs its own Nginx, XMPP server, and video bridge — all CPU-intensive. Putting it on the same server as your Rails app would cause port conflicts and performance issues.
Part 1: The Rails Side — Models, Controllers, and Embedding
Step 1: The Models
We need two models for video meetings. The pattern works for any use case — classrooms, consultations, team calls.
Migration:
# db/migrate/xxx_create_staff_meetings.rb
class CreateStaffMeetings < ActiveRecord::Migration[8.0]
def change
create_table :staff_meetings do |t|
t.references :organiser, null: false, foreign_key: { to_table: :users }
t.string :title, null: false
t.text :description
t.integer :meeting_type, default: 0, null: false # quick_call, meeting, panel
t.integer :status, default: 0, null: false # scheduled, live, ended, cancelled
t.string :jitsi_room_name, null: false
t.datetime :scheduled_at
t.integer :duration_minutes, default: 30
t.timestamps
end
add_index :staff_meetings, :jitsi_room_name, unique: true
add_index :staff_meetings, [:organiser_id, :status]
create_table :staff_meeting_participants do |t|
t.references :staff_meeting, null: false, foreign_key: true
t.references :user, null: false, foreign_key: true
t.integer :status, default: 0, null: false # invited, joined, declined
t.datetime :joined_at
t.datetime :left_at
t.timestamps
end
add_index :staff_meeting_participants,
[:staff_meeting_id, :user_id],
unique: true, name: "idx_smp_meeting_user"
end
end
Model:
# app/models/staff_meeting.rb
class StaffMeeting < ApplicationRecord
belongs_to :organiser, class_name: "User"
has_many :staff_meeting_participants, dependent: :destroy
has_many :participants, through: :staff_meeting_participants, source: :user
validates :title, presence: true
validates :jitsi_room_name, presence: true, uniqueness: true
enum :meeting_type, { quick_call: 0, meeting: 1, panel: 2 }
enum :status, { scheduled: 0, live: 1, ended: 2, cancelled: 3 }
before_validation :generate_room_name, on: :create
def start!
update!(status: :live)
end
def end_meeting!
update!(status: :ended)
staff_meeting_participants.where(left_at: nil).find_each do |p|
p.update!(left_at: Time.current)
end
end
def jitsi_domain
ENV.fetch("JITSI_DOMAIN", "meet.jit.si")
end
def jitsi_url
"https://#{jitsi_domain}/#{jitsi_room_name}"
end
def jitsi_embed_url(user)
base = "https://#{jitsi_domain}/#{jitsi_room_name}"
token = JitsiTokenService.new(user, self).generate
params = token ? ["jwt=#{token}"] : []
hash_params = [
"userInfo.displayName=%22#{CGI.escape(user.display_name)}%22",
"config.prejoinPageEnabled=false",
"config.startWithAudioMuted=true",
"config.virtualBackgrounds.enabled=true",
"config.noiseSuppression.enabled=true",
"config.reactions.enabled=true",
"interfaceConfig.SHOW_JITSI_WATERMARK=false",
"interfaceConfig.SHOW_POWERED_BY=false",
"interfaceConfig.TOOLBAR_ALWAYS_VISIBLE=true"
].join("&")
url = base
url += "?#{params.join('&')}" if params.any?
url += "##{hash_params}"
url
end
private
def generate_room_name
return if jitsi_room_name.present?
self.jitsi_room_name = "app-#{meeting_type}-#{SecureRandom.hex(6)}"
end
end
Key design decisions:
- jitsi_room_name is auto-generated with SecureRandom.hex — unique, unguessable
- jitsi_embed_url builds the full iframe URL with config params passed via URL hash
- Config params disable the pre-join screen, enable virtual backgrounds, and hide Jitsi branding
- JWT token is included when JITSI_SECRET is configured (production), omitted in development
Step 2: The JWT Token Service
This generates signed tokens so Jitsi knows who's a moderator and who's a participant.
# app/services/jitsi_token_service.rb
class JitsiTokenService
def initialize(user, room_holder)
@user = user
@room_holder = room_holder
end
def generate
return nil unless self.class.jwt_enabled?
payload = {
iss: app_id,
sub: jitsi_domain,
aud: "jitsi",
room: @room_holder.jitsi_room_name,
exp: 4.hours.from_now.to_i,
nbf: Time.current.to_i,
context: {
user: {
id: @user.id.to_s,
name: @user.display_name,
email: @user.email,
moderator: moderator?.to_s
}
},
moderator: moderator?
}
JWT.encode(payload, secret, "HS256", { typ: "JWT" })
end
def self.jwt_enabled?
ENV["JITSI_APP_ID"].present? && ENV["JITSI_SECRET"].present?
end
def self.jitsi_domain
ENV.fetch("JITSI_DOMAIN", "meet.jit.si")
end
private
def moderator?
@user.staff? # Customize: who gets moderator rights
end
def app_id
ENV.fetch("JITSI_APP_ID", "myapp")
end
def secret
ENV.fetch("JITSI_SECRET", "")
end
def jitsi_domain
self.class.jitsi_domain
end
end
Add the JWT gem to your Gemfile:
gem "jwt", "~> 2.7"
How it works:
- In development (no env vars): returns nil, Jitsi uses anonymous auth
- In production (with env vars): returns a signed JWT, Jitsi validates it and grants moderator to staff
Step 3: The Controller
# app/controllers/meetings_controller.rb
class MeetingsController < ApplicationController
before_action :authenticate_user!
def index
@meetings = StaffMeeting.for_user(current_user)
.includes(:organiser)
.order(created_at: :desc)
end
def create
@meeting = StaffMeeting.new(meeting_params)
@meeting.organiser = current_user
if @meeting.save
invite_participants
if @meeting.quick_call?
@meeting.start!
redirect_to join_meeting_path(@meeting)
else
redirect_to @meeting, notice: "Meeting scheduled."
end
else
render :new, status: :unprocessable_entity
end
end
def join
@meeting = StaffMeeting.find(params[:id])
unless @meeting.live?
redirect_to @meeting, alert: "Meeting is not live."
return
end
# Record attendance
participant = @meeting.staff_meeting_participants
.find_or_create_by(user: current_user)
participant.update!(status: :joined, joined_at: Time.current)
@jitsi_embed_url = @meeting.jitsi_embed_url(current_user)
end
private
def meeting_params
params.require(:staff_meeting).permit(
:title, :description, :meeting_type,
:scheduled_at, :duration_minutes
)
end
def invite_participants
if params[:invite_all]
User.staff.active.where.not(id: current_user.id).find_each do |user|
@meeting.staff_meeting_participants.find_or_create_by(user: user)
end
elsif params[:participant_ids].present?
params[:participant_ids].each do |uid|
@meeting.staff_meeting_participants.find_or_create_by(user_id: uid)
end
end
end
end
Step 4: The Jitsi Embed View
<%# app/views/meetings/join.html.erb %>
<div class="mb-4 flex items-center justify-between">
<div>
<h2 class="text-lg font-bold flex items-center gap-2">
<span class="w-2.5 h-2.5 bg-red-500 rounded-full animate-pulse"></span>
<%= @meeting.title %>
</h2>
<p class="text-sm text-gray-500"><%= @meeting.organiser.display_name %></p>
</div>
<%= link_to "Leave", meetings_path, class: "btn btn-outline text-sm" %>
</div>
<div class="rounded-xl overflow-hidden shadow-lg border"
style="height: calc(100vh - 200px); min-height: 400px;">
<iframe
src="<%= @jitsi_embed_url %>"
class="w-full h-full border-0"
allow="camera; microphone; fullscreen; display-capture; autoplay; clipboard-write"
allowfullscreen>
</iframe>
</div>
Why iframe instead of Jitsi's External API?
The Jitsi External API (JavaScript SDK) requires HTTPS to work (WebRTC secure context). During development on http://localhost, the API loads but WebRTC fails silently — the video just spins forever. The iframe approach works on both HTTP and HTTPS because the iframe content is served from https:// regardless.
In production, you can optionally use the External API for more control (see the Stimulus controller approach in Part 2).
Step 5: The Stimulus Controller (Optional — Enhanced Embed)
For production with HTTPS, the External API gives you more control:
// app/javascript/controllers/jitsi_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["container"]
static values = {
room: String,
domain: { type: String, default: "meet.jit.si" },
displayName: String,
subject: String
}
connect() {
this.loadTimeout = setTimeout(() => this.showFallback(), 10000)
this.loadJitsiApi()
}
disconnect() {
if (this.loadTimeout) clearTimeout(this.loadTimeout)
if (this.api) {
try { this.api.dispose() } catch(e) {}
}
}
loadJitsiApi() {
if (window.JitsiMeetExternalAPI) {
this.initJitsi()
return
}
const script = document.createElement("script")
script.src = `https://${this.domainValue}/external_api.js`
script.async = true
script.onload = () => this.initJitsi()
script.onerror = () => this.showFallback()
document.head.appendChild(script)
}
initJitsi() {
clearTimeout(this.loadTimeout)
const options = {
roomName: this.roomValue,
parentNode: this.containerTarget,
width: "100%",
height: "100%",
userInfo: { displayName: this.displayNameValue },
configOverwrite: {
startWithAudioMuted: true,
prejoinPageEnabled: false,
virtualBackgrounds: { enabled: true },
noiseSuppression: { enabled: true },
reactions: { enabled: true },
toolbarButtons: [
"microphone", "camera", "desktop", "chat",
"raisehand", "reactions", "tileview",
"virtualBackgrounds", "noisesuppression",
"participants-pane", "settings",
"fullscreen", "hangup"
]
},
interfaceConfigOverwrite: {
SHOW_JITSI_WATERMARK: false,
SHOW_POWERED_BY: false,
DEFAULT_BACKGROUND: "#013220",
TOOLBAR_ALWAYS_VISIBLE: true,
PROVIDER_NAME: "My App",
APP_NAME: "Video Call"
}
}
this.containerTarget.innerHTML = ""
this.api = new window.JitsiMeetExternalAPI(this.domainValue, options)
if (this.subjectValue) {
this.api.executeCommand("subject", this.subjectValue)
}
this.api.addEventListener("videoConferenceLeft", () => {
this.containerTarget.innerHTML = `
<div class="flex items-center justify-center h-full bg-gray-900 text-white">
<p>You have left the meeting.</p>
</div>
`
})
}
showFallback() {
const url = `https://${this.domainValue}/${this.roomValue}`
this.containerTarget.innerHTML = `
<div class="flex flex-col items-center justify-center h-full bg-gray-900 text-white p-8 text-center">
<p class="text-lg font-bold mb-4">Video embed unavailable</p>
<a href="${url}" target="_blank" class="bg-blue-500 text-white px-6 py-3 rounded-lg">
Open in Jitsi Meet
</a>
</div>
`
}
}
What You Have So Far
At this point, your app can:
- Create and schedule video meetings
- Embed Jitsi video directly in your pages
- Track who joins and when
- Generate JWT tokens for moderator control (when configured)
- Fall back gracefully if the embed fails
In development: Uses meet.jit.si (free, 5-minute embed limit)
In production: Uses your own Jitsi server (unlimited, full control)
Next: Part 2 — Real-Time Notifications with ActionCable
We'll add incoming call popups, persistent meeting banners, and dashboard widgets so participants know when a meeting is live — all without polling.
Leave a comment