Many services in one Rails app — why your catch-all eats the next service
A lambda in a route constraints option drops the outer host constraint
Read this if you are serving several domains from one Rails app. By the end you will know the basic shape for splitting routes by host, and how to avoid the trap where adding a 404 route turns the next service's pages into 404s too.
The basic shape: split by host
Our repo runs hundreds of services in a single Rails app. Domains like app51.negabaro.com and app55.negabaro.com all hit the same process, and the routes look at the Host header to send each request where it belongs.
Pass constraints an object that responds to matches?(request), and the routes inside that block only match when it returns true. We use a short class called SimpleHostConstraint. All it does is check whether the request host is on an allow list.
Each service ends its block with match '*path' for a 404, so unknown URLs land on that service's own 404 page.
The trap: one lambda, and the next service goes 404
Our blog apps serve post images through Active Storage (/rails/active_storage/...). The catch-all swallows that path too, so images break. The natural fix is to add constraints: ->(req) { ... } to the catch-all and skip that path.
Do that, and perfectly fine pages in other services start returning 404.
The intuitive assumption is that the outer block already filters by host, so the inner lambda only needs to care about the path. It does not work that way.
Why: a route-level lambda replaces the outer conditions
The answer is in action_dispatch/routing/mapper.rb (Rails 8.0.4). When a route gets a non-Hash constraint (a lambda or a matches? object), Rails sets @blocks = blocks(options_constraints) and replaces the whole list. The host check collected from the outer constraints ... do block is gone.
With a Hash, Rails keeps @blocks = scope_params[:blocks] and inherits the outer conditions. So constraints: { slug: /[a-z-]+/ } is fine. Only the lambda is the problem.
The catch-all ends up as a *path that ignores the host. Routes are matched top to bottom, so every service drawn after that line gets caught here and returns 404. Services drawn before it keep working. That gives you the odd symptom where some apps work and others do not.
Two ways to fix it
One: check the host again inside the lambda. Our repo does this with add_catch_all_404_except_active_storage_v2(allowed_hosts:), which still sits in config/routes.rb next to a comment calling it a workaround.
Two: wrap the lambda in a constraints ->(req) { ... } do block instead of passing it as a route option. As a block, the condition is appended to the outer list instead of replacing it (merge_blocks_scope).
I ran both through the reproduction script below. Requests for the other service reached their own routes. I prefer the block version. You do not have to keep the host list in two places.
Reproducing it takes 20 lines
No server needed. Build an ActionDispatch::Routing::RouteSet yourself and feed it requests with Rack::MockRequest. When routes misbehave, narrowing it down this way takes seconds and no restarts. The runnable code is in the details below.
Does the outer host check survive?
| How you write it | Outer host check |
|---|---|
match ..., constraints: { slug: /[a-z-]+/ } | kept |
match ..., constraints: ->(req) { ... } | dropped |
constraints ->(req) { ... } do match ... end | kept (appended) |
Reproduction script (runs as is)
Inside a Rails project: bundle exec ruby repro.rb. Checked on Rails 8.0.4.
require "action_dispatch"
require "rack/mock"
class HostC
def initialize(h) = @h = h
def matches?(r) = r.host == @h
end
ok = ->(name) { ->(env) { [200, {}, [name]] } }
set = ActionDispatch::Routing::RouteSet.new
set.draw do
constraints HostC.new("a.localhost") do
get "/hello", to: ok.("A hello")
match "*path", to: ok.("A 404"), via: :all,
constraints: ->(req) { !req.path.start_with?("/rails/active_storage") }
end
constraints HostC.new("b.localhost") do
get "/hello", to: ok.("B hello")
end
end
%w[a.localhost b.localhost].each do |host|
env = Rack::MockRequest.env_for("http://" + host + "/hello")
puts host + " -> " + set.call(env)[2].join
end
a.localhost -> A hello
b.localhost -> A 404 ← the a catch-all ate the b request
The fix — wrap it in a block
constraints HostC.new("a.localhost") do
get "/hello", to: ok.("A hello")
constraints ->(req) { !req.path.start_with?("/rails/active_storage") } do
match "*path", to: ok.("A 404"), via: :all
end
end
a.localhost -> A hello
b.localhost -> B hello
Adding req.host == "a.localhost" && inside the lambda gives the same result.
⚠ Check the other apps too
The app whose catch-all you changed looks fine. What breaks is another app drawn after it. After touching routes, open one URL from an app further down.
Key Points
To split by host, pass an object with matches?(request) to a constraints block
A lambda in the route option constraints: replaces the outer block condition list entirely (@blocks = blocks(...) in mapper.rb)
Hash constraints inherit the outer conditions, so they are safe
Symptom: only services drawn after that catch-all return 404
Fix: re-check the host inside the lambda, or wrap the lambda in a constraints block
RouteSet + Rack::MockRequest reproduces it in seconds without a server