---
title: Attach your own domain
description: Put a Pro application's production environment on a domain of your own with two DNS records, and see what changes for your users' sign-in and passkeys.
slug: /guides/attach-your-own-domain
order: 34
visibility: public
redirects_from: []
---

# Attach your own domain

Prompt:

> Put our app on app.example.com.

Also works:

- "Serve the app from our own domain instead of the ai.host address."
- "Why does app.example.com still say pending?"

## What your tool does

- Reads the `custom-domain` [skill](/concepts/glossary#skill) (`read_context`, id `skill:custom-domain`) and follows it.
- Checks that the application is on Pro, the plan that supports a domain of your own, and asks you before it calls `set_plan`.
- Asks you for the exact hostname, such as `app.example.com`, and tells you how many of your users' passkeys the attach takes out of use before you write any record.
- Adds the `custom_domain` entry to the manifest's `services` and submits the whole manifest with `submit_manifest`, which needs no browser approval.
- Hands you the CNAME record from the answer and the TXT record from `read_status`, exactly as the platform gives them.
- Writes nothing at your DNS provider itself, unless you have connected the provider to your tool and ask it to.
- Waits with `read_status` until the domain's state reads `serving`, then opens the domain to confirm it answers.
- Before it removes the domain, tells you that the detach takes your users' passkeys on the domain out of use, and asks you first.

## What you need

- A domain you control, and the login to the DNS provider that holds its records.
- The application on Pro ([Plan and usage](/guides/plan-and-usage)) and deployed to production ([Deploy an application](/getting-started/deploy-an-application)).

## Before your AI starts

This section is for your AI tool: what it checks and gathers before it begins. You don't need to do these steps yourself.

- A connected, signed-in tool ([Connect Claude Code or Codex](/getting-started/connect-your-tool)).
- The application's identifier from `list_applications`, and its plan from `read_usage`, which must read `pro`.
- The application's current manifest, because a submission replaces the whole document.

## Steps

### 1. Choose the name

One application attaches one hostname, on its production environment only. The development hostname stays as it is.

The hostname is lowercase and has at least three labels, such as `app.example.com`. A bare domain such as `example.com` or `example.co.uk` cannot be attached: attach `www` or another name under it instead. A wildcard, a public suffix, and a name another application already holds cannot be attached either. Neither can a name the platform keeps for itself: any name under `ai.host`, the edge name your CNAME record points at among them, and any name under `turnzero.ai`. A name that is not lowercase, has fewer than three labels, or is a wildcard is refused `manifest_invalid`.

### 2. Declare the domain in the manifest

Your tool adds this entry to `services` and submits the whole manifest:

```json
{ "kind": "custom_domain", "domain": "app.example.com" }
```

The answer has a `custom_domain` member with two things. The first is the CNAME record to write at your DNS provider, from your hostname to the platform's edge name, such as `edge.ai.host`. The second is `passkeys_to_strand`, how many of your users' passkeys the attach will take out of use ([What changes for your users](#what-changes-for-your-users)).

The platform then sets the domain up in the background. Nothing changes for the application's visitors until the domain serves (step 5).

A submission on Free or Standard is refused 409 `custom_domain_not_on_plan`. Any other name step 1 rules out, such as `example.co.uk` or a name another application holds, is refused 400 `custom_domain_invalid`, with the reason. Either refusal leaves the manifest as it was.

### 3. Write the CNAME record

Your tool hands you the record exactly as answered: its name, the type `CNAME`, and its value. Write it at your DNS provider, in the zone that holds your domain. The platform writes no record in your zone and never asks for your provider's login.

Until step 5 reads `serving`, keep every link and address your application hands out on the managed hostname.

### 4. Write the TXT record

Within a few minutes, the platform's hosting provider issues the TXT record that proves you control the name, and the platform reads it. You write that record in your own zone, as you wrote the CNAME record; the platform writes nothing there. `read_status` then shows both records, the passkey count, and the domain's state under the same `custom_domain` member. Your tool reads the TXT record there, never composing one, and hands you its name, the type `TXT`, and its value.

If that name already holds a TXT value at your provider, replace it with the new value or add the new value beside it.

While the platform is busy with other domains, the domain's `reason` reads `queued` and shows no TXT record yet. If the TXT record still does not appear a few minutes after `queued` clears, the problem is the platform's, not yours: report it with `submit_feedback` ([Feedback and ratings](/guides/feedback-and-ratings)).

### 5. Wait until the domain serves

Your tool re-reads `read_status` about once a minute. The domain's `state` moves through these:

| State | What it means |
| --- | --- |
| `records_pending` | The platform is waiting for your CNAME record to be visible on the public internet. |
| `validating` | The CNAME record was found, and the domain is being checked. |
| `issuing` | The certificate is being issued, and the domain is being added to the platform's edge. |
| `serving` | The domain answers your production environment. |
| `detaching` | The domain is being removed, after you removed the entry (step 7). |
| `failed` | Something stopped the attach, and the domain's `reason` says what. |

DNS changes take time to spread, so `records_pending` can last from minutes to an hour or more, depending on your provider.

A `failed` state shows one of these reasons:

| Reason | Cause | What to do |
| --- | --- | --- |
| `records_missing` | The records were not found in time, so the platform asks for a new TXT record value. | Check the CNAME record and submit the manifest again. Once the domain is back in `records_pending`, write the TXT record `read_status` shows a few minutes later, as in step 4: its value changes. |
| `caa_blocks_issuer` | A CAA record in your zone does not allow the certificate's issuer. | Change or remove that CAA record, then submit the manifest again. |
| `capacity` | The platform's hosting provider is at its limit for domains. | Nothing on your side; ask the platform operator, then submit the manifest again. |
| `provider_refused` | The platform's hosting provider refused the domain, for the reason in the text. | Correct what the text names, then submit the manifest again. |
| `certificate_lapsed` | A domain that was serving lost its certificate, seen on two hourly checks. | Check that both records still stand, then submit the manifest again. Until the domain serves again, your users' sign-in stays on it; detaching the domain (step 7) returns sign-in to the managed hostname. |

A failed domain leaves `failed` only when you submit the manifest again. If the domain never served, nothing changed for your users. If it had served, sign-in stays on the domain while it is failed, until you detach it.

Once the domain serves, two more reasons can appear beside `serving`. `chain_not_found` means the CNAME record no longer points at the edge name, though the domain still serves for now: restore the record. `revalidation_pending` means the certificate's renewal needs a new TXT record, which `read_status` now shows: write it as in step 4.

### 6. Confirm

Open `https://app.example.com`. The page answers there, and the application's cookies are set on that hostname. The managed production hostname keeps serving the application, but a sign-in started there does not complete ([What changes for your users](#what-changes-for-your-users)). The development hostname is unchanged.

From now on, hand out links on your domain. Once the domain serves, the platform restarts the production copy, so its `APP_PUBLIC_HOST` setting reads your domain ([The settings a deployed copy receives](/concepts/the-settings-a-deployed-copy-receives)); build links from that setting. If that restart is refused, `read_status` shows the refusal as the domain's `reason`. The copy then keeps the managed hostname until the next deploy, promote, or `restart_application`.

### 7. Change or remove the domain

To detach the domain, remove the entry from `services` and submit the manifest again. The domain moves to `detaching` and then stops answering. If the domain had served, the platform restarts the production copy, so `APP_PUBLIC_HOST` reads the managed hostname again.

To change the name, detach first and wait until `read_status` no longer shows the `custom_domain` member. Then declare the new name. A new name declared while the old one is still detaching is refused `custom_domain_invalid` with the reason `held`.

While a domain is attached, `set_plan` onto Free or Standard is refused 409 `plan_custom_domain_conflict`. Detach the domain and wait until `read_status` no longer shows it before you move the plan.

## What changes for your users

Your users' passkeys work only on the hostname they were registered on. When the domain first serves, the production sign-in moves to the domain, so every passkey registered on the managed hostname stops working. The number is `passkeys_to_strand` in step 2's answer, which `read_status` keeps current until the domain serves. So attach the domain before your users register passkeys, if you can.

Once the domain serves, your users sign in on the domain. A sign-in started on the managed hostname does not complete, whatever its method, and the person signs in again on the domain. Through `records_pending`, `validating`, and `issuing`, nothing changes for your users.

A user whose passkey stopped working signs in with an emailed code or another method on the domain and is offered a new passkey there. Detaching the domain reverses the move: the old passkeys work again, and the ones registered on the domain stop working. The detaching submission's `passkeys_stranded` gives that count. A rename while the domain is attached retires the old passkeys for good ([Rename the application](/guides/manage-versions-and-environments#rename-the-application)).

A native app that signs in through the application must name the domain. On iOS, the app's associated domains add `webcredentials:app.example.com`, and the platform serves the association files on the domain as on the managed hostname. An Android app's declared signing fingerprints stay accepted on the domain ([Sign in from a native app](/guides/sign-in-from-a-native-app#7-register-a-passkey-from-inside-the-app)).

A client whose redirect address is an `https` address on the domain, such as `https://app.example.com/callback`, can be declared with `configure_realm` once you have attached the domain, before it serves. Sign-in accepts that redirect only while the domain carries sign-in, from its first serving until you detach it.

## Expected result

`read_status` shows the production environment's `custom_domain` member with the `state` `serving`, both records, and `passkeys_stranded`, the passkeys the attach took out of use. `https://app.example.com` answers the application, and the managed production hostname still serves its pages, with sign-in on the domain. After the platform's restart, `APP_PUBLIC_HOST` in the production copy reads your domain. The development hostname serves as before.

## Refusals

This table lists the refusals these steps most often meet; the [refusals page](/reference/refusals) lists every refusal with its cause and remedy.

| Refusal | Status | Cause | Remedy |
| --- | --- | --- | --- |
| `custom_domain_not_on_plan` | 409 | The manifest declares a domain, and the application's plan does not support one. The `plan` is named, and nothing is written. | Move the application to Pro with `set_plan`, then submit the manifest again. Or remove the `custom_domain` entry. |
| `custom_domain_invalid` | 400 | The hostname is refused for the `reason` given: `apex`, `reserved`, `public_suffix`, or `held`. | Declare a different hostname and submit again: for an apex, a name under it such as `www`; for a held name, a name of your own. If your own earlier domain holds the name, detach it and wait until `read_status` no longer shows it first. |
| `custom_domain_capacity` | 409 | The platform's domains stand at their limit, given in `bound`. Nothing is written. | Nothing on your side raises the limit. Ask the platform operator, then submit again. Removing the entry lets the submission through without the domain. |
| `custom_domains_not_yet_served` | 400 | The platform does not serve domains of your own yet. | Remove the `custom_domain` entry and submit the manifest again. The platform operator turns the feature on. |
| `plan_custom_domain_conflict` | 409 | `set_plan` names a plan that does not support a domain while one is attached. The `plan` and the `domain` are named. | Remove the entry, submit the manifest, and wait until `read_status` no longer shows `custom_domain`. Then repeat `set_plan`, or choose Pro. |
| `plan_quantity_unset` | 409 | The plan has no value set for whether it supports a domain. The `plan` and `measure` members name it, and nothing is written. | Choose another plan, or wait for platform staff to set it. |

## Related

- [The manifest](/concepts/the-manifest) lists the `custom_domain` entry beside the other kinds.
- [Plan and usage](/guides/plan-and-usage) covers the plans and `set_plan`.
- [Manage end users](/guides/manage-end-users#5-passkeys-for-your-users) covers your users' passkeys.
- [Sign in from a native app](/guides/sign-in-from-a-native-app) covers the association files a native app relies on.
- [Manage versions and environments](/guides/manage-versions-and-environments#rename-the-application) lists what a rename retires.
- [Diagnose a network that blocks the platform](/guides/diagnose-a-blocked-network) explains a network that filters a domain by its category.
