Skip to content
April 06, 2026 · 8 min read Ruby on Rails

Building a Self-Hosted Video Calling System with Rails 8 & Jitsi Meet (Part 1:)

3-part tutorial on integrating Jitsi Meet video calling into a Rails 8 application, from iframe embedding to self-hosted production deployment.

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

text
┌─────────────────────────────┐         ┌─────────────────────────┐
│  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:

ruby
# 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:

ruby
# 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.

ruby
# 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:

ruby
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

ruby
# 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

erb
<%# 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:

javascript
// 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.

Filed under Ruby on Rails

Discussion

Comments

Leave a comment

No comments yet. Be the first to share your thoughts!

Enjoyed this post?

Subscribe to get notified about new articles.