# Custom domains, DNS & SSL
URL: https://openship.io/docs/guides/custom-domains.md

Add custom and wildcard domains, point DNS at your app, and set up HTTPS with a connected provider or manual TXT verification.

import { Step, Steps } from 'fumadocs-ui/components/steps';

When you first deploy, Openship gives your app a working web address for free. This guide is about the next
step: putting it on a name **you** own, like `app.example.com`, with the little padlock (HTTPS) turned on
automatically.

Here's the idea in plain terms. A **domain** is the name people type to reach your app. **DNS** is the
internet's phone book — it's how a name like `app.example.com` gets connected to the actual computer running
your app. **SSL** (that padlock) is what makes the address start with `https://` and keeps traffic private.
Openship handles the padlock for you; your only real job is adding a couple of entries to your domain's phone
book. This guide walks you through all of it.

<Callout title="What you need first">
- A project already deployed in Openship (see [Your first deployment](/docs/getting-started/first-deployment)).
- A domain you own, bought from a **registrar** (Namecheap, Cloudflare, GoDaddy, Google Domains, etc.).
- Access to that registrar's **DNS settings** — the page where you add records. That's where you'll paste
  the two lines Openship gives you.
</Callout>

For a self-hosted server behind Cloudflare Tunnel, use [managed tunnel ingress](/docs/guides/cloudflare-tunnel) to publish custom hostnames and optionally protect them with Cloudflare Access.

