# Custom domains

A hosted app lives at `https://<slug>.<apps domain>/`. You can also serve it at a domain you own, such as `tools.acme.com`. The app behaves the same there: sign-in, live queries, endpoints, storage, and inspection all work, and nothing in the capsule changes.

```sh
fuast domains add tools.acme.com        # prints the DNS records to create
fuast domains check tools.acme.com      # check DNS now; exits 1 until the domain is live
fuast domains list                      # status and records of every domain (alias: ls)
fuast domains remove tools.acme.com     # alias: rm
```

Each command targets the app in `fuast.json`, or takes a slug after the hostname: `fuast domains add tools.acme.com my-app`. Add `--json` for machine-readable output. In the dashboard, open the app and use the **Domains** tab.

Only the app owner and workspace admins can add, check, or remove domains. App deploy tokens (`fuast token create --app`) cannot.

## DNS records

Create two records at your DNS provider. `fuast domains add` and the Domains tab show the exact values, with copy buttons.

| Type | Name | Value | Why |
| --- | --- | --- | --- |
| `TXT` | `_fuast.tools.acme.com` | `fuast-verify=<token>` | Proves you control the domain. The token is unique to this domain and app. |
| `CNAME` | `tools.acme.com` | the platform's target, e.g. `fuast-production.up.railway.app` | Sends the domain's traffic to fuast. |

