# Fly.io custom domain certificates: fly certs, DNS and errors

> Add a custom domain to a Fly.io app with fly certs add: A/AAAA vs CNAME, DNS-01 wildcards, CAA, imported certificates and stuck validation.

Source: https://ssl.sh/guides/fly-io-custom-domain-certificate/
Last updated: 2026-11-10

Fly.io issues a Let's Encrypt certificate for each custom domain you attach with `fly certs add`, then renews it for you. Validation runs automatically through a TLS-ALPN or HTTP-01 challenge once your A/AAAA or CNAME records point at the app. Wildcards need a DNS-01 `_acme-challenge` CNAME.

You can also import your own certificate with `fly certs import`. Single-hostname certificates cost $0.10 per month after the first 10 per organization; wildcards cost $1 per month. Steps follow the Fly.io documentation as of November 2026; `my-app` and `example.com` are examples.

## At a glance

| Question | Fly.io |
|---|---|
| CA for automatic certificates | Let's Encrypt |
| Validation | TLS-ALPN (preferred), HTTP-01, or DNS-01 |
| Apex (`example.com`) | `A` and `AAAA` to the app's IPs (recommended) |
| `www` and other subdomains | `CNAME` to the app's unique `fly.dev` target |
| CAA must allow | `letsencrypt.org` |
| Wildcard certificates | Yes, DNS-01 via `_acme-challenge` CNAME; $1/month |
| Upload your own certificate | Yes, `fly certs import` |
| Certificate statuses | `active`, `pending_validation`, `pending_ownership` |

## Step 1: Attach the domain

Run `fly certs add` once per hostname, including the apex and `www`:

```bash
fly certs add example.com -a my-app
fly certs add www.example.com -a my-app
fly certs add "*.example.com" -a my-app   # quote wildcards to avoid shell expansion
```

Each command prints the DNS options for your setup. You can see them again later with `fly certs setup example.com`, and the dashboard shows the same under **Certificates**.

If the app has no public IPv4 and IPv6 addresses yet, allocate the recommended ones (`fly ips allocate-v4` and `fly ips allocate-v6` exist for finer control):

```bash
fly ips allocate -a my-app
fly ips list -a my-app
```

## Step 2: Create the DNS records

Fly.io can only issue or renew after verifying the domain through at least one of: an `AAAA` record pointing at the app, an `_acme-challenge` CNAME, or a `_fly-ownership` TXT record.

| Name | Record | Value |
|---|---|---|
| `example.com` | `A` | the app's IPv4 address from `fly certs setup` |
| `example.com` | `AAAA` | the app's IPv6 address |
| `www.example.com` | `CNAME` | the app's unique target, shown by `fly certs setup` |

A and AAAA records are Fly.io's recommendation for most cases, and the only sensible choice at the apex unless your provider flattens CNAMEs (ANAME/ALIAS). CNAMEs suit subdomains and apps whose IPs may change.

Two optional records solve special cases:

- **`_acme-challenge` CNAME** for the DNS-01 challenge, required for wildcards and useful when you want the certificate before moving traffic.
- **`_fly-ownership` TXT** to prove ownership when the domain is behind a CDN or proxy, when you import a certificate, or when you do not want to use IPv6. Several values can go in one record separated by semicolons.

## Step 3: Check CAA records

If the domain publishes CAA records, Let's Encrypt must be allowed:

```dns
example.com.  3600  IN  CAA  0 issue "letsencrypt.org"
```

Test with the [CAA checker](https://ssl.sh/tools/caa-checker/). A CAA record that names only another CA stops issuance outright.

## Step 4: Watch validation

```bash
fly certs check example.com -a my-app
fly certs list -a my-app
```

`fly certs check` re-runs DNS and validation checks. The Machines API exposes the same certificate states: `active` when the certificate is issued, `pending_validation` while a challenge is outstanding, and `pending_ownership` while ownership is unproven. Responses can also carry `validation_errors` and a `rate_limited_until` timestamp.

## Using your own certificate

`fly certs import` uploads your own PEM full chain and key:

```bash
fly certs import example.com --fullchain fullchain.pem --private-key private-key.pem -a my-app
```

The status may show `pending_ownership` until an `AAAA` record or the `_fly-ownership` TXT record proves ownership. Custom and Fly-managed certificates can coexist on one hostname: the custom one is served first and the Let's Encrypt one is the fallback. Fly.io's automatic renewal covers the Let's Encrypt certificate it manages; an imported certificate has to be replaced by you before it expires. To remove only one kind, use `fly certs remove example.com --custom` or `--acme`.

## Verify

A verified hostname shows the status `active` and presents a Let's Encrypt certificate (or your imported one). Check it with the [SSL checker](https://ssl.sh/tools/ssl-checker/), or:

```bash
curl ssl.sh/example.com
```

Then confirm what Fly Proxy serves:

```bash
openssl s_client -connect example.com:443 -servername example.com </dev/null 2>/dev/null \
  | openssl x509 -noout -issuer -subject -ext subjectAltName -dates
```

Expect a Let's Encrypt issuer for managed certificates, or your CA for an imported one.

## Renewal

Fly.io renews the certificates it manages; the docs note that renewals do not count against Let's Encrypt's certificates-per-domain limit. Renewal needs the same verification as issuance, so keep the `AAAA` record, `_acme-challenge` CNAME or `_fly-ownership` TXT in place. Imported certificates are your job, and lifetimes are shrinking: 200 days today, 100 days from March 15, 2027 and 47 days from March 15, 2029. Track both kinds with [ssl.sh monitoring](https://ssl.sh/monitor/) (in development).

## Troubleshooting

**Creation or validation hangs, stalls or fails.** Fly.io points first at Let's Encrypt rate limits:

- Certificates per Registered Domain: 50 per week
- Duplicate Certificate limit: 5 per week
- Failed Validation limit: 5 failures per hostname, per hour

Fly.io cannot reset them; wait for the window to pass. Run Let's Debug against the domain to catch DNS, nameserver, CAA and networking problems, and avoid deleting and re-adding certificates while testing. See [Let's Encrypt rate limits](https://ssl.sh/errors/lets-encrypt-rate-limit/).

**Behind Cloudflare.** With the orange-cloud proxy on, add the `_fly-ownership` TXT record so Fly.io can issue through HTTP-01, and set Cloudflare's SSL mode to Full or Full (Strict); Flexible causes redirect loops. DNS-01 certificates conflict with Cloudflare Universal SSL, which inserts hidden `_acme-challenge` TXT records. If issuance still fails, import a Cloudflare Origin Certificate and verify ownership with the same TXT record.

**CAA errors.** Add `0 issue "letsencrypt.org"`; see [CAA record prevents issuance](https://ssl.sh/errors/caa-record-prevents-issuance/).

**Wrong certificate in the browser.** Check `fly certs list`: the hostname may be missing from the app, or a conflicting DNS record sends some visitors elsewhere.
