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:
- check out the repository on the CI worker
- set up Ruby on the CI worker, with gem caching taken care of for us for better build times
- 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 - use a GitHub Action from the community to deploy to the platform of our choice
- 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!