Running SecretHive in production
What an installation needs before it holds anything real, in the order we closed it ourselves.
The image
One image for linux/amd64 and linux/arm64. It runs as a non-root user, works under an arbitrary user id in group
0, has no shell and no package manager, and writes only under /data.
8443 |
HTTPS: the pages and the API |
8080 |
Plain HTTP: answers /health and sends everything else to HTTPS, under one of the server's own names (Tls__HostNames, the public base URL, localhost); a request under any other name gets 400 |
/data |
The database (with SQLite), the server's own backups, the encrypted session keys, theme packs |
/run/secrets |
Where it expects the two secret files, read-only |
The two secret files
| File | Setting | What it is |
|---|---|---|
master.key |
SecretHive__MasterKeyFile |
32 random bytes as base64 (openssl rand -base64 32). Encrypts everything. Losing it loses all. The server does not start without it |
admin.token |
SecretHive__Auth__BootstrapAdminTokenFile |
The bootstrap administrator's token: at least 32 characters with no whitespace, not even a final newline (printf '%s' "$(openssl rand -hex 24)"); the server refuses to start otherwise. At first start it is the password of the first account, admin. On the API it is all-powerful: it acts as Vault Admin in every vault |
Both in a password manager before anything else, and both readable only by the container's user on the host. Use the bootstrap token to set things up and for recovery; day to day, people sign in and scripts use keys.
The token does not have to stay. Once a super admin signs in with a password of their own choosing or through a
sign-in provider, take admin.token away and restart: the server runs without it and no token opens anything
(Admin → Tokens says "not in use"). Without the file and without such an administrator the server refuses to
start, so there is never an installation nobody can get into. To get back in later, put a token file in place and
restart. While it is there, the token is held to the same pace as a key.
Keep the master key and the backups apart. A backup opens with the master key; whoever holds both holds everything. If a host backup tool snapshots the folder with the database and its backups, exclude the folder with the master key from it, and only after the key is in the password manager. We had this wrong for a day and answered it by replacing the master key.
Settings
Environment variables; __ separates levels.
| Setting | Default | |
|---|---|---|
SecretHive__Database__Provider |
Sqlite |
or SqlServer |
SecretHive__Database__ConnectionString |
Data Source=/data/secrethive.db |
The default is the image's; outside it the setting is required |
SecretHive__DataDirectory |
/data |
|
SecretHive__PublicBaseUrl |
The public https:// address when a proxy is in front; a sign-in provider sends the browser back here |
|
SecretHive__Tls__HostNames |
The names the certificate the server makes for itself is for. Set it: without it the certificate is for the container's own name, and a new one is made with every new container | |
SecretHive__Tls__CertificateFile, __KeyFile |
Your own certificate and key as PEM files, instead of a self-made one | |
SecretHive__Tls__Enabled |
true |
false only where TLS ends at something else on the same machine |
SecretHive__Proxy__Trusted |
The proxy's address, comma-separated for several. Only then is a forwarded address believed | |
SecretHive__Proxy__ClientAddressHeader |
The header that names the visitor when a CDN is in front of the proxy, such as CF-Connecting-IP. Read with care: see below |
|
SecretHive__Backup__Enabled |
true |
|
SecretHive__Backup__IntervalHours |
24 |
|
SecretHive__Backup__Keep |
14 |
|
SecretHive__Backup__Directory |
/data/backups |
|
SecretHive__Keys__RequestsPerMinute |
300 |
Per key, and likewise for the bootstrap token and for each job signing in with a token. 0 switches it off |
SecretHive__Http__Port, SecretHive__Tls__Port |
8080, 8443 |
|
SecretHive__Logging__Json |
on in a container | One JSON object per log line |
SecretHive__RetiredMasterKeyFiles |
Only during a master key rotation, below | |
OTEL_EXPORTER_OTLP_ENDPOINT |
A collector for the audit events, traces and metrics |
Behind a reverse proxy
The server speaks HTTPS itself, so the proxy re-encrypts to it. Have the proxy trust exactly the server's
certificate for that hop rather than switching verification off; the certificate the server makes for itself is
stable across restarts, upgrades and a master key rotation, as long as Tls__HostNames is set. It is valid for 825
days and is made anew in its last 30, or when the names change: the proxy then needs the new one.
Do not put the proxy's own single sign-on in front of it: the API is used by hosts and jobs that carry a key or a token, not a browser session. SecretHive does its own sign-in.
Set Proxy__Trusted to the address the proxy's requests arrive from. That is what makes the audit trail and the
key and vault network limits see the visitor instead of the proxy. Check it on a vault's Allowed networks card,
which shows the address your own request comes from. (The pace of a key is counted per key, not per address.)
Proxy__ClientAddressHeader hands the decision to that header. With a CDN in front of the proxy, the last hop
in X-Forwarded-For is the CDN, and the CDN names the visitor in a header of its own. When SecretHive is told to
read it, the address in that header is what the audit trail records and what the allowed networks of a vault or a
key are checked against. Whoever can reach the proxy around the CDN, or whose own copy of the header the proxy
passes on, chooses that address. Measured: a request from the trusted proxy carrying X-Real-IP: 10.9.9.9 read a
vault limited to 10.9.9.9. Set the header only when the proxy overwrites it on every request and nothing reaches the
proxy but the CDN; otherwise leave it off and limit networks on the proxy's own addresses.
Database
SQLite in /data is what it is designed around. SQL Server works the same; the storage and restore tests run against both, every build starts the image against both, and a
backup made on one restores into the other.
Backup and restore
The server backs itself up: every 24 hours it writes one file with every row, verifies it by reading it back, and
keeps the last 14. A backup can also be made on Admin → Backups or with POST /api/v1/backups.
- A backup holds what the database holds: values stay encrypted as they are, and the master key is not in it.
- It is sealed with a key derived from the master key, so a file that was changed, or made for another installation, is refused.
- It is independent of the database product.
Restore is the server's own command, into an empty database only:
docker run --rm -v ./secrets:/run/secrets:ro -v ./data:/data -v ./backup:/backup:ro <image> restore /backup/<file>.shbak
It checks the seal first, writes everything in one transaction, and exits 2 with nothing written when it refuses. Then start the server normally. This runs with the shipped image on every build, including a restore from SQLite into SQL Server.
Copy the backups off the host with whatever backs up the host. What is lost with the host is then at most what changed since the last backup, and the next two sections are about noticing and narrowing that.
Health, and an alert on the backup's age
GET /health on port 8080 needs no credential and holds no secret:
{"database":true,"masterKey":"09e8d4394f664cdc","backup":{"lastVerifiedUtc":"2026-10-10T20:25:44Z","ageHours":0.4},"securityEvents24h":5}
Alert on backup.ageHours over a day and a bit, and on the number being absent: a server that is down, a host
that is off and a monitoring agent that died all look like "no data", and that is exactly when nobody is told
otherwise. Have the alert evaluated somewhere other than the host itself.
masterKey is the id of the key in use, not the key. securityEvents24h is worth a glance when it moves.
The audit trail off the host
Every audit row is also one JSON line on the container's log, category SecretHive.Audit, written with the row
(rows a restore brings back are history and are not logged again). Ship
that log to wherever your logs go, or point OTEL_EXPORTER_OTLP_ENDPOINT at a collector. Then the rows written since
the last backup survive the host. The server writes several ordinary lines per request; shipping only the audit
category and warnings keeps the volume small. A line holds names and never a value.
Replacing the master key
Every version of every secret has a key of its own, and only that key is encrypted under the master key. A rotation rewrites one small record per version and reads no value.
- Make the new key, and put it in the password manager before the next step.
- Start the server with the new key as
SecretHive__MasterKeyFileand the old one named inSecretHive__RetiredMasterKeyFiles. At that start everything moves across: the versions, a sign-in provider's client secret, the certificate the server made for itself, the session keys. The audit trail getskeyring.rotatedwith both key ids. - Make a backup. Backups from before the rotation open only with the old key.
- Take the old key out of the configuration and restart. Keep it for as long as you keep backups made under it.
People stay signed in and the server keeps its certificate. A server started with a key the database is not under does not start, changes no secret, and says which key it expects. There is no page for this, on purpose: an administrator's browser session should not be able to re-key the vault.
What every answer carries
X-Content-Type-Options: nosniff, X-Frame-Options: DENY and Referrer-Policy: no-referrer on every answer, and
Strict-Transport-Security for a year over HTTPS. The session cookie is HttpOnly, strictly same-site and, with TLS
on, only ever sent over HTTPS. A page that is open stops working when its account is disabled or changed.
From 0.27, a Content-Security-Policy as well: scripts, styles, images, fonts and connections from the server
itself only, no inline script, nothing may frame a page (frame-ancestors 'none'), and a form may post only to the
server and to the enabled sign-in providers. A provider added on the providers page is in the policy within 30
seconds. If your proxy sets its own headers, let this one through: a proxy that rewrites X-Frame-Options to
ALLOW-FROM leaves framing open in today's browsers, which ignore that value; frame-ancestors still holds.
Upgrading
Replace the image. The database is brought up to date at start. Copy the database file first; a backup from just before is the way back.
What the server contacts
Nothing on its own behalf: no telemetry, no update check, no licence call. See What to open in a firewall.