Simon.Fish

Engineering and educating for a free and open web.

Ruby logo Ruby on Ruby on Rails logo Rails developer with four years of industry experience.
Experienced with Turbo logo Hotwire, ViewComponent logo ViewComponent, and more.

Used under the Unsplash License. Originally taken by Elijah Macleod.

Static Site Building with Rails

After cycling through a whole bunch of different tooling, I thought this time it made sense to build my personal site on the stack I love the most — Rails. However, Rails is different from create-react-app, Bridgetown, Gatsby, and Jekyll in that it doesn’t compile to static HTML. It’s designed around serving an app with serverside logic, so if you want to take advantage of inexpensive hosting options like Netlify and GitHub Pages, Rails isn’t the tool for the job — at least not out of the box. I sought to change that.

First of all, this work’s pretty heavily inspired by this gist, so credit to @WattsInABox for figuring this out. The overview, if you want to solve it for yourself, is as follows: we serve a production build of the site locally, and crawl it with wget to save all the pages and assets. That gives us all the static HTML we need!

To follow along, you’ll probably want to start with rails new -O --minimal <app_name>, and create actions, routes, and views as you’re used to doing. Bear in mind that any serverside logic will be null and void once you compile to something static. Now, let’s get into the static-specific business...

Serving the Site

We create a new environment that’s configured specifically for this purpose, called static. It’s configured to serve our static assets and minify them to make our build as tight as possible. Before running the server, we clean and precompile the assets so that they’re ready to be served. We give Rails a moment to wake up, and then use wget to crawl it. Then, we can use any minimal server to serve the crawled content.

#!/bin/sh

# script/build

bundle check || bundle install
rake assets:clean
rake assets:precompile
# Run the server in the static environment
RAILS_ENV=static bundle exec rails s -p 3000 -d
# Create the output directory and enter it
mkdir out
cd out
# Give the server a little time to come
# alive - we'll get a "Connection refused"
# error from wget otherwise
sleep 5
# Mirror the site to the 'out' folder, ignoring links with query params
wget --reject-regex "(.*)\?(.*)" -FEmnH http://localhost:3000/
# Kill the server
cat tmp/pids/server.pid | xargs -I {} kill {}
# Clean up the assets
rake assets:clobber

Now your static site files should sit in the out directory. They’re ready to be served by a lightweight server of your choice. Since we’re in Ruby, it only made sense to me to use this from within out:

ruby -rwebrick -e'WEBrick::HTTPServer.new(:Port => 8000, :DocumentRoot => Dir.pwd).start'

Your site should now be accessible at localhost:8000, and should be blazing fast thanks to everything being static! But there’s a catch.

If you followed me this far, then you might be wondering why all of your links are broken. By default Rails URL helpers don't append any format extension to links generated by URL helpers. But since we’re purely building a HTML site, we can be more restrictive with routes and enforce that they only ever generate .html links. Thanks to Kyle Tolle for this solution, which sets the format for a block of routes. URL helpers will respect this, too! If you’ve defined a root URL, it should be done outside this scope.

# config/routes.rb
root 'home#index'
scope format: true, defaults: { format: 'html' } do
  # Define your resources here like normal
end

Deployment

The next part is deploying to a service like GitHub Pages. That’s all handled by GitHub Actions:

name: Build and Deploy
on:
  push:
    branches:
      - main
  repository_dispatch:
    types: [publish-event]
jobs:
  build-and-deploy:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout 🛎️
        uses: actions/checkout@v2
      - uses: ruby/setup-ruby@v1
        with:
          ruby-version: '3.1.0' # Not needed with a .ruby-version file
          bundler-cache: true # runs 'bundle install' and caches installed gems automatically
      - name: Install and Build 🔧
        env:
          CONTENTFUL_ACCESS_TOKEN: ${{ secrets.CONTENTFUL_ACCESS_TOKEN }}
          CONTENTFUL_SPACE_ID: ${{ secrets.CONTENTFUL_SPACE_ID }}
          SECRET_KEY_BASE: ${{ secrets.SECRET_KEY_BASE }}
        run: |
          script/build
      - name: Deploy 🚀
        uses: JamesIves/github-pages-deploy-action@v4.2.2
        with:
          branch: gh-pages # The branch the action should deploy to.
          folder: out # The folder the action should deploy.
      - name: Archive server logs
        uses: actions/upload-artifact@v2
        with:
          name: static-logs
          path: log/static.log

To explain each of those steps in turn, we:

  1. check out the repository on the CI worker
  2. set up Ruby on the CI worker, with gem caching taken care of for us for better build times
  3. run the build script on the CI worker, setting some environment variables from repository secrets — you’ll need to set SECRET_KEY_BASE from the environment, but it’s unlikely to matter unless you’re encrypting credentials with it
  4. use a GitHub Action from the community to deploy to the platform of our choice
  5. keep a copy of the server logs for review in case of an issue during mirroring

You might also notice the on key. We expose two ways to trigger a deployment this way. The first is by pushing or merging to the main branch, and the second is a webhook entrypoint — see this article from Contentful for a guide on how to do that. Speaking of which, they’re my CMS of choice! Even for a personal website, I’d recommend having some kind of CMS. You get the benefits of a separation between your data and your markup, and particularly with this setup, the overhead of that is minimal.

Contentful as ActiveRecord

I’ve been using Contentful as a CMS for my site for a long while. It’s a good practice to have — you can separate your data from your view layer and have less copy baked into your code. Contentful’s own Rails guide has been quite helpful here — particularly, their ContentfulRenderable module can be adapted not to need a backing database so that you’re purely fetching from Contentful itself. I did so as follows:

# frozen_string_literal: true

# Something that sources its data from Contentful.
module ContentfulRenderable
  extend ActiveSupport::Concern
  included do |base|
    base.class_attribute :content_type_id, default: base.name.camelize(:lower)
  end

  class_methods do
    def client
      @client ||= Contentful::Client.new(
        access_token: CONTENTFUL_ACCESS_TOKEN,
        space: CONTENTFUL_SPACE_ID,
        dynamic_entries: :auto,
        raise_errors: true,
        raise_for_empty_fields: false,
        api_url: Rails.env.development? ? 'preview.contentful.com' : 'cdn.contentful.com'
      )
    end

    # Overridable
    # Override this method to change the parameters set for your Contentful query on each specific model
    # For more information on queries you can look into: https://www.contentful.com/developers/docs/references/content-delivery-api/#/reference/search-parameters
    def all(**params)
      client.entries(content_type: content_type_id, include: 2, **params)
    end
  end
end

What this now means is that all that you need to do is create a class that will include ContentfulRenderable, and it will use the associated content type from Contentful. So if you have a class called PortfolioItem, it’ll use the portfolioItem content type you’ve created in Contentful. And if the content type ID doesn’t quite match up to your class name of choice, you can set it yourself using self.content_type_id as in the original tutorial. Additionally, the Contentful credentials now come from the environment thanks to a gem of mine, nvar.

One more bonus you get is that you can define scopes by passing in more params to all — for example:

class PortfolioItem
  include ContentfulRenderable

  # Returns only max priority portfolio items
  def self.key_items
    all('fields.priority' => 3)
  end
end

That brings this tutorial to an end. You’re looking right at a site that uses all of this — if you’re impressed, I’d highly encourage you to give it a shot yourself!