# Domains, DNS & SSL
URL: https://openship.io/docs/troubleshooting/domains-ssl.md

Fix a custom domain that won't verify, DNS that hasn't propagated, an SSL certificate stuck on Provisioning, and the external-ingress (TXT-only) case.

Adding a custom domain has two moving parts: the **DNS records** you paste at your registrar, and the
**verification** step where Openship looks those records up and confirms them. Almost every domain problem is
really one of those two records not resolving the way Openship expects. This page walks through the exact
messages you'll see and what each one means.

If you haven't added a domain yet, start with the [custom domains guide](/docs/guides/custom-domains) — this
page is for when something is already stuck.

<Callout title="The two records, in one sentence">
Every custom domain needs a **routing record** (an `A` or `CNAME` that points the name at your app) and a
**TXT ownership record** at host `_openship-challenge` (a secret code that proves the domain is yours).
Verify checks both.
</Callout>

## Verification keeps failing

<Callout type="error" title="Symptom / what you see">
You click **Verify** and it doesn't turn green. A toast titled **"Verification failed"** appears, and its body
is the exact reason Openship returned — one or both of:

- `A record not pointing to server for app.example.com` (self-hosted) or `CNAME record not found for app.example.com` (Openship Cloud)
- `TXT record _openship-challenge.app.example.com must equal "…"`

(If Openship can't produce a specific reason it falls back to the generic *"\{hostname} could not be verified yet."*)

From the API, `POST /api/domains/:id/verify` returns **422** with `cnameVerified` and `txtVerified` flags that
tell you which check is still failing.
</Callout>

**What it means.** Openship ran two independent lookups and at least one came back wrong. The message names the
record that failed, so you never have to guess. Fix whichever record is mentioned — the other one is already
fine.

| Message you see | Which record | What's wrong |
|---|---|---|
| `A record not pointing to server for …` | Routing (`A`) — self-hosted | The `A` record is missing, or points at a different IP than your server. |
| `CNAME record not found for …` | Routing (`CNAME`) — Openship Cloud | The `CNAME` is missing or doesn't point at the Cloud target. |
| `TXT record _openship-challenge.… must equal "…"` | Ownership (`TXT`) | The `_openship-challenge` TXT record is missing, or its value doesn't match the secret code. |

<Callout title="Why the API says “cnameVerified” even on a self-hosted A record" type="info">
The verify response reports the routing check as `cnameVerified` regardless of whether your project uses an
`A` record (self-hosted) or a `CNAME` (Cloud). It's the same "does the routing record point the right way?"
check — only the record *type* differs. `txtVerified` is always the ownership TXT.
</Callout>

### If the routing record is failing

**How to fix**

1. Open your project's **Domains** tab and reopen the **DNS Records** panel for the domain (or run
   `openship domain records <domainId>`).
2. Compare the routing row against what's actually at your registrar. Self-hosted expects an `A` record with
   host `@` pointing at your **server's public IP**; Cloud expects a `CNAME` with host `@` pointing at the
   **Openship Cloud target**.
3. Fix any mismatch — copy the value from Openship, don't retype it. Delete any old, conflicting record for the
   same name first (a stale `A`/`CNAME` left in place is a common cause).
4. Wait for the change to take effect, then click **Verify** again.

<Callout title="Self-hosted: the IP has to be reachable" type="warn">
The `A` record must resolve to the same public IP as the server this project is deployed on. If your server
sits behind a NAT or a proxy that changes its address, the lookup won't match and verification will keep
failing — point the record at the address the outside world actually sees.
</Callout>

### If the TXT ownership record is failing

**How to fix**

1. At your registrar, add (or correct) a **TXT** record with host `_openship-challenge` — Openship looks it up
   at `_openship-challenge.<your-domain>`.
2. Set its value to the exact string from the **DNS Records** panel. Use the **Copy** button; a single extra
   space or a missing character will fail the match.
3. Save, wait for propagation, and click **Verify** again.

<Callout title="Already verified?" type="info">
If you re-run Verify on a domain that's already done, Openship simply replies **"Already verified"** and
changes nothing — that's expected, not an error.
</Callout>

## DNS hasn't propagated yet

<Callout type="error" title="Symptom / what you see">
The records look correct at your registrar, but Verify still reports the routing or TXT record as not found —
sometimes minutes after you saved them.
</Callout>

**What it means.** DNS isn't instant. After you save a record, it has to spread across the internet's caches
before Openship's lookup can see it. As the DNS Records panel itself notes: *"DNS changes can take up to 48
hours to propagate globally."* Usually it's a few minutes; occasionally it's much longer.

**How to fix**

1. Double-check there are no typos in the **Host** field and no leftover conflicting record for the same name.
2. Wait. Save the records, give it several minutes, then click **Verify** again — re-clicking once DNS
   resolves is the reliable path.
3. Openship also ships a *pending-verification sweep* (`POST /api/domains/verify-pending`) that re-checks
   still-**Pending** domains and flips them to **Verified** on their own. It isn't on by default — it only runs
   if your operator has wired it to a scheduler (cron / systemd timer) — so don't wait on it; re-click Verify.

