OpenFactory Vaultwarden vault with Caddy TLS edge and Postgres backend

Build a Vaultwarden Password Vault on OpenFactory

A three-VM Bitwarden-compatible vault: Vaultwarden + Caddy TLS + Postgres, from one prompt

May 14, 2026

← Back to Blog

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.

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.

What you'll build

  • 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.

Why build it on OpenFactory

  • Three VMs, one prompt. Vaultwarden, a TLS edge, and a database. Same prompt scales to a replica or a second region without restructuring.
  • Signups closed by default. The env file ships with SIGNUPS_ALLOWED=false so the box isn't an internet-facing free-account bonanza.
  • HTTP redirects to HTTPS automatically. The Caddy edge handles the 301; clients only ever talk to TLS.
  • Scenario assertions ride along. The build fails closed if the vault is unreachable from the edge, if Postgres doesn't accept the connection, or if the HTTP redirect breaks.

Topology

Three Debian Trixie VMs on 10.73.0.0/24. Public traffic terminates at caddy-tls; the vault and the database are subnet-only.

The prompt

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.

Running it

  1. Open the chat builder at console.openfactory.tech and paste the prompt into a new conversation.
  2. Review the streamed build plan. You'll see the topology, per-node recipes, and the scenario assertions that will run after boot. Edit the prompt and re-run if anything is off.
  3. Click Build group. OpenFactory fans the plan out to per-node ISO builds. When every ISO reaches built, boot the group on the runner network from the same UI.
  4. Exercise the stack. The scenario assertions run automatically against the live VMs. From the host you can also hit the service ports directly to confirm end-to-end behavior.

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.

What's still your responsibility

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:

  • Real Caddy with ACME. The compatibility service shapes the redirect; swap to the actual Caddy binary and point vault.yourdomain at it for automatic Let's Encrypt issuance.
  • ADMIN_TOKEN rotation. The placeholder is exactly that. Generate a strong token, store it in your secrets manager, and never commit it.
  • Push notification keys. If you want push to wake the mobile clients, the hosted-Bitwarden push relay keys belong in the env file at deploy.
  • SMTP credentials. Invite emails, password-reset emails. Out of scope of the recipe.
  • Off-host backups of /var/lib/vaultwarden/data. The whole vault — if you lose this, you lose every saved credential.
  • Bitwarden client compatibility testing. Vaultwarden tracks Bitwarden server APIs closely but not 1:1; sanity-check mobile login + autofill before you tell the family to switch.

Where to go next

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. For verifying the kernel under the vault, read the runtime attestation post. And for fleet-scale identity rollouts, the Enterprise & GxP page is the way in.

Ready to ship this in production?

OpenFactory's free flow is for browsing. Persistent VMs, SSH access, snapshots, your own ISO, and fleet deployment live on a paid plan.