Julik Tarkhanov

If you need subdomains: just use subdomains

Eelco recently wrote about using subdomains in Rails, outlining a seemingly neat idea about having them as subdomains in production but using paths in development. It is clever and looks very usable at first sight. It’s also a very bad idea that is likely to get you side effects you really won’t be happy about. I normally don’t do “rebuttal” posts, but in this case — since I have dealt with that problem before — it feels warranted. Without being too lyrical about it, I want to outline why you don’t want to use that approach and propose a couple of alternatives.

So, the proposition is this. In production, your tenants/sites are on subdomains called something like site1.product.com, site2.product.com, and so on. In development, however, you will have http://localhost:3000/site1, http://localhost:3000/site2, and so on. This gives you the following URL structure — quoting Eelco:

  • Production: https://api.example.com/v1/users
  • Development: http://localhost:3000/api/v1/users

Here is why this is quite a bad idea, in no particular order.

All of your non-Rails-generated URLs will break

If you are using URLs that are not computed by the Rails router, they will be different for production and development. Even something as innocent as a root-relative URL to a static image file will need to be made dynamic to function.

CORS will be a problem

CORS can be a challenge at the best of times, but even more so in setups where your origin changes between your development and production environments. With your API under /api/v1/users, the URL is going to be considered same-origin when you call it from your application. With a subdomain, it is likely that the URL is going to be considered an external origin — depending on where you make the request from — and your fetch() calls will become cross-origin. This, in turn, will lead to the following dance:

  • You discover your fetch() requests fail with a CORS error — but only when actually deployed to production.
  • You try to implement CORS preflight configuration, only to discover it does not even get requested in development. Upon deployment, it turns out to be faulty.
  • You end up creating a CORS configuration that is vastly more permissive than you actually need, potentially creating security exposure.

CORS is something that you want to face early, you want to face it in local development first, and you want your development environment to be as close to your production environment as possible. The proposed setup robs you of all of that.

CSP will be a problem

The content security policy — should you decide to use it — relies on the same classification of origins that CORS relies on and, in some instances, specifies restrictions or permissions based on the origin of self. With a path-routed endpoint, your API is still going to be self in terms of CSP. With subdomains, it will no longer be. Therefore:

  • Scripts served via this “namespaced” URL will likely work in development but may easily break in production, as their execution will be suppressed by CSP.
  • Any CSP tweaks you make in development will have no effect, because your URLs — again — will be different origins from CSP’s point of view.

Rails host authorization will be a problem

Rails, by default, configures host authorization to permit certain hostnames only. In development, because of the path routing, this part of the application won’t be exercised at all — so you won’t notice it kicking in. Afterwards:

  • Your production deployment will refuse to serve the API because you forgot to add your subdomains to the host authorization config.
  • Adding it to the host authorization will work — if you do it right — but you will only be able to test it on your production deployment setup.

Well-known URLs will be a problem

While it’s not usually the first point of concern when building an application, there are plenty of URLs that are expected to be at the root of your URL namespace and take effect for the entire hostname — regardless of the subdomain nesting. Just a short sampler of those:

  • favicon.ico
  • apple-touch-icon.png
  • .well-known/apple-app-site-association
  • .well-known/<literally-anything-else>
  • robots.txt
  • sitemap.xml

…and many, many others. Most user agents that will access your application won’t bother reading your HEAD element for the meta elements or LINK elements — they will proceed right ahead and try to load those files directly. For pure APIs, it often doesn’t matter that much, but at the very least you will often want to fence them off in robots.txt by setting a Disallow: — well, you no longer can! If you actually have sites under those subdomains, the sitemap.xml will be different for every site as well.

All of that simply doesn’t work with path namespacing.

Cookies (and thus authentication) will be a problem

With paths, a cookie you set (like a Rails session cookie) will be just… a cookie. But with subdomains, you will need to remember to make it a wildcard cookie on the same domain by setting it with .myapp.com instead of myapp.com. Having a path-based setup will make you blind to this issue until… you deploy.

Overscaling prematurely in an exquisitely inconvenient way

The article stipulates:

Using separate subdomains for APIs and webhooks (like webhooks.helptail.com) creates a foundation for growth. This approach lets you scale high-traffic parts of your app independently when customer usage surges, implement targeted security policies without complicating your main application (eg. dashboard.helptail.com), and establishes an architecture that simplifies future migrations as you expand (globally through CDNs).

Now, is scaling via subdomains a good way to add capacity to specific endpoints? Yes. Does it allow for targeted security policies? Yes. Does it establish an architecture that simplifies future migrations as you expand? Yes. But there is something that is not mentioned there.

Literally all of this becomes relevant when growth is actually happening and is premature before that. Turning a same-origin application into a multi-origin application is no small feat. If you go for it – you should think about whether it is actually necessary.

Moreover, if you do take those steps, the proposed setup will make it excruciatingly painful for you to make sure all of those moving parts work, because using paths occludes the fact that you are turning your application into a multi-origin one. CORS is hard and can drive even the best developers up the wall.

Having a subdomains setup but not being able to actually excercise it locally is no good way to work; it is self-torture.

There is some merit to having “a subdomain for everything” if you want to use an API gateway of some description — but for most Rails apps just starting out, an API gateway will be utter overkill. Moreover — API gateways are usually a solution to a people problem (lots of disparate APIs managed by lots of different teams) — and it is not something most Rails apps will be subject to. Anticipating the use of API gateways is speculation that is unlikely to pay off.

What to do instead

If you want to go with subdomains — sure, have at it. There is a whole palette of possible approaches you can take to make them work in development:

  • Use my Zeroconf setup
  • Use hard-coded hostnames in /etc/hosts. Hey, it’s old, it’s robust, it works everywhere…
  • Use .localhost URLs
  • Use any subdomain on localhost-based public DNS services like lvh.me or localtest.me, as per this thread

Literally anything that gives you actual separate subdomains (and thus — separate origins and separate URL root namespaces) will work much better than what Eelco is suggesting.

Or — even better — do not do subdomains until you have very good reasons to.