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:

buttondown.combuttondown.comLoad balancer (HAProxy)Load balancer (HAProxy)Marketing siteMarketing siteApplicationApplicationbuttondown-application.herokuapp.combuttondown-application.herokuapp.commarketing.buttondown.commarketing.buttondown.comDockerDockerHerokuHeroku

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.email
    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.)

Lightning bolt
This post is referenced by:

Lightning bolt
About the author

I'm Justin Duke — a software engineer, writer, and founder. I currently work as the CEO of Buttondown, the best way to start and grow your newsletter, and as a partner at Third South Capital.

Lightning bolt
Greatest hits

Lightning bolt
Elsewhere

Lightning bolt
Don't miss the next essay

Get a monthly roundup of everything I've written: no ads, no nonsense.