How to Build a Ruby on Rails App using YugabyteDB and PostgreSQL
Introduction
Since its emergence in 2005, Ruby on Rails has been regarded as a tried and true web development framework. Many developers value the built-in scaffolding, testing, and ORM features that Rails provides.
The framework supports many relational databases, with PostgreSQL the most popular choice. Applications can be easily configured to run on YugabyteDB to improve scalability and resilience.
This blog demonstrates how easy it is to get up and running with Ruby on Rails, backed by PostgreSQL. We’ll also show how easily you can move an application to YugabyteDB.
Getting Started
Before initializing a Rails application, we must install some dependencies, starting with Ruby. Much like the Node Version Manager, familiar to those managing versions of Node.js, Ruby users rely on the Ruby Version Manager to manage versions of Ruby on their machines.
Here’s how to install it and set a global Ruby version.
# Download and install rbenv curl -fsSL https://github.com/rbenv/rbenv-installer/raw/main/bin/rbenv-installer | bash # Add rbenv to bash so that it loads every time you open a terminal echo 'export PATH="$HOME/.rbenv/bin:$PATH"' >> ~/.bashrc echo 'eval "$(rbenv init -)"' >> ~/.bashrc source ~/.bashrc # Install the latest stable version of Ruby rbenv install 3.3.0 rbenv global 3.3.0
This installation can be verified by checking the Ruby version in the terminal.
ruby -v ruby 3.3.0 (2023-12-25 revision 5124f9ac75) [arm64-darwin23]
Now, we can install Rails using the RubyGems package manager.
# Install rails gem install rails # Update rbenv's shims to Ruby executables rbenv rehash
I’ve chosen to run Postgres v15 using Docker. To do this you must install Docker Desktop. After successfully installing and running Docker Desktop, you can create a PostgreSQL container by running the following commands.
# Pull the Docker image for PG15 docker pull postgres:15 # Run the PG Docker container using this container image docker run --name postgres \ -e POSTGRES_USER=postgres \ -e POSTGRES_PASSWORD=password \ -e POSTGRES_DB=database \ -v postgres-data:/var/lib/postgresql/data \ -p 5432:5432 \ -d postgres:15
Once Ruby, Rails, and PostgreSQL are installed in our environment, we are ready to build an application.
Building a Ruby on Rails Application
I’m going to build a simple financial application example, which displays the portfolio values of its users.
First, we create a Rails application using the command-line interface. By passing the –d flag, we can specify that we want to use PostgreSQL with our application.
rails new rails_yb -d postgresql cd rails_yb
Rails is an MVC (model-view-controller) framework. Let’s start by creating databases and configuring data models.
Models and Databases
The configuration for this PostgreSQL database can be set in the database.yml file.
# config/database.yml default: &default adapter: postgresql encoding: unicode # For details on connection pooling, see Rails configuration guide # https://guides.rubyonrails.org/configuring.html#database-pooling pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> username: postgres password: password port: 5432 host: localhost development: <<: *default database: rails_yb_development test: <<: *default database: rails_yb_test ...
Using the Rails CLI, we can create databases for the development and test environments.
rails db:create
Let’s create a model and associated migration file for a portfolio.
rails generate model Portfolio name:string balance:decimal user_id:integer
This command generates a model that can later be edited to include validations, associations, and business logic.
# app/models/portfolio.rb class Portfolio < ApplicationRecord end
Additionally, a migration file is generated to run against our PostgreSQL instance.
# db/migrate/20241118211420_create_portfolios.rb class CreatePortfolios < ActiveRecord::Migration[7.1] def change create_table :portfolios do |t| t.string :name t.decimal :balance t.integer :user_id t.timestamps end end end
Let’s manually edit this file to specify the precision and scale of a portfolio balance, as well as indexing on user_id. This index is created in addition to that on the id column, which Active Record creates automatically.
# db/migrate/20241118211420_create_portfolios.rb class CreatePortfolios < ActiveRecord::Migration[7.1] def change create_table :portfolios do |t| t.string :name t.decimal :balance, precision: 15, scale: 2 t.integer :user_id t.timestamps end add_index :portfolios, :user_id end end
To run this migration against the database, we can simply apply it.
rails db:migrate
Using the psql CLI, we can verify that the portfolios table was created.
rails_yb_development=# \d+ portfolios Table "public.portfolios" Column | Type | Collation | Nullable | Default | Storage | Compression | Stats target | Description ------------+--------------------------------+-----------+----------+----------------------------------------+----------+-------------+--------------+------------- id | bigint | | not null | nextval('portfolios_id_seq'::regclass) | plain | | | name | character varying | | | | extended | | | balance | numeric(15,2) | | | | main | | | user_id | integer | | | | plain | | | created_at | timestamp(6) without time zone | | not null | | plain | | | updated_at | timestamp(6) without time zone | | not null | | plain | | | Indexes: "portfolios_pkey" PRIMARY KEY, btree (id) "index_portfolios_on_user_id" btree (user_id) Access method: heap
Another helpful feature of the Rails framework is the ability to work with seed files. We can use the Portfolio model to generate records.
# db/seeds.rb Portfolio.create([ { name: 'Retirement Fund', balance: 150000.00, user_id: 1 }, { name: 'Savings Account', balance: 25000.50, user_id: 1 }, { name: 'Growth Portfolio', balance: 78000.75, user_id: 1 }, { name: 'Retirement Fund', balance: 750000.10, user_id: 2 }, { name: 'Savings Account', balance: 15000.20, user_id: 2 }, { name: 'Growth Portfolio', balance: 1000, user_id: 2 } ])
Now, just apply the seeds file and verify the records have been stored properly.
rails db:seed
rails_yb_development=# select count(*) from portfolios; count ------- 6 (1 row)
With our model and underlying database set up, we can move on to creating the controllers and views of our application.
Controllers, Views, and Routes
Generating a controller for our portfolios allows us to bind our model to a view.
rails generate controller Portfolios
# controllers/portfolios_controller.rb class PortfoliosController < ApplicationController def index @portfolios = Portfolio.all end end
Now we can loop over the @portfolios in a view to display the data derived from the underlying model and database.
# app/views/portfolios/index.html.erb, add: <h1>Portfolios</h1> <table> <thead> <tr> <th>Name</th> <th>Balance</th> <th>User ID</th> </tr> </thead> <tbody> <% @portfolios.each do |portfolio| %> <tr> <td><%= portfolio.name %></td> <td><%= number_to_currency(portfolio.balance) %></td> <td><%= portfolio.user_id %></td> </tr> <% end %> </tbody> </table>
Finally, I created a route to the global routing configuration to display this view as the application index action.
#In config/routes.rb, add: Rails.application.routes.draw do root 'portfolios#index' resources :portfolios, only: [:index] end
Running the Application
Starting the application server with auto-reload capabilities is simple with the Rails CLI.
rails server * Listening on http://127.0.0.1:3000
By visiting http://localhost:3000 we can verify that the application’s index route displays the portfolios view!
Moving from PostgreSQL to YugabyteDB
To take advantage of the resiliency and scalability of distributed SQL, Rails developers can choose YugabyteDB for their Postgres-compatible workloads.
Here’s how to start a 3-node YugabyteDB cluster in Docker.
docker run -d --name yugabytedb-node1 --net rails_yb_network \ -p 15433:15433 -p 5433:5433 \ -v ~/yugabyte-volume/node1:/home/yugabyte/yb_data --restart unless-stopped \ yugabytedb/yugabyte:latest \ bin/yugabyted start --tserver_flags="ysql_pg_conf_csv={log_statement=all,yb_silence_advisory_locks_not_supported_error=true}" \ --base_dir=/home/yugabyte/yb_data --background=false \ --cloud_location=gcp.us-east1.us-east1-a \ --fault_tolerance=region while ! docker exec -it yugabytedb-node1 postgres/bin/pg_isready -U yugabyte -h yugabytedb-node1; do sleep 1; done docker run -d --name yugabytedb-node2 --net rails_yb_network \ -p 15434:15433 -p 5434:5433 \ -v ~/yugabyte-volume/node2:/home/yugabyte/yb_data --restart unless-stopped \ yugabytedb/yugabyte:latest \ bin/yugabyted start --tserver_flags="ysql_pg_conf_csv={log_statement=all,yb_silence_advisory_locks_not_supported_error=true}" \ --join=yugabytedb-node1 --base_dir=/home/yugabyte/yb_data --background=false \ --cloud_location=gcp.us-central1.us-central1-a \ --fault_tolerance=region docker run -d --name yugabytedb-node3 --net rails_yb_network \ -p 15435:15433 -p 5435:5433 \ -v ~/yugabyte-volume/node3:/home/yugabyte/yb_data --restart unless-stopped \ yugabytedb/yugabyte:latest \ bin/yugabyted start --tserver_flags="ysql_pg_conf_csv={log_statement=all,yb_silence_advisory_locks_not_supported_error=true}" \ --join=yugabytedb-node1 --base_dir=/home/yugabyte/yb_data --background=false \ --cloud_location=gcp.us-west2.us-west2-a \ --fault_tolerance=region
This cluster can be configured to have fault tolerance across regions. This means that data is replicated across regions, and the database can handle a full regional failure with minimal RTO.
The database is run using the yb_silence_advisory_locks_not_supported_error tserver flag. This allows us to run migrations with Active Record without encountering issues with advisory locks. Advisory locks ensure that only one session can run a migration at a given time, so we’ll have to take precautions to prevent this when using YugabyteDB.
docker exec -it yugabytedb-node1 \ bin/yugabyted configure data_placement --fault_tolerance=region --base_dir=/home/yugabyte/yb_data +-----------------------------------------------------------------------------------------------+ | yugabyted | +-----------------------------------------------------------------------------------------------+ | Status : Configuration successful. Primary data placement is geo-redundant. | | Fault Tolerance : Primary Cluster can survive at most any 1 region failure. | +-----------------------------------------------------------------------------------------------+
Using the ysqlsh CLI for YugabyteDB, we can query for the servers in our distributed database deployment.
docker exec -it yugabytedb-node1 bin/ysqlsh -h yugabytedb-node1 \ -c 'select * from yb_servers()' host | port | num_connections | node_type | cloud | region | zone | public_ip | uuid ------------+------+-----------------+-----------+-------+-------------+---------------+------------+---------------------------------- 172.18.0.4 | 5433 | 0 | primary | gcp | us-west2 | us-west2-a | 172.18.0.4 | e63156683e2a48e0b72e8d5cae900d79 172.18.0.3 | 5433 | 0 | primary | gcp | us-central1 | us-central1-a | 172.18.0.3 | 0ef880db7f634b06a72d7170fe4c7650 172.18.0.2 | 5433 | 0 | primary | gcp | us-east1 | us-east1-a | 172.18.0.2 | fd5ba9bf784b40eb9b65c01057423ba3 (3 rows)
We can also visit the YugabyteDB UI at http://localhost:15433 to view tablet distribution across nodes. Each node in our 3-node cluster holds 8 tablet leaders, meaning the primary copy of our data is spread evenly across regions.
The Rails application can be run on YugabyteDB by updating the database configuration and re-running the database create and migrate scripts. Alternatively, the existing PostgreSQL database could be migrated to YugabyteDB using YugabyteDB Voyager.
# config/database.yml ... # username: yugabyte # password: yugabyte # port: 5433
rails db:create rails db:migrate rails db:seed rails server
To demonstrate the built-in resilience of YugabyteDB, I’ll stop the node in us-central1, simulating a regional outage.
docker stop yugabytedb-node2
Within seconds, the cluster is rebalanced to place tablet leaders in the us-east1 and us-west2 regions. As a result, our application can continue to serve consistent reads from the database.
Conclusion
The Ruby on Rails community leans on relational databases as the system of record for their applications. As PostgreSQL is often their database of choice, the simple migration path to YugabyteDB makes it an ideal fit for developers who want to increase the availability and scalability of their applications.
Want to learn more about YugabyteDB? Check out one of these recent blogs: