Custom Domain is the managed domain-connection platform for SaaS products. It lets your users put a domain they already own in front of your product, with the DNS records written, the ownership proven, and the TLS certificate issued and renewed for them. This repository is the front door to that organization: it holds the profile page GitHub renders at github.com/CUSTOM-DOMAIN-APP, the community health files every other repository here inherits, and the screenshots and demo capture those pages embed.
| What it is | The .github metadata repository for the Custom Domain organization: the org profile page, org-wide community health defaults, and the brand assets those pages embed. |
| Who it's for | Anyone landing on this organization from a search result, an MCP registry entry, an npm package page, or a docs link, plus the maintainers who edit the org's public surface. |
| Live at | customdomain.ai (product) · docs.customdomain.ai/docs (docs) · status.customdomain.ai (uptime) |
| Stack | Markdown and static assets only. No build, no runtime, no dependencies. GitHub renders it directly. |
| Status | Public. Active. Default branch main. Rendered live on the organization page. |
GitHub gives every organization one repository with a reserved name, .github, and reads
two things out of it. The file at profile/README.md becomes the organization's front page,
shown above the repository list to every visitor who opens
github.com/CUSTOM-DOMAIN-APP. The community health
files at the root become the defaults for every repository in the organization that does not
ship its own copy, so a contributor who clicks "Report a vulnerability" in any of the fifteen
repositories here reaches the same policy.
That makes this repository small but load-bearing. It is the only place in the organization where a stranger's first impression is written, and the only place a security report gets routed from. It carries no code, no build step and no dependencies: four Markdown files, one profile page, and four committed image assets that the profile page embeds by relative path.
The rest of this document is split in half on purpose. Everything above the Quickstart explains what Custom Domain is and who it is for, because that is what a first-time visitor needs. Everything below it explains how this repository and the organization's public surface are put together, because that is what a maintainer needs.
Every multi-tenant product eventually hits the same request: a customer wants the product to
live at app.acme.com instead of acme.yourproduct.com. It sounds like a small feature. It
is not. There are dozens of DNS providers with incompatible record models and no shared API.
Apex domains cannot hold a CNAME at all under RFC 1034, so half your customers need a
different record type than the other half. Certificates have to be issued and then renewed
forever, per customer, without anyone thinking about it. And every one of those steps happens
in an account you do not control, by a person who has never opened a DNS panel.
So the feature ships as a support-ticket generator. The customer is handed a table of records to copy, gets one of them wrong or puts it on the wrong host, and the connection sits in "pending" while nobody can tell them why. Teams that build this in-house typically spend weeks on it and then keep paying for it in a queue of "my domain says pending" tickets that never fully goes away. The work is unglamorous, it is nobody's differentiator, and it is genuinely hard to get right for the long tail of registrars.
Custom Domain replaces that whole surface with one connect flow that you embed, call, or hand to an agent. Your user types their domain. The platform works out where its DNS actually lives, picks the best rail it can use for that provider, writes the records, watches public DNS until they resolve to the intended values, and issues a certificate at a managed edge that then terminates TLS for that hostname.
- Connects a domain in about thirty seconds on the one-click authorization path, from the moment the user types it to the moment the hostname serves HTTPS.
- Covers 63 DNS and registrar providers through a single census, with 25 or more of them configured fully automatically and no dead end anywhere else: providers that offer scoped API tokens take a pasted token, and everything else falls back to a guided manual flow whose records are verified automatically once they appear.
- Issues and renews real Let's Encrypt certificates at the edge. Verified end to end in July 2026 against a live connected domain: a browser-trusted production certificate, valid chain, issued on first handshake and cached thereafter.
- Proves control without a verification chore. There is no separate ownership challenge to paste. Control is established by the rail that writes the records (an authorization at the provider, a one-click apply, or a scoped token), or by the records showing up in the domain's own authoritative DNS, checked by value rather than by presence.
- Handles email domains through the same flow, writing MX, SPF, DKIM and DMARC records from server-side templates rather than asking anyone to hand-assemble an SPF string.
- Gives AI agents a first-class path. The hosted MCP server exposes twelve tools over
streamable HTTP, speaks protocol revision
2025-06-18, and is listed in the official MCP registry asai.customdomain/mcp. No tool accepts a raw DNS record as input, which closes off the prompt-injection paths that would otherwise end in arbitrary DNS writes. - Isolates tenants strictly. A July 2026 black-box production audit walked the API as a
second tenant and got
404on every resource belonging to the first, with no cross-tenant leakage found.
Two of the platform's Domain Connect templates are merged upstream into the
Domain-Connect/Templates registry (#1323 and
#1340), which is what lets a supporting provider apply the record set from its own dashboard
rather than from ours.
Pricing starts at $0: the free Starter plan includes ten domain connections per year and the full product surface, widget, API, automatic TLS, monitoring and the MCP server included.
If your product has tenants, and your tenants have brands, this is for you.
| Audience | The shape of the need | Start here |
|---|---|---|
| Website builders | Every published site wants its own name in the address bar, at tenant scale. | connect-domain-for-website-builders |
| Email platforms | Sending domains need SPF, DKIM, DMARC and return-path written correctly the first time. | connect-domain-for-email-platforms |
| AI agents | An agent that ships a site needs a real domain: search it, buy it, connect it, without a human in the loop. | connect-domain-for-ai-agents |
| Agencies | Client domains connected under your own brand, without collecting anyone's registrar login. | connect-domain-for-agencies |
It also fits e-commerce platforms giving each merchant a storefront domain, creator tools giving each creator a domain, and any multi-tenant SaaS where a shared subdomain is the thing customers complain about. The common thread is not the industry, it is the shape: you have many customers, each customer has a brand, and the address bar is the most visible place that brand either shows up or does not. Products that get this right stop looking like a tool their customers rent and start looking like something their customers own, which is why custom domains keep appearing near the top of enterprise-plan feature lists rather than in the backlog. The cost of getting it wrong is equally consistent: a queue of tickets that says "pending" and a sales objection nobody on the team can answer. The vocabulary, if the category is new to you, is in the glossary: bring your own domain and custom domain vs subdomain.
There is nothing to install to work on this repository. Clone it, edit Markdown, push.
git clone https://github.com/CUSTOM-DOMAIN-APP/.github.git
cd .github
# The org front page. This is the file GitHub renders at github.com/CUSTOM-DOMAIN-APP.$EDITOR profile/README.md
# Check every link still resolves before you push (see Testing, below).
grep -oE 'https?://[^)"< ]+' profile/README.md README.md | sort -u | \
whileread -r u;doprintf'%s %s\n'"$(curl -sL -o /dev/null -w '%{http_code}' --max-time 12 "$u")""$u";done
git commit -am "profile: <what changed>"&& git pushTo use the product instead, connect a domain from the command line with an API key from
the console. Keys look like sk_live_... and are shown
once, at creation.
export CUSTOMDOMAIN_API_KEY="$(op read op://EJ-Products/CustomDomain-api-key-prod/api_key)"# 1. Create a connection for your user's domain.
curl -X POST https://api.customdomain.ai/v1/connections \
-H "Authorization: Bearer $CUSTOMDOMAIN_API_KEY" \
-H "Content-Type: application/json" \
-d '{"domain": "app.customer.com", "application_id": "<application_id>"}'# 2. Poll until it reports connected: records written, DNS observed, TLS issued.
curl https://api.customdomain.ai/v1/connections/<connection_id> \
-H "Authorization: Bearer $CUSTOMDOMAIN_API_KEY"Or point an MCP client at the hosted server and let an agent do it. Nothing to install and nothing to keep running:
claude mcp add --transport http customdomain https://mcp.customdomain.ai/mcp \
--header "Authorization: Bearer $CUSTOMDOMAIN_API_KEY"Exact request and response shapes live in the API reference; the tool catalog lives in customdomain-mcp.
Nine tracked files. Every one of them is either rendered by GitHub or embedded by something that is.
.github/
├── README.md You are here. Explains the org and this repository.
├── profile/
│ └── README.md THE ORG FRONT PAGE. GitHub renders this at github.com/CUSTOM-DOMAIN-APP.
├── CONTRIBUTING.md Org-wide contribution defaults: where docs live, house style.
├── SECURITY.md Org-wide vulnerability policy and the reporting address.
├── SUPPORT.md Org-wide support routing: docs, discussions, book a call.
└── assets/
├── connect-flow-demo.gif 3.5 MB. A real domain going live, start to finish.
├── provider-logos.png 769 KB. The supported provider wall.
├── app-connect-domain.jpg 39 KB. The console, mid-connect.
└── app-domain-live.jpg 40 KB. The same domain, live on HTTPS.
Two constraints are worth knowing before you edit. First, profile/README.md references
assets with a relative ../assets/... path, which resolves correctly on the organization page
because GitHub serves the profile from this repository's tree; a rename of assets/ breaks
the front page silently. Second, GitHub's Markdown sanitizer strips inline <svg> markup
entirely, so every image here is a committed file referenced by <img src>, never inline
vector markup. That is a hard constraint, not a stylistic choice.
This repository is one of four layers that make up everything a stranger can see. Nothing here runs; the layer boundaries are about who renders what.
| Layer | Rendered by | Source of truth | What it is for |
|---|---|---|---|
| Organization profile | GitHub, from profile/README.md | This repository | The 30-second pitch and the map of every repository |
| Community health defaults | GitHub, from the root *.md files | This repository | One security policy, one contributing guide, one support route, inherited org-wide |
| Documentation site | Fumadocs and Next.js in the docs repository | CUSTOM-DOMAIN-APP/docs | 100+ MDX pages: guides, concepts, and 60+ API reference pages |
| Product surfaces | The private product monorepo | CUSTOM-DOMAIN-APP/custom-domains | The console, REST API, edge, widget and MCP server |
The important property is that no layer copies another. The docs site reads its own content/
directory directly, with no sync step in the path a reader sees. This README and the profile
README are separately authored and neither is generated. When they disagree, the repository
that owns the subject wins: the docs repository owns behavior, this one owns positioning.
Fifteen repositories, one product. Public unless noted.
| Repository | Language | What it holds |
|---|---|---|
docs | MDX | The documentation source of truth, plus the Fumadocs renderer in site/ that serves docs.customdomain.ai |
customdomain-sdk | TypeScript | The browser SDK (customdomain-js), the React wrapper (@customdomain/react), and the widget bundle they load |
customdomain-mcp | Markdown | The hosted MCP server's public face: twelve tools, auth model, client configs for Claude, Cursor and ChatGPT |
custom-domain-checks | JavaScript | A GitHub App that posts a domain-health check on every push: DNS resolution, target correctness, CAA, certificate expiry, HTTPS enforcement |
awesome-custom-domains | Markdown | The curated map of the category: managed services, DIY building blocks, protocols, examples |
connect-domain-for-website-builders | Markdown | Use-case guide: records, verification, TLS at tenant scale, connect-flow UX |
connect-domain-for-email-platforms | Markdown | Use-case guide: sending-domain onboarding, SPF, DKIM, DMARC, return-path, deliverability |
connect-domain-for-ai-agents | Markdown | Use-case guide: the MCP server, the API flow, agent-safe DNS security |
connect-domain-for-agencies | Markdown | Use-case guide: fleet-scale client domains, white-label connection, drift monitoring |
customdomain-brand-kit | Assets | Logos, wordmarks, color tokens, and the build script that derives every other asset from them |
.github | Markdown | This repository |
custom-domains | Go, TypeScript | Private. The product monorepo: Go control plane and TLS-terminating edge, Next.js console, widget, MCP service |
customdomain-site | TypeScript | Private. The marketing site at customdomain.ai (Next.js, HeroUI, Tailwind) |
dashboard-custom-domain | TypeScript | Private. The internal marketing engine behind marketing.customdomain.ai |
CD-APPENDIX | Markdown | Private. Product profile, architecture notes and diligence material |
The boundary that matters most: the SDK never talks to a DNS provider. It opens the widget, the widget talks to the control plane, and only the control plane holds provider credentials and writes records. That is what makes it safe to embed in a customer's settings page, and it is why the widget token is minted server-side rather than shipping an API key to a browser.
your app ──► widget / REST / MCP ──► control plane
│
┌─────────────────────┼─────────────────────┐
▼ ▼ ▼
provider detect write records poll public DNS
(NS + zone walk) (OAuth · token · Domain (by value, ~60s)
Connect · guided manual)
│
▼
edge issues Let's Encrypt cert
(TLS-ALPN-01 :443 · HTTP-01 :80)
│
▼
hostname serves HTTPS ──► status: live
- Detect. The control plane resolves the domain's nameservers, walks up the zone for subdomains, and works out which provider actually answers for it and which rails that provider supports.
- Write. The best available rail applies the record set. In descending order of user effort that is: a one-click authorization at the provider, a Domain Connect apply, a scoped API token the user pastes, or a guided manual list the user copies.
- Observe. A background poller re-checks
pendingandpropagatingconnections about once a minute and compares each record against public DNS by value, not merely by presence. Polling faster than that gains nothing. - Issue. Once the connection reaches
liveorpropagating, the edge asks the control plane whether the hostname is approved, then issues a Let's Encrypt certificate on the first TLS handshake and caches it. - Serve. The edge terminates TLS for the customer hostname and proxies to your origin.
Connections move pending to propagating to live, with failed as the one terminal
error. Key completion off the boolean connected rather than the status string, so your code
survives the enum growing. Automatic rails give up after 24 hours in propagating; manual
connections get 72 hours from pending, because a human has to get to their DNS panel. Both
failure states clear on their own if the connection later verifies, so failed is recoverable
and is never a reason to tell a user to start over.
This repository has no runtime and therefore no environment variables. Its configuration is the set of paths GitHub reads by convention, plus repository metadata set through the API.
| Configuration | Where it lives | Effect |
|---|---|---|
| Organization front page | profile/README.md | Rendered above the repository list on the org page |
| Contribution defaults | CONTRIBUTING.md | Inherited by every repo in the org without its own copy |
| Security policy | SECURITY.md | Powers "Report a vulnerability" org-wide; routes to legal@customdomain.ai |
| Support routing | SUPPORT.md | Shown in the new-issue chooser org-wide |
| Repository description, homepage, topics | GitHub repository metadata | gh repo edit, not a file in the tree |
Integrators consuming the platform supply exactly three credential shapes. All three are secrets. None of them belongs in a file that is committed anywhere, in this organization or in yours. Reference them from a secret manager and inject at run time.
| Name | What it is | Scope | Reference |
|---|---|---|---|
CUSTOMDOMAIN_API_KEY | Console API key, sk_live_... or sk_test_..., shown once at creation and stored only as a hash | The whole tenant: every application, connection, webhook and billing record | op://EJ-Products/CustomDomain-api-key-prod/api_key |
APPLICATION_ID | The application identifier, also called CLIENT_ID in OAuth contexts. Not a secret on its own | One application | op://EJ-Products/CustomDomain-app-prod/application_id |
CLIENT_SECRET | Returned once when an application is created. Its only job is minting short-lived JWTs | One application | op://EJ-Products/CustomDomain-app-prod/client_secret |
Prefer the JWT path over a raw API key whenever an agent or a service only needs one
application: exchange APPLICATION_ID and CLIENT_SECRET at POST /token for an hour-long
bearer token. A leaked sk_live_ key carries the entire tenant; a leaked JWT expires. Widget
tokens follow the same rule and are minted server-side, never in the browser.
This repository ships by being pushed. There is no workflow, no build and no artifact.
git push origin main
# GitHub re-renders github.com/CUSTOM-DOMAIN-APP within seconds.# Assets are served from raw.githubusercontent.com/CUSTOM-DOMAIN-APP/.github/main/assets/...Three operational notes for anyone editing it:
- Community health inheritance is opt-out, not opt-in. Adding a file here changes the
behavior of fifteen repositories at once. A repository that ships its own
SECURITY.mdkeeps it; every other one starts pointing at this one on the next page load. - Asset weight is a real cost.
connect-flow-demo.gifis 3.5 MB and loads on every visit to the organization page. Anything added toassets/should earn its bytes, and new screenshots should stay in the tens of kilobytes, like the two already there. - Renaming this repository breaks the org profile. GitHub only reads
profile/README.mdfrom a repository literally named.github.
Where the rest of the platform runs, for context: the private product monorepo deploys the Go
control plane and edge together on AWS, with the edge holding its own elastic IP so it can own
ports 80 and 443 for customer hostnames; the docs container rebuilds from the docs
repository's content/; and the SDK publishes to npm from a tagged release workflow using npm
provenance and Trusted Publishing, so no long-lived npm token sits in the repository. Live
availability for all of it is published at
status.customdomain.ai, and the control plane reports its
own build on an unauthenticated endpoint, which is the fastest way to confirm what is actually
deployed:
curl -s https://api.customdomain.ai/v1/config
# {"api_version":"v1","env":"production","service":"customdomain-control-plane",# "products":["connect","secure","sell","power","monitor","mcp"],"version":"0.1.400"}There is no test suite to run here, and inventing one would be theater. What this repository is actually verified against is link rot and render correctness, both of which are cheap to check and are the only two ways it can be wrong.
# 1. Every link resolves. Use GET, not HEAD: several hosts answer 405 to HEAD.
grep -ohE 'https?://[^)"< ]+'*.md profile/*.md | sed 's/[.,)]*$//'| sort -u | \
whileread -r u;do
code=$(curl -sL -o /dev/null -w '%{http_code}' --max-time 15 "$u")
[ "$code"= 200 ] ||printf'FAIL %s %s\n'"$code""$u"done# 2. Every relative asset reference points at a file that exists.
grep -ohE '\.\./assets/[A-Za-z0-9._-]+' profile/README.md | sort -u | \
whileread -r a;do [ -f"${a#../}" ] ||echo"MISSING $a";done# 3. No secret ever lands in the tree.
grep -rnE 'sk_live_|sk_test_|ghp_|AKIA[0-9A-Z]{16}'. --exclude-dir=.git &&echo"STOP"||echo"clean"Run the first check before every push. A dead link on the organization front page is the most visible defect this repository can have, and it is the one that decays without anyone touching the file.
Corrections are welcome, including typo-only pull requests. Open one against main.
- Product documentation lives in
CUSTOM-DOMAIN-APP/docsundercontent/, not here. Merged changes reach the live docs site directly. - Use-case guides live in the four
connect-domain-for-*repositories and welcome provider notes, corrections and clearer examples. - The tools list in
awesome-custom-domainsaccepts anything genuinely relevant to connecting customer-owned domains: one line per tool, alphabetical within its section, factual descriptions.
House style, applied everywhere in this organization: plain language, short paragraphs, tables where they clarify, no em dashes or en dashes, American English, and real DNS and TLS facts only. No invented numbers. If you cannot source a figure, leave it out.
Questions and ideas belong in Discussions on the docs repository.
Report vulnerabilities by email to legal@customdomain.ai with reproduction steps; reports are acknowledged within two business days. In scope: the customdomain.ai product and APIs, the hosted MCP server, the embeddable widget, and every repository in this organization. Please do not open a public issue for a security report. Security posture, compliance frameworks and sub-processors are published at trust.customdomain.ai. Full policy: SECURITY.md.
This repository holds documentation and brand assets and carries no separate license file;
the assets in assets/ are Custom Domain brand material and are not licensed for reuse.
The organization's code is open: custom-domains and customdomain-sdk are Apache-2.0,
and customdomain-mcp, docs, custom-domain-checks, awesome-custom-domains and the four
connect-domain-for-* guides are MIT. Each repository carries its own LICENSE file and
that file governs.
Custom Domain is built and operated by EVERJUST.
Connect your first domain free · Read the docs · Book a call