Vault
A self-hosted password manager at vault.fiventhree.com, running Vaultwarden on the same Postgres as the Tracker, so one nightly backup and one restore test cover both. Live since 3 August 2026.
Overview#
The fifth thing under the domain, and the first one I did not write.
Vaultwarden is an existing, well-regarded Bitwarden-compatible server. There is no code of mine in it. What is mine is the deployment: where the data lives, how it gets backed up, what the reverse proxy will and won't allow, and which doors are shut. That is the interesting part anyway, and pretending otherwise would be dishonest about a project whose whole point is trustworthiness.
What the security actually rests on#
Worth stating first, because it decides how much everything else matters.
Vault items are encrypted in the browser, with a key derived from the master password. The master password never reaches the server. Vaultwarden stores ciphertext.
So someone who steals the database gets item names, URLs and counts. That is metadata, which is not nothing, but it is not the passwords. The master password is the security boundary, not the server. Which cuts both ways: there is no reset. No recovery email, no admin override, no support desk. Lose it and the vault is a pile of undecryptable blobs.
That single fact shaped every other decision here. If the server cannot help you recover, the server's job is narrower than usual: hold the ciphertext reliably, and refuse to be the weak link.
Why it shares the Tracker's database#
Vaultwarden defaults to SQLite in a Docker volume. That would have worked, and it would have been the one thing on the box with no verified backup.
Instead it runs on the same PostgreSQL instance as the Tracker, in its own database. The Tracker already had a nightly pg_dump with thirty-day retention, a --verify mode that restores into a throwaway container and counts tables, and a scheduled job pulling the dumps to a machine that is not the VPS. Pointing the vault at that same Postgres meant all of it applied on day one.
The attachments are the exception. Those live on a volume rather than in Postgres, so the backup script tars them separately. An attachment is as lost as a password if it's gone.
The restore test matters more here than anywhere else in the ecosystem. A backup you have never restored is a hypothesis.
The doors that are shut#
Signups. Open just long enough to create one account, then closed and verified closed from a private window. Left open, anyone who finds the URL can register. They could not read my items, but they would be squatting on my server and it would stop being obvious who is meant to be there.
Invitations. Off. Even with signups closed, invitations are another way an account can come into existence. This is a one-person vault; there is nobody to invite.
The admin panel. No ADMIN_TOKEN is set, so /admin does not exist. It is the usual way self-hosted instances get taken over, and for one user the settings it manages change about twice a year. Changing them means editing compose and restarting, which is a fine price.
Password hints. Disabled explicitly rather than left to the absence of SMTP. A hint is a clue to the one secret protecting everything.
Icon fetching. Set to internal. The default fetches a favicon for each saved site, which tells whoever serves those icons exactly which services you hold credentials for.
The CSP is tighter here than anywhere else#
Every site on the domain gets a shared set of security headers. The vault gets those plus a stricter Content-Security-Policy, because it is the one origin where a single injected script defeats the whole security model. Client-side encryption is worth nothing if an attacker can run code on the page that does the encrypting.
So: default-src 'self', connect-src 'self', frame-ancestors 'none', Cache-Control: no-store, X-Robots-Tag: noindex.
script-src allows 'unsafe-eval', which looks like a hole and is not a choice I made. Bitwarden's web client needs it for its WASM crypto. Upstream's requirement, documented here so nobody later reads it as carelessness.
One real casualty: the web vault's "check this password against known breaches" feature calls api.pwnedpasswords.com, and connect-src 'self' blocks it. The failure is silent. The signup button simply does nothing, which cost me a confused twenty minutes. I chose to leave it blocked. The breach check is a convenience; the CSP is protecting the one thing on this infrastructure with no recovery path. It uses k-anonymity and would be fairly safe to allow, but "fairly safe" is a different standard from the one this origin is held to.
The failure that was worth the evening#
Everything above was correct and the site still would not load. Firefox reported SSL_ERROR_INTERNAL_ERROR_ALERT.
The container was healthy. DNS resolved to the right address. The Caddyfile on disk had a correct site block for the hostname, with a valid certificate configuration.
The Caddyfile is bind-mounted into the Caddy container. docker compose up -d recreates a container when its spec changes (image, ports, environment), and not when the contents of a mounted file change. So git pull updated the Caddyfile while the running Caddy carried on serving the config it had read at startup, which had no block for the new hostname. No block meant Caddy never requested a certificate for it, so when a browser opened a TLS connection with that name it had nothing to present and aborted the handshake.
Two things made this hard to see. The error is at the TLS layer, so it names a transport problem for what is a configuration problem. And every individual check passed. The file was right, the container was up and DNS was correct, because the thing that was wrong was not a state anywhere, it was the gap between the file and the process that had read it.
deploy.sh now runs caddy reload after every deploy. It re-reads the config with no dropped connections, it is idempotent, and an invalid Caddyfile fails the deploy while the previous config keeps serving. Cheap enough to run unconditionally rather than trying to detect whether the file changed, since the detection is the part that would rot.
What I would change#
The vault is defined in the Tracker's compose file, because that is where Caddy lives and Caddy has to reach it on the same network. That makes lifestyle-tracker/docker-compose.prod.yml "everything on the VPS" rather than "the Tracker", which is a wart. At product six, Caddy moves into its own project and each product gets an external network.
Not fixed yet, on purpose. It is a refactor with no user-visible benefit and a real chance of taking the whole domain offline mid-change. It is written down instead, which is the honest way to carry a known compromise.
Roadmap
- Running on the existing Postgres in its own databaseShipped
- Covered by the nightly dump and restore testShipped
- Signups and admin panel both closedShipped
- Tighter CSP than any other origin on the domainShipped
- Move Caddy into its own project so the vault stops living in the Tracker's compose fileNext
Timeline
Self-hosted password vault
Vaultwarden at vault.fiventhree.com, on the same Postgres as the Tracker so one nightly dump and one restore test cover both. Signups and admin panel closed, and a tighter CSP than any other origin on the domain.
Read more