Rails 8 Default URL Options Guide

Rails applications generate URLs in multiple contexts: mailers, background jobs, Action Cable broadcasts, and controller redirects. Without proper configuration, these URLs break or point to localhost in production. The solution lies in understanding and configuring default_url_options correctly across all environments.

The Problem: Broken URLs Outside Request Context

When Rails generates a URL inside a controller action, it automatically knows the current host from the request. But mailers, background jobs, and WebSocket broadcasts run outside the request cycle. Without explicit configuration, they either fail or generate unusable URLs.

Consider a password reset mailer that sends a link. In development, it might work because localhost is assumed. In production, the email contains http://localhost:3000/password_resets/abc123 instead of the actual domain. Users click dead links, and support tickets pile up.

Configuring Default URL Options Per Environment

Rails provides several places to set default URL options. The most reliable approach configures them in environment files, ensuring consistency across all URL-generating components.

# config/environments/development.rb
Rails.application.configure do
  # Host for URL generation in mailers, jobs, etc.
  config.action_mailer.default_url_options = { host: 'localhost', port: 3000 }
  
  # Routes helper uses this when called outside controllers
  Rails.application.routes.default_url_options = { host: 'localhost', port: 3000 }
end
# config/environments/production.rb
Rails.application.configure do
  # Use environment variable for flexibility across staging/production
  config.action_mailer.default_url_options = { 
    host: ENV.fetch('APP_HOST', 'example.com'),
    protocol: 'https'
  }
  
  Rails.application.routes.default_url_options = { 
    host: ENV.fetch('APP_HOST', 'example.com'),
    protocol: 'https'
  }
  
  # Force SSL ensures generated URLs use https
  config.force_ssl = true
end

Setting both action_mailer.default_url_options and routes.default_url_options covers the two main URL generation pathways. The mailer setting handles email links, while the routes setting covers background jobs, Action Cable, and any code calling route helpers directly.

Dynamic Host Configuration for Multi-Tenant Apps

Multi-tenant applications need URLs that reflect the current tenant's subdomain or custom domain. Static configuration falls short here. Instead, override default_url_options in ApplicationController and ApplicationMailer.

# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
  def default_url_options
    options = super
    
    if current_tenant&.custom_domain.present?
      options[:host] = current_tenant.custom_domain
      options[:protocol] = 'https'
    elsif current_tenant&.subdomain.present?
      options[:host] = "#{current_tenant.subdomain}.#{ENV.fetch('APP_DOMAIN')}"
      options[:protocol] = 'https'
    end
    
    options
  end
end
# app/mailers/application_mailer.rb
class ApplicationMailer < ActionMailer::Base
  default from: '[email protected]'
  layout 'mailer'
  
  private
  
  def set_url_options_for_tenant(tenant)
    if tenant&.custom_domain.present?
      self.default_url_options = { host: tenant.custom_domain, protocol: 'https' }
    elsif tenant&.subdomain.present?
      self.default_url_options = { 
        host: "#{tenant.subdomain}.#{ENV.fetch('APP_DOMAIN')}", 
        protocol: 'https' 
      }
    end
  end
end
# app/mailers/user_mailer.rb
class UserMailer < ApplicationMailer
  def password_reset(user)
    @user = user
    @tenant = user.tenant
    
    set_url_options_for_tenant(@tenant)
    
    mail(to: @user.email, subject: 'Reset your password')
  end
end

This pattern keeps URL generation flexible while maintaining clean separation. Mailers explicitly receive tenant context, making the dependency clear and testable.

URL Options in Background Jobs

Background jobs present a unique challenge. They execute without request context and potentially on different servers. The solution involves passing URL options explicitly or setting them at job execution time.

