Implementing Custom Domains on Roon

railsBlog

Recently, I launched a product with my friend Drew Wilson called Roon. It’s a really simple and beautiful blogging platform. Roon has a web app, iOS app, and API. I thought it would be good to write some about how I built various pieces of Roon’s infrastructure.

A few weeks ago, we rolled out custom domain support. This allows people to pay a yearly fee to have the option to use a custom domain instead of something like sam.roon.io. A few folks on Twitter replied with stuff like “I have no idea how you would even begin to implement something like that”. It’s actually surprisingly easier than you’d think.

First, a little about Roon’s infrastructure. It’s a Rails app running on Heroku. It’s decently straight forward as most blogging apps are. I really wanted to stay on Heroku so I don’t have to manage servers and such. Even though we’re on Heroku, it’s still pretty easy to add custom domains.

We implemented Stripe so we can take money from people that want to enable this add-on. (Maybe in the future I’ll write more about that if that’s interesting. Let me know on Twitter.) Once a user has paid for the feature, we enable an extra field in the settings to let them input a custom domain. Nothing crazy here.

Once they have paid, we use the Heroku API to add their custom domain to our app on Heroku and setup some redirects for their old subdomain. Pretty simple. I’ll walk you through the code.

In the Blog model, we do a few things. First, I include the `ActiveModel::Dirty` mixin so it’s easy to see if the user changed their custom domain. Next, I simply add a `before_save` callback to check if they have changed their custom domain. If the user has changed their custom domain, I fire off a Sidekiq worker to add or remove domains. Here’s the code:

include ActiveModel::Dirty

before_save :add_custom_domain

def add_custom_domain
  # Only run the worker if they have changed their domain and
  # if we have the Heroku API token environment variable set.
  if custom_domain_changed? && ENV['HEROKU_API_TOKEN']
    # The same worker adds and removes domains
    CustomDomainWorker.perform_async('add' => custom_domain, 'remove' => custom_domain_was)
  end
end

All pretty straight forward. Obviously there’s some validation to make sure it’s a valid domain, it’s not a domain they’re not allowed to use, and that they’ve paid for the add-on.

The worker to do the actual work is pretty simple too. All this does is hit the Heroku API and add or remove domains. Here’s the code:

# Setup some exceptions so we can see more information errors when things fail
class FailedToAddDomainToHeroku < Exception; end
class FailedToRemoveDomainToHeroku < Exception; end

class CustomDomainWorker
  include Sidekiq::Worker
  include Sidekiq::Logging

  # Don't retry if things fail because it will probably always fail. This
  # may or may not be the best idea.
  sidekiq_options retry: false

  # Setup the logger so we can get more info from the workers
  def initialize
    logger.level = Logger::INFO
  end

  # This is the main method the worker calls
  def perform(domains)
    # Grab the API token from the environment variable
    @api_token = ENV['HEROKU_API_TOKEN']

    # Add a domain
    add = domains['add']
    if add && add.length > 0
      logger.info "Adding #{add}"
      add_domain(add)
    end

    # Remove a domain
    remove = domains['remove']
    if remove && remove.length > 0
      logger.info "Removing #{remove}"
      remove_domain(remove)
    end
  end

  # Add a domain to heroku
  def add_domain(domain)
    # Make a POST request to add the domain
    response = HTTParty.post 'https://api.heroku.com/apps/roon/domains', headers: {
      'Authorization' => @api_token,
      'Accept' => 'application/vnd.heroku+json; version=3',
      'Content-Type' => 'application/json'
    }, body: {
      hostname: domain
    }.to_json

    logger.info response.body

    # If it wasn't a created response code, it failed. 
    unless response.code == 201
      raise FailedToAddDomainToHeroku, JSON(response.body)['message'] and return
    end

    logger.info "Added #{domain} to Heroku"
  end

  # Remove a domain
  def remove_domain(domain)
    # Make a DELETE request to the domain
    response = HTTParty.delete "https://api.heroku.com/apps/roon/domains/#{domain}", headers: {
      'Authorization' => @api_token,
      'Accept' => 'application/vnd.heroku+json; version=3',
    }

    # It failed if it wasn't a 200
    unless response.code == 200
      raise FailedToAddDomainToHeroku, JSON(response.body)['message'] and return
    end

    logger.info "Removed #{domain} from Heroku"
  end
end

So overall, it’s pretty simple. If you’re unfamiliar with Sidekiq or other queueing system, I highly recommend learning about them. Making network requests to third-party services should be done in the background if possible. You don’t want to make your other requests slow if you’re waiting on something like Twitter, Facebook, Heroku, etc.

Anyway, custom domains are pretty easy to support. Hopefully that was helpful! Go make stuff.

Free Workshops

Watch one of our expert, full-length teaching videos. Choose from HTML, CSS or WordPress.

Start Learning

Sam Soffes

Sam Soffes is an iOS and Ruby Developer. He is the co-creator of Roon and the VP of Engineering at Seesaw. Follow him on Twitter: @soffes.

Comments

2 comments on “Implementing Custom Domains on Roon

  1. Great rundown on how one might implement this in a production environment. This is helpful even if you don’t use Heroku, Rails, or Sidekiq. Thanks for writing it.