Rails https and non-standard ports in vagrant

October 21th, 2012 - Madrid

Our way of dealing with http and https requests from a vagrant machine was to forward from the guest machine to some higher ports such as 8080 and 8433 and then add some rules to the Mac OSX built-in firewall to bind 80 to 8080 and 443 to 8443 on the host machine. Thus, the links and redirects generated by our rails app didn’t need to know anything about the ports that we were using to connect from our outside world.

We decided that we wanted to have as many machines and services running simultaneously as we wanted without having to worry about conflicting ports, so we adopted another strategy in which we specify the forwarded ports in a configuration file in our rails app, and we take care of them as transparently as possible.

We also wanted rails to generate the proper urls for multiple domains, so we set a host header in our nginx directives along with the X-Forwarded-Proto $scheme that lets rails know that it’s dealing with a http or https request.

in our nginx server:

location / {
    proxy_pass  http://local_3000;
    proxy_set_header Host $host;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_redirect off;
}

To let our app know which are the ports we are using, we set them in a application.yml file. This file is ignored in our repo so each developer can set their own ports.

config/application.yml

development:
  http_port: 8080 #leave null for 80
  https_port: 8443 #leave null for 443
test:
  http_port: null #leave null for 80
  https_port: null #leave null for 443
production:
  http_port: 9080 #leave null for 80
  https_port: 9443 #leave null for 443

We load this settings in our application.rb they way that Ryan proposes in this railscast

config/application.rb

 CONFIG = YAML.load(File.read(File.expand_path('../application.yml', __FILE__)))
 CONFIG.merge! CONFIG.fetch(Rails.env, {})
 CONFIG.symbolize_keys!

Instead of using Rails.application.routes.default_url_options, we define a method in our ApplicationController in order to have different defaults depending on the current request scheme.

app/controllers/application_controller.rb

def default_url_options(options={})
   port = request.ssl? ? CONFIG[:https_port] : CONFIG[:http_port]
   options = { :port=> port }
   options
 end

 def default_host_with_port
   "#{url_options[:host]}#{":#{url_options[:port]}" if url_options[:port]}"
 end

helper_method :default_url_options, :default_host_with_port

the first method gives proper defaults for url routes when the kind of request is preserved, when we want to change for example from a https request to http we need to be specific:

 my_route_url(:protocol => 'http', :port=>CONFIG[:http_port])

the helper default_host_with_port replaces the request.host_with_port that we were using to specify the path to some assets:

  image_tag("//#{default_host_with_port}/path_to_image")

The trickiest part was to take care of the redirects. It was unclear for us how rails was filling in the remaining part of the url every time we used a redirect_to to a path instead of a url, and worst of all, we couldn’t easily change the way other gems handled their redirects, specially when devise relied on warden to handle the response.

Fortunately, we could take advantage of the middleware to detect if we were performing a redirect to another location in our server and set the proper port before letting the response leave our app.

app/middlewate/set_special_ports.rb

class SetSpecialPorts
  def initialize(app)
    @app = app
  end

  def call(env)

   server_name = env["SERVER_NAME"]

    # execute the request using our Rails app
   status, headers, body = @app.call(env)

    if internal_redirect?(status, headers, server_name)
      [status, {"Location" => url_with_port(headers["Location"])}, ['Redirecting you to the new location...']]
    else
    # just send the response as it is
      [status, headers, body]
    end
  end

  def internal_redirect?(status, headers, server_name)
    uri = URI.parse(headers["Location"]) if headers["Location"]
    [300, 301, 302, 307].include?(status) && uri.try(:host) == server_name
  end

  # add the ports for http and https defined in our CONFIG settings through application.yml
  def url_with_port(url)
    uri = URI.parse(url)
    uri.port = CONFIG["#{uri.scheme}_port".to_sym]
    uri.to_s
  end

end


config/application.rb

config.middleware.insert_before "Warden::Manager", "SetSpecialPorts"