<Callout title="Confirm propagation yourself" type="info">
To check what the world currently sees, look the record up directly — e.g. `dig TXT _openship-challenge.app.example.com`
or `dig A app.example.com`. If your own lookup doesn't return the new value yet, Openship won't see it either;
that's a propagation delay, not an Openship problem.
</Callout>

## SSL stuck on "Provisioning" (or no certificate issued)

<Callout type="error" title="Symptom / what you see">
The domain is **Verified**, but the **SSL** pill stays on **Provisioning** instead of moving to **Active** —
so `https://` doesn't work yet.
</Callout>

**What it means.** Verifying a domain kicks off certificate issuance from Let's Encrypt **in the background** —
the verify response comes back fast and the SSL status catches up on its own. **Provisioning** means the
certificate is still being requested (or a request hasn't finished). It normally clears within a minute or two.

**How to fix**

1. Give it a moment, then open the domain's **⋯** menu and click **Recheck SSL**. This is a read-only check
   (no certificate is re-issued, no rate limit is spent) that flips the status to **Active** as soon as the
   certificate is actually in place.
2. If Recheck reports *"No valid certificate found for \{hostname} yet. If you just deployed, give Let's Encrypt
   a moment, then recheck."* — the certificate genuinely isn't there yet. Wait a little longer and recheck
   again.
3. If it never provisions, click **Renew SSL** to force a fresh issuance attempt. Any real failure reason
   (rate limit, ACME outage) surfaces on that action.

<Callout title="Renewing certificates" type="info">
Once a certificate is **Active** you normally never touch it again. Renewal targets certificates within about
two weeks of expiry: on Openship Cloud, and on self-hosted setups where your proxy or edge (Traefik, Caddy, a
load balancer) owns TLS, this is handled for you; a self-hosted operator can also point a scheduled job at the
renew endpoint. **Renew SSL** is the manual fallback if a certificate ever lapses.
</Callout>

<Callout title="“Recheck / Renew SSL” errors that the domain must be verified" type="warn">
SSL actions require a **verified** domain — attempting them on a pending one returns
*"Domain must be verified before SSL can be managed"*. Finish verification first (above), then manage the
certificate.
</Callout>

<Callout title="If SSL was never set up on this host" type="warn">
The certificate toolchain (certbot) is installed during a **deploy**, not by the on-demand Renew/Recheck
buttons. If a host has never provisioned SSL for any custom domain, **redeploy the project** — the deploy
preflight installs the toolchain and issues the certificate as part of the build, with the progress in the
deploy logs. See [Troubleshooting → Deployments](/docs/troubleshooting/deployments).
</Callout>

## External ingress: your edge terminates TLS (TXT only)

<Callout type="error" title="Symptom / what you see">
Your app already sits behind a Cloudflare Tunnel, a load balancer, or another reverse proxy that handles
HTTPS — but Openship keeps asking for an `A`/`CNAME` and tries to issue its own certificate, which you don't
want.
</Callout>

**What it means.** By default Openship assumes it's the edge: it wants the domain to resolve to it and it
issues the certificate. When **your** edge terminates TLS, that's the wrong model — Openship should only
confirm you own the name and stay out of the certificate business.

**How to fix**

1. When adding the domain, turn on **External ingress & TLS**. As the toggle explains: *"TLS terminates
   upstream (Cloudflare Tunnel, load balancer). Verify by TXT only — the domain need not point at this server,
   and we won't issue a certificate."* From the API, pass `"externalIngress": true` on `POST /api/domains`.
2. Add **only** the `TXT` record with host `_openship-challenge` — there's no routing record to add, because
   your edge already controls where the name points.
3. Click **Verify**. Openship checks the TXT alone and confirms with *"Domain verified — TLS is handled by
   your external ingress; no certificate is issued here."*

<Callout title="What the SSL pill shows" type="info">
An external-ingress domain reads **External TLS** for its SSL status — that's correct and final. Openship
issues nothing and never runs certbot for it; your edge owns the padlock. **Recheck SSL** and **Renew SSL**
don't apply.
</Callout>

<Callout title="Turn it on only if you mean it" type="warn">
If your edge does **not** handle HTTPS, leave External ingress off — otherwise the domain will verify but
serve plain HTTP, with no certificate from anyone. When in doubt, leave it off and let Openship issue the
certificate.
</Callout>

## Still stuck?

<Callout title="One more thing to check: the hostname itself" type="info">
If **Add domain** is rejected before you even reach verification, the hostname failed a basic check — e.g.
`Domain "app.example.com" is already in use` (it's attached elsewhere) or
`"…" is not a valid public hostname.` (a bare IP, `localhost`, or a single-label name). Free `*.opsh.io`-style
subdomains aren't added here either — those live in the project's public endpoints, not as custom domains.
</Callout>

<Cards>
  <Card title="Custom domains guide" href="/docs/guides/custom-domains" description="The full add → point DNS → verify → HTTPS walkthrough, click by click." />
  <Card title="Domains API reference" href="/docs/api/domains" description="Every domain endpoint, the preview/verify responses, and the cnameVerified / txtVerified flags." />
  <Card title="Project Domains tab" href="/docs/dashboard/projects" description="Where domains, verification status, and the SSL pill live in the dashboard." />
</Cards>
