
A three-VM Bitwarden-compatible vault: Vaultwarden + Caddy TLS + Postgres, from one prompt
March 14, 2026
Vaultwarden is the Rust reimplementation of the Bitwarden server, fully compatible with every official Bitwarden client — mobile, browser extension, desktop. It does what the hosted Bitwarden plan does, at a fraction of the resource cost, on hardware you own. It's the fourth-most-deployed app in the 2026 r/selfhosted survey and the third-most-planned-next.
The resource gap is the whole reason it exists. The official Bitwarden server stack wants 4+ GB of RAM and runs a fleet of containers behind a .NET runtime and MS SQL; Vaultwarden is a single Rust binary that idles around ~50 MB of RAM and will sit happily next to Pi-hole or Nextcloud on a Raspberry Pi (self-hosting cost write-up, 2026). It also unlocks the features Bitwarden gates behind a paid tier — Bitwarden lists $40/year for Families and $4/user/month for Teams, so a ten-person team that switches to Vaultwarden keeps roughly $480/year in its pocket while getting the same clients (Vaultwarden vs Bitwarden, 2026). What you trade for that is operational ownership: the patching, the TLS, and the backups are now yours.
This post walks through the production-shaped Vaultwarden stack on OpenFactory: three buildable VMs — Vaultwarden, a Caddy TLS edge that auto-issues ACME certs, and a Postgres backend — from one prompt, shipped as bootable ISOs. The lab builds the wiring; the rest of this post is about the two things people get wrong when they take it to production — the mandatory TLS edge and the admin token — plus what it actually takes to keep the vault recoverable.
vaultwarden (10.73.0.10:80) — the Rust app server withSIGNUPS_ALLOWED=false and an ADMIN_TOKEN placeholder ready to rotate.caddy-tls (10.73.0.20:443) — TLS edge with tls internal as bootstrap; swap to real ACME by pointing a public hostname at it.postgres (10.73.0.30:5432) — configured for the Vaultwarden database with the subnet allowed in pg_hba.conf.SIGNUPS_ALLOWED=false so the box isn't an internet-facing free-account bonanza.Three Debian Trixie VMs on 10.73.0.0/24, arranged as a single front door. caddy-tls is the only node a client ever connects to; it terminates TLS on :443, 301-redirects bare HTTP up to HTTPS, and reverse-proxies to the vault over plain HTTP on the trusted subnet. The vault and its database never accept a connection from outside 10.73.0.0/24.
caddy-tls speaks plain HTTP or SQL inside the trusted subnet and is never exposed directly.Why TLS is not optional with Vaultwarden. This is the single most common way a self-hosted vault goes wrong. Vaultwarden simply will not work from any device over plain HTTP — the official Bitwarden clients refuse to talk to a non-HTTPS server, and the web vault's cryptography depends on a secure context (Vaultwarden self-hosting guide, 2026). That is exactly why this topology puts the TLS edge in front as a first-class VM instead of an afterthought. In the lab it boots with tls internal (a self-signed cert); in production you point a real DNS name at it and Caddy auto-issues a Let's Encrypt certificate with no further config.
Paste this verbatim into the chat builder at console.openfactory.tech. Nothing above or below it — the builder expects the prompt body to start at the “Build a compact multi-node lab…” line.
Build a compact multi-node lab named `vaultwarden-password-vault`.
Output discipline: keep the plan small. Use one startup script per node, about 25 shell lines or less. Do not install the real Vaultwarden binary, Caddy with ACME, or Rust toolchains. Do not pull external `apt` repos at build time. Write deployment-time config examples and tiny Python stdlib or shell compatibility stubs only. The goal is a buildable preparation lab, not a production deployment.
## Topology
Create 3 buildable `debian-trixie` nodes, all `x86_64`, SSH enabled, DHCP/default route intact with lab aliases, firewall disabled, DNS `1.1.1.1` and `8.8.8.8`, user `ops` password `vault-ops` in `sudo`. Every recipe must set top-level `test_config` to `{ "enabled": false, "tests": [] }`.
- `vaultwarden`: role `vault-app`, 1 GB RAM, 12 GB disk, alias `10.73.0.10/24`, x `230`, y `60`
- `caddy-tls`: role `tls-edge`, 1 GB RAM, 8 GB disk, alias `10.73.0.20/24`, x `110`, y `220`
- `postgres`: role `database`, 2 GB RAM, 16 GB disk, alias `10.73.0.30/24`, x `350`, y `220`
Connections: `caddy-tls` to `vaultwarden:80`; `vaultwarden` to `postgres:5432`.
## Common Recipe Requirements
All nodes: features `headless`, `ssh`; packages `openssh-server`, `python3`, `curl`, `jq`, `iproute2`, `netcat-openbsd`, `ca-certificates`. Each startup script adds the alias with `IFACE=$(ip route show default | awk '{print $5; exit}')`, `ip link set "$IFACE" up || true`, and `ip addr add <alias> dev "$IFACE" || true`. If `os.startup_scripts[].after` is present, it must be the string `"network-online.target"`, not an array.
## Node Requirements
`vaultwarden`: features `headless`, `ssh`. Write `/etc/vaultwarden/vaultwarden.env` with `ROCKET_PORT=80`, `DATABASE_URL=postgresql://vaultwarden:vaultwarden@10.73.0.30:5432/vaultwarden`, `DOMAIN=https://vault.lab`, `SIGNUPS_ALLOWED=false`, `ADMIN_TOKEN=lab-admin-token-replace-me`. Create `/var/lib/vaultwarden/data` mode `0750 ops:ops`. Add a Python stdlib HTTP service on `0.0.0.0:80` exposing:
- `GET /alive` -> `200 <RFC3339 timestamp>` (`text/plain`)
- `GET /api/version` -> `200 "compat-1.0"`
- `GET /api/config` -> `200 {"version":"compat-1.0","server":{"name":"lab-vault","url":"https://vault.lab"}}`
- `GET /metrics` -> `vaultwarden_compat_up 1`
Register `vaultwarden-compat.service`.
`caddy-tls`: features `headless`, `ssh`. Write `/etc/caddy/Caddyfile`:
```
{
auto_https off
admin off
}
:443 {
tls internal
reverse_proxy 10.73.0.10:80
}
:80 {
redir https://{host}{uri} permanent
}
```
Add a Python stdlib HTTPS-shaped service on `0.0.0.0:443` (plain TCP socket bound, no real TLS) AND a plain HTTP service on `0.0.0.0:80` returning a `301` redirect with `Location: https://$host$uri`. Register `caddy-compat.service`. Write `/root/caddy-notes.md` documenting that the real Caddy binary will replace the stub at deployment time and auto-issue ACME certs.
`postgres`: features `headless`, `ssh`, `postgresql`; packages `postgresql`, `postgresql-client`. Configure Postgres to listen on `0.0.0.0:5432`, best-effort create role/database `vaultwarden` password `vaultwarden`, allow `10.73.0.0/24` in `pg_hba.conf`. Expose `:9187/metrics` listener with `pg_compat_up 1`.
## Scenario
Emit exactly one group scenario named `vaultwarden-password-vault-validation`. Put `custom_tests[].assertions[]` inside the scenario entry; leave `scenarios[].tests` empty. Every assertion needs `on_vm`. Use only `port_listening`, `command_output`, and `http_responds`; do not emit `vm_boots`, `network_reachable`, or `service_running`.
- `Stack ports listen`: `port_listening` for `vaultwarden:80`, `caddy-tls:80`, `caddy-tls:443`, `postgres:5432`, `postgres:9187`.
- `Vaultwarden alive`: on `vaultwarden`, `curl -fsS http://localhost/alive | grep -E '^[0-9]{4}-[0-9]{2}-[0-9]{2}T' && echo alive`.
- `Vaultwarden config`: on `vaultwarden`, `curl -fsS http://localhost/api/config | jq -e '.server.name == "lab-vault"' >/dev/null && echo config-ok`.
- `Caddy redirects HTTP to HTTPS`: `http_responds` for `http://10.73.0.20/` on `caddy-tls`, expected status `301`.
- `Caddy reaches vaultwarden`: on `caddy-tls`, `nc -z -w 5 10.73.0.10 80 && echo upstream-reachable`.
- `Vaultwarden reaches postgres`: on `vaultwarden`, `nc -z -w 5 10.73.0.30 5432 && echo db-reachable`.
Preserve warnings that real Vaultwarden Rust binary distribution, real Caddy with ACME issuance against a public DNS name, ADMIN_TOKEN rotation, SMTP credentials, push notification keys, off-host backups of `/var/lib/vaultwarden/data`, Bitwarden client compatibility testing, and `10.73.0.0/24` aliasing are deployment-time concerns.Driving OpenFactory from an AI agent instead of the browser? The same flow is exposed through the OpenFactory MCP server — submit the prompt programmatically, get the build-plan preview back, and call create_build / start_vm on the resulting recipes. Single-image builds go straight through the openfactory CLI.
The prompt produces a buildable preparation lab — the right topology, the right ports listening, deployment-time config templates dropped in the right places, and tiny compatibility services that prove the wiring works. A few things still sit outside the recipe and need operator attention before this carries real load:
vault.yourdomain at it for automatic Let's Encrypt issuance.vaultwarden hash (it defaults to Bitwarden's m=64 MiB, t=3, p=4 parameters) and paste the resulting $argon2id$... string as the ADMIN_TOKEN (Vaultwarden admin-page wiki). One sharp edge to know: do not generate the hash with the distro argon2 CLI — its default parameters differ from what Vaultwarden's PHC parser expects and you get a silent “Invalid admin token” loop even with the right password. Generate the underlying secret with openssl rand -base64 32, hash it with Vaultwarden's own command, and keep it in your secrets manager.pg_dump of the vaultwarden database and the contents of /var/lib/vaultwarden/data. That directory holds the file attachments, Sends, and — critically — the rsa_key pair Vaultwarden uses to sign tokens; restore the database without it and existing sessions break. (On a smaller SQLite deployment the equivalent is the db.sqlite3 file plus that same data folder.) If you lose this, you lose every saved credential, so it is the one backup you test a restore of.vs hosted Bitwarden or 1Password. The honest trade is convenience for control. Hosted Bitwarden is cheap and someone else does the patching, TLS, and backups; 1Password is more polished still. Vaultwarden wins when you want every premium feature unlocked, no per-seat billing, and your secrets sitting on hardware you physically control. It loses if you won't commit to running TLS correctly and testing a restore — an unmaintained vault is worse than a hosted one.
Is a single small box safe enough? The vault is encrypted client-side, so the server stores ciphertext — but it also stores the keys that sign sessions and any unencrypted metadata, so the box still deserves the basics: closed signups (this build ships SIGNUPS_ALLOWED=false), a hashed admin token, real TLS, and fail2ban on the login endpoint to blunt credential stuffing.
Password vaults are step one; identity is step two. The nginx + TLS reverse-proxy post shows the same pattern with full security headers and rate limiting if you want to swap Caddy for nginx. The Nextcloud cloud-stack and Immich photo vault posts round out the same private-data stack behind the same TLS edge. For verifying the kernel under the vault, read the runtime attestation post. For fleet-scale identity rollouts, the Enterprise & GxP page is the way in, and pricing has the tiers if you'd rather OpenFactory build and host it.
OpenFactory's free flow is for browsing. Persistent VMs, SSH access, snapshots, your own ISO, and fleet deployment live on a paid plan.