For differences in proxy settings, route protections, configuration inspection,
and trusted proxies, see [Self-hosted vs Cloud](/docs/guides/the-edge#self-hosted-vs-cloud).

## Choose the primary hostname during deployment

In the deployment wizard, **Include www** adds the companion hostname. For
Cloud and self-hosted destinations, the hostname you typed stays primary and the companion
redirects to it with HTTP `301`. Under **Domain behavior**, choose either hostname
as primary, or **Serve both** to serve the app on both addresses.

The domain list shows which hostname redirects and where it sends visitors.
Redirect-only hostnames do not need a separate app port or static path. Existing
projects keep their current choice until you change it. Both hostnames still need
DNS records, verification and HTTPS.

The same choices are available for Cloud deployments opened through Desktop.

## Steps

<Steps>

<Step>

### Open your project's Domains tab

Go to your project and open the **Domains** tab. This is home base for everything about addresses and HTTPS.

</Step>

<Step>

### Add your domain

Press **Add domain**. In the form that opens, choose **Custom domain**, then type the full address you want,
like `app.example.com`.

If your project runs a server, you'll also see a **Maps to port** field — this is which internal port the
domain sends traffic to. Openship pre-fills it with your app's port, so you can usually leave it.

There are two optional toggles:

- **Include www** — also claim the `www.` version of your domain. It becomes a domain in its own right: it
  gets its own DNS record, its own **Verify**, and its own certificate, and by default it sends visitors on to
  the bare domain with a `301` redirect (see [Redirect one domain to another](#redirect-one-domain-to-another)
  if you'd rather have it the other way round, or have both serve the app).
- **External ingress & TLS** — turn this on **only** if your own edge (like a Cloudflare Tunnel or a load
  balancer) already handles HTTPS. Most people leave it off. See [DNS setup, three ways](#dns-setup-three-ways)
  below.

<Callout title="www needs its own DNS record" type="warn">
Because `www.example.com` is a separate domain, it needs a separate DNS record — Openship lists it alongside
the bare domain's in the next step. Skip it and `www` will stay Pending: it can't be verified, so it never gets
a certificate.
</Callout>

When the form is filled in, press the **Add domain** button at the bottom of the form to save it. (Yes,
it's the same label as the button you just clicked — the first one opens the form, this one saves it.)

<Callout title="Adding a domain doesn't put it live yet" type="info">
Saving the domain just registers your intent. It stays **Pending** until you add the DNS records and verify —
that's the next two steps.
</Callout>

</Step>

<Step>

### Copy the DNS records Openship shows you

Right after you add the domain, a **DNS Records** panel appears with the exact entries to create at your
registrar. Each row has a **Type**, a **Host**, and a **Value**, and a **Copy** button next to it.

The panel shows the records for your deployment:

- A **routing record** (an `A` or `CNAME` — Openship picks the right one for you, see below) that points the
  name at your app.
- On **Openship Cloud**, an ownership **TXT** record at `_openship-challenge.<hostname>`.
  Self-hosted HTTP verification does not need that record. Wildcard HTTPS uses a separate
  `_acme-challenge` TXT value generated by the certificate authority; see [Wildcard domains](#wildcard-domains).

Copy each value exactly as shown — don't retype it, use the Copy button.

If Cloudflare hosts your DNS, choose **Connect a DNS provider** in this panel. An owner or admin can
add a token with **Zone:Read** and **DNS:Edit** for the relevant zones without leaving the deployment
setup. The connection is saved in **Settings → Credentials** and can be reused across the workspace.
Connecting verifies the token; it does not change DNS records.

Credentials belong to the instance where you connect them. For a Cloud project opened through a
linked local or desktop instance, connect the provider in the Cloud dashboard; local credentials
are not copied to Cloud.

For a saved domain, review the proposed additions or changes, then click **Auto-configure DNS**.
Existing values are shown before repointing a record, unchanged records are skipped, and conflicting
record sets are left for manual review. The record list refreshes after applying.

During a new deployment, you can connect the provider ahead of time. If the domain has not been saved
yet, deploy first and apply the records from its DNS settings afterward, or use **Manual DNS setup**
to publish them now. Cloudflare is currently the supported provider for automatic record writes;
other providers use the manual steps below.

</Step>

<Step>

### Paste them into your registrar

Open your domain registrar in another tab and find its **DNS** or **DNS records** page. Add each record from
Openship — matching the Type, Host, and Value. Save.

<Callout title="What does “Host” mean at my registrar?" type="info">
Registrars phrase it differently — you might see "Host", "Name", or "@". Paste the host exactly as Openship
shows it. If your registrar rejects `@`, it usually means "the root of the domain"; and a host like
`_openship-challenge` is added as a subdomain entry. When in doubt, copy Openship's value verbatim.
</Callout>

</Step>

<Step>

### Verify

Back in Openship, click **Verify** next to the domain. On a self-hosted or Desktop-connected server,
Openship obtains or reuses a certificate before reporting success. On Openship Cloud, it checks the
routing and ownership records, then requests managed HTTPS. A wildcard on your own server opens
**DNS-verified HTTPS**, where you choose a connected DNS provider or manual TXT verification.

<Callout title="DNS is not instant" type="info">
After you save records at your registrar, they can take a few minutes — occasionally up to 48 hours — to
spread across the internet. If Verify doesn't work on the first try, wait a bit and click it again. Openship
also re-checks ordinary pending domains when the verification job is enabled. Manual TXT challenges wait
for you to publish the record and press **Check TXT & enable HTTPS**.
</Callout>

</Step>

<Step>

### Watch HTTPS turn on

The **SSL** status becomes **Active** after the certificate is installed. On your own server, verification
includes this step. On Openship Cloud, managed HTTPS can remain **Provisioning** after ownership is verified.
Once routing is configured and SSL is active, your app is reachable at `https://app.example.com`.
Certificates renew automatically with HTTP verification or a connected DNS provider. Manual TXT certificates
require a new TXT challenge at renewal; the domain details show when renewal is due.

</Step>

</Steps>

## Wildcard domains

Add `*.example.com` to the project's **Domains** tab or the service's public routes. Openship
automatically selects DNS-01 for HTTPS; HTTP verification cannot issue wildcard certificates.
The wildcard certificate covers `app.example.com` and `api.example.com`. It does not cover
`example.com` or `deep.app.example.com`. Add the base domain separately if it should serve the app too.
Choose a concrete hostname for the project's primary address and for links you open in a browser.

On a self-hosted or Desktop-connected server, deploy the project, then open **DNS-verified HTTPS**
on the wildcard's card. The inline panel separates routing DNS from certificate verification:

1. **Point the domain at this server.** Add the displayed routing record, usually `A` with name `*`
   and your deployment server's IP. You can also preview and apply it through a connected provider.
   This record sends application traffic to the server; it is separate from the certificate TXT.
2. **Enable HTTPS.** Choose one of the methods below. Progress and **Certificate logs** stay in
   the panel. Existing certificates remain installed if an attempt fails.

### Automatic provider

Connect **Cloudflare** from the DNS panel or **Settings → Credentials**, using a token with **Zone:Read** and **DNS:Edit** for
the relevant zone. Choose **Automatic provider**, then **Enable HTTPS automatically**. Openship
publishes the certificate challenge, waits for DNS, obtains the certificate and removes its temporary
TXT record. Keep the connection enabled so Openship can renew the certificate automatically.

The routing record still needs to point at the deployment server. A successful DNS certificate
challenge proves domain control; it does not prove that application requests reach the correct port.

### Manual TXT

Use this with any DNS provider when you do not want to connect an API credential:

1. Select **Manual TXT** and press **Get TXT record**.
2. Copy the exact **Name** and **Value** from the generated TXT card. For `*.example.com`, the full
   name is `_acme-challenge.example.com`. In a provider that appends `example.com` automatically,
   enter `_acme-challenge` as the name. For `*.apps.example.com`, enter `_acme-challenge.apps`.
3. Add that TXT value alongside any existing values at the same name. Do not replace unrelated
   verification records. Wait for DNS propagation, then press **Check TXT & enable HTTPS**.
4. Once the panel says **HTTPS is ready**, remove only this attempt's TXT value if desired.

You can leave the page, reconnect, or restart Openship and return to the saved challenge. Retrying
an active attempt reuses its order. Complete it before the deadline shown in the panel; an expired
or invalid challenge needs a new attempt. **Cancel challenge** lets you discard the attempt or
switch methods. Installation that has already started must finish.

<Callout title="Manual TXT also means manual renewal" type="warn">
When the certificate is within 14 days of expiry, the domain details show that renewal needs attention.
Choose **Renew SSL**, publish the new TXT value and complete the check. Openship does not repeatedly
attempt unattended issuance for a manual domain. To enable automatic renewal, connect Cloudflare,
open the setup panel and choose **Automatic provider**.
</Callout>

Cloud projects and domains marked **External ingress & TLS** use their existing managed or external
HTTPS flow. The local TXT panel applies to certificates Openship installs on your own deployment server.

### SDK and MCP

The SDK and MCP use the same saved challenge and permission checks as the dashboard. With an initialized
SDK client and the saved domain's ID:

```ts
await client.domains.startDnsChallenge(domainId, { mode: "manual" });
const pending = await client.domains.dnsChallenge(domainId);
// Read status until it is "waiting", then publish pending.record in DNS.
if (pending?.status === "waiting") {
  await client.domains.checkDnsChallenge(domainId, { attemptId: pending.id });
}
// Read status again for completion, errors and logs.
```

For renewal, start with `{ mode: "manual", force: true }`. To cancel, call
`client.domains.cancelDnsChallenge(domainId, { attemptId })`. The
[Domains API reference](/docs/api/domains) and [MCP tools reference](/docs/api/mcp-tools) list the equivalent
start, status, check and cancel tools. Read status while work is running; stop polling while waiting
for someone to publish DNS or after the attempt settles.

## Retry routing with logs

For a deployed project, open **Domains** and choose **Retry routing** from the warning banner, the
page action, or a domain's menu. This repairs the project's saved routes and checks pending domains
and HTTPS. A domain card whose backing record is missing also offers **Retry routing** directly.

The log viewer shows progress and the reason for any failure, including SSH connectivity and
certificate errors. Working domains retain their state when another domain fails. After the attempt,
the cards refresh from the server; the warning clears only when the checks succeed. Existing usable
certificates are reused.

Closing the viewer stops watching; an accepted repair continues on the server. If the connection
drops before completion, the viewer reports an unknown outcome and keeps the logs it received.

## DNS setup, three ways

Openship figures out which records you need based on where your project runs. You don't choose the first two —
they're picked for you. The records panel tells you which one applies.

<Tabs items={['Self-hosted (A record)', 'Openship Cloud (CNAME)', 'External edge']}>

<Tab value="Self-hosted (A record)">

Your project runs on your own server, so the domain points straight at that machine's public IP address.

| Type | Host | Value |
|---|---|---|
| `A` | `@` | your server's public IP |

The `A` record is guidance. Self-hosted verification obtains a certificate through ACME; it does not
compare public DNS with your server IP or require an ownership TXT record. The hostname must reach the
server for the selected ACME challenge to succeed.

</Tab>

<Tab value="Openship Cloud (CNAME)">

Your project runs on Openship Cloud, so the domain points at a Cloud address using a `CNAME` (a "this name is
an alias for that name" record).

| Type | Host | Value |
|---|---|---|
| `CNAME` | `@` | an Openship Cloud target |
| `TXT` | `_openship-challenge` | a long verification string |

Openship confirms the `CNAME` and the TXT, then handles HTTPS for you.

</Tab>

<Tab value="External edge">

For advanced setups where **your own edge already terminates HTTPS** — a Cloudflare Tunnel, a load balancer, or
another reverse proxy in front of Openship. Turn on **External ingress & TLS** when adding the domain.

| Type | Host | Value |
|---|---|---|
| `TXT` | `_openship-challenge` | a long verification string |

On **Openship Cloud**, add the TXT record above to prove domain ownership. On a **self-hosted instance**,
the operator already owns the server: no DNS record is requested or checked. Openship does not issue a
certificate in either case; the SSL status reads **External TLS**.

For a homelab behind **Nginx Proxy Manager**, point your internal DNS name at NPM and configure it to forward
to the Openship server reachable on your LAN or tunnel. NPM owns HTTPS. Choose **External ingress & TLS**
for the domain, then verify it. Your rotating home WAN address is not a verification requirement.
Clients outside your LAN still need a reachable address, VPN, or tunnel; private DNS alone does not provide
public access.

</Tab>

</Tabs>

## Redirect one domain to another

Any domain on a project can send its visitors to another domain on the same project instead of serving the
app. Openship answers a `301` (or `302`, if you pick it) and keeps the rest of the URL, so
`https://www.example.com/pricing?ref=x` lands on `https://example.com/pricing?ref=x`.

Open your project's **Domains** tab, press **Edit**, and set **Redirect** on the domain you want to send
*away*. Pick the destination from the list — it only offers this project's own domains — and choose whether
it's permanent (`301`) or temporary (`302`). Choose **Serve the app here** to turn a redirect off again.

The two common uses:

- **Pick a canonical hostname.** `www.example.com` → `example.com`, or the reverse. This is what **Include www**
  sets up for you (pointing `www` at the bare domain), and you can flip it either way. Without a redirect both
  hostnames serve the same app, which search engines treat as duplicate content.
- **Move to a new domain.** Keep the old one verified and redirect it to the new one, so existing links and
  bookmarks keep working.

<Callout title="A redirecting domain still needs DNS, verification and a certificate" type="info">
It has to answer `https://` before it can redirect anyone, so it goes through the same setup as any other
domain. That's also why it keeps showing its own **Status** and **SSL** rows.
</Callout>

Two things Openship won't let you do, because both break the site rather than the setting: redirecting to a
domain outside the project (that would turn a domain you own into an open redirect for someone else's traffic),
and creating a loop — `A` → `B` → `A` would bounce every request until the browser gave up.

Self-hosted deployments apply redirects through the server’s edge configuration.
Openship Cloud applies the same redirect intent through its managed edge.

## Bring your own certificate

For setups where your upstream needs the **origin** to present a real certificate too — most commonly
**Cloudflare Full (strict)** — upload your own instead of using Let's Encrypt.

1. On a verified domain, open its menu and choose **Upload certificate**.
2. Paste the **certificate** (full chain, PEM) and the matching **private key** (PEM). A free
   [Cloudflare Origin CA](https://developers.cloudflare.com/ssl/origin-configuration/origin-ca/) certificate
   (15-year) is a great fit here.
3. Openship serves HTTPS from that certificate and stops running Let's Encrypt for this domain.

<Callout title="Uploaded certificates don't auto-renew" type="info">
There's nothing for Openship to renew, so it watches the expiry and reminds you — upload a fresh certificate
before it lapses.
</Callout>

## CLI

The CLI supports ordinary domain creation and verification. The `add` command prints the DNS records and
the domain's ID; use that ID to verify. For manual TXT, use the dashboard, SDK, API or MCP lifecycle above.

```bash
# See exactly which records a domain will need (nothing is saved)
openship domain preview app.example.com

# Add the domain to a project (prints the records + the domain ID)
openship domain add app.example.com --project <projectId>

# Once your DNS is in place, verify it
openship domain verify <domainId>

# List a project's domains and their status
openship domain list --project <projectId>
```

<Callout title="Other domain commands" type="info">
`openship domain records <domainId>` reprints the records for an existing domain, `openship domain primary
<domainId>` makes a domain the project's main address, and `openship domain renew <domainId>` /
`openship domain verify-ssl <domainId>` manage the certificate. Full details are in the
[Domains API reference](/docs/api/domains).
</Callout>

## Troubleshooting

<Callout title="Verify keeps failing" type="warn">
Read the failure message for the selected verification mode. Cloud failures identify the routing or ownership
record. Self-hosted failures identify the certificate challenge or server connection. For manual TXT, compare
the exact `_acme-challenge` name and value, then retry the saved challenge from **DNS-verified HTTPS**.
</Callout>

<Callout title="DNS is not propagating" type="error">
This is the most common hold-up and it's usually just time. DNS changes can take minutes, and occasionally up
to 48 hours, to spread worldwide. Save the records, then wait and retry Verify — Openship also re-checks
ordinary pending domains automatically when the job is enabled. Manual TXT waits for your check. Check for a
typo in the record name or a conflicting routing record. Remove conflicting routing records as needed, but keep
unrelated TXT values at the challenge name.
</Callout>

<Callout title="Verified, but SSL is stuck on “Provisioning”" type="warn">
The certificate is still being issued, or a request to Let's Encrypt hasn't completed. Give it a minute, then
open the domain's **⋯** menu and click **Recheck SSL** — a quick, no-cost check that flips the status to
**Active** once the certificate is actually in place. If it never provisions, use **Renew SSL** to try again;
the error message will name the real reason.
</Callout>

<Callout title="The bare domain works but www doesn't (or the other way round)" type="warn">
They're two separate domains, so they're set up separately. Check the **Domains** tab: whichever one is still
**Pending** is missing its DNS record. Openship listed both records when you added the domain — open the failing
domain's DNS panel again, add the one it's asking for, then press its own **Verify**. A working apex tells you
nothing about `www`; each has its own record, verification and certificate.
</Callout>

<Callout title="Verify failed and you want the actual reason" type="info">
On a self-hosted install, Verify streams the certificate request live and the log stays on screen whatever the
outcome — that log names the real cause (DNS not resolving, port 80 unreachable, a proxy answering `404`). Use
**Copy log** if you want to keep it, and **Try again** to re-run without closing the window.
</Callout>

## Related guides

<Cards>
  <Card title="Domains API reference" href="/docs/api/domains" description="Every domain endpoint — add, preview, verify, primary, and SSL — with request shapes." />
  <Card title="Deploy from GitHub" href="/docs/guides/deploy-from-github" description="The full flow for getting a repo online, if you haven't yet." />
  <Card title="Environment variables" href="/docs/guides/environment-variables" description="Give your app its secrets and settings, safely." />
</Cards>
