DEV Community

Cover image for Set up a basic multi-tenant architecture in Rails without gem - 2
Harsh patel
Harsh patel

Posted on • Updated on

Set up a basic multi-tenant architecture in Rails without gem - 2

Please go through this article
Multi tenancy with Apartment Gem

A simple multi-tenancy architecture in Rails without using the apartment gem can be implemented using a subdomain-based approach. Here's an outline of the steps:

Set up your Rails app with subdomains routing:

# config/routes.rb
Rails.application.routes.draw do
  constraints(subdomain: /.+/) do
    resources :tenants, only: [:show]
    resources :posts, only: [:index, :show]
    root to: 'tenants#show'
  end
  root to: 'home#index'
end
Enter fullscreen mode Exit fullscreen mode

Create a Tenant model to store information about each tenant:

# app/models/tenant.rb
class Tenant < ApplicationRecord
  validates :subdomain, presence: true, uniqueness: true
  has_many :posts
end
Enter fullscreen mode Exit fullscreen mode

Create a middleware to set the current tenant based on the subdomain:

# app/middleware/current_tenant.rb
class CurrentTenant
  def initialize(app)
    @app = app
  end

  def call(env)
    tenant = Tenant.find_by(subdomain: request.subdomain)
    if tenant
      RequestStore.store[:tenant] = tenant
      @app.call(env)
    else
      [404, { 'Content-Type' => 'text/plain' }, ['Tenant not found.']]
    end
  end

  private

  def request
    @request ||= Rack::Request.new(env)
  end
end
Enter fullscreen mode Exit fullscreen mode

Use the middleware in your Rails app:

# config/application.rb
config.middleware.use CurrentTenant
Enter fullscreen mode Exit fullscreen mode

Scope your models to the current tenant:

# app/models/post.rb
class Post < ApplicationRecord
  belongs_to :tenant

  default_scope -> { where(tenant_id: RequestStore[:tenant].id) }
end
Enter fullscreen mode Exit fullscreen mode

Use the tenant data in your controllers and views:

# app/controllers/tenants_controller.rb
class TenantsController < ApplicationController
  def show
    @tenant = RequestStore[:tenant]
  end
end
Enter fullscreen mode Exit fullscreen mode
# app/views/tenants/show.html.erb
<h1><%= @tenant.name %></h1>
<%= link_to 'Posts', posts_path %>
Enter fullscreen mode Exit fullscreen mode

This implementation provides a basic setup for a multi-tenancy architecture in Rails, with the tenant information being set based on the subdomain, and the models being scoped to the current tenant. You can further add error handling and security measures as required.

Example Request: GET http://tenant1.example.com/posts

Example Response:

HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
...

<h1>Tenant 1</h1>
<a href="/posts">Posts</a>
Enter fullscreen mode Exit fullscreen mode

2nd Example

class Tenant < ApplicationRecord
  has_many :users
end

class User < ApplicationRecord
  belongs_to :tenant
end

Enter fullscreen mode Exit fullscreen mode
class TenantMiddleware
  def initialize(app)
    @app = app
  end

  def call(env)
    tenant = Tenant.find_by(subdomain: request.subdomain)
    if tenant
      Current.tenant = tenant
    else
      raise ActiveRecord::RecordNotFound
    end

    @app.call(env)
  end
end

Enter fullscreen mode Exit fullscreen mode

Add a scope to your User model:

class User < ApplicationRecord
  belongs_to :tenant
  default_scope -> { where(tenant: Current.tenant) }
end
Enter fullscreen mode Exit fullscreen mode

Configure your database connection:

class ApplicationRecord < ActiveRecord::Base
  self.abstract_class = true
  establish_connection(ENV.fetch("#{Rails.env}_DATABASE_URL"))
end

class Tenant < ApplicationRecord
  establish_connection("#{Rails.env}_tenant_#{id}")
  has_many :users
end

class User < ApplicationRecord
  belongs_to :tenant
  default_scope -> { where(tenant: Current.tenant) }
end
Enter fullscreen mode Exit fullscreen mode

Now, the application will automatically switch database connections based on the subdomain in the request URL. This way, each tenant will have their own isolated data.

Example request-response:

Request:
GET http://tenant1.example.com/users

Response -:

[
  {
    "id": 1,
    "name": "User 1",
    "email": "user1@example.com"
  },
  {
    "id": 2,
    "name": "User 2",
    "email": "user2@example.com"
  }
]
Enter fullscreen mode Exit fullscreen mode

