Deploying a Ruby on Rails Application with NixOps
Date: Nov. 19, 2019
By: Robert Prije
Rails applications depend on installed Ruby libraries provided by gems. Usually gems are installed imperatively. However, we want to leverage NixOps to manage both Rails applications and their gem dependencies in its customary declarative and reproducable manner.
In this blog post we will examine how to use Nix and NixOps to:
- initialise a Rails application
- script the starting of that Rails application
- deploy the Rails application and starter script to a host.
Code has been written to assist in describing this process. That code is
available at
https://github.com/xc-jp/blog-post-code
under the Rails
subdirectory. To keep this blog post reasonably high level
and easy to understand, only sections of code relevant to the topics being
discussed will be pasted along with the filename in the repository the code
segment can be found in. If you are following along with the blog, you are
encouraged to check out a copy of the repository and run the provided commands
from within the checked out Rails
directory as well as explore the code
in the Rails
directory.
Gems
Rails dependencies and Rails itself are supplied through gems. NixOS supports
gems through an application named
Bundix. Bundix itself builds upon
Bundler, a management system for projects using gems.
What Bundix provides on top of Bundler is management of a gemset.nix
file
which associates each gem with a hash to allow nix to guarantee the same
dependencies will be used whenever the project is built.
The following script found under update_gemset_nix.sh
takes a Bundler
Gemfile
and produces a Bundler Gemfile.lock
and Bundix gemset.nix
:
#!/usr/bin/env bash
set -e -u -o pipefail
GEMFILE="${1:-}"
[[ -n $GEMFILE ]] || (echo "Usage: $0 GEMFILE"; exit 1)
[[ -e $GEMFILE ]] || (echo "Couldn't find $GEMFILE"; exit 1)
[[ $(basename $GEMFILE) == Gemfile ]] || \
(echo "$GEMFILE doesn't look like a Gemfile"; exit 1)
NIXPKG_FILE="$(dirname $(readlink -f $0))/package-set.nix"
cd $(dirname $GEMFILE)
nix run -f "$NIXPKG_FILE" bundler -c bundler lock
rm -f gemset.nix
nix run -f "$NIXPKG_FILE" bundix -c bundix
The Gemfile
, Gemfile.lock
and the gemset.nix
can then be fed into the
Nix function bundlerEnv
. overlay.nix
contains a function rubyEnv
which
takes a directory containing these files and uses bundlerEnv
to produce
a derivation providing the gems described in the Gemfile
:
rubyEnv = dir: super.bundlerEnv {
name = "example-ruby-env";
inherit (self) ruby;
gemfile = dir + /Gemfile;
lockfile = dir + /Gemfile.lock;
gemset = dir + /gemset.nix;
};
Initialising a Rails Application
Initiating a Rails application within nix requires some bootstrapping. A Rails application generates its own gem dependencies upon initialisation but at the same time is itself installed as a gem.
We create a Gemfile
specifically for bootstrapping:
source 'https://rubygems.org'
ruby '2.6.5'
gem 'rails', '~> 6.0.0'
We also create a shell environment with access to the Rails gem. In
overlays.nix
:
initShell = mkShell { buildInputs = [
(self.rubyEnv ./.)
];};
Finally, the script new_rails_app.sh
contains the steps needed for
bootstrapping a new Rails application:
#!/usr/bin/env bash
set -e -u -o pipefail
cd "$(dirname $0)"
./update_gemset_nix.sh ./Gemfile
nix-shell package-set.nix -A initShell --run "rails new example --skip-bundle --skip-bootsnap --skip-webpack-install"
./update_gemset_nix.sh ./example/Gemfile
cd example
nix-shell ../package-set.nix -A devShell --run "rails webpacker:install"
This calls the previously mentioned update_gemset_nix.sh
on the Gemfile
used for bootstrapping. update_gemset_nix.sh
initialises Gemfile.lock, and
gemset.nix giving us everything we need to be able to enter into a Nix shell
containing Rails. We use that nix shell to initialise the Rails application
with rails new example
which will put the Rails application under a
subdirectory named example
.
The created example
directory will contain its own Gemfile
along with
the rest of the initialised application. We run update_gemset_nix.sh
on
this new Gemfile
to generate another Gemfile.lock
and gemset.nix
this
time with for creating a Nix shell environment with all dependencies required
for the Rails application to run.
The --skip-bootsnap
and --skip-webpack-install
arguments are necessary
because Bootsnap and Webpack rely on the gems found in the newly created
Gemfile
but our initialisation Nix environment doesn’t have access to
those. So instead, we create the Rails application without them.
Bootsnap is an optimising cache library that’s not critical for the running of a Rails Application. Getting this working is left as an exercise for the reader.
Webpack provides Rails’ JavaScript environment and
is critical for Rails to run. We manually install it from within our newly
created Nix shell environment providing all Rails’ dependencies with the command
nix-shell ../package-set.nix -A devShell --run "rails webpacker:install"
Let’s run new_rails_app.sh
now:
$ ./new_rails_app.sh
Fetching gem metadata from https://rubygems.org/.............
Fetching gem metadata from https://rubygems.org/.
Resolving dependencies...
[...]
Done in 4.83s.
Webpacker successfully installed 🎉 🍰
$ ls example
app config.ru gemset.nix package.json README.md vendor
babel.config.js db lib postcss.config.js storage yarn.lock
bin Gemfile log public test
config Gemfile.lock node_modules Rakefile tmp
Now that we’ve initialised the Rails application, we no longer need the
bootstrap Gemfile
in the top-level directory. From now on we’ll only need the
generated Gemfile
within the example
directory. We can enter into a shell
within which the rails
command and dependencies needed by our application are
available with the command:
$ nix-shell ./package-set.nix -A devShell
If extra dependencies need to be added to Gemfile
, update_gemset_nix.sh
can
regenerate the Gemfile.lock
and gemset.nix
needed to ensure the environment
receives the updated dependencies:
$ ./update_gemset_nix.sh ./example/Gemfile
Rails Startup Script
Next we’ll create a conventient way for entering into the Rails environment and
starting the web service. This is accomplished with a simple shell script
constructed within Nix. In overlay.nix
:
railsApp = super.writeShellScriptBin "start-app" ''
set -e -u -o pipefail
export APP_PATH="''${APP_PATH:-$(mktemp -p /tmp -d rb.XXXXXX)}"
cd $APP_PATH
tar -xzf ${self.rubySrcTarball}
chmod -R u+w $APP_PATH
export PATH=$PATH:${self.yarn}/bin
${self.rubyEnv rubySource}/bin/rails server --binding 0.0.0.0 --port 3000
'';
Here we initialise an APP_PATH
environment variable for use by Rails. We can
supply it as an environment variable outside the script, otherwise it will
be initialised as a temporarily created path.
We then unpack rubySrcTarball
which is just a tar’d version of everything in
the example directory:
let
[...]
rubySource = ./example;
[...]
in
{
rubySrcTarball = buildTar "rails-blog-example" rubySource;
We then ensure the yarn
binary is available to Rails and start the Rails
server listening on all addresses and on port 3000.
Testing it out:
$ nix-build ./package-set.nix -A railsApp
[...]
/nix/store/dxxacgjj1b40sy6qwx0d1maaf15qpiwp-start-app
$ /nix/store/dxxacgjj1b40sy6qwx0d1maaf15qpiwp-start-app/bin/start-app
=> Booting Puma
=> Rails 6.0.1 application starting in development
=> Run `rails server --help` for more startup options
Puma starting in single mode...
* Version 4.3.0 (ruby 2.6.5-p114), codename: Mysterious Traveller
* Min threads: 5, max threads: 5
* Environment: development
* Listening on tcp://0.0.0.0:3000
Use Ctrl-C to stop
Navigating a web browser to http://localhost:3000/ now shows the default Rails application welcome page.
Deploying a Rails Application
Finally, we’ll use NixOps to deploy our script and the Rails application environment to a host. The minimal logical network specification looks like this is in rrails-nixops.nix:
{
network.description = "Example";
example =
{ pkgs, ... }:
{
nixpkgs.overlays = [
(import ./overlay.nix)
];
environment.systemPackages = [
pkgs.railsApp
];
networking.firewall.allowedTCPPorts = [ 3000 ];
};
}
We see here why we’ve been placing all our packages in an
overlay within overlay.nix
.
Overlays allow us to take a nixpkgs package set and make modifications to it.
In short, an overlay is a function taking a self
argument representing the
final package set after all modifications have been made, and a super
argument
representing the package set before the modifications in the current overlay
have been made. self
is able to refer to the final package set because
Nix is a lazy language. The contents of self
will not be computed until
needed.
The file package-set.nix
used overlay.nix
to define a a NixPkgs
package set extended with our own packages for use by shell.nix
and many of
our Nix commands above:
{ pkgs ? (import ./pkgs.nix) }:
import pkgs { overlays = [ (import ./overlay.nix) ]; }
Now we use that same overlay.nix
in our logical network definition to
extend the NixPkgs provided to NixOps with our same modifications.
Having made our modifications with overlay.nix
, we have access to
pkgs.railsApp
which we make available as a system package.
Finally, we ensure port 3000 is open on the host firewall allowing our Rails application to be reached from outside the virtual host.
Now we try deploying our host and starting the app:
$ nix-shell
# example-vbox.nix is the same as from earlier blog posts
$ nixops create ./example-vbox.nix rrails-nixops.nix
$ nixops deploy --force-reboot
example> creating VirtualBox VM...
[...]
example> activation finished successfully
example> deployment finished successfully
$ nixops ssh example
# ip addr show | grep inet
[...]
inet 192.168.56.105/24 brd 192.168.56.255 scope global dynamic noprefixroute enp0s8
[...]
# start-app
=> Booting Puma
=> Rails 6.0.1 application starting in development
=> Run `rails server --help` for more startup options
Puma starting in single mode...
* Version 4.3.0 (ruby 2.6.5-p114), codename: Mysterious Traveller
* Min threads: 5, max threads: 5
* Environment: development
* Listening on tcp://0.0.0.0:3000
Use Ctrl-C to stop
Then navigating to http://192.168.56.105:3000/ (with the IP address found
through the above ip addr show
command) successfully displays our Rails
applicaiton start page.