Can You Build a Web Application in Ruby Without Rails? Yes, You Can!
This is the third article in the series on building awesome web applications with Rails. It shows how to build a simple web app in Ruby from scratch, without using Rails.
This is the third article in the series on building awesome web applications with Rails. The first talked about why build for web and use Rails, and the second showed how to set up a development environment for building Rails apps.
However, starting with this post, we are going to take a detour from Rails and try to build a simple web application from scratch, without using an ounce of Rails.

But Why?
I strongly believe that to understand the benefits of any tool, you should try to accomplish the same task without using that tool.
Rails is not different. It's a tool that helps us quickly build web applications.
However, it also abstracts and hides a lot of things from our plain eyes. Hence sometimes it feels like 'magic'. When things go wrong, and they will, it's difficult to find the root cause of the failure.
For this reason, for the next few articles in this series, we will build a web application using only Ruby, without Rails.
Of course, it goes without saying that this is only for learning purposes, to understand the fundamentals of web applications and frameworks. You should be using Rails for your real-world applications.
I hope this series of articles will give you a peek under the hood and explain some of the core concepts like routing, controllers, views, and much more.
Without any further adieu, let's get started. We will set up our project and create a very simple application that you can access in the browser.
Note: I've published the source code for this post on Github. Checkout the repository and switch to the project-setup
branch.
Create a new directory named web
.
➜ mkdir web
➜ cd web
Set up Bundler

Bundler makes sure Ruby applications run the same code on every machine.
It does this by managing the gems that the application depends on. Given a list of gems, it can automatically download and install those gems, as well as any other gems needed by the gems that are listed.
Initialize Bundler for your project by running the following commands from the project root directory.
➜ gem install bundler
➜ bundle init
It will create a Gemfile
in the project. Whenever we want to use a gem in our project, we will add it to this file, and run bundle install
command to install it.
As a shortcut, bundler provides the bundle add
command, which adds the gem to the Gemfile
and runs the bundle install
for us.
The first gem we will install using Bundler is the Puma app server.
Install Puma


To run our web application, we will need an application server.
The purpose of an application server is to take an incoming HTTP request and pass it to our application. Our app will then process it, build the response HTML, and hand it back to the application server.

Note: This is a simplified diagram. In reality, we will need a web server/reverse proxy such as Nginx that sits in front of Puma and handles the incoming requests from the web. But let's not worry about that now.
Rails uses the Puma
application server. We will use the same.
Install Puma using bundler.
➜ bundle add puma
This will install Puma on your machine, but how will it talk to our application? For that, we need Rack.
Install Rack
All Ruby application servers and web applications follow the Rack specification.
Rack provides a simple interface using which application servers communicate with the applications.
The Rack specification has two conditions:
- The application should have a
call
method that takes anenv
object representing the incoming HTTP request. - The application should return an array containing the status, headers, and response.
To learn more about Rack specification, check out this article.

So that is Rack, a specification. However, in addition to being an abstract protocol, Rack is also a gem. It provides a bunch of useful tools, such as middleware and a convenient DSL (domain-specific language) to run rack-compliant web applications.
Let's Install Rack using Bundler.
➜ bundle add rack
Now, let's create an application Puma will talk to using the Rack specification.
Create a Web Application
Create `application.rb`, our application. Yes, IT IS our entire app.
class App
def call(env)
headers = {
'Content-Type' => 'text/html'
}
response = ['<h1>Hello World!</h1>']
[200, headers, response]
end
end
As you can see, it follows the Rack protocol.
- It has a
call
method that takes anenv
object. - It returns an array containing the status, headers, and response.
The Final Piece of the Puzzle
At this point, we have the Puma application server, and we also have our application app.rb
.
The only thing missing is the glue that connects the two.
How does Puma pass the incoming request to our application?
The answer is: a rackup (.ru) file.
When we launch Puma (next step) without any arguments, it looks for this rackup file in the current directory called config.ru
. It's a convention.
So let's add a config.ru
file in the web
directory.
➜ touch config.ru
Add the following code to this file. It loads the rack gem and also loads our application.
require 'rack'
require_relative './app'
run App.new
The run
method is provided by the Rack gem that we installed earlier. It takes a Ruby object as an argument and invokes the call method on it.
Since the App
class has a call
method, Rack can run it just fine.
We have the final piece of the puzzle. The only thing remaining is running the application.
Run the Application
To launch the web server, run the puma
command from the web
directory.
➜ web git:(main) ✗ puma
Puma starting in single mode...
* Puma version: 6.0.0 (ruby 3.1.0-p0) ("Sunflower")
* Min threads: 0
* Max threads: 5
* Environment: development
* PID: 6041
* Listening on http://0.0.0.0:9292
Use Ctrl-C to stop
Now our web server is running and serving our application. Open your browser and go to http://0.0.0.0:9292 URL.

Congratulations! You have the simplest web application written in Ruby.
Reloading Application
At this point, we have a small problem.
If you make a change in your application, it won't be reflected in the browser. For this, you need to restart the server by pressing ctrl + c
on the keyboard. After making a change, restart the server by running rackup
again. Your change will show up now.
It can get tedious to restart the server after every change.
Is there a better way?
Yes! The Rack gem ships with a Rack::Reloader
middleware that reloads the application after changing the source code.
A middleware is a small, focused, and reusable application that provides useful functionality to your main app.
Middleware sits between the user and the application code. When an HTTP request comes in, the middleware can intercept, examine, and modify it. Similarly, it can examine and modify the HTTP response before forwarding it to the user.
Check out my article on Rack middleware to learn more.
Use the Reloader
Add the Rack::Reloader
middleware in the config.ru
file.
require 'rack'
require_relative './app'
use Rack::Reloader, 0 # <- Add this line
run App.new
Now launch the web server using puma
command. The Reloader middleware will automatically reload the application when the next request arrives, if the source code was changed. So we don't have to restart the server manually.
Problem solved.
What's Next?
In the next article in this series, we will refactor our application to a more Rails-like structure. Currently, our app is returning a hard-coded string response directly from the Ruby code, which is not ideal. We will learn how to separate the "view" from the application logic and move it to a different part of the codebase.
Stay tuned!!
Note: I've published the source code for this post on Github. Checkout the repository and switch to the project-setup
branch.