3rd Example

  1. First, you'll need to create a model to represent the tenant. For example:
class Tenant < ApplicationRecord
  has_many :photos

  validates :name, presence: true, uniqueness: true
end
Enter fullscreen mode Exit fullscreen mode
  1. Next, you'll need to create a model to represent the photos that are uploaded by each tenant. For example:
class Photo < ApplicationRecord
  belongs_to :tenant

  has_one_attached :image

  validates :image, presence: true
end
Enter fullscreen mode Exit fullscreen mode
  1. You'll need to create a mechanism to set the tenant context for each request. One way to do this is to use a before_actionin your ApplicationController. For example:
class ApplicationController < ActionController::Base
  before_action :set_tenant

  private

  def set_tenant
    tenant = Tenant.find_by(subdomain: request.subdomain)
    Tenant.set_current_tenant(tenant)
  end
end
Enter fullscreen mode Exit fullscreen mode

4.In your controller, you'll need to handle the file attachment and save it to the database. For example:

class PhotosController < ApplicationController
  def new
    @photo = Photo.new
  end

  def create
    @photo = Tenant.current_tenant.photos.new(photo_params)

    if @photo.save
      redirect_to @photo, notice: "Photo was successfully uploaded."
    else
      render :new
    end
  end

  private

  def photo_params
    params.require(:photo).permit(:image)
  end
end
Enter fullscreen mode Exit fullscreen mode
  1. In your routes, you'll need to specify the tenant_id in the URL to ensure that each photo is associated with the correct tenant. For example:
constraints subdomain: /^[\w-]+/ do
  resources :photos, only: [:new, :create]
end

Enter fullscreen mode Exit fullscreen mode
  1. Finally, you'll need to add a form field for the file attachment in your view. For example:
<%= form_with model: @photo, local: true do |form| %>
  <% if @photo.errors.any? %>
    <div id="error_explanation">
      <h2><%= pluralize(@photo.errors.count, "error") %> prohibited this photo from being saved:</h2>

      <ul>
        <% @photo.errors.full_messages.each do |message| %>
          <li><%= message %></li>
        <% end %>
      </ul>
    </div>
  <% end %>

  <div class="field">
    <%= form.label :image %>
    <%= form.file_field :image %>
  </div>

  <div class="actions">
    <%= form.submit %>
  </div>
<% end %>
Enter fullscreen mode Exit fullscreen mode

Here's an example of an HTTP request and response for uploading a photo for a tenant with subdomain "tenant1":

Request -:

POST http://tenant1.yourdomain.com/photos
Content-Type: multipart/form-data

photo[image]: (binary data for the photo image file)

Enter fullscreen mode Exit fullscreen mode

Response -:

HTTP/1.1 201 Created
Content-Type: application/json

{
  "status": "success",
  "message": "Photo was successfully uploaded."
}

Enter fullscreen mode Exit fullscreen mode

Here's an example of an HTTP request and response for uploading a photo:

#Request -:

POST /tenants/1/photos
Content-Type: multipart/form-data

photo[image]: (binary data for the photo image file)
Enter fullscreen mode Exit fullscreen mode
#Response -:

HTTP/1.1 201 Created
Content-Type: application/json

{
  "status": "success",
  "message": "Photo was successfully uploaded."
}
Enter fullscreen mode Exit fullscreen mode

Oldest comments (2)

Collapse
 
superails profile image
Yaroslav Shmarov

Hey Harsh, thanks for the great write-up!
How would you deal with subdomain multitenancy and ssl when deploying to production?

Collapse
 
harsh_u115 profile image
Harsh patel

Hello @superails
Thanks for taking some time to read my blog and write your question.

According to my knowledge, when deploying, we can include a wildcard in the nginx server configuration file.

server {
  listen 80;
  server_name ~^(?<subdomain>.+)\.example\.com$;
  return 301 https://$host$request_uri;
}

server {
  listen 443 ssl;
  server_name ~^(?<subdomain>.+)\.example\.com$;

  ssl_certificate /path/to/cert.pem;
  ssl_certificate_key /path/to/cert.key;

  location / {
    proxy_pass http://app_server;
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
  }
}
Enter fullscreen mode Exit fullscreen mode

Let me know if i miss anything or i need to add here.
Thanks