How to Add Custom Domains to a Multi-Tenant Go App


To add custom domains to a Go app, wrap your ServeMux in a handler that reads the request's hostname, looks up which tenant owns that domain, and stores the tenant in the request's context.Context. The customer adds a CNAME that points at you, and every domain needs a TLS certificate, which a TLS proxy in front of your Go service issues and renews.
The standard library gets you most of the way, but it has opinions you'll trip over: r.Host keeps the port, ServeMux panics on a wildcard host, http.CrossOriginProtection rejects forms with "Origin does not match Host" once a proxy rewrites Host, and httputil.ReverseProxy can pass a client's X-Forwarded-Host straight to your tenant lookup.
The example is a link shortener built on net/http, html/template and a pure-Go SQLite driver. All the code here is copied from the Go example on GitHub. Each gotcha has a unit test there, and a Playwright suite drives the app through scd-mock, our stand-in for the production proxy.
In this guide:
- Run the example first
- What reaches your Go server when someone opens go.acme.com
- A table for customer domains
- Route by hostname in front of ServeMux
- Fix "Unknown domain" on localhost: r.Host includes the port
- ServeMux panics with "host contains '{'"
- Fix "Origin does not match Host" from CrossOriginProtection
- ReverseProxy passes the client's X-Forwarded-Host through
- A certificate for each customer's hostname
- Set up SaaS Custom Domains for your Go app
- Keep the dashboard in sync with webhooks
- Test the whole flow on your laptop
- Frequently asked questions
Run the example first
The product is called Snip. Two brands are seeded, Acme Rockets and Globex, each with a couple of short links. A brand's owner connects a domain like go.acme.com, and go.acme.com/launch then redirects to Acme's launch page. Each brand's domain also has a small public form for reporting a broken link. With Docker:
git clone https://github.com/saas-custom-domains/custom-domains-go
cd custom-domains-go
docker compose up --buildIn the dashboard at http://localhost:3000, connect go.acme.localhost to Acme Rockets. Wait a few seconds and the badge switches to Live. Then visit http://go.acme.localhost/launch and you land on Acme's link target. Port 80 on your machine is scd-mock, our API and proxy in miniature, so the Go server receives the request with the headers production would add. Any name ending in .localhost resolves to 127.0.0.1 without editing your hosts file.
Prefer go run for the app itself? The README shows how to keep only the mock in Docker and point it at your dev server. It needs Go 1.27; an older go command fetches the right toolchain because of the toolchain line in go.mod.
| Tested with | |
|---|---|
| Go | 1.27.3 |
| modernc.org/sqlite | 1.61.0 |
Checked on October 28, 2026.
What reaches your Go server when someone opens go.acme.com
Your customer makes one DNS change that routes the domain to SaaS Custom Domains: a CNAME for a subdomain like go.acme.com, or, because a bare acme.com can't carry a CNAME, two A records with our anycast IPs. The exact records are in step 4. If you'd rather customers see your brand, white-label the target as in.snip.example. Our main custom domains guide goes deeper into DNS, verification and apex domains.
When the record resolves, the browser connects to our proxy, which terminates TLS and forwards the request to your Go server along with the customer's domain. Whichever hostname your handler goes by ends up in r.Host, not in r.Header, and it's the input for every decision that follows: is this our own hostname, a connected customer, or a domain nobody registered with us?
A table for customer domains
A domain belongs to one brand and stays pending until DNS checks out. The example keeps it in SQLite through modernc.org/sqlite, which is pure Go, so the binary builds with CGO_ENABLED=0 and runs in a distroless image. The schema is plain SQL that works in Postgres too:
CREATE TABLE IF NOT EXISTS custom_domains (
id INTEGER PRIMARY KEY,
brand_id INTEGER NOT NULL
REFERENCES brands(id) ON DELETE CASCADE,
host TEXT NOT NULL UNIQUE,
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'active')),
https_ready INTEGER NOT NULL DEFAULT 0,
provider_uuid TEXT UNIQUE,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);The UNIQUE on host is the guard against two brands connecting one domain and also the index for the lookup on every request. https_ready lets the badge tell working DNS apart from an issued certificate. provider_uuid is the domain's ID at whoever issues your certificates, so you can check or delete it later.
The insert turns a unique violation into an error the dashboard can show. With modernc's driver that's an error code check, not a string match:
se, ok := errors.AsType[*sqlite.Error](err)
if ok && se.Code() == sqlite3.SQLITE_CONSTRAINT_UNIQUE {
return d, errHostTaken
}Hostnames get one canonical form before they're stored or looked up. Hosts also knows which names are yours:
// normalizeHost turns " Go.Acme.com.:443 " into "go.acme.com".
// r.Host keeps whatever port the client used, as in
// "go.acme.localhost:3000", so every lookup goes through this first.
func normalizeHost(s string) string {
s = strings.ToLower(strings.TrimSpace(s))
host, port, err := net.SplitHostPort(s)
if err == nil && isDigits(port) {
s = host
}
return strings.TrimSuffix(s, ".")
}
func (h Hosts) IsReserved(host string) bool {
if h.IsApp(host) {
return true
}
if h.Domain == "" {
return false
}
return host == h.Domain || strings.HasSuffix(host, "."+h.Domain)
}Hosts.App comes from APP_HOSTS: every hostname that serves your own product, including internal ones. IsReserved keeps a customer from connecting admin.snip.example and putting their page on your domain. The dashboard checks both before it saves anything.
Route by hostname in front of ServeMux
Go 1.22 gave ServeMux patterns methods and path wildcards, and a pattern could already begin with a host. The host part looks like the obvious tool for multi-tenancy, and it doesn't fit: it only takes literal hostnames, registered at startup. Your customers' domains live in a database and change all day. (The panic section shows what happens when you try a wildcard.)
The example builds two muxes instead, one for your own product and one for customer domains, and puts a small handler in front that picks one per request:
func (a *App) Handler() http.Handler {
// Rejects cross-site form posts (CSRF). When the browser sends no
// Sec-Fetch-Site, it compares Origin with r.Host, which is why
// hostRouter sets r.Host to the customer's domain.
csrf := http.NewCrossOriginProtection()
// The dashboard, plus the webhook SaaS Custom Domains calls.
app := http.NewServeMux()
app.HandleFunc("GET /{$}", a.dashboard)
app.HandleFunc("POST /brands/{id}/domains", a.addDomain)
app.HandleFunc("POST /domains/{id}/check", a.checkDomain)
app.HandleFunc("POST /domains/{id}/remove", a.removeDomain)
app.HandleFunc("POST /webhooks/saascustomdomains", a.webhook)
// Customer domains: go.acme.com/launch redirects.
tenant := http.NewServeMux()
tenant.HandleFunc("GET /{$}", a.brandHome)
tenant.HandleFunc("GET /{slug}", a.followLink)
tenant.HandleFunc("POST /report", a.report)
root := http.NewServeMux()
// Load balancers and Docker call this by IP address, so it skips
// the host check.
root.HandleFunc("GET /up", health)
root.Handle("/", a.hostRouter(
csrf.Handler(app), csrf.Handler(tenant),
))
return root
}Splitting the routes this way removes a whole class of bugs. GET /launch simply doesn't exist on snip.example, so nobody can follow Acme's links on your domain before Acme's DNS is verified. And POST /brands/1/domains doesn't exist on go.acme.com, so the dashboard can't be driven from a customer's domain.
hostRouter does the lookup:
func (a *App) hostRouter(app, tenant http.Handler) http.Handler {
return http.HandlerFunc(func(
w http.ResponseWriter, r *http.Request,
) {
// Behind SaaS Custom Domains, Host is our own hostname and
// the customer's domain is in X-Served-For. Only trust it
// with the upstream's auth token, or anyone who can reach
// the app could claim any customer's domain.
servedFor := r.Header.Get("X-Served-For")
if servedFor != "" && !a.fromProxy(r) {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
host := normalizeHost(cmp.Or(servedFor, r.Host))
if a.hosts.IsApp(host) {
app.ServeHTTP(w, r)
return
}
ctx := r.Context()
brand, err := a.store.BrandByActiveHost(ctx, host)
if errors.Is(err, errNotFound) {
http.Error(w, "Unknown domain", http.StatusNotFound)
return
}
if err != nil {
serverError(w, err)
return
}
r = r.WithContext(context.WithValue(ctx, brandKey{}, brand))
if servedFor != "" {
// The token proved where the request came from, so
// give the rest of the app the visitor's domain:
// http.CrossOriginProtection compares Origin with
// r.Host, and links on the page are built from it.
r.Host = host
}
tenant.ServeHTTP(w, r)
})
}Without a proxy in front there's no X-Served-For, and step 5 explains those lines. Run the app on its own and cmp.Or falls back to r.Host.
A domain that isn't in the table, or is still pending, gets a 404 before any handler runs. Anyone can aim a domain they own at your IP; your server shouldn't answer for it. BrandByActiveHost is a single indexed query, so it's cheap enough to run on every request. If it ever shows up in a profile, an in-memory cache with a short TTL in front of it is enough.
The tenant lives in the context
Handlers on the tenant mux get the brand from the context, through an unexported key type that no other package can collide with:
type brandKey struct{}
func brandFrom(ctx context.Context) Brand {
return ctx.Value(brandKey{}).(Brand)
}The public form uses it. Notice what the handler never reads: a brand ID from the form or the URL. A report posted on go.acme.com is about Acme, whatever the request body says:
func (a *App) report(w http.ResponseWriter, r *http.Request) {
brand := brandFrom(r.Context())
link := strings.TrimSpace(r.FormValue("link"))
if link == "" {
a.renderBrand(w, r, http.StatusUnprocessableEntity,
"Tell us which link is broken.")
return
}
err := a.store.AddReport(r.Context(), brand.ID, link, a.clientIP(r))
if err != nil {
serverError(w, err)
return
}
// A relative URL keeps the browser on the customer's domain.
http.Redirect(w, r, "/?reported", http.StatusSeeOther)
}http.Redirect with a path keeps the browser on whichever domain it's on, which is what you want when there are thousands of them. The short links themselves use 302 Found rather than 301: browsers cache a permanent redirect indefinitely, and a brand will want to change where /launch points.
Health checks and webhooks aren't on your domain
Docker's health check calls http://127.0.0.1:3000/up, and load balancers on AWS and elsewhere also probe by IP address. 127.0.0.1 isn't in APP_HOSTS, so a router that checks every request would answer "Unknown domain" and the container would never report healthy. That's why /up is registered on the outer mux, where no host check happens.
Webhooks run into the same check from another side. Inside docker compose, the mock delivers them to http://app:3000/webhooks/..., and app is a hostname too. APP_HOSTS should hold each hostname your infrastructure reaches the container by, alongside the public one.
Fix "Unknown domain" on localhost: r.Host includes the port
Open http://go.acme.localhost:3000/launch with a router that uses r.Host as is, and you get:
404 Unknown domainr.Host is go.acme.localhost:3000. Go keeps the port exactly as the client sent it, and the database holds go.acme.localhost. In production on port 443 browsers leave the port out, so this tends to show up only in development or behind a proxy that adds one.
What makes it confusing is that ServeMux strips the port when it matches a host pattern. Register go.acme.localhost/ and a request for go.acme.localhost:3000 matches, which suggests the port is gone. It isn't; your handler still sees it. normalizeHost above drops a numeric port with net.SplitHostPort, which also handles IPv6 literals like [::1]:3000 that a naive split on : would mangle. The repo's TestHostWithPortFindsTheBrand fails without it.
ServeMux panics with "host contains '{'"
Go 1.22 brought wildcards to ServeMux patterns, so it's natural to try one in the host:
mux.HandleFunc("{tenant}.snip.example/", handler)That compiles, then panics at startup:
parsing "{tenant}.snip.example/": at offset 0: host contains '{'
(missing initial '/'?)Wildcards only work in the path. The quieter mistake is *.snip.example/: it registers without complaint, because * is taken as a literal character, and it never matches a request. Your subdomain tenants all get the mux's 404.
Even if host wildcards existed, they'd cover subdomains of your own domain, not go.acme.com. Custom domains need a lookup per request, which is what hostRouter does. Host patterns are still fine for a handful of fixed names you control, such as a separate api.snip.example.
The repo pins both behaviors in gotchas_test.go, together with the other standard library quirks in this guide. CI runs it weekly against the latest Go release, so a change in net/http shows up as a failing test.
Fix "Origin does not match Host" from CrossOriginProtection
Go 1.25 added http.CrossOriginProtection, a CSRF defense with no tokens to embed in forms. The example wraps both muxes with it. Put a proxy in front that sends your own hostname in Host, post the report form on a customer's domain, and some browsers get a 403 with this body:
cross-origin request detected, and/or browser is out of date:
Sec-Fetch-Site is missing, and Origin does not match HostThe check has two stages. If the browser sent Sec-Fetch-Site, that header decides: same-origin passes, cross-site fails, and Host is never consulted. Only when the header is missing does Go compare the hostname in Origin with r.Host. Behind a proxy that forwards Host: app.snip.example, a browser on go.acme.com sends Origin: https://go.acme.com, and those differ.
So who leaves out Sec-Fetch-Site? We checked with Chromium 156. Over HTTPS it sent Sec-Fetch-Site: same-origin, and the form worked without any fix. Over plain HTTP on a name other than localhost, it sent no fetch metadata at all, because browsers only add those headers for secure origins. Older browsers, Safari before 16.4 among them, never send them. In practice you'll meet the error when you test through a local proxy over HTTP, which is exactly how our end-to-end test caught it, and from the occasional visitor on an old browser.
AddTrustedOrigin can't help here. It takes a fixed list of origins, the same problem host patterns have. The fix is to tell the rest of the app the visitor's domain once you know the request is genuine. Our proxy keeps your app's own hostname in Host on purpose, since hosting platforms route on it and many apps only answer on their own domain. The customer's domain arrives separately, in X-Served-For. If your app needs it for CSRF checks, links or absolute redirects, hostRouter hands it over after the token check:
if servedFor != "" {
r.Host = host
}
tenant.ServeHTTP(w, r)Because CrossOriginProtection wraps the tenant mux, it runs after this line and compares Origin with the customer's domain. Templates that print r.Host now show go.acme.com/launch instead of app:3000/launch, which is what the Playwright test saw before the fix. TestFormPostThroughTheProxy sends the proxy's headers with an Origin and no Sec-Fetch-Site, and fails with the 403 above if you delete the line.
If you'd prefer, our support team can switch X-Forwarded-Host to carry the customer's domain for your upstream. CrossOriginProtection never reads that header, though, so for this check setting r.Host is the fix.
ReverseProxy passes the client's X-Forwarded-Host through
Nothing in net/http reads X-Forwarded-Host, X-Forwarded-Proto or X-Forwarded-For. That's a sensible default: those headers are only meaningful when your own proxy set them. Trouble starts with a pattern that circulates in multi-tenant Go snippets:
host := r.Header.Get("X-Forwarded-Host")
if host == "" {
host = r.Host
}Put that behind a Go proxy built with httputil.NewSingleHostReverseProxy and any visitor picks their tenant. That constructor still uses the deprecated Director hook, and the ReverseProxy docs spell out the consequence: incoming X-Forwarded-For, X-Forwarded-Host and X-Forwarded-Proto headers are preserved. Send X-Forwarded-Host: go.victim.com to the proxy and your backend receives it unchanged.
A proxy built with Rewrite drops incoming X-Forwarded-* headers before your function runs, and SetXForwarded adds fresh ones:
proxy := &httputil.ReverseProxy{
Rewrite: func(pr *httputil.ProxyRequest) {
pr.SetURL(target)
pr.SetXForwarded()
},
}Neither version strips headers it has no opinion about, though. A client can send X-Served-For through either one. That's why the example never trusts X-Served-For on its own and checks the upstream token first; TestGotchaReverseProxyPassesClientForwardedHeaders demonstrates all three behaviors against a test backend.
The same applies to the visitor's IP. Behind any proxy, r.RemoteAddr is the proxy. Requests from our proxy carry the visitor in X-SaaS-Domains-IP, and clientIP reads it only when the token matches, falling back to RemoteAddr otherwise.
A certificate for each customer's hostname
So far the example runs over plain HTTP. A browser won't show go.acme.com until a valid certificate names that exact hostname, and certificates from Let's Encrypt and ZeroSSL last a few months at most. With thousands of customer domains, keeping that true at all hours is its own system. Run it yourself and you need:
- A TLS layer in front of your Go service that orders a certificate on the first connection for a hostname it hasn't seen, and turns away hostnames nobody connected, so junk domains aimed at your IP can't use up your CA rate limits.
- One certificate store that every instance reads and writes, so a second replica doesn't order duplicates or lose the challenge race.
- Renewal that keeps working when a customer's DNS drifts, plus a backup CA to switch to when your usual one is down.
- Fixed IP addresses for customers on bare domains like
acme.com, which can't use a CNAME. - Alerts on failed orders and renewals, and a person on call, because a broken certificate fails during the handshake, before any of your handlers or logs see the request.
The HTTPS chapter of our main custom domains guide covers every point in more depth. SaaS Custom Domains runs all of it, so your Go service keeps speaking plain HTTP and the setup below is a handful of API calls.
Set up SaaS Custom Domains for your Go app
SaaS Custom Domains is a TLS proxy between your customers' visitors and your app:
- Certificates are issued with Let's Encrypt and ZeroSSL, stored by us and renewed automatically.
- Our edge runs in 119 AWS locations; when one has trouble, visitors reach the next closest.
- Bare domains point at two anycast IPs that don't change.
- Adding or removing a domain is a single API request, and webhooks tell your app about DNS and certificate events.
- Pass a contact address and we email that person the records to create, then recheck until the records are live.
It costs $29/month for your first 100 domains and $0.29 per domain per month after that, and the rate drops once you pass 1,000 domains. The free trial lasts 7 days, takes a card, and can be cancelled whenever you want.
1. Generate an API token
Create one on the API tokens page, and copy the account UUID from account settings. Both become environment variables, SCD_API_TOKEN and SCD_ACCOUNT_UUID.
2. Point an upstream at your app
An upstream tells our proxy where to send requests, here app.snip.example. You set it up once, in our dashboard or with a call to the upstreams API, then store the UUID as SCD_UPSTREAM_UUID and the auth token as PROXY_AUTH_TOKEN. Step 5 relies on that token.
3. Create domains with net/http
The API client needs nothing beyond net/http and encoding/json. Errors come back as 400 with error and error_code fields, so the client decodes them into a typed error:
type APIError struct {
StatusCode int
Message string `json:"error"`
Code string `json:"error_code"`
}
func (c *SCDClient) CreateDomain(
ctx context.Context, host, dnsContact string,
) (SCDDomain, error) {
body := map[string]string{"host": host}
if dnsContact != "" {
// We email this address the exact DNS records to add.
body["instructions_recipient"] = dnsContact
}
var d SCDDomain
err := c.do(ctx, http.MethodPost, "", body, &d)
return d, err
}
func (c *SCDClient) DeleteDomain(
ctx context.Context, uuid string,
) error {
err := c.do(ctx, http.MethodDelete, "/"+uuid, nil, nil)
// Already gone is fine: the goal is that it doesn't exist.
apiErr, ok := errors.AsType[*APIError](err)
if ok && apiErr.StatusCode == http.StatusNotFound {
return nil
}
return err
}do builds the URL from the account and upstream, sets the bearer token, applies a 10-second timeout through the context and decodes the response. The dashboard turns error_code: "host_taken" into "already connected to another account" with errors.AsType.
The optional DNS contact field matters more than it looks. The person connecting a domain in your dashboard often can't edit DNS, and with instructions_recipient set we email the records to the person who can. We send instructions; we don't change anyone's DNS for them.
Adding a domain saves the row first, so the unique index rejects duplicates before anything leaves your server, then registers it with us. When the API call fails, the local row is removed:
func (s *DomainService) Add(
ctx context.Context, brandID int64, host, dnsContact string,
) (Domain, error) {
d, err := s.store.InsertDomain(ctx, brandID, host)
if err != nil {
return d, err
}
res, err := s.scd.CreateDomain(ctx, host, dnsContact)
if err != nil {
// Don't keep a row the proxy doesn't know about.
return d, errors.Join(err, s.store.DeleteDomain(ctx, d.ID))
}
d.ProviderUUID = res.UUID
return d, s.store.SetProviderUUID(ctx, d.ID, res.UUID)
}4. Give your customer the DNS records
A subdomain needs one CNAME, a bare domain two A records:
go.acme.com. CNAME in.saascustomdomains.com.
acme.com. A 75.2.96.173
acme.com. A 99.83.186.151If you white-label the target, the CNAME can say in.snip.example instead. Records hosted on Cloudflare need the grey cloud, "DNS only". When a customer won't give up Cloudflare's proxy, create their domain with challenge_type: "dns01". They create one more CNAME at _acme-challenge, and we validate the certificate through DNS rather than HTTP.
5. Check the token before reading X-Served-For
Requests from our proxy arrive with your upstream's hostname in Host and X-Forwarded-Host, and X-Forwarded-Proto: https. The customer's domain is in X-Served-For and the visitor's IP in X-SaaS-Domains-IP.
A tenant chosen from a header is only as safe as the check in front of it. Anyone who can reach app.snip.example directly could set X-Served-For: go.acme.com. So we add the upstream's auth token to each request we forward, in X-SaaS-Domains-Auth-Token (docs). hostRouter answers 403 when X-Served-For shows up without it:
func (a *App) fromProxy(r *http.Request) bool {
token := r.Header.Get("X-SaaS-Domains-Auth-Token")
return secureCompare(token, a.cfg.ProxyAuthToken)
}
// secureCompare compares in constant time, so a wrong guess doesn't
// reveal how many leading characters were right. An empty expected
// value never matches.
func secureCompare(given, expected string) bool {
if expected == "" {
return false
}
g, e := []byte(given), []byte(expected)
return subtle.ConstantTimeCompare(g, e) == 1
}The empty check covers a deployment where PROXY_AUTH_TOKEN is missing: subtle.ConstantTimeCompare on two empty slices returns 1, which would let any request with an empty token header through. Requests to your own hostname carry no X-Served-For, so the dashboard is unaffected. Only after the token matches does hostRouter use the header and set r.Host.
Keep the dashboard in sync with webhooks
"Check DNS" in the dashboard calls verify_dns_records when someone clicks it. The rest of the time, a webhook does the work: register /webhooks/saascustomdomains in our dashboard, and we POST to it whenever one of your domains passes or fails DNS or gets its certificate:
func (a *App) webhook(w http.ResponseWriter, r *http.Request) {
token := r.URL.Query().Get("token")
if !secureCompare(token, a.cfg.WebhookToken) {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
var payload struct {
Event string `json:"event"`
Domain struct {
UUID string `json:"uuid"`
} `json:"domain"`
}
body := http.MaxBytesReader(w, r.Body, 1<<20)
if err := json.NewDecoder(body).Decode(&payload); err != nil {
http.Error(w, "Bad Request", http.StatusBadRequest)
return
}
ctx := r.Context()
d, err := a.store.DomainByProviderUUID(ctx, payload.Domain.UUID)
if errors.Is(err, errNotFound) {
// Not ours, or already removed: acknowledge it so it isn't
// retried.
w.WriteHeader(http.StatusNoContent)
return
}
if err != nil {
serverError(w, err)
return
}
// Each event sets a state rather than toggling one, so a repeated
// delivery changes nothing.
switch payload.Event {
case "domain_dns_proxy_record_valid":
err = a.store.SetStatus(ctx, d.ID, "active")
case "domain_dns_proxy_record_invalid":
err = a.store.SetStatus(ctx, d.ID, "pending")
case "tls_certificate_issued":
err = a.store.SetHTTPSReady(ctx, d.ID)
case "domain_deleted":
err = a.store.DeleteDomain(ctx, d.ID)
}
if err != nil {
serverError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}The token check runs before the body is read, and MaxBytesReader caps what a forged request can make you parse. Three badge states come out of these events: Waiting for DNS, DNS OK, waiting for HTTPS and Live. Respond quickly with a 2xx; we retry failed deliveries, up to 10 attempts. The webhooks docs list every event.
Removing a domain reverses the order of adding one. Remove deletes it from our API first and from your table second, so a failed API call leaves the row in place for another try, and DeleteDomain treats our 404 as done. Once the row is gone, BrandByActiveHost stops finding it and the domain returns 404. domain_deleted covers domains someone removed in our dashboard instead of yours.
Test the whole flow on your laptop
compose.yaml runs the app beside scd-mock. The mock implements the domain API, fires the same webhooks and forwards requests with production's headers, auth token included, so you can go from "add domain" to a working redirect without an account or real DNS.
You can also act as the proxy with curl, once go.acme.localhost is connected:
curl -i http://localhost:3000/launch \
-H "X-Served-For: go.acme.localhost" \
-H "X-SaaS-Domains-Auth-Token: local_proxy_token"That returns the 302. Change the token and it's a 403.
In Go, the proxy cases are cheap to unit-test: httptest.NewRequest plus a few headers and the router's response. The repo covers the 403s, pending and unknown domains, health checks by IP, r.Host with a port, the form behind the proxy, webhooks on Host: app:3000, the API client against an httptest.Server, and the rollback when the API rejects a domain. gotchas_test.go tests the standard library itself: the ServeMux panic, CrossOriginProtection's message, and ReverseProxy's header handling.
The Playwright suite runs against docker compose. Its browser sends anything addressed to a *.test name to the mock proxy, which means the app gets Host: app:3000 while the browser keeps the real Origin, just as in production. The main test adds a domain, waits for the webhook to make it Live, follows a short link, files a broken-link report, deletes the domain and checks for the 404.
Frequently asked questions
How do I route requests by hostname in Go's net/http?
For a few fixed hostnames, put the host in the ServeMux pattern, as in mux.Handle("api.example.com/", h). For customer domains, wrap your muxes in a handler that normalizes r.Host, looks the domain up in your database, and calls the right mux. ServeMux host patterns are literal strings, so they can't hold domains added at runtime.
Why does r.Host include the port in Go?
r.Host is the Host header as the client sent it, and browsers include any port other than the scheme's default. Strip it with net.SplitHostPort before a lookup, keeping the original when there's no port. ServeMux ignores the port while matching host patterns, but your handler still sees it.
Does the upstream work when my Go service runs on Fly.io, Railway or Kubernetes?
Yes, as long as the service is reachable at a public hostname. The upstream is that hostname plus a port and whether to use TLS, for example snip.fly.dev on 443. Our proxy connects to it and sends that hostname in Host, so the platform's router or your ingress delivers the request like any other. Put every hostname your platform uses for internal calls, such as health checks, in APP_HOSTS.
Why does http.CrossOriginProtection say "Origin does not match Host"?
The browser didn't send Sec-Fetch-Site, so Go fell back to comparing the Origin header with r.Host, and a proxy had replaced Host with your app's hostname. Current browsers send Sec-Fetch-Site over HTTPS, so you'll mostly see this over plain HTTP or from old browsers. Once the proxy's token checks out, set r.Host to the visitor's domain before the check runs.
How do I read the visitor's IP and domain behind a proxy in Go?
Nothing in net/http reads X-Forwarded-For or X-Forwarded-Host, so r.RemoteAddr is the proxy. Read those headers only from a proxy you control that overwrites them, and prefer httputil.ReverseProxy with Rewrite over NewSingleHostReverseProxy. Behind SaaS Custom Domains, use X-SaaS-Domains-IP and X-Served-For once the auth token matches.
Will a root domain such as acme.com work?
It will. A bare domain can't hold a CNAME record under the DNS rules, so acme.com gets A records with fixed IPs instead: 75.2.96.173 and 99.83.186.151 for SaaS Custom Domains. Customers whose DNS provider offers ALIAS records or CNAME flattening can use those.
Next steps
Your Go app now has the pieces: normalized domains with a unique index, a router in front of two ServeMuxes, the tenant in the request context, forms that pass CrossOriginProtection behind a proxy, and HTTPS for every customer domain through SaaS Custom Domains. The example repo runs all of it with one docker compose up.
Building on something else too? There are guides for FastAPI, Next.js, Django, Rails and plain Node.js.
Questions about running this in production? Write to support@saascustomdomains.com or use the chat widget on our site. You can try it on real domains with the 7-day trial, or book a 15-minute call if you'd like a walkthrough first.


