# acme.sh / nginx troubleshoot cookbook

Symptom → cause → fix. Diagnose by reading the latest attempt's stderr from `~/.acme.sh/<host>*/`. The cookie-cutter shape of the issue log (`Using CA: …`, `Single domain=…`, `Verify error:…`, `Cert success.`) gives the failure point quickly.

## `Verify error:no TXT record` or `no AAAA record` for `_acme-challenge.<host>`

DNS-01 mode, not webroot. Either DNS just changed and hasn't propagated — `dig +short _acme-challenge.<host>` and retry in 5–10 min — or Cloudflare proxy is on (turn DNS-only for the duration of the issue).

Fix: stop, fix DNS, retry.

## ACME issue returns `NXDOMAIN looking up A for <host>` (or AAAA)

You're trying to add a brand-new subdomain on a domain that already has working subdomains. acme.sh can't issue because LE's resolver can't find any A/AAAA record for `<host>` at all.

**Root cause:** `<host>` is NOT in a wildcard zone. Each subdomain needs its own A record at the registrar (or wherever the parent zone's NS is delegated). Just because `api.example.com`, `frps.example.com`, `2api.example.com` resolve does **not** mean `quota.example.com` resolves. Don't assume.

Verify with one of:
```bash
dig +short <host> @8.8.8.8     # external resolver, no local cache
dig +short <host>              # system resolver
nslookup <host>                # verbose; shows resolver + NXDOMAIN explicitly
```

If empty / NXDOMAIN: STOP the ACME flow. Fix DNS first (add an A record pointing at the host's public IP, e.g. `111.229.126.184`), wait ~60 s for propagation, retry. Do not spend multiple `--issue` attempts on a broken DNS state — every retry fails the same way.

While you're there, also confirm the A value's IP actually matches the host. A common mistake is typing an internal address (`127.0.0.1`, `10.0.0.9`) into the public DNS record. The cert will issue but no one outside the LAN can reach the vhost.

Verified 2026-07-05: `acme.sh --issue -d quota.spannerjun.top` failed with
```
DNS problem: NXDOMAIN looking up A for quota.spannerjun.top - check that a DNS record exists for this domain
DNS problem: NXDOMAIN looking up AAAA for quota.spannerjun.top - check that a DNS record exists for this domain
```
even though `mihome`, `api`, `frps`, `2api` all resolved. The user had to add an A record at the registrar before issuance could succeed. If placeholder files were already created (`/etc/nginx/conf.d/<host>.conf`, `/etc/nginx/ssl/<host>.*`), clean them up while waiting for DNS — otherwise nginx keeps serving a self-signed cert, which makes the next curl probe misleading.

**Sub-case: "DNS is added" locally, but LE's first `--issue` still says NXDOMAIN.** This is real, not user error. LE's resolver pool (Google Public DNS + Cloudflare authoritative + a few others) re-fetches the new record 30–90 s after your authoritative nameserver serves it. Empirical pattern from the same session (quota.spannerjun.top, 2026-07-05): local `dig +short @8.8.8.8` returned the new IP within ~15 s; `--issue` returned NXDOMAIN at ~30 s; succeeded at ~90 s on the next `--force` retry.

```bash
# Verify DNS resolves from an external resolver first
dig +short <host> @8.8.8.8        # if IP appears, ready

# Re-issue immediately with --force, do NOT wait blindly
acme.sh --issue --force -d <host> -w /var/www/acme-challenge

# If still NXDOMAIN after 3 attempts over ~10 min, STOP — different problem:
#   - typo in the host FQDN at the registrar
#   - zone not delegated (CNAME or A pointing elsewhere)
#   - registrar dashboard claimed saved but the API write hasn't propagated
```

**Hard rule:** never pipeline `--issue` and `--install-cert` as one shell call when DNS is settling. The hazard below is what happens otherwise.

**Related hazard: when `--issue` fails the `--install-cert` that follows leaves a `0 bytes` cert file.** Sequence that bites you:

1. Step 2 placeholder self-signed `/etc/nginx/ssl/<host>.crt` and `.key` are ~1140 bytes.
2. `--issue` fails (NXDOMAIN / DNS settling).
3. You re-issue. **But** if you also re-run `--install-cert` immediately (or it auto-runs after a successful re-issue earlier), acme.sh writes to the install paths unconditionally. Without a source `.cer`, it can clobber the placeholder with a `0-byte` file.
4. nginx now has an empty certificate. `nginx -t` returns `cannot load certificate`. `systemctl reload nginx` exits non-zero, but the master keeps running the previous good config — so the vhost is half-dead.

Recovery:

```bash
ls -la /etc/nginx/ssl/<host>.*                          # look for 0-byte files
test -s /etc/nginx/ssl/<host>.crt || \
  openssl req -x509 -nodes -days 1 -newkey rsa:2048 \
    -keyout /etc/nginx/ssl/<host>.key \
    -out    /etc/nginx/ssl/<host>.crt \
    -subj "/CN=<host>"                                   # repaint placeholder
nginx -t && systemctl reload nginx
# then re-issue once DNS is stable
```

## `Challenge mismatch` / `curl: (7) Couldn't connect`

- ACME HTTP-01 needs port 80 reachable from outside
- Behind NAT? Port forward 80 to the host
- Behind Cloudflare but you think you're not? Confirm with `curl -H "Host: <host>" http://<yourip>/.well-known/acme-challenge/dummy` — if response is NOT 404 from nginx, something is fronting you

## `nginx: [emerg] BIO_new_file() failed`

Placeholder cert path doesn't exist yet. Run `openssl req -x509 -nodes -days 1 ...` (see SKILL Step 2) before `nginx -t`.

## `nginx -t` keeps warning: `conflicting server name "..." on 0.0.0.0:80`

Two vhost files declare the same `server_name`. Most common cause when retrofitting: the old `mihome.conf` already covered a wildcard or you accidentally edited the wrong file. `grep -rn "server_name <host>" /etc/nginx/` to find duplicates.

## `curl: (60) SSL certificate problem: unable to get local issuer certificate`

Server is fine, **client** can't find the LE intermediate CA in its trust store. Verify with `openssl s_client -servername <host> -connect <host>:443 </dev/null | grep "Verify return code"`:
- `Verify return code: 0 (ok)` → server is good, fix the client CA bundle
- `Verify return code: 21 (unable to verify the first certificate)` → server isn't sending the full chain; the `fullchain.crt` (not `cert.crt`) must be the one nginx loads. Re-run `--install-cert` with `--fullchain-file` (the script does this by default).

**The actual remediation when you do see code 21 (verified 2026-07-05, quota.spannerjun.top):** even with `acme.sh --install-cert --fullchain-file` writing `/etc/nginx/ssl/<host>.fullchain.crt` correctly, the vhost's `ssl_certificate` directive by the SKILL Step-1 template still points at `…/<host>.crt` (leaf only). Nginx serves the leaf and the client has to walk up the chain from its trust store — which **does** work for all major browsers but **fails** for `openssl s_client` and some strict clients. Cleanest fix:

```bash
# Point nginx at the fullchain (leaf + LE intermediate)
sed -i 's|ssl_certificate     /etc/nginx/ssl/<host>.crt;|ssl_certificate     /etc/nginx/ssl/<host>.fullchain.crt;|' \
  /etc/nginx/conf.d/<host>.conf
nginx -t && systemctl reload nginx

# Verify
echo | openssl s_client -connect <host>:443 -servername <host> 2>&1 | grep "Verify return"
# expect: Verify return code: 0 (ok)
```

After this, `curl https://<host>/…` works **without** `-k` even on a host with no pre-installed CA bundle (e.g. minimal Docker images, fresh Ubuntu before `update-ca-certificates` runs).

For consistency across vhosts: if you maintain more than one `<host>.conf`, run the same `sed` on each so `s_client -showcerts` output is uniform across the stack.

## `--install-cert` succeeds but `nginx -t` says `cannot load certificate`

`--key-file` and `--cert-file` swapped in the install, or `acme.sh` wrote to a path that doesn't exist. Check the contents of `/etc/nginx/ssl/<host>.{crt,key}`:
- `crt` should start with `-----BEGIN CERTIFICATE-----`
- `key` should start with `-----BEGIN PRIVATE KEY-----` or `-----BEGIN EC PRIVATE KEY-----`

If swapped, just swap them or re-run the install with correct paths.

## Certificate re-issued but not deployed

acme.sh renewed the cert but reloadcmd failed. Look in cron log or check `~/.acme.sh/<host>*/<host>.conf`:
```
Le_ReloadCmd='__ACME_BASE64__...'
```
The base64 decodes to whatever you passed. If it's wrong, re-run:
```bash
acme.sh --install-cert -d <host> --ecc --reloadcmd "systemctl reload nginx"
```
This **only updates the reloadcmd** without re-issuing.

## ACME challenge 404 even with the right webroot

The `location /dir/ { root /X; }` path-doubling pitfall (see SKILL pitfalls). Switch to `root /var/www;` or use `--nginx` mode.

## `Please update your account with an email address first` (ZeroSSL)

Not using ZeroSSL but acme.sh defaulted there. Run:
```bash
acme.sh --set-default-ca --server letsencrypt
```
Then re-issue.

## Cert is fine but browser still warns

Browser cached the old cert. Hard-reload (Ctrl+Shift+R), or `chrome://net-internals/#hsts` to check HSTS state. Also: when adding a new domain **after** an old one was issued under HSTS preload, the preload binds the cert name and the old cert's expiry can shadow the new one. In practice this is rare; usually it's just the browser cache.

## `--issue` succeeds for staging (`--test`) but fails for prod (`--force`)

Rate limit. Let's Encrypt allows ~50 certs/week per registered domain. If you're spinning up many test certs, you can hit the limit. Fix: don't test with `--test`; staging cert is on `staging-letsencrypt`, the limit on prod is shared with test for that domain.

## Race when reloading nginx right after install

acme.sh's `--install-cert` calls `--reloadcmd` synchronously. `systemctl reload nginx` returns once the master process reloads — workers may still be using the old config for a fraction of a second. If a request lands in that window, you'll see `SSL_ERROR_NO_CYPHER_OVERLAP` or empty body. Doesn't recur after the reload completes (~50 ms). Usually not worth solving.

## nginx `worker_processes` and large `ssl_session_cache`

If you bake a huge `shared:SSL:10m` cache and have 4 workers × 10 MiB × N vhosts → eventually each worker's `*temp*` memory segments blow up. Either drop to `shared:SSL:5m` per vhost, or define the cache once at `http {}` level and reference it in each vhost with the same name. Out of scope for 2–3 vhosts but warns ahead.
