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
endSetting 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
endThis 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
endFor 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
endTesting 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
endThis 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 likeposts_urlthat respect configuration. - Forgetting protocol: Omitting
protocol: 'https'in production causes mixed-content warnings or security issues. - Port in production: Including
port: 3000in production configuration exposes internal architecture. Production apps typically run behind load balancers on standard ports. - Using _path helpers in mailers: Path helpers like
post_pathgenerate relative URLs (/posts/1). Mailers require absolute URLs, so always use_urlhelpers (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
endThis 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.