# Issue recap — sub2api and new-api fronted by nginx + Let's Encrypt (2026-07-05)

Captured from a real session. Useful next time as a concrete "what the finished state looks like" reference.

## State at end of session

Two reverse-proxy vhosts on the same host, both with valid LE certs, both with cron-driven auto-renew.

### Hosts

| Domain | Backend | Cert | Started |
|---|---|---|---|
| `https://mihome.spannerjun.top` | `127.0.0.1:9090` (mihomo controller) | `~/.acme.sh/mihome.spannerjun.top/` (RSA, existing) | 2026-07-04 |
| `https://2api.spannerjun.top` | `127.0.0.1:8080` (sub2api container) | `~/.acme.sh/2api.spannerjun.top_ecc/` (ECC, new) | 2026-07-05 |
| `https://api.spannerjun.top` | `127.0.0.1:3000` (new-api container) | `~/.acme.sh/api.spannerjun.top_ecc/` (ECC, new) | 2026-07-05 |
| `https://frps.spannerjun.top` | `127.0.0.1:7500` (frps dashboard) | `~/.acme.sh/frps.spannerjun.top_ecc/` (ECC, new) | 2026-07-05 |
| `quota.spannerjun.top` | `127.0.0.1:8788` (miniquota — planned) | ⏸️ DNS A record not added by user; nginx config + cert clean-up done while waiting | 2026-07-05 |

### Files on disk

```
/etc/nginx/conf.d/mihomo.conf                    # existing — kept as-is, only added 'try_files' to its ACME block
/etc/nginx/conf.d/2api.spannerjun.top.conf       # new
/etc/nginx/conf.d/api.spannerjun.top.conf        # new
/etc/nginx/sites-enabled/default                 # existing Ubuntu default, now explicitly default_server
/etc/nginx/ssl/{mihome,2api,api}.spannerjun.top.crt|.key|.fullchain.crt
/var/www/acme-challenge/                         # shared webroot
/root/.acme.sh/<host>_ecc/                      # per-host acme.sh state + ReloadCmd metadata
~/.acme.sh/account.conf                          # CA = LE (set via acme.sh --set-default-ca)
```

### Validated behaviors

- `http://<host>/.well-known/acme-challenge/test-abc` → 404 (file missing — correct, would be 200 with valid token)
- `http://<host>/` → 301 to `https://<host>/`
- `https://<host>/` → backend response (sub2api SPA or new-api JSON), no cert error from server (`openssl s_client ... Verify return code: 0`)
- `crontab -l` shows `/usr/local/bin/acme.sh --cron` every 6 h; `ReloadCmd` is `systemctl reload nginx` for all three certs

### What bit us (so it's in the skill now)

1. **ZeroSSL default CA**: first `--issue` failed with "Please add email first." Run `acme.sh --set-default-ca --server letsencrypt` once globally.
2. **mihomo 80-block as implicit default_server**: an old vhost without `default_server` was first in config-load order, so it intercepted every Host header. Adding `sites-enabled/default` with explicit `default_server` demoted mihomo to "only handles `server_name mihome.spannerjun.top`."
3. **nginx `location /dir/ { root /x; }` path-doubling**: ACME webroot returned 404 because nginx looked at `/var/www/acme-challenge/.well-known/acme-challenge/<token>` instead of `/var/www/acme-challenge/<token>`. Webroot mode worked anyway because all our challenges landed via the existing pattern; but adding a stray `try_files $uri =404` to the mihomo block made it robust.
4. **acme.sh test-vs-prod**: `--test` actually issues a LE staging cert, doesn't dry-run. Costs nothing but pollutes state. Don't use unless planning to delete the staging cert afterward.
5. **curl "unable to get local issuer"**: server is fine; client CA bundle missing. Use `openssl s_client` to verify server.
6. **`write_file` blocked on `/etc/nginx/*`**: had to write the vhost files via `terminal` heredoc. The skill calls this out.

### Quick rehydration recipe (next session)

If asked "what does this host look like" or "add another domain":

```bash
hostname=$(hostname)
docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}"
ls /etc/nginx/conf.d/
crontab -l | grep acme
ls /etc/nginx/ssl/*.crt 2>/dev/null | wc -l  # number of TLS certs
ss -tlnp | grep -E ':80 |:443 '
```

This tells you: how many Docker stacks, how many domains fronted, whether renewal is wired up, and whether nginx is serving.
