doorman — a command-line client for the admin API: contexts, putting a site behind the gate, handing out PINs and share links, and a doctor that checks the whole path.
Doorman ships no admin UI (on purpose), which for a
long time meant the only way to add a site was curl. doorman is a thin client
over the same admin API — no new concepts, one less
place to get a JSON body wrong.
curl -fsSL https://agentics.dk/install/doorman-cli.sh | bash
Windows PowerShell:
irm https://agentics.dk/install/doorman-cli.ps1 | iex
The release component is doorman-cli; the installed command is doorman. It is
versioned with the server it talks to — doorman version and the instance's
/healthz report the same semver, because one release cuts both.
A context is a base URL and an admin token, stored in
~/.config/doorman/config.json with mode 0600:
doorman context add arvo --url https://kunde.arvo.works --token "$DOORMAN_ADMIN_TOKEN"
doorman context list
doorman context use arvo
The base URL is any hostname the instance serves — the admin API answers on all
of them, under the reserved prefix. The CLI adds /_doorman/api/v1 itself.
Three sources, highest first:
| Source | Wins over | Use it for |
|---|---|---|
--url / --token flags | everything | one-off calls against another instance |
DOORMAN_URL / DOORMAN_ADMIN_TOKEN | the config file | CI, where nothing should be on disk |
| the active context | — | daily work |
401 prints which of the three the credential came from. A token pasted into a
shell six weeks ago and an env var exported by a different tool fail identically
otherwise.
The four calls a platform needs, as four commands:
# 1. the site — upstream is the docker network alias, never a hostname
doorman site add griller \
--host griller.arvo.works \
--upstream griller:3000 \
--display-name "Griller.dk" \
--pin --banner="Forhåndsvisning for Griller.dk" \
--session-hours 168 --accent '#f87f2e' --locale da
# 2. a way in — a PIN to read aloud, or a link to paste into an email
doorman pin create griller --label "Anna hos Griller" --expires 2026-09-01
doorman link create griller --label "Godkendelse af forsiden" --single-use
# 3. who has been in
doorman audit griller --limit 50
# 4. take it down
doorman site rm griller
Once a site has requestLog.enabled, doorman requests is the other half of
that question — everyone who reached the site, not only everyone who got in:
doorman site set griller requestLog.enabled=true requestLog.retentionDays=30
doorman requests griller --limit 100
doorman requests griller --find 203.0.113.9 --from 2026-08-01 --to 2026-08-12
doorman stats is the third question — not who was let in and not who was here,
but how the site is doing. It needs no request log at all:
doorman site set griller analytics.enabled=true
doorman stats griller --days 30
doorman stats griller --from 2026-08-01 --to 2026-08-12 --top 40
# returning visitors, once the site asks for devices and a visitor consents
doorman site set griller analytics.identity=device
doorman stats griller --cohorts --weeks 12
The note: lines under the report come from the instance and are printed
verbatim — they say which figures the collection method cannot support. See
analytics and consent.
A share link's url is shown once: doorman stores the record, not the token.
Nothing recovers it — issue a new link and revoke the old one.
site rm deletes the site directory, PINs, links and the audit log. To stop
serving while keeping the history, doorman site set griller enabled=false or
give it an expiresAt.
PUT /sites/{slug} is create-or-replace, not a patch. site set therefore reads
the record, changes the keys you named, and writes the whole thing back:
doorman site set griller access.magicLinkEnabled=true theme.title="Griller.dk"
Do not hand-roll this with curl unless you send the full record. A partial body
resets every field it omits — the classic version is a one-flag edit that quietly
turns the site's PIN off.
doorman site set kunde \
sso.enabled=true \
sso.issuer=https://id.agentics.dk/realms/agentics \
sso.clientId=doorman-kunde \
sso.label="Log ind med Agentics" \
sso.allowedDomains=@kunde.dk
Register https://<host>/_doorman/signin-sso as the redirect URI at the
provider, and prefer a public client with PKCE — then there is no secret.
sso.clientSecret is write-only: it never comes back from a read, so site set
cannot preserve a value it was never shown. Naming it with an empty value —
sso.clientSecret= — therefore means clear the stored secret, and any other
edit leaves it alone.
--json is available on every read command (site list, site get, pin list,
link list, ticket list, audit), so the same calls work in a script. Without
it you get a plain table, the same in a terminal and in CI logs.
doorman doctor <slug> walks the path a visitor takes and checks each hop — the
same list self-hosting asks you to verify by hand
after adding a customer:
$ doorman doctor griller
site griller — enabled, expires 2026-10-01 ok
hosts griller.arvo.works — served by this instance ok
upstream griller:3000 — alias resolves, answered 200 ok
access pin — 2 active PINs, 1 share link ok
crawlers X-Robots-Tag: noindex, nofollow, noarchive ok
gate unauthenticated navigation → 302 /_doorman/gate ok
unknown an unknown Host got 404 ok
magic link access.magicLinkEnabled, but SMTP is not configured FAIL
1 problem. Magic links are only offered when SMTP is configured on the instance;
this site's gate will show the PIN form only.
A site with sso.enabled gets one more row. Half-configured customer SSO is the
failure that leaves no trace — the gate hides a button it cannot build a
challenge for — so doctor fails on a missing issuer or clientId, warns when
neither requiredRole nor allowedDomains narrows who gets in, and otherwise
prints the redirect URI to register at the provider.
Exit codes: 0 all checks passed, 1 you used it wrong, 2 the API said no,
3 doctor found a problem. A 503 from the upstream check is the interesting
one — it means the alias does not resolve or nothing is listening, which is the
one thing about deploying behind doorman that goes wrong more than once.
The API is the product surface; the CLI is a convenience. Every command above has
a curl equivalent in the admin API reference, and a
platform integrating against doorman should call the API directly rather than
shelling out to this.