There are few technical decisions I regret more with Buttondown than the decision to combine the author-facing app, the subscriber-facing app, and the marketing site all under a single domain. Most technical decisions are reversible with sufficient grit and dedication; this one is not, because it requires customers to change their URLs and domains.
There are a number of reasons why this was a bad decision, and that’s probably worth an essay in its own right, but this is more meant to discuss how we work around the problem.
At a high level, it looks something like this:
All requests run through buttondown-load-balancer
, which is a Docker container in Heroku containing HAProxy. I got the bones of this container from a lovely blog post from Plane.
The HAProxy configuration looks something like this:
global
maxconn 256
defaults
mode http
timeout connect 5000ms
timeout client 50000ms
timeout server 50000ms
frontend http
# $PORT comes from Heroku, via some sort of dark magic.
bind "0.0.0.0:$PORT"
option forwardfor
redirect scheme https code 301 if ! { hdr(x-forwarded-proto) https }
# We want to find references to the old TLD and redirect them to the new one.
# Don't redirect POST requests, because they're used for webhooks.
acl is_old_tld hdr(host) -i buttondown.com
http-request redirect prefix https://buttondown.com code 302 if METH_GET is_old_tld
# Yes, it is unfortunate that this is hardcoded and can't be pulled out
# from the sitemap of `marketing`. But in practice I do not think it is
# a big deal.
# 1. These represent absolute paths without subfolders
acl is_marketing path -i / /climate /alternatives /pricing /sitemap.xml /sitemap-0.xml /stack /open-source
# 2. These represent subfolders. (Legal has a trailing slash so usernames can start with legal.)
acl is_marketing path_beg /features /use-cases /comparisons /legal/ /changelog /rss/changelog.xml /blog /rss/blog.xml /api/og /comparison-guides /stories/ /testimonials /resources
# Docs technically live on a subdomain, but lots of people try to go to `buttondown.com/docs`.
acl is_marketing path_beg /docs
# 3. `_next` is a Next.js-specific thing...
acl is_marketing path_beg /_next /_vercel
# 4. But `next-assets` is something I made up just to make it easy to namespace the assets.
# (There's a corresponding `/next-assets` folder in `marketing`.)
acl is_marketing path_beg /next-assets
# Where the magic happens: route all marketing traffic to the marketing app.
use_backend buttondown-marketing if is_marketing
default_backend buttondown-app
backend buttondown-app
# We need to set `req.hdr(host)` so that `app` can correctly route custom domains.
http-request set-header X-Forwarded-Host %[req.hdr(host)]
http-request set-header X-Forwarded-Port %[dst_port]
reqirep ^Host: Host:\ buttondown.herokuapp.com
server buttondown-app buttondown.herokuapp.com:443 ssl verify none
backend buttondown-marketing
http-request set-header X-Forwarded-Host buttondown.com
http-request set-header X-Forwarded-Port %[dst_port]
reqirep ^Host: Host:\ marketing.buttondown.com
server buttondown-marketing marketing.buttondown.com:443 ssl verify none
This allows us to deploy the marketing site and the application site separately and without real worry about polluting the two (and indeed, our marketing site is on Vercel whereas the application site is on Heroku).
The only real source of angst comes from keeping the routing information up to date. As you can see from the above file, we have a thinner list of potential routes on the marketing site so we have to enumerate them, and often we forget to do so and so new pages are “hidden” (ie served by the Django app, which then throws a 404.)
Another challenge was testing this. When I first developed this approach three years ago it was, frankly, pretty reasonable to test in prod — Heroku is very quick at reverting Docker deploys, so I could just push and yoink if necessary. Now, though, a few seconds of downtime corresponds to thousands of page-views lost; we’re using Hurl as a very lovely testing harness, with an approach largely inspired by this blog post from InfoQ.
All in all: this approach is janky, and required a lot of tinkering, but is very stable in its steady state. I cannot emphasize enough that if you are starting an app, you should not even entertain the idea of doing this: namespace your actual application under app.
or dashboard.
or whatever and call it a day, and your marketing department will thank you. But if you’re already stuck and need a way out: this one works.
(By the way, if someone has a non-insane way to do this, please let me know. This approach has worked well, is stable, and does not feel particularly unperformant, but it feels bad.)