# app/jobs/weekly_digest_job.rb
class WeeklyDigestJob < ApplicationJob
  queue_as :mailers
  
  def perform(user_id)
    user = User.find(user_id)
    tenant = user.tenant
    
    # Set URL options before any URL generation
    Rails.application.routes.default_url_options = url_options_for(tenant)
    
    DigestMailer.weekly(user).deliver_now
  end
  
  private
  
  def url_options_for(tenant)
    if tenant.custom_domain.present?
      { host: tenant.custom_domain, protocol: 'https' }
    else
      { host: "#{tenant.subdomain}.#{ENV.fetch('APP_DOMAIN')}", protocol: 'https' }
    end
  end
end

For simpler applications without multi-tenancy, relying on the environment configuration works fine. The job inherits whatever default_url_options the environment specifies.

Action Cable and Turbo Stream Broadcasts

Turbo Stream broadcasts from models or jobs also need URL options when the broadcast includes links. Set options before broadcasting or configure them globally.

# app/models/comment.rb
class Comment < ApplicationRecord
  belongs_to :post
  belongs_to :user
  
  after_create_commit :broadcast_to_post
  
  private
  
  def broadcast_to_post
    # Ensure URL options are set for any links in the partial
    Rails.application.routes.default_url_options = {
      host: ENV.fetch('APP_HOST'),
      protocol: Rails.env.production? ? 'https' : 'http'
    }
    
    broadcast_append_to(
      post,
      target: 'comments',
      partial: 'comments/comment',
      locals: { comment: self }
    )
  end
end

Testing URL Generation

Tests often fail because URL options differ between test and development environments. Configure them explicitly in the test environment and in test setup.

# config/environments/test.rb
Rails.application.configure do
  config.action_mailer.default_url_options = { host: 'test.host' }
  Rails.application.routes.default_url_options = { host: 'test.host' }
end
# spec/support/url_helpers.rb
RSpec.configure do |config|
  config.before(:each, type: :mailer) do
    Rails.application.routes.default_url_options = { host: 'test.host' }
  end
  
  config.before(:each, type: :job) do
    Rails.application.routes.default_url_options = { host: 'test.host' }
  end
end

This setup prevents flaky tests caused by URL generation failures and ensures consistent assertions about generated URLs.

Common Mistakes to Avoid

Several patterns cause URL generation headaches:

  • Hardcoding hosts in views: Never write href="https://example.com/path". Always use route helpers like posts_url that respect configuration.
  • Forgetting protocol: Omitting protocol: 'https' in production causes mixed-content warnings or security issues.
  • Port in production: Including port: 3000 in production configuration exposes internal architecture. Production apps typically run behind load balancers on standard ports.
  • Using _path helpers in mailers: Path helpers like post_path generate relative URLs (/posts/1). Mailers require absolute URLs, so always use _url helpers (post_url).

Environment Variables for Flexibility

Hardcoding hosts creates deployment friction. Using environment variables allows the same codebase to run across staging, production, and review apps.

# config/application.rb
module MyApp
  class Application < Rails::Application
    config.after_initialize do
      default_host = ENV.fetch('APP_HOST', 'localhost')
      default_protocol = ENV.fetch('APP_PROTOCOL', 'http')
      default_port = ENV['APP_PORT'] # nil in production
      
      url_options = { host: default_host, protocol: default_protocol }
      url_options[:port] = default_port.to_i if default_port.present?
      
      Rails.application.routes.default_url_options = url_options
    end
  end
end

This centralizes URL configuration and makes deployment to new environments trivial. Set APP_HOST and APP_PROTOCOL in the environment, and URL generation works correctly everywhere.

Summary

Proper URL configuration requires setting options in multiple places: environment files for static configuration, controller and mailer overrides for dynamic hosts, and explicit settings in background jobs. The key principles are using environment variables for flexibility, always specifying protocol in production, and preferring _url helpers over _path helpers when URLs leave the browser context. With these patterns in place, password reset emails, webhook callbacks, and Turbo broadcasts all generate correct, clickable URLs regardless of where the code executes.

10 claps
← Back to Blog