- **Apex domains** (`acme.com`) cannot have a CNAME. Use your provider's `ALIAS`, `ANAME`, or flattened CNAME record pointing at the same target. fuast accepts the domain when its addresses match the target's.
- **Cloudflare:** set the record to *DNS only* (grey cloud) unless the platform operator tells you otherwise. A proxied record hides the CNAME and the check fails.
- When the platform issues certificates through its host (see [HTTPS](#https)), the list includes one or two more records from that provider: usually a `TXT` record that lets it issue a certificate, and its own `CNAME` value. Create every record in the list.

Keep the records in place after the domain is live. fuast re-checks live domains every few hours.

## Verification

After `add`, the domain is `pending`. fuast checks DNS in the background: often at first, then less often (backing off to once an hour). `fuast domains check` or **Check now** checks immediately. DNS changes can take minutes, sometimes hours, to show up.

| Status | Meaning |
| --- | --- |
| `pending` | Waiting for the records. The response says which record is missing or wrong. |
| `active` | Both records are right. The app is served on the domain. |
| `error` | The certificate provider refused the domain, or a live domain lost its records (two failed checks in a row). The `error` field says why. Fix DNS and check again. |

A domain that is never verified is released after 7 days, so nobody can hold a hostname they don't control. A DNS timeout never takes a live domain down; only definite answers ("no such record") do.

Rules for hostnames:

- A full hostname with a dot. Internationalized names are converted to punycode. Uppercase and a trailing dot are fine.
- Not an IP address, not `localhost`, not a reserved name (`.local`, `.test`, `.internal`, ...), no wildcards (`*.acme.com`).
- Not the platform's own hosts, and nothing under the apps domain (for `<name>.<apps domain>`, use an [alias](#aliases)).
- One app per hostname. A hostname connected to another app is refused until it is removed there.
- At most 5 custom domains per app (the platform can change this).

## HTTPS

Custom domains are served over HTTPS. Who issues the certificate depends on the platform:

- **On fuast.com and Railway-hosted platforms**, fuast registers the domain with Railway, which issues a Let's Encrypt certificate once DNS is in place, usually within an hour. The Domains tab shows the certificate status (`issuing`, `valid`, `failed`).
- **Other self-hosted platforms** terminate TLS in front of fuast (for example Cloudflare for SaaS). The operator handles certificates.

## What changes for the app

- **Primary domain.** The first domain that is live with a valid certificate becomes the app's primary domain. It is the app's URL in the dashboard, in `fuast deploy` output, in share emails, and in the URLs `ctx.storage.url()` returns.
- **The old address keeps working.** Pages opened at `<slug>.<apps domain>` redirect to the primary domain. The call API, WebSocket, endpoints, storage, and inspection routes keep answering on the old address too, so webhooks and scripts don't break.
- **Each hostname is its own origin.** Sign-in works on every domain through the same one-time-code handshake as the subdomain, and each domain gets its own app session cookie (`__Host-` prefixed on HTTPS). Someone signed in on `<slug>.<apps domain>` signs in once more on `tools.acme.com` (one click if they are signed in to fuast). Guests get a separate guest session per hostname.
- **Origin checks** accept requests from the hostname the request arrived on. A page on another domain cannot call the app's API with a visitor's cookies.
- `req.url` in [endpoints](https://fuast.com/docs/capsule-api.md#endpoints) uses the hostname the request arrived on. In the UI, build links with `appUrl(path)` from `fuast/client`: it returns a path on whatever origin the page was opened on.

Removing a domain stops serving it at once. The app falls back to its next live domain, or to the subdomain.

## Aliases

A name without a dot adds an alias under the platform's apps domain instead: `fuast domains add my-tool` serves the app at `my-tool.<apps domain>` too, with no DNS work. Aliases appear in `fuast domains list` next to custom domains.

## HTTP API

The CLI and dashboard call these platform routes with your session or a personal token (`Authorization: Bearer <token>`).

| Method | Path | Body | Returns |
| --- | --- | --- | --- |
| `GET` | `/api/apps/<slug>/domains` | | `{ custom: CustomDomain[], domains: Alias[], url, config }` |
| `POST` | `/api/apps/<slug>/domains` | `{ "hostname": "tools.acme.com" }` | `{ ok, domain: CustomDomain }` |
| `POST` | `/api/apps/<slug>/domains/<hostname>/check` | | `{ ok, domain: CustomDomain }` |
| `DELETE` | `/api/apps/<slug>/domains/<hostname>` | | `{ ok, warning? }` |

```ts
type CustomDomain = {
  hostname: string; url: string; status: "pending" | "active" | "error"; primary: boolean; error: string | null;
  records: { type: string; name: string; value: string; purpose: "ownership" | "routing" | "provider-verification" | "acme";
             status: "ok" | "pending"; current: string | null; note?: string }[];
  tls: { provider: "manual" | "railway"; certificate: "pending" | "issuing" | "valid" | "failed" | "external"; message: string | null };
  verifiedAt: number | null; lastCheckedAt: number | null; nextCheckAt: number | null; createdAt: number;
};
```

Errors use the usual shape (`{ "error": "...", "code": "..." }`): `invalid_hostname`, `ip_address`, `wildcard`, `reserved` (400), `limit` (400), `taken` and `already_added` (409), `not_found` (404), `rate_limited` (429).

## Running your own platform

Custom domains are on by default. Set these on the platform (and `FUAST_BASE_URL` on the web process, which already gets it from `start.sh`):

| Variable | Effect |
| --- | --- |
| `FUAST_CUSTOM_DOMAIN_TARGET` | Where users point their CNAME, e.g. your Railway public domain. Comma-separate several hostnames or IPs. Unset: only the TXT record is checked. |
| `FUAST_CUSTOM_DOMAIN_TLS` | `manual` (default) or `railway` (default when `RAILWAY_API_TOKEN` is set). |
| `RAILWAY_API_TOKEN` | Railway account or workspace token (sent as `Authorization: Bearer`). A project token works as `RAILWAY_TOKEN` with `FUAST_CUSTOM_DOMAIN_TLS=railway` (sent as `Project-Access-Token`). |
| `RAILWAY_PROJECT_ID`, `RAILWAY_ENVIRONMENT_ID`, `RAILWAY_SERVICE_ID` | The service domains are attached to. Railway injects these into running services. |
| `FUAST_RAILWAY_TARGET_PORT` | Optional port Railway routes custom domains to (default: the service's). |
| `FUAST_CUSTOM_DOMAIN_LIMIT` | Custom domains per app (5). |
| `FUAST_CUSTOM_DOMAIN_PENDING_DAYS` | Days before a never-verified domain is released (7; `0` keeps them). |
| `FUAST_WEB_HOSTS` | Extra hostnames that serve the dashboard (never treated as custom domains). |
| `FUAST_CUSTOM_DOMAINS` | `off` turns the feature off. |

With `railway`, fuast calls Railway's public GraphQL API (`customDomainCreate` when a domain is added, `customDomain` on each check, `customDomainDelete` on removal or when the app is deleted or terminated) and shows the records Railway asks for. Railway limits custom domains per service (2 on Hobby, 20 on Pro by default), and that limit applies to all apps on the platform together.

With `manual`, put something in front of fuast that terminates TLS for any hostname that reaches it (Cloudflare for SaaS custom hostnames, Caddy on-demand TLS, ...) and forwards the original `Host`. The web front end passes every hostname that is not the dashboard's own through to the platform, which serves the app that owns the verified domain or a "Domain not connected" page.
