About

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 ... endkept (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

1

To split by host, pass an object with matches?(request) to a constraints block

2

A lambda in the route option constraints: replaces the outer block condition list entirely (@blocks = blocks(...) in mapper.rb)

3

Hash constraints inherit the outer conditions, so they are safe

4

Symptom: only services drawn after that catch-all return 404

5

Fix: re-check the host inside the lambda, or wrap the lambda in a constraints block

6

RouteSet + Rack::MockRequest reproduces it in seconds without a server

Use Cases

One Rails app serving several domains (multi-tenant or multi-service) Excluding paths like Active Storage from a catch-all 404 Narrowing down "only some apps return 404" routing bugs without a server
← find_or_initialize_by vs create_or_find_by: Surviving Concurrent Requests Why API keys are shown only once, and why we skipped the "show again" button →