This is the first post in my series Learning Docker with Sinatra. We start by building a simple Sinatra app that just serves a single endpoint with some static content. Then, we build a Docker container around it. In the next parts of the series, we will be adding a database and orchestration.
You can find the complete source code on GitHub: jdno/docker-sinatra-api
A simple Sinatra app
My longterm goal with this series is to get a working web service that I can use in a service-oriented web application. So I don’t want to create just any Sinatra app, but one that is actually usable in the long run.
We start by creating the
Gemfile. Obviously, our first dependency is
sinatra. Since we want to build an API, our app will only respond with
JSON formatted output. To do this, we need a second dependency called
sinatra-contrib, which provides the module
Sinatra::JSON. This module makes
it really easy to return JSON.
Here is the complete
bundle install to pull in the dependencies and get ready for the next
Creating the app
Sinatra is a really nice framework, since it allows
us to create a web application with just a few lines of code. Create a file and
app.rb, and paste in the following code:
You can start the app with the following command:
When browsing to localhost:4567, you can see the
The beauty of Sinatra is that you really don’t need anything else to get started. Later on, we will be adding a database, but now this is the application we are working with.
Preparing for Passenger
While this simplicity is great for development, it comes with a few limitations with regards to a production environment. Especially from a performance perspective, there is a lot to be gained by extending Rack, the bundled web server, with a high-performance application server like Passenger, Puma, Unicorn or Thin.
For all of these, you need to add a rackup file called
config.ru. This file
tells the application servers how to start Rack. Simply create it in the
same directory as your app, and paste the following lines in it:
Now we are ready to go to the next section and put everything in a container.
The next step is to add Docker. We want to be able to run our app from within a Docker container, which allows us to later use the app in an orchestration of multiple containers, e.g. to build a nice development environment with a local database.
Docker Hub - so many options
We won’t be building our own base image, but instead rely on Docker Hub and the images that are already available there. But we still have to make a choice: What application server do we want to use? We could just go with the built-in Rack server that we just run locally. Or we could go with a dedicated application server like Unicorn, Puma, Thin or Passenger…
For this project, I want to use Passenger. It provides a great, scalable foundation for whatever we might want to do with the app itself.
When you start searching for
passenger docker online, you will almost
immediately get to
the official GitHub repository for the Docker images made by
Phusion, the company behind
Phusion provides official Docker images for
Passenger that come with different
flavors. There are images for Ruby, Node.js and Meteor, and even
more if you want more customization. It should not come as a suprise that we
choose the Ruby version. When writing this post, the most current version
supported is Ruby 2.2.3 in the
Our first Dockerfile
Having picked a base image, we can start creating our
Dockerfile. In the first
line, we say which base image we want to extend. The last few digits declare the
specific version of the image, and it is highly recommended that you do specify
them. Otherwise, you won’t be able to build identical images from the same
Dockerfile once a new version gets released. And that kinda destroys the whole
purpose of it. Below, you find the first line of our
The next few lines are taken directly from the documentation that is available
on phusion/passenger-docker. We
set the environment variable
HOME, run through the base image’s
process, and enable nginx and Passenger:
Next, we have to install our app and configure the application server. Before we do that, though, I want to improve the directory structure. To configure nginx, we need at least one configuration file. I don’t want to put it in the same folder as our app, but instead keep the app and the Docker configuration separated. This is what I went with:
Having cleaned up the directory, we can continue with the configuration. In the
Dockerfile, we can add the following lines. The first deletes nginx’s
default site, and the second adds our site.
Of course, we need to provide some configuration for the site. Right now,
vhost.conf is either empty or not existing. The following
tells nginx to listen on port 80, enables Passenger and points it to the
directory our app lives in.
After configuring the application server, the only thing missing is our app. The base image already includes a user account that has no elevated privileges, and we will use this user to run our app. So we start by creating a directory in the user’s home:
Next, we want to copy the app and install its dependencies. To take advantage of
Docker’s caching capabilities, we run
bundle in its own layer. This approach
is taken directly from Docker’s article on combining
Compose and Rails. The following lines
copy the files
Gemfile.lock to the temporary folder, and execute
bundle install in it:
Now, the only thing missing is our app. This step is really straightforward, since we are only going to copy the app to the destination directory. To be absolutely sure all permissions are set correctly, we lastly change the owner of the app to the user that used by Passenger.
There is one last thing we do, and that is clean up after
to decrease the image size.
If you put everything together, you end up with a nice
Dockerfile that extends
the official image for Passenger and provides a performant and scalable
foundation for the future.
For the record, here is the complete file:
Building the image
Now that we have everything in place, we can finally dockerize our app by building an image. This is done with the following command (substitute the name with your own name):
Running the command will give you a lot of output, which should end with a line like this:
Congratulations! You dockerized Sinatra.
Running the app
Let’s quickly recap how we run the app previously: We executed the app locally
with the command
ruby app.rb. By default, the built-in application server
started to listen on port 4567, and we where able to access the app by opening
the URL localhost:4567 in the browser. This resulted in
the JSON formatted output
Now, we want to achieve the same result by starting our container. If you take a look at the nginx configuration, you can see that the server is listening on port 80 for incoming connections. Hidden from you, the base image exposes port 80 and 443. This is Docker terminology and means that those ports are accessible from outside the container. We still need to create a mapping, though, from a port on the host system to the exposed port on the container.
You start containers with the
docker run command. The command has the
Looking at the command and what we have built, the following traits have to be kept in mind:
- We need to map port 80 on the container to a port on the host system.
- We only run a daemon in the container, and therefore need no command.
Putting everything together, this gives us the following command to start our app:
Depending on your host system, you can now access the app at one of the following URLs:
- On OS X and Windows, use the IP of the docker-machine. This is the
virtualization layer between your operating system and Docker. By default,
it uses the IP 192.168.99.100, but you can check with the command
$ docker-machine ls. Depending on the IP, you can access the app at the address 192.168.99.100:4567.
- On Linux, you don’t need the virtualization layer, and can directly go to localhost:4567.
Opening the URL gives you the same output as running the app locally, in our
"Hello World!". Nice!
This is it for the first part on my series on how to use Docker with Sinatra. Looking back, we achieved quite a few things:
- We built a simple Sinatra app.
- We created a Docker image with our app.
- We started a Docker container and made our app available to our host system.
This is a great result for the first day! In the next part, we will be looking at docker-compose and use it to start our app, and after that build a database container.
I hope you were able to take something away from this! If you have any questions or know how to do things in a smarter way, please share them with me in the comment section below. The same goes for bugs or errors you find in my code.
You can find the complete source code on GitHub: jdno/docker-sinatra-api
Hope to see you in the next part!