Configuring the gateway
Every setting, what to put in it, and what happens if you leave it out.
Set them in a .env file next to docker-compose.yml (copy .env.example to
start). Running without Docker? The same names work as ordinary environment
variables, and penname-gateway --env-file .env reads the same file.
Two are required: PENNAME_API_KEY and PENNAME_MASTER_KEY. Everything
else has a working default.
- Writing the .env file
- Where secrets live
- PENNAME_MASTER_KEY — required
- PENNAME_DB_URL — strongly recommended
- PENNAME_ANTHROPIC_BASE_URL
- PENNAME_UPSTREAM_BASE_URL
- PENNAME_API_KEY — required
- PENNAME_CONTROL_PLANE_URL — leave it unset
- ENABLE_LOGGING — on by default
- DETAIL_LOGGING — off by default
- LOG_VALUES — off by default
- PENNAME_PORT
- The scope header
- PENNAME_ALIAS_STYLE — typed by default
- Keywords — protect what no column holds
- What SECRET does — read this before marking a column SECRET
- PENNAME_SCHEMA_ONLY -- off by default
- Everything else
- Checking it works
Writing the .env file
NAME=value, one per line. No quotes, and no spaces around the =.
PENNAME_MASTER_KEY=7f3a9c2e...
PENNAME_CONTROL_PLANE_URL=https://penname.io
PENNAME_DB_URL=postgresql://readonly:pass@host.docker.internal:5432/yourdb
Not this:
PENNAME_CONTROL_PLANE_URL: https://... # that is YAML, not .env
PENNAME_CONTROL_PLANE_URL = "https://..." # spaces and quotes both wrong
You never edit docker-compose.yml. A line in it like
PENNAME_CONTROL_PLANE_URL: ${PENNAME_CONTROL_PLANE_URL:-https://penname.io}
only means "take this one from .env". Everything you configure lives in .env.
A # starts a comment. A value with a # in it — some passwords — does need
quoting: PENNAME_DB_URL='postgresql://u:pa#ss@host/db'.
Where secrets live
penname-gateway init writes two files on purpose:
| File | Holds | Share it? |
|---|---|---|
.env |
settings: port, provider, control plane URL | yes — paste it into a support ticket freely |
secrets.env |
PENNAME_MASTER_KEY, PENNAME_API_KEY, PENNAME_DB_URL |
never |
secrets.env is readable by you only, listed in .gitignore, and reaches the
container through env_file: — not through a ${...} variable that compose
would happily interpolate from anywhere. Keeping the three secrets in a file of
their own is what makes "send me your .env" a safe thing for support to ask.
On a platform with a real secret store — Kubernetes, Docker Swarm, AWS or GCP secret managers — hand each one over as a file instead:
PENNAME_MASTER_KEY_FILE=/run/secrets/master_key
PENNAME_API_KEY_FILE=/run/secrets/api_key
PENNAME_DB_URL_FILE=/run/secrets/db_url
The gateway reads the file's contents (trailing newline ignored). Setting both
the variable and the _FILE to different values is refused at startup rather
than resolved one way in silence. A mounted secret never shows in
docker inspect, the shell history or the process list, which is where an
environment variable ends up on show.
Running more than one gateway for the same data — production and staging, or several replicas — every one of them must use the same master key, or the same person gets a different pen name from each. Print it from an existing install and hand it to the next one:
penname-gateway key show # or: docker compose exec gateway penname-gateway key show
docker run -it -v ${PWD}:/setup pennameio/gateway init --master-key <that key>
What none of this does is encrypt the key at rest: the gateway has to read it to start, so anything it could decrypt, an attacker on the same machine could too. Disk encryption is the operating system's job — BitLocker, FileVault, LUKS — and it is the right place for it.
PENNAME_MASTER_KEY — required
The secret your pen names are derived from. Same key plus same person plus same scope always gives the same alias, on any machine, with nothing stored.
PENNAME_MASTER_KEY=7f3a... # 64 hex characters
Generate one, once:
openssl rand -hex 32
# no openssl (Windows)?
docker run --rm python:3.12-slim python -c "import secrets; print(secrets.token_hex(32))"
Treat it like a database password. Store it wherever you keep those.
- Leave it out and the gateway refuses to start, with a message telling you this.
- Lose it and every alias you have ever issued becomes unresolvable — you
cannot turn
PERSON_7K3Q2M9XABback into a name without it. - Change it and every alias changes. That is the intended way to retire a whole alias space, not something to do casually.
- It never leaves your machine. penname's hosted dashboard never sees it.
PENNAME_DB_URL — strongly recommended
The database the gateway reads to learn which values are sensitive. Paste the URL your application already uses.
PENNAME_DB_URL=postgresql://readonly:pass@host.docker.internal:5432/yourdb
Use a read-only database user. The gateway only ever runs SELECT, and a
read-only user is how you make that true rather than trusted.
In Docker, localhost means the container, not your machine. If your
database runs on your host, use host.docker.internal instead. That name
resolves on macOS and Windows automatically, and docker-compose.yml adds it on
Linux too, so one .env works everywhere.
An async driver is fine — postgresql+asyncpg://, mysql+aiomysql:// and the
rest are swapped for their synchronous equivalent automatically, and the swap is
logged rather than done silently.
Leave it out and only emails, phone numbers and Israeli ID numbers are
protected. Those have a recognisable shape; names do not. Without a database
Dana Levy reaches the model unchanged. This is the single setting that decides
whether your customers' names are protected.
PENNAME_ANTHROPIC_BASE_URL
Where /v1/messages is forwarded — the Claude half of the gateway.
PENNAME_ANTHROPIC_BASE_URL=https://api.anthropic.com/v1 # the default
Separate from PENNAME_UPSTREAM_BASE_URL on purpose, so one gateway serves
both providers: an app that switches from GPT to Claude changes its own
client and nothing else. Point an Anthropic SDK at http://localhost:8088
(no /v1) and it works exactly as the OpenAI SDK does at
http://localhost:8088/v1.
Your Anthropic key is not configured here either — the gateway forwards the
x-api-key header your app already sends.
PENNAME_UPSTREAM_BASE_URL
The model provider you actually use. Anything OpenAI-compatible.
PENNAME_UPSTREAM_BASE_URL=https://api.openai.com/v1 # the default
Your provider API key is not configured here. The gateway forwards whatever
Authorization header your application already sends, so it never holds your
OpenAI key and never needs to.
To see what the model would receive without sending anything anywhere, point
this at penname-echo — see Checking it works.
PENNAME_API_KEY — required
Your penname key. Get one at https://penname.io/signup.
PENNAME_API_KEY=pk_live_...
The gateway does not run without it. It authorizes the key at startup and
before every call, and a gateway that has never been authorized proxies nothing
— it answers 402 and reports itself unhealthy. There is no setting that turns
this off; leaving PENNAME_CONTROL_PLANE_URL out does not turn it off either
(see below).
Two things are deliberately tolerant, because they are our problem and not yours:
- An outage on our side. Once a gateway has been authorized it keeps serving
for
PENNAME_OFFLINE_GRACE_SECONDS(a day by default) if the control plane becomes unreachable, then fails closed. - A key over quota or suspended. The gateway stays up and answers
402, recovering by itself within a minute of the key being good again. Only a key the control plane does not recognise stops it at startup, with a message saying so.
The key is not your master key and not your OpenAI key — see the two keys in the README.
PENNAME_CONTROL_PLANE_URL — leave it unset
Where the key is authorized, your schema is uploaded (table and column names, never a row) and your field map comes from.
PENNAME_CONTROL_PLANE_URL=https://penname.io # the default; you can omit it
Set it only if you run your own control plane. It is not a way to run without one: unset means the hosted control plane, not "ask nobody".
With it reachable, the gateway uploads your schema so the dashboard is not empty, then fetches the choices you saved and polls for changes, so a dashboard edit takes effect without a restart. Until you have marked anything, it reflects your schema and proposes a map itself — a reasonable start, but a guess, not your decision.
ENABLE_LOGGING — on by default
One line per request each way, plus which value became which alias.
ENABLE_LOGGING=true # default; set false for silence
-> gpt-4o protected 3 of 259 known (PERSON 1, PHONE 1, ILID 1) 4 msg 83 KB
PERSON D**a -> PERSON_BRHB6GR62Y
PHONE 05*******9 -> PHONE_A0RVW3C5BY
<- gpt-4o restored 3 21 in / 8 out tokens 0.4s
"3 of 259 known" means three values were actually swapped, out of 259 the gateway had loaded so it could recognise them. The dictionary is the whole address book; the first number is what left your network. Only the values that were swapped are listed underneath.
Values are masked: enough to see the right column was picked, not enough to identify anyone.
DETAIL_LOGGING — off by default
Also prints every word the model received, as plain text, under the swap list.
DETAIL_LOGGING=true
-> gpt-4o protected 2 of 259 known (EMAIL 1, PERSON 1) 3 msg 1 KB
PERSON D*******y -> PERSON_2A9BS5W338
EMAIL d********@e********** -> EMAIL_2YZZC836R4
sent to the model, word for word:
system You are support.
Be brief.
user email PERSON_2A9BS5W338 at EMAIL_2YZZC836R4
assistant lookup({"who": "PERSON_2A9BS5W338"})
<- gpt-4o restored 2 9 in / 3 out tokens 0.4s
The swap list is your value and what the model got instead; the text under it is the whole conversation exactly as the model read it. Only the words: no JSON, no request settings, no tool schemas, no reply. It is the protected copy, so it reads in aliases rather than customer data.
Still long: a real system prompt is printed in full per request, so left on it
fills the container's log. Turn it on to see exactly what left, then turn it off:
DETAIL_LOGGING=false and docker compose up -d --force-recreate, which also
starts the new container with an empty log. While it is on, the startup line
says logging on + words sent (DETAIL_LOGGING).
Use it before launch. Turn it on, send your AI features a fake test customer, and check that the log holds only pen names and never the test values. That is the check in Check it works, and it should pass before any real customer goes through the gateway.
LOG_VALUES — off by default
Prints the real value beside each alias instead of a mask.
LOG_VALUES=true
PERSON Dana Levy -> PERSON_BRHB6GR62Y
EMAIL dana@example.com -> EMAIL_W0JZFM75RM
Your log then contains customer data. Logs get shipped to log services, attached to support tickets, and pasted into chats — that is how this data escapes, far more often than through the model. The gateway prints a warning at startup while it is on.
Turn it on to diagnose a specific mapping, then turn it off.
PENNAME_PORT
Which port on your machine publishes the gateway. Docker only.
PENNAME_PORT=8088 # the default
Change it only if something else already has 8088 — you will know, because
docker compose up fails saying the port is in use. The container keeps 8088
internally either way; only the published port moves.
The gateway is published on 127.0.0.1 only. It holds your master key and reads
your database, so it has no business being reachable from the internet.
The scope header
A call with no scope header protects no names. Not fewer names — none.
Emails, phones and ID numbers still go, because those have a shape a detector
can find; a name does not, so the only way to protect one is to know whose row
it is. The count in the log (protected 1 of 1) counts what was replaced and
cannot see what it never knew about, which is why an install can look like it
is working long before it protects anything.
Since 0.3.0 the gateway says so out loud:
- the first such call logs
NO X-Penname-Scope HEADER — names from your database were NOT protected in this call, - every such call is marked
NO-SCOPE(names unprotected)on its log line, - and the count reaches your dashboard, so you can see the share.
To refuse those calls instead of reporting them, set PENNAME_REQUIRE_SCOPE=true
(below). Every deployment handling real data should.
X-Penname-Scope: tenant=45; business=89; engagement=E-2026-17; user=123
This is how the gateway knows which rows to read. Emails, phone numbers and ID numbers have a shape a detector recognises without it; names do not, so without this header names go to the model in the clear.
Do not guess the key names. Start the gateway and ask it:
curl -s localhost:8088/scope
It reads them off your own schema, and the same list is printed at startup:
scope keys (outermost first): tenant, business, engagement, user
Framework noise in a table name is stripped for you: auth_user answers to
user, tbl_customer to customer, crm_contact to contact. The response
also carries a database section — how many tables, whether relationships are
declared, what kind of keys — so you can see what the gateway understood about
your schema before trusting it.
Every table is filtered by the kind of id it actually holds — businesses by
tenant_id, memberships by business_id, evidence by engagement_id. A
table your request says nothing about is skipped, not queried with an id that
belongs to something else.
You do not have to send every key. The gateway follows your foreign keys
outwards, up to two tables, so tenant=45 on its own still reaches evidence
through engagements and businesses. Send more ids when you have them and the
reads get narrower and cheaper; send fewer and the coverage stays.
owner=<user id> keeps working and keeps meaning what it always meant.
What happens when the scope is wrong
| A key no table uses | ignored; the log warns scope … matched no table |
| A key that fits some tables and not others | those tables are read, the rest are skipped |
| A text id where the column holds integers | that path is skipped, not offered to the database. engagement=E-2026-17 is never sent to a user_id column |
| No header at all | detectors only — shapes protected, names not |
PENNAME_REQUIRE_SCOPE — off by default
PENNAME_REQUIRE_SCOPE=true
Refuse the call, 422, when the scope header matches nothing in the field map.
Without it such a call still goes to the model, protected by detectors alone —
and from the outside that looks exactly like a call that was fully protected.
Turn it on once your app is sending the header, and a regression that drops it
becomes an error you can see instead of a leak you cannot.
PENNAME_REQUIRED_SCOPE_KEYS=tenant,engagement
Stricter: name the keys this deployment is never protectable without. A request
missing one is refused with Missing engagement scope — protection cannot be guaranteed, whatever else it carried.
PENNAME_SCOPE_NAMESPACE — the outermost key by default
Aliases are namespaced, so the same person is the same pen name across calls. That namespace has to stay still: if it were "every key in the header", a request that happened to mention an engagement would give a person a different alias from the request before it, and a conversation would stop restoring halfway through. So one key names the namespace — by default the outermost one the schema knows about, which is the one every request carries.
Set it explicitly if you would rather pin it (PENNAME_SCOPE_NAMESPACE=tenant),
or to * for the old behaviour of using every key sent.
PENNAME_DB_URL and MongoDB
The URL's scheme picks the reader. Anything SQLAlchemy speaks is relational —
PostgreSQL, MySQL/MariaDB, SQLite, SQL Server, Oracle — with async schemes
rewritten to their sync driver automatically. mongodb:// (and
mongodb+srv://) selects the document reader:
PENNAME_DB_URL=mongodb://readonly:pw@localhost:27017/mydb
The URL must name the database. Collections are read as tables and top-level
fields as columns, with the shape learned by sampling documents (a field must
appear in the sample to be mappable). userId-style fields resolve to their
collection the way user_id columns do; owner_id resolves to the people
collection by convention. Everything above — the ownership axis, catalogues,
membership collections, the field map from the dashboard — works identically;
joins run as short chains of queries. Not yet read: fields nested inside
sub-documents, and arrays.
PENNAME_MAX_JOINS — 2 by default
How far the gateway will join outwards to reach a scope column. Two hops covers data -> owner -> organisation. A schema whose axis is genuinely deeper raises it; the price is per-request join depth, so it is a choice, not a default.
Polymorphic ownership — handled, guarded
owner_id next to owner_type (Rails, Laravel) means the SAME id column
points at different tables row by row — and owner_id=55 with
owner_type='Organization' is not user 55. The pair never produces an
unguarded read: each owner-capable table gets its own scope path with the
type column bound into the query ("User", "user", "users" — the data
decides the spelling), and the audit line on every load names the guard.
PENNAME_SCOPE_TIERS / PENNAME_SCOPE_IGNORE — usually not needed
The gateway reads the schema once for its shape: who the people are
(users, accounts, whatever it is called here), what they can belong to
(tenants, organisations, teams — proven by NOT NULL links and membership
tables, however many levels deep), and which referenced tables are lookup
lists that merely describe them. A lookup list — plans, roles,
currencies — is never a scope key: narrowing by one would read other
customers' rows wholesale. GET /scope shows every conclusion with its
reason, in the ownership block.
Two facts no schema can state are yours to override, comma-separated table or key names:
PENNAME_SCOPE_TIERS=project # "projects" IS a tenancy boundary here
PENNAME_SCOPE_IGNORE=regions # "regions" is a label, not a boundary
PENNAME_ALIAS_STYLE — typed by default
What an alias looks like on the wire.
PENNAME_ALIAS_STYLE=typed # PERSON_7K3Q2M9XAB, PHONE_A0RVW3C5BY (default)
PENNAME_ALIAS_STYLE=neutral # REF_7K3Q2M9XAB, REF_A0RVW3C5BY
Typed aliases tell the model what each token IS — it can put the phone number in the phone field of a tool call and address the person by role. That is also their cost: the provider learns that your prompts contain phone numbers, names, bank accounts — the categories, never the values.
neutral removes even that. Every alias reads REF_<tag>; nothing in the
payload says which was a name and which was an ID number. The trade is answer
quality on tasks where the type mattered — the model sees three identical-
looking tokens and has to work out from context which one to call.
The tag itself is identical in both styles (the kind is folded into the HMAC either way), so you can flip this setting without re-keying anything, and replies keep restoring. A prefix of some kind has to stay — restore has to find these strings again in whatever the model writes back.
Keywords — protect what no column holds
A paragraph column can carry a name that never appears as a structured value — "Met Kaplan Holdings' CFO", a project codename, a phrase that must not leave. The field map cannot reach inside prose, and a name has no shape a detector recognises. Keywords are you saying it directly: this string, wherever it appears, gets this treatment.
Set them in the dashboard (Field map → Keywords) — they reach the gateway with the next map poll, no restart — or pin them locally:
PENNAME_TERMS_FILE=/path/keywords.json
{"ORG": ["Kaplan Holdings"], "PERSON": ["Yosef Mizrahi"], "SECRET": ["Project Bluebird"]}
The kind decides the treatment: SECRET refuses any call carrying the value; NONE exempts it from the detectors (a support address that looks like an email, because it is one); anything else aliases it wherever it appears — inside paragraphs included — with the reply restored as usual. Keywords apply to every request, scope header or not, and the alias derives from the value itself, so it is stable with nothing stored. File and dashboard keywords are additive; a dashboard edit never erases a locally pinned term.
For names nobody typed anywhere — new people appearing in free text — see
PENNAME_NER: best-effort statistical detection, never a guarantee.
What SECRET does — read this before marking a column SECRET
SECRET is not “protect this harder”. It means this value must never reach a
model at all, and what happens then depends on PENNAME_SECRET_MODE:
| Mode | What happens to the call | Default |
|---|---|---|
redact |
the value is cut out of the prompt and the call goes on. The model sees [secret removed by penname] |
yes |
block |
the whole call is refused with a 422 | no |
warn |
the value is sent anyway and logged loudly | no |
So by default a SECRET column does not stop your app — it silently removes
that value from the prompt. If your product legitimately hands that value to
a model, mark the column something else. A signing link is the example that
cost a customer an afternoon: penname proposes SECRET for signing_token
(anyone holding it can sign), and an assistant whose job is to send signing
links then finds the link deleted from its prompt, with no error to explain it.
Values shorter than 8 characters can never block or be redacted — a column
named password_reset_channel holding sms is reported at startup as a weak
secret instead, because blocking the word “sms” would take the product down.
PENNAME_SCHEMA_ONLY -- off by default
Read the column names, never the rows.
PENNAME_SCHEMA_ONLY=true
The gateway still reflects your schema, so the dashboard works and you can mark
columns sensitive -- but it never runs a SELECT for data. Emails, phone
numbers and ID numbers stay protected, because a detector recognises them by
shape in the prompt itself.
Names do not. A name is ordinary text; the only way to know a given string is a customer's name is to have read it from your customer table. Turn this on and names reach the model unchanged. The gateway prints a notice saying so at every startup, so nobody inherits this setting without knowing what it costs.
Worth being clear about what this does and does not change: the gateway is a proxy, so it holds your prompt in memory either way. This setting stops it reading your database, not reading your traffic. What it never affects is the hosted dashboard, which receives schema and token counts and never content.
Everything else
Working defaults; most people never set these.
| Setting | Default | What it does |
|---|---|---|
PENNAME_MAP_POLL_SECONDS |
10 |
How often to ask the dashboard whether the field map changed. One integer per ask, so this is cheap. Floor of 5. |
PENNAME_MAP_REFRESH_SECONDS |
300 |
Full re-fetch of the map even when the version looks unchanged, so a missed edit heals itself. |
PENNAME_MAP_FILE |
none | Read the field map from a local JSON file instead of the dashboard — for pinning the map in version control, or keeping the last-known one when the dashboard is unreachable. It is not a way to run without a key. |
PENNAME_TERMS_FILE |
none | Keywords from a local JSON file ({"ORG": ["Kaplan Holdings"]} or [{"kind","value"}]), additive to the dashboard's. See Keywords below. |
PENNAME_SCOPE_HEADER |
X-Penname-Scope |
The header your app sends to say whose data a request is about. Rename it only if it collides with something. |
PENNAME_REQUIRE_SCOPE |
off | Refuse a call whose scope header matches nothing in the field map, instead of sending it with only the detectors. See below. |
PENNAME_REQUIRED_SCOPE_KEYS |
none | Comma-separated keys this deployment cannot be protected without, e.g. tenant,engagement. A request missing one is refused. |
PENNAME_SCOPE_NAMESPACE |
outermost key | Which scope key names the aliases. Set it to pin one (tenant), or to * for the pre-scope-keys behaviour of using every key in the header. |
PENNAME_CUSTOMER_ID |
customer |
Labels this gateway in its own logs. Cosmetic. |
PENNAME_NER |
off | Best-effort machine detection of names the field map missed. Needs the ner extra (large: transformers + torch). A guess, never a substitute for the map. |
PENNAME_NER_MODEL |
built-in | Which NER model to load, when PENNAME_NER is on. |
PENNAME_SECRET_MODE |
redact |
What a SECRET value in a prompt does: redact cuts it out and sends the rest (never stops your app, never leaks it), block refuses the call, warn sends it anyway and logs. |
PENNAME_ANTHROPIC_BASE_URL |
https://api.anthropic.com/v1 |
Where /v1/messages is forwarded, so one gateway serves both providers. |
PENNAME_REPORT_USAGE |
on | Send token counts (never content) to the control plane, so your Usage page can show your own spend. Set false to send nothing — it silences the usage page, it does not turn the licence check off. |
PENNAME_OFFLINE_GRACE_SECONDS |
86400 |
How long an already-authorized gateway keeps serving when the control plane is unreachable. A day, so an outage on our side never stops your product. Before the first successful authorization there is no grace. |
Checking it works
curl http://localhost:8088/healthz
"tables" is the number that matters: non-zero means it read your schema and
worked out which columns hold customer data. Zero means it found no table it
could scope to a user — the field map will fix that.
Then send one real request and read the log. protected 3 of 259 known (PERSON 1, PHONE 1, ILID 1) tells you it worked; protected 0 tells you it did
not. The second number is the dictionary size, not what was sent.
To see what the model would have received without sending anything at all, point the gateway at the bundled fake provider:
penname-echo --port 9099
# then, in the gateway's .env:
PENNAME_UPSTREAM_BASE_URL=http://host.docker.internal:9099/v1
penname-echo prints every message it receives and forwards nothing. With
--expect "Some Real Name" it becomes a pass/fail check: it reports any value
that reaches it that should not have.