Multi-Instance CLIs for Self-Hosted Services
A small pattern for when you have more than one of the same thing — and a back-door for when your own email is the problem.
By Nick Meinhold & Claude
TL;DR: Most CLIs for self-hosted services assume one deployment. I run two Outline wikis and two Kan boards. A small --site NAME flag plus namespaced env vars (SERVICE_<NAME>_API_KEY) makes one CLI talk to all of them, and changes nothing for existing callers. Along the way I needed to authenticate to a service whose magic-link email was going to a mailbox I couldn't reach — turns out when you self-host and administer the database, you don't need the mailbox.

The problem
I self-host Outline for the Imagineering community wiki, and another instance for an unrelated project called xdeca. Same software, different deployments, different teams, different data. The Outline CLI I'd been using — and the one most people ship for one-service-per-CLI tools — reads OUTLINE_API_KEY and OUTLINE_API_URL from the environment and hits whichever one is set. Pick one.
To switch instances I'd been doing the obvious bad thing: editing .env, re-sourcing, and praying I hadn't forgotten which was which. Twice in a week I'd run a documents.create against the wrong wiki.
Same shape for Kan, the kanban tool. Two deployments. One CLI. Constant context-swap tax.
The pattern
Three additions to a single-instance CLI turn it into a multi-instance one:
- A global
--site NAMEflag that's parsed before the subcommand sees the args. - Namespaced env vars:
OUTLINE_<NAME>_API_KEY,OUTLINE_<NAME>_API_URL. - A
SERVICE_DEFAULT_SITEenv var that picks the default when--siteis omitted.
Existing bare OUTLINE_API_KEY is kept as a final fallback, so nothing breaks for callers that haven't migrated. Resolver:
function resolveCreds(site) {
const effective = site ?? process.env.OUTLINE_DEFAULT_SITE;
if (effective) {
const prefix = `OUTLINE_${effective.toUpperCase().replace(/[^A-Z0-9]+/g, "_")}_`;
const key = process.env[`${prefix}API_KEY`];
const url = process.env[`${prefix}API_URL`];
if (!key) die(`no API key for site '${effective}'. Set ${prefix}API_KEY.`);
return { token: key, base: url ?? DEFAULT_BASE };
}
return {
token: process.env.OUTLINE_API_KEY,
base: process.env.OUTLINE_API_URL ?? DEFAULT_BASE,
};
}
The --site flag has to be peeled off argv before the subcommand's parseArgs(..., { strict: true }) runs, because strict mode rejects unknown options. Standard shape — same way git -C DIR <subcommand> and docker --context NAME <subcommand> sit before their subcommands. Hoisted global flags don't belong inside per-subcommand parsers.
.env after migration:
export OUTLINE_DEFAULT_SITE=xdeca
export OUTLINE_XDECA_API_KEY="ol_api_..."
export OUTLINE_XDECA_API_URL="https://kb.xdeca.com/api"
export OUTLINE_IMAGINEERING_API_KEY="ol_api_..."
export OUTLINE_IMAGINEERING_API_URL="https://outline.imagineering.cc/api"
That's it. outline auth hits xdeca (the default). outline --site imagineering documents.search --query 'foo' hits the other one. The single .env is now the canonical store for both sites' credentials, and the friction of "which one am I pointed at right now" goes away.
The subplot: getting authenticated in the first place
Wiring the pattern took 30 minutes. Getting credentials for the second instance was the part that turned weird.
To mint an API token for the second Outline I needed an authenticated session. The instance has no SSO configured — only email magic links. I asked it to email a link to nick@xdeca.com. Nothing arrived.
nick@xdeca.com lives on Migadu, which is my own mail host. The email was probably sitting in a mailbox I hadn't configured a client for on the laptop I was on. Classic. The mailbox lookup was real work, the link would expire in 10 minutes, and I was tired.
Then I remembered something obvious:
The magic link is just a URL with a token query param. The email is purely a transport — the link works regardless of which browser visits it or where it was copied from.
So where does the token live? The auth library that Outline (and Kan, and a growing fleet of self-hosted tools) uses — Better-Auth — stores every issued magic-link token in a verification table:
SELECT identifier, value, "expiresAt"
FROM verification
ORDER BY "createdAt" DESC LIMIT 5;
id | identifier | value | expiresAt
---+----------------------------------+-------------------------------------+--------------------
61 | nlPULVsHhvhKxCyTIAYxaXSfnLsnsSKZ | {"email":"nick.meinhold@gmail.com"} | 2026-06-05 23:22
59 | wfWSsTfLeoeeZAzpuVNdhdiKEqOesjnR | {"email":"nick@xdeca.com"} | 2026-06-05 23:13
The identifier is the token. The value is the address it was sent to. I administer the database — the token I "should have" gotten by email is sitting right there.
So I built the URL by hand:
https://tasks.xdeca.com/api/auth/magic-link/verify?token=wfWSsTfLeoeeZAzpuVNdhdiKEqOesjnR&callbackURL=https://tasks.xdeca.com/
Pasted into a logged-out browser. Better-Auth validated the token against the row, minted a session, set the cookie, redirected. Logged in. No email needed. Total elapsed time: about 20 seconds.
Why this isn't an exploit
I want to be careful here, because "skip the email" can sound like a security claim. It isn't one.
I can already authenticate as any user on a service I administer — that's what administering a service means. The trick isn't bypassing auth; it's noticing that the friction of the email flow is optional for me specifically when I own the data layer. The token in the DB is the same token the email would have given me. The email is one transport for it. Direct read on the DB is another.
For anyone who isn't an administrator, none of this changes anything. The verification table sits behind the same auth wall as everything else.
Where this helps
- Self-hosted Better-Auth services where SMTP is broken, or you haven't warmed up DKIM/SPF and your magic-link emails get spam-filtered.
- The "which of me is the real me" problem when you've signed up under multiple emails over the years and the canonical account is on an inbox you can't reach.
- Automating credential lifecycle from a clean state — spin up an instance, register a user, mint a token, all without ever standing up email infrastructure.
- Recovery when your password manager doesn't have the right inbox credentials.
The pattern generalises beyond Better-Auth. Most auth libraries store verification state server-side somewhere. If you administer that data store, you control the auth flow without needing the message bus.
A small caveat
Better-Auth happens to store tokens unhashed in verification.identifier, which is what makes this a one-line query. Authelia hashes them at rest — same shape of trick, but you'd intercept at the send layer (read the outbound SMTP queue, or hook the email-sending function in your container) instead of the store layer. JWT-style magic links don't need server-side storage at all, but if you have the signing secret in your env you can mint them directly. Different store, same realisation: the email is incidental.
The shape
Both bits of work sit on the same axis. The multi-instance CLI flag makes the friction of switching between your self-hosted things disappear. The auth back-door makes the friction of getting into one of them disappear when the normal path is blocked. Same underlying move: when you own the substrate, treat the human-facing flow as one option among several, not as the only path.
If you self-host anything with Better-Auth and run more than one of them, both patterns are about an hour to wire in. Worth it for the friction reduction alone — and worth it for the small moment of oh, when you realise the magic link was never really the magic.
Code for the multi-instance Outline and Kan CLIs lives in ~/.claude/cli-tools/ — they're personal tools, but the resolver pattern at the top of this post is the whole insight and drops cleanly into any single-instance CLI.