--- name: proxy-manager title: Proxy Manager description: Use for anything about this machine's domains, proxying or TLS — which hostnames it serves, where one forwards, whether a site is down, when a certificate expires, and creating, editing, enabling or deleting any of it. Carries the pass-through route into Nginx Proxy Manager's own API, the one 404 that means something else, and what to confirm before doing. version: 1 --- Officer proxies through **Nginx Proxy Manager**. This plugin holds NPM's admin credential and hands you NPM's own API through it. ## The route ``` /api/proxy-manager/npm/ -> /api/ ``` Method, path, query string and body are forwarded unchanged, and **NPM's own status and body come back unchanged** — including its errors. A token is attached for you. ```bash curl -s -H "Authorization: Bearer $OFFICER_AGENT_KEY" -H "Host: $OFFICER_API_HOST" \ "$OFFICER_API_URL/api/proxy-manager/npm/nginx/proxy-hosts?expand=certificate,access_list" ``` **The contract is NPM's API, not ours.** If NPM can do it, this can do it — `docs/npm-api.md` in the plugin has the endpoints, and NPM's own docs cover the rest. Do not go looking for a curated Officer route for each operation; there isn't one, and that is the design. **Never reach NPM directly.** Not by its port, not by `docker exec`, not by finding the credential. The credential is deliberately not yours, this route is the sanctioned path, and going around it is unauthorised, unlogged and unbounded. If this route refuses you, that refusal is the answer. ### The panels use different routes `/api/proxy-manager/hosts`, `/certificates` and `/status` also exist. They sort and compute `daysUntilExpiry` for the screen. Read them if you want the same shaped answer the panel shows; use `/npm/*` for anything else. ## What you can do Everything NPM can, which is roughly: | | | | --- | --- | | `GET/POST/PUT/DELETE /npm/nginx/proxy-hosts[/:id]` | the proxy hosts | | `POST /npm/nginx/proxy-hosts/:id/enable` · `/disable` | in and out of service | | `…/redirection-hosts`, `…/dead-hosts`, `…/streams` | the other three kinds of host | | `…/certificates[/:id]`, `…/certificates/:id/renew` | Let's Encrypt and uploaded | | `GET /npm/nginx/certificates/dns-providers` | which DNS providers this NPM can drive, with their credential field names | | `…/access-lists[/:id]` | who may reach a host | A proxy host carries `domain_names`, `forward_scheme`, `forward_host`, `forward_port`, `enabled`, `ssl_forced`, `certificate_id` (0 means none), `access_list_id`, and `meta.nginx_online` — **which is the field to read when somebody says a site is down**, not whether the row exists. ## Confirm before you change something **Do what is asked.** The owner runs this machine; a request to create, edit, enable, disable or delete a host is a legitimate request and refusing it is not caution, it is being unhelpful about somebody's own server. **But say what will happen first, and wait, when the change is not trivially reversible:** - **Deleting** a proxy host, a redirection, a stream, a certificate or an access list. Name exactly what goes and what it currently serves. A deleted host is not recoverable from here. - **Editing** an existing host's domains or upstream. That is a live site changing where it points. - **Disabling.** One click back, so a short confirmation is enough — but a domain goes dark meanwhile. - **Anything touching a certificate** that a live host is using. Creating something new needs no ceremony: report what you made. **Never infer a change from a question.** "Is `foo.example.com` up?" asks about `meta.nginx_online`. It is not an instruction to restart, disable or recreate anything. ## Issuing a certificate **Verified against NPM 2.15.1 on 2026-08-25, by reading its own source.** Most documentation and most memory of this API are wrong in the same way — see the warning below. **If the name already resolves to this machine's public address**, Let's Encrypt can reach it on port 80 and there is nothing else to arrange: ```bash curl -s -X POST -H "Authorization: Bearer $OFFICER_AGENT_KEY" -H "Host: $OFFICER_API_HOST" \ -H 'content-type: application/json' \ -d '{"provider":"letsencrypt","domain_names":["sub.example.com"]}' \ "$OFFICER_API_URL/api/proxy-manager/npm/nginx/certificates" ``` That is the whole body. Then attach it to a host by `PUT`ting that host with `certificate_id` set to the new certificate's `id`, and `ssl_forced: true` if it should redirect. **Do NOT send `letsencrypt_email` or `letsencrypt_agree`.** They are what every tutorial shows and NPM 2.15 does not accept them: `meta` is `additionalProperties: false`, so the request is rejected outright. The email comes from the NPM ACCOUNT — `internal/certificate.js` looks up the owning user and refuses with *"A valid email address must be set on your user account"* if it has none. That is a setting in NPM's own UI, not something this API can supply. **If the name does NOT resolve publicly** — a private or VPN-only hostname — Let's Encrypt cannot visit it, and the only proof it accepts is a DNS record: ```jsonc { "provider": "letsencrypt", "domain_names": ["private.example.com"], "meta": { "dns_challenge": true, "dns_provider": "cloudflare", // an id from /npm/nginx/certificates/dns-providers "dns_provider_credentials": "dns_cloudflare_api_token = …", // that provider's ini template, filled in "propagation_seconds": 60 } } ``` **You cannot fill in `dns_provider_credentials` yourself, and must not try.** Officer stores DNS provider keys encrypted and never returns them — not to you, not to the owner. Reaching for one is the refusal-shaped mistake this skill warns about. **So there is a different route for this, and it is the only one that works:** ```bash curl -s -X POST -H "Authorization: Bearer $OFFICER_AGENT_KEY" -H "Host: $OFFICER_API_HOST" \ -H 'content-type: application/json' \ -d '{"domain_names":["private.example.com"]}' \ "$OFFICER_API_URL/api/proxy-manager/domains/certificate" ``` Note the path: `/domains/certificate`, **not** `/npm/…`. It is not a pass-through. The platform works out which DNS provider owns that name from the Domains list, decrypts the stored key, builds the request above and hands back NPM's own answer. You ask for a certificate and never see the key that got it. `propagation_seconds` may be passed and defaults to 120 — long, because a challenge record that has not spread yet fails in a way that reads as a bad credential. **Expect this to take minutes**, not seconds, and do not retry a request that has not come back. What it refuses, and what each refusal means: - **400 "Not in the domain list"** — the apex is not in Domains. Officer does not know whose DNS it is. The owner adds it in the Domains section; you cannot. - **409 "No API key stored"** — the domain is known, its provider is known, and no key has been entered. Again the owner's to do, from the same section. - **400 "cannot span two DNS providers"** — one certificate carries one credential. Split the request. ## Certificates, read the way Let's Encrypt behaves Renewal runs at **30 days**, so under 30 is normal and not worth mentioning. **Under 14 means renewal has actually failed** and the domain goes dark at zero — raise that unprompted. `expires_on` is null on some uploaded certificates; that is unknown, not urgent. **Nothing warns anybody.** There is no notification for this yet. If you notice one inside 14 days, say so in the conversation, because nothing else will. ## The 404 that is not a 404 **NPM answers an unauthenticated read with 404, not 401.** Its router only registers a resource once the request carries a token, so "not signed in" and "no such endpoint" are identical responses. The pass-through already re-mints its token and retries once, so a 404 that reaches you is about your request rather than about the token. Read it as "no such thing" — but if every route 404s, the stored credential is wrong and the owner needs to fix it at `/proxy-manager?config=1`. It is not a rule about the whole API: `GET /npm/nginx/certificates/dns-providers` answers a proper 403 unauthenticated. ## Two answers that are not failures - **409 `unconfigured`** — no NPM connection is stored. Nothing is broken; the owner has not connected one. Only the owner can, at `/proxy-manager?config=1`. - **403** — either the account lacks `proxy-manager`, or it has it at **read** level, which permits GET and refuses every write. Do not look for another way round; report it.