Account-based subdomains in Rails

Account-based subdomains are a powerful feature that can give your app extra security by isolating user data while giving your users the ability to customize their experience. In this article, you'll learn what account-based subdomains are, why they matter, and how to implement them in a Rails application.

For many applications, access is usually through a single domain, such as yourapp.com. This way, the application developer is able to offer a unified experience to all users. This works great most of the time, but imagine a situation where you need to give each user a customized experience; how would you achieve this?

One of the ways you can customize the user experience in a single application is by using account-based subdomains. Instead of giving users a single entry point into your app, offer them customized subdomains: user1.exampleapp.com, user2.exampleapp.com, and so forth. With account-based subdomains, you essentially give users an individualized entry point where they can manage their app experience as they wish.

In this article, you'll learn what account-based subdomains are all about, why they matter, and when you should use them. You'll iteratively build a simple Ruby on Rails application that implements account-based subdomains, which should provide foundational knowledge you can use when building your next app.

Pre-requisites

Here's what you'll need to follow along with this tutorial:

  • Ruby on Rails 7 installed on your development machine (we'll use version 7.1.1 for this tutorial).
  • Some experience using Ruby and Rails since we won’t cover beginner concepts in this tutorial.

You can find the source code for the example app we'll be building here.

What are account-based subdomains?

Account-based subdomains are a common feature of multi-tenant applications and are subdomains named after user accounts: user1.exampleapp.com, account1.exampleapp.com, and so forth.

Each subdomain acts as an individualized entry point for users belonging to that account. This fine-grained separation of users gives you two main advantages:

  • Personalization - Since users are separated at the account level, it's often easier to offer personalized user experiences, such as custom branding and settings.
  • Security - A major concern for any app developer building a multi-tenant app is security, especially how to prevent users from accessing other user accounts. With an account subdomain-based structure, you have more control when it comes to isolating user data.

As you can see, account-based subdomains are great for separating user data in multi-tenant environments.

You can use them in a multi-tenant app that utilizes either a single database or multiple databases. For this tutorial, we'll use a multi-tenant single database because it is simpler to implement.

The app we'll be building

The app we'll be building is a Ruby on Rails 7 to-do app featuring account-based subdomains that allow different users to log into their own accounts and create to-do task lists that cannot be accessed by other users.

Creating the app skeleton

Spin up a new Rails 7 application by running the command below:

rails new todo_app -css=tailwind

It's not necessary to use Tailwind CSS for your app; you can use whatever meets your needs.

Now cd into the app directory and run the command below to create the database (we use SQLite to keep things simple):

cd todo_app
rails db:create

Adding basic user authentication

There’s no need to recreate things from the ground up. Let' use the Devise gem to quickly build out a user authentication system for our to-do app.

Start by adding the Devise gem to the app's Gemfile:

# Gemfile
# ...
gem 'devise'
# ...

Then run bundle install, followed by rails devise:install. Additionally, make sure to follow the instructions that will be printed to console after the Devise installation, especially the one on adding the line config.action_mailer.default_url_options = { host: 'localhost', port: 3000 } to the development configuration. Otherwise, you might experience weird configuration errors.

Next, create the basic user model by running the commands shown below:

rails generate devise User && rails db:migrate

Up to this point, you should have:

  • A working user model
  • Basic authentication routes, including login and logout routes.

Next, let's add a root route to act as our app's home page.

Adding a homepage

We don't need to create a dedicated home page; instead, let's define the login page as the app's homepage by modifying the routes.rb file as shown below:

# config/routes.rb

Rails.application.routes.draw do
  # ...
  devise_scope :user do
    root 'devise/sessions#new'
  end
end

Now spin up the app's server with bin/dev, and you should be greeted by the user login page when you visit localhost:3000.

Login page as the root page

We’re making good progress so far. Let's now shift our attention to the Todo model next.

Modeling to-dos

Let's now add the Todo model with the command shown below:

rails generate scaffold Todo title description:text status:boolean users:references

Then run the migration that will be generated with rails db:migrate.

Finally, open up the User model and modify it to associate it with the newly created Todo model:

# app/models/user.rb

class User < ApplicationRecord
  # ...
  has_many :todos
end

Next, let's make sure a to-do is associated with the user who created it.

Associating to-dos to users

Open the todos_controller.rb file and modify the create method as shown below:

# app/controllers/todos_controller.rb

class TodosController < ApplicationController
 # ...
 def create
    @todo = Todo.new(todo_params)
    @todo.user_id = current_user.id # Add this line
    # ...
  end
  # ...
end

Now, whenever a new to-do is created, its user_id field is populated with the currently logged in user's ID.

With that done, it would be a good idea to redirect users to the index of to-dos after successful login.

Let's build this functionality next.

Redirecting users to the to-do index

Open the application_controller.rb file and add the following lines:

# app/controllers/application_controller.rb

class ApplicationController < ActionController::Base
  # ...
  def after_sign_in_path_for(resource)
    todos_path
  end
end

Additionally, let's also ensure that all Todo CRUD actions can only be performed by authenticated users:

# app/controllers/todos_controller.rb

class TodosController < ApplicationController
  before_action :authenticate_user!
  # ...
end

Go ahead and register a couple of users. Then log in as these users and create a few to-do items. Now, since we haven't created any separation in the user data, it's possible for users to access each other's data, as shown in the following screenshot.

Data visible to all users

Next, let's work on user-data separation and the account-based subdomain structure so that whenever user1 logs into their dashboard, they are only able to see their to-do items. Likewise, user2 and other users of the application will only be able to see their own data.

Separating user data and implementing account-based subdomains

When it comes isolating user data in a multi-tenant environment, there are a few options:

  • Separate databases and schemas for all users - It's possible to isolate each user by creating separate databases and schemas for each one. This is the most secure strategy, but it’s very complex to implement and costly to maintain.
  • Same database, separate schema for all users - Another very secure strategy with separate schemas for each user but still uses the same database. Relatively costly and complex to set up and maintain.
  • Same database, same schema for all users - This is the most common and the most cost-effective method employed by many app developers when dealing with multiple users or accounts. Here, all users share the same database and schema, and it’s the strategy we’ll follow for the app we're building.

It's possible to build multi-tenant functionality with subdomains using various gems, such as the Apartment gem, acts_as_tenant, activerecord_multi_tenant, and others. However, for this tutorial, we will not use any gems for this purpose.

Our first task will be to create a Tenant model to store information about the tenant (or account), including the subdomain name.

Setting up the tenant structure

Create a new model named Account with the attributes subdomain and user_id:

rails generate model Account subdomain user:references

In a real-world multi-tenant application, you would structure this type of tenant model in such a way that multiple users (i.e., members of the same tenant or account) can be associated with a tenant. However, since ours is a simple app, we'll assume that a single tenant is associated with a single user.

Now modify the User and Todo models as shown below:

# app/models/user.rb

class User < ApplicationRecord
  # ...
  has_one :account, dependent: :destroy
end

And the Todo model:

# app/models/todo.rb

class Todo < ApplicationRecord
  belongs_to :account
end

Next, let's modify the user creation action so that an account is created automatically upon user creation.

Creating accounts upon user creation

To automatically create an account upon user creation, we'll need to modify the create action in the Devise registrations controller. Run the following command to generate this controller:

rails generate devise:controllers users -c=registrations
# Depending on how you've setup the user sessions, you might also need to generate the sessions controller as well...
rails generete devise:controllers users -c=sessions

Now modify the routes.rb file as shown below:

# config/routes.rb

Rails.application.routes.draw do
  # ...
  devise_for :users, controllers: {
      sessions: 'users/sessions',
      registrations: 'users/registrations'
    }
  # ...
end

Next, we need to allow a user enter a name attribute when they register since we'll use this as the subdomain attribute when creating an account.

Open the newly generated registrations_controller and modify it by whitelisting the name attribute as shown below:

# app/controllers/users/registrations_controller.rb

class Users::RegistrationsController < Devise::RegistrationsController
  before_action :configure_sign_up_params, only: [:create]

  protected

  def configure_sign_up_params
    devise_parameter_sanitizer.permit(:sign_up, keys: [:name])
  end
end

Then add the name field in the new user registration form:

<!-- app/views/devise/registrations/new.html.erb -->

<h2 class="text-2xl">Sign up</h2>

<%= form_for(resource, as: resource_name, url: registration_path(resource_name)) do |f| %>
  <!-- ... -->

  <div class="mb-6">
    <%= f.label :name %>
    <%= f.text_field :name, autofocus: true %>
  </div>

  <!-- ... -->
<% end %>

Additionally, generate a new migration to add the name column in the users table:

rails generate migration add_column_name_to_users name
rails db:migrate

Then make sure to whitelist this new attribute in the registrations controller:

# app/controllers/users/registrations_controller.rb

class Users::RegistrationsController < Devise::RegistrationsController
  before_action :configure_sign_up_params, only: [:create]

  protected

  def configure_sign_up_params
    devise_parameter_sanitizer.permit(:sign_up, keys: [:name])
  end
end

Finally, we'll need to modify the create action in the same registrations controller to allow for the creation of an associated Account when a new user is created:

# app/controllers/users/registrations_controller.rb

class Users::RegistrationsController < Devise::RegistrationsController
  after_action :create_user_account, only: :create

  protected

  def create_user_account
    Account.create(user_id: resource.id, subdomain: resource.name)
  end
end

You also need to modify redirect behavior after a user successfully registers. Let's keep things simple and redirect the user to the to-dos index like so:

# app/controllers/users/registrations_controller.rb

class Users::RegistrationsController < Devise::RegistrationsController
  # ...

  protected
  # ...

  def after_sign_up_path_for(resource)
    todos_path
  end
end

With that done, whenever a new user registers, an associated tenant account is created for them.

User account created automatically

Account-based subdomains

So far, we have created all the building blocks for setting up account-based subdomains in our to-do app. Let's put these together and finalize the build.

We’ll start by modifying the routes.rb file to add a subdomain constraint as shown below:

# config/routes.rb

Rails.application.routes.draw do
  # ...

  constraints subdomain: /.*/ do
    resources :todos
  end

  # ...
end

Here, we set up a routing constraints block for wild-card subdomains and then nest the todos resource within it. Route constraints are convenient routing structures that enable developers to define specific conditions under which a route matches (you can read more on the topic here.)

Before moving on, we should note that by default, Rails sets the top-level domain name length to 1. However, since we're working with localhost as our domain and need to test out subdomains, we need to set this to 0. Open the development config file and add the line below:

# config/environments/development.rb

config.action_dispatch.tld_length = 0

Next, modify the create action in the to-dos controller to assign an account_id automatically like so:

# app/controllers/todos_controller.rb

class TodosController < ApplicationController
  # ...
  def create
    @todo = Todo.new(todo_params)
    @todo.account_id = current_user.account.id
    # ...
  end
  # ...
end

Then modify the index action to make sure to-dos are scoped to the current account:

# app/controllers/todos_controller.rb

class TodosController < ApplicationController
  # ...
  def index
    if request.subdomain.present?
      current_account = Account.find_by_subdomain(request.subdomain)
    else
      current_account = current_user.account
    end

    @todos = Todo.where(account_id: current_account.id)
  end
  # ...
end

Now if you create a few to-do items while logged in as different users, you should be able to see the data isolation at work in the to-dos listing as shown in the screenshot below:

Separate user data

Wrapping up

In this article, we've explained how to easily implement account-based subdomains in your Rails application. We've also learned why account-based subdomains are a powerful and useful tool. Obviously, the setup we've implemented is not production-ready, but it provides a good foundation upon which to base your next app build.

There are several ways to improve the structure of the app we've built, including utilizing custom middleware to match subdomain requests to the app's resources, using concerns to abstract away some of the code in the to-dos controller, and much more that we won't get into for now. Hopefully, you'll take it as a challenge to implement these on your own.

Happy coding!

What to do next:
  1. Try Honeybadger for FREE
    Honeybadger helps you find and fix errors before your users can even report them. Get set up in minutes and check monitoring off your to-do list.
    Start free trial
    Easy 5-minute setup — No credit card required
  2. Get the Honeybadger newsletter
    Each month we share news, best practices, and stories from the DevOps & monitoring community—exclusively for developers like you.
    author photo

    Aestimo Kirina

    Aestimo is a family guy, Ruby developer, and SaaS enterpreneur. In his free time, he enjoys playing with his kids and spending time outdoors enjoying the sun, running, hiking, or camping.

    More articles by Aestimo Kirina
    Stop wasting time manually checking logs for errors!

    Try the only application health monitoring tool that allows you to track application errors, uptime, and cron jobs in one simple platform.

    • Know when critical errors occur, and which customers are affected.
    • Respond instantly when your systems go down.
    • Improve the health of your systems over time.
    • Fix problems before your customers can report them!

    As developers ourselves, we hated wasting time tracking down errors—so we built the system we always wanted.

    Honeybadger tracks everything you need and nothing you don't, creating one simple solution to keep your application running and error free so you can do what you do best—release new code. Try it free and see for yourself.

    Start free trial
    Simple 5-minute setup — No credit card required

    Learn more

    "We've looked at a lot of error management systems. Honeybadger is head and shoulders above the rest and somehow gets better with every new release."
    — Michael Smith, Cofounder & CTO of YvesBlue

    Honeybadger is trusted by top companies like:

    “Everyone is in love with Honeybadger ... the UI is spot on.”
    Molly Struve, Sr. Site Reliability Engineer, Netflix
    Start free trial
    Are you using Sentry, Rollbar, Bugsnag, or Airbrake for your monitoring? Honeybadger includes error tracking with a whole suite of amazing monitoring tools — all for probably less than you're paying now. Discover why so many companies are switching to Honeybadger here.
    Start free trial
    Stop digging through chat logs to find the bug-fix someone mentioned last month. Honeybadger's built-in issue tracker keeps discussion central to each error, so that if it pops up again you'll be able to pick up right where you left off.
    Start free trial
    “Wow — Customers are blown away that I email them so quickly after an error.”
    Chris Patton, Founder of Punchpass.com
    Start free trial