I used 1Password for years, then moved to Apple Passwords once it became a standalone app. It was already on all of my Apple devices and it meant one less subscription. But I also use Linux and want the same access to my passwords there, and I'd like to keep secure notes (passports, for example), which Apple Passwords doesn't support.

So I moved to Bitwarden, with my own server: Vaultwarden, an alternative Bitwarden server written in Rust, running in my homelab. Everyone in the family uses the normal Bitwarden apps, and features that need a paid licence on a self-hosted Bitwarden server (built-in verification codes, attachments, emergency access) are included.

This is the written companion to the video, with every command to copy and paste and the details that didn't fit on camera. The Caddyfile, the token file, the service override and the Tailscale policy are also in nightlyventures/configs.

What we're building

Two containers on Proxmox. Caddy is the HTTPS entry point with a real certificate; Vaultwarden stores the vault. Bitwarden clients connect to Caddy, which passes requests on to Vaultwarden.

At home:
Bitwarden → local DNS → Caddy LAN IP:443 → Vaultwarden LAN IP:8000

Away from home (my setup):
Bitwarden → Tailscale → Caddy Tailscale IP:443 → Vaultwarden

Away from home (public alternative):
Bitwarden → your public IP:443 → router → Caddy → Vaultwarden

Every client uses the same address. I'm using https://vault.nightlyventures.dev; replace it with your own hostname throughout. You need:

  • A Proxmox host.

  • A domain whose DNS you can manage. I use Cloudflare. DuckDNS works too, with a different plugin (see the DuckDNS section below).

  • A local DNS server your home devices actually use: your router, Pi-hole, AdGuard Home or similar.

CADDY_LAN_IP and VAULTWARDEN_LAN_IP below are placeholders for the two container addresses. Caddy runs Debian (systemd); Vaultwarden runs Alpine (OpenRC), so the service commands differ per container.

1. Deploy the two containers

Vaultwarden

Open the Proxmox VE Community Scripts, find Vaultwarden and, as with any of these scripts, read what it does before you paste it into your Proxmox shell. The script offers Debian or Alpine:

  • Debian compiles Vaultwarden from source, so it needs more RAM during installation.

  • Alpine installs a pre-built binary from Alpine's own package repository.

I picked Alpine with the Default Install. When it finishes, Vaultwarden is running with a self-signed certificate, so your browser won't trust it yet. Note its IP address.

Caddy

Run the Caddy helper script in the same way: Debian, Default Install. When it asks whether to install the xCaddy add-on, don't answer yet. xcaddy compiles a custom Caddy build, and the default container only has 512 MB of RAM. In a second tab, go to the Caddy container → Resources → Memory and set it to 1024 MiB. You can do this while the container is running. Then choose Yes.

Note Caddy's IP address too, and reserve both addresses in your router's DHCP settings. We'll use them in the configuration, so they shouldn't change later.

2. Build Caddy with the Cloudflare plugin

To get a real certificate, Caddy has to prove that you own the domain. With the DNS challenge, Caddy creates a temporary DNS record through your DNS provider's API, so you don't need to open any ports. That requires the provider's plugin; for Cloudflare that's caddy-dns/cloudflare.

You can also download a ready-made binary with your plugins from the Caddy download page, which supports pretty much every major DNS provider. I build it with just the plugin I need. In the Caddy console, as root:

# Build Caddy with Cloudflare support
cd /root
xcaddy build --with github.com/caddy-dns/cloudflare

# Install our build and keep package updates from replacing it
dpkg-divert --divert /usr/bin/caddy.default --rename /usr/bin/caddy
mv ./caddy /usr/bin/caddy.custom
update-alternatives --install /usr/bin/caddy caddy /usr/bin/caddy.default 10
update-alternatives --install /usr/bin/caddy caddy /usr/bin/caddy.custom 50

# Restart Caddy and verify the Cloudflare module
systemctl restart caddy
caddy list-modules --skip-standard

You should see dns.providers.cloudflare in the output. The dpkg-divert and update-alternatives lines follow Caddy's own instructions for custom builds on Debian: the packaged binary moves aside, your build gets priority, and a package update (including the helper script's own update) no longer replaces your build with one that lacks the plugin. To switch between the two binaries later, run update-alternatives --config caddy and restart Caddy.

3. Give Caddy a Cloudflare token

In the Cloudflare dashboard, create an API token with these permissions, limited to the zone that contains your vault hostname:

  • Zone → Zone → Read

  • Zone → DNS → Edit

The steps are similar for other DNS providers. Store the token in a separate file that only root can read. That way you can show or share your Caddyfile without exposing the token:

touch /etc/caddy/caddy.env
chown root:root /etc/caddy/caddy.env
chmod 600 /etc/caddy/caddy.env
nano /etc/caddy/caddy.env

Add one line with your token:

CLOUDFLARE_API_TOKEN=YOUR_TOKEN

Then make the file available to the Caddy service:

SYSTEMD_EDITOR=nano systemctl edit caddy

Add this above the line that says everything below will be discarded:

[Service]
EnvironmentFile=/etc/caddy/caddy.env
ExecStart=
ExecStart=/usr/bin/caddy run --config /etc/caddy/Caddyfile

The last two lines are an addition to what you see in the video, and I recommend them. The stock Caddy service starts with --environ, which prints every environment variable to the system log on each start, including your token. Overriding ExecStart without that flag keeps the token out of journalctl, and out of any log you paste into a forum.

Now replace the default example in /etc/caddy/Caddyfile with this site block, which refers to the token by name:

vault.nightlyventures.dev {
    tls {
        dns cloudflare {env.CLOUDFLARE_API_TOKEN}
    }
}

Restart Caddy so it picks up the new environment:

systemctl restart caddy

4. Connect Caddy to Vaultwarden

Firewall: only Caddy may reach Vaultwarden

Caddy will handle HTTPS, and it talks to Vaultwarden over plain HTTP on port 8000 inside your network. So first make sure that only Caddy can reach that port. In Proxmox, on the Vaultwarden container → Firewall:

  1. A rule allowing Caddy: direction IN, action ACCEPT, source CADDY_LAN_IP, protocol TCP, destination port 8000. Enable it.

  2. A rule for everyone else: direction IN, action DROP, protocol TCP, destination port 8000, no source. Enable it.

  3. Check the order: rules are evaluated top to bottom and Proxmox adds new rules at the top, so drag the ACCEPT rule above the DROP rule if needed. If DROP comes first, Caddy can't reach Vaultwarden either and you'll get a 502.

  4. Enable the firewall at all three levels: Datacenter → Firewall → Options, the container's Firewall → Options, and the Firewall checkbox on the container's network device (Hardware/Network → net0). Careful with the Datacenter level: once it's on, the Proxmox host itself only accepts the web UI (8006) and SSH (22) from its own local network. If you manage Proxmox over Tailscale, a VPN or from another VLAN, first add a Datacenter rule that accepts TCP 8006 and 22 from those ranges, or you'll lock yourself out.

Enabling the container's firewall also blocks other incoming traffic to the Vaultwarden container, such as SSH and ping, because a container's default input policy is DROP. That's fine here: we use the Proxmox console. Never forward port 8000 on your router.

Turn off Vaultwarden's own TLS

In the Vaultwarden console, open /etc/conf.d/vaultwarden:

nano /etc/conf.d/vaultwarden

Remove the export ROCKET_TLS=... line and make sure these are set:

export ROCKET_ADDRESS=0.0.0.0
export ROCKET_PORT=8000
export DOMAIN=https://vault.nightlyventures.dev

The DOMAIN line isn't in the video, where I set the same value later in the admin panel. Setting it now is better: two-step login with a hardware key (WebAuthn) is tied to this address, so it should be correct before you register a key.

rc-service vaultwarden restart
rc-service vaultwarden status

To check the firewall, run curl http://VAULTWARDEN_LAN_IP:8000/alive from the Caddy console (it should answer) and from another machine on your network (it should time out).

Add the reverse proxy

Back in the Caddy console, add one line to the site block in /etc/caddy/Caddyfile:

vault.nightlyventures.dev {
    tls {
        dns cloudflare {env.CLOUDFLARE_API_TOKEN}
    }
    reverse_proxy http://VAULTWARDEN_LAN_IP:8000
}

Validate and restart:

caddy validate --config /etc/caddy/Caddyfile --envfile /etc/caddy/caddy.env
systemctl restart caddy
systemctl is-active caddy

5. One address at home: local DNS

Caddy now has a certificate for the vault address, but your devices at home still need to find Caddy when they look up that name. Add a local DNS A record:

  • Name: vault.nightlyventures.dev (without https://)

  • Address: CADDY_LAN_IP, not Vaultwarden's

On UniFi that's Policy Engine → Create New Policy → DNS → Host (A). Pi-hole and AdGuard Home have equivalent local DNS settings.

Open https://vault.nightlyventures.dev on a device at home. It should load the vault without a certificate warning. If it doesn't resolve, check that the device actually uses your local DNS: a VPN, manually configured DNS or the browser's secure DNS setting can bypass it.

6. Create your account and turn on two-step login

Create your account in the web vault. Use a long master password and keep a safe copy. I also keep a physical copy: write it down or print it, and put it in a binder.

Before you add any passwords, turn on two-step login under Settings → Security → Two-step login. You can use a hardware key or an authenticator you already have; I like the separate Bitwarden Authenticator app. Verification codes for other websites can live in your vault, but the code for the vault itself has to live outside it, or you can't get in.

You won't need that code every time you unlock the vault, only when you log in, for example on a new device. Keep a paper copy of the recovery code too: together with your master password, it gets you back in if you lose your authenticator.

7. Move your passwords over from Apple Passwords

Connect the iPhone

Install the Bitwarden app, choose Self-hosted as the server and enter your vault address. Then log in with your account and the code from your authenticator.

Import with Credential Exchange (CXP)

On iOS 26 and later, Apple Passwords can hand credentials directly to another password manager:

  1. Open Apple Passwords → ⋯ → Export Data to Another App.

  2. Select what you want to move.

  3. Choose Bitwarden and confirm.

This uses the Credential Exchange Protocol, an industry standard from the FIDO Alliance that a growing number of password managers support. In my test, passwords, passkeys and verification codes came across, and the imported passkey and codes worked afterwards. The export is a copy: Apple Passwords keeps its entries, so keep using it as a fallback until you've checked everything.

Right now, Bitwarden supports CXP imports in its iPhone and Android apps, not yet on the Mac. If you can't use CXP, export a CSV from Passwords on the Mac and import it in Bitwarden using the "Safari and macOS (csv)" format. A CSV doesn't include passkeys, and it's unencrypted, so keep it out of synced folders and delete it once the import is done.

Mac app and browser extension

The Bitwarden desktop app and the browser extensions work the same way: Self-hosted, your vault address, then log in. In Safari, enable the extension under Settings → Extensions and give it website access. When you sign in with a passkey, pick Bitwarden's passkey in the prompt so Apple Passwords doesn't supply it.

Make Bitwarden your autofill on iPhone

Once you've checked the import, go to Settings → General → AutoFill & Passwords, enable Bitwarden and disable Passwords. In the Bitwarden app, under Settings → Account security, you can enable Face ID to unlock the vault.

8. Share with the family

Close signups, via the admin panel

Family members will join by invitation, so there's no need to keep registration open. That setting lives in the Vaultwarden admin panel, which has its own password, separate from your master password.

  1. Generate a long password and save it in your vault as "Vaultwarden admin", with https://vault.nightlyventures.dev/admin as its URL.

  2. In the Vaultwarden console, run update, choose YES (Verbose Mode), and pick option 2, Reset ADMIN_TOKEN, within ten seconds. If the menu times out, it runs option 1 (a normal update), which is harmless: just run update again. Paste the password. The helper stores a hash of it and restarts Vaultwarden. If you ever lose it, you can reset it the same way.

  3. Sign in at /admin with the password you just saved (not the hash).

  4. Under General settings, disable Allow new signups and keep Allow invitations enabled.

  5. Check that Domain URL is your vault address, without a trailing slash, and save. Vaultwarden uses it for links and for features that depend on the server's address. If you skipped the DOMAIN line in step 4, set it here.

Create an organization and invite

Bitwarden shares items through organizations: everyone keeps a personal vault, and an organization's collections hold the items you share.

  1. In the web vault, create a new organization called Family. It comes with a Default collection.

  2. Move a login you want to share into the organization and its Default collection, so new members don't start with an empty vault. Moving an item makes it owned by the organization, so only move what you actually want to share.

  3. Under Members, invite the family member's email address. Give them access to the collection, otherwise their vault stays empty, and set the permissions that suit the person.

  4. If your homelab has a mail relay, Vaultwarden emails the invitation. Without one, just give them the vault address: they register with exactly the invited email address and choose their own master password. Anyone else trying to sign up gets "registration not allowed".

  5. Back in Members, the new member shows as needing confirmation. Compare the fingerprint phrase shown there with the one in their Settings → My account. If the words match, confirm.

  6. They sync, and the shared items appear.

9. Reaching the vault from anywhere

The Bitwarden apps keep a copy of your vault, so you can unlock it and use the passwords you already synced even when the server is out of reach, as long as you're still logged in. To sync new passwords and changes away from home, though, you need a way to reach your server. There are two options. Pick one; they're alternatives, not a combination.

Option A: private, with Tailscale (my setup)

I already use Tailscale on all my devices, so I added Caddy to the same tailnet. The vault login page never touches the public internet.

1. Give Caddy the TUN device. In Proxmox, Caddy container → Resources → Add → Device Passthrough → /dev/net/tun. Shut the container down and start it again. (Background: Tailscale in LXC containers.)

2. Install Tailscale in the Caddy container and sign in:

curl -fsSL https://tailscale.com/install.sh | sh
tailscale up --accept-dns=false
tailscale ip -4

The last command shows Caddy's Tailscale IP; copy it. Then, in the Tailscale admin console, open the Caddy machine and disable key expiry. Device keys expire after 180 days by default, and when Caddy's key expires, remote sync silently stops for the whole family.

3. Add a public DNS record. At Cloudflare, add an A record for your vault hostname pointing to Caddy's Tailscale IP, with the proxy status set to DNS only. Make sure there is no AAAA record for the same name.

This is split-horizon DNS: the same address gets a different answer depending on where you ask. At home, your local DNS answers with Caddy's LAN IP. Away from home, Cloudflare answers with the Tailscale IP, which only devices on your tailnet can reach. Publishing a 100.x address doesn't expose anything: it isn't reachable from outside your tailnet, and at worst a device without Tailscale gets a connection error.

4. Give the family access. Family members need Tailscale on their phones as well as a vault account. Invite them to your tailnet as Members. If you don't want them to reach everything else on your tailnet, narrow the access policy. A minimal example for a small tailnet, where you keep full access and members can only reach Caddy on HTTPS:

{
  "grants": [
    {
      "src": ["[email protected]"],
      "dst": ["*"],
      "ip": ["*"]
    },
    {
      "src": ["autogroup:member"],
      "dst": ["CADDY_TAILSCALE_IP"],
      "ip": ["tcp:443"]
    }
  ]
}

Merge this into your existing policy instead of pasting over it, and remove the default allow-all rule: a narrow grant doesn't cancel a broader one. Test it from a family account. See Tailscale's docs on grants.

If the same Caddy also serves public websites through a port forward, restrict the vault's site block to local and Tailscale addresses, placed before reverse_proxy:

@outside not remote_ip private_ranges 100.64.0.0/10
respond @outside 403

Check it: turn off Wi-Fi on your phone and connect Tailscale. Add a note to the vault on another device, then open Bitwarden on the phone and sync. The note should appear. That change came from your server at home, over Tailscale. Simply opening an already synced password proves nothing, because that also works offline.

Option B: public HTTPS

Point the vault's DNS record to your home's public IP and forward TCP 443 (and only 443) to CADDY_LAN_IP:443. The Bitwarden apps then connect without a VPN. This doesn't work on every internet connection: behind your ISP's CGNAT, a port forward never reaches you.

The trade-off is that the Vaultwarden login page is public. If you go this way:

  • Keep signups closed and two-step login on.

  • Block the admin panel from the outside world by adding this to the site block, then validate and restart. Check that both /admin and /admin/ return 404. This also blocks the admin panel through this hostname at home, so remove the block temporarily (with the port forward closed) when you need it.

vault.nightlyventures.dev {
    tls {
        dns cloudflare {env.CLOUDFLARE_API_TOKEN}
    }
    @admin path /admin /admin/*
    respond @admin 404
    reverse_proxy http://VAULTWARDEN_LAN_IP:8000
}
  • DNS validation doesn't need port 80, so don't open it.

  • If your public IP changes, something has to update the A record. Certificate validation doesn't do that. Use your router's DDNS client, or build Caddy with caddy-dynamicdns as an extra --with module and configure its dynamic_dns global option. Don't use a DDNS updater together with the Tailscale route: it would overwrite your Tailscale IP.

10. Backups

Yes, unfortunately, because for data this important you really can't skip them. Proxmox makes this easy:

  1. Under Datacenter → Storage, make sure you have a backup target: Proxmox Backup Server, a NAS or even an external USB drive.

  2. Under Datacenter → Backup, add a job with that storage. Include Vaultwarden; I include Caddy too.

  3. Pick a schedule and save. I run mine twice a day.

A container backup includes the root disk, which is where the helper script puts Vaultwarden's data: the database with your passwords and passkeys, attachments, keys and configuration. Extra mount points are only included if Backup is enabled on them, and device passthrough is never included. The Caddy backup holds your build, configuration, certificates and, with Option A, its Tailscale identity. If you ever restore a copy for testing, keep it offline while the original is running.

Restore one once, so you know it actually works. Ideally, follow the 3-2-1 rule: three copies including the original, on two types of media, one of them off-site. I'll cover how I do that in a separate video. For file-level backups outside Proxmox, the Vaultwarden wiki lists what to include. A Proxmox snapshot backup of a running container is crash-consistent, which SQLite handles fine; if you want to be extra safe, use the "stop" backup mode, which briefly stops the container. What you shouldn't do is copy the database file by hand while Vaultwarden is running.

11. Updates

Vaultwarden

The Bitwarden apps and Vaultwarden update separately. Your phone may update the app automatically, and if the server falls behind, features can stop working properly. For example, Vaultwarden 1.37.2 was required for Bitwarden clients 2026.8.0 and newer. Security fixes matter too: 1.37.4 (5 October 2026) fixed seven security advisories, one rated high.

On Alpine, Vaultwarden updates come through Alpine's package repository, so they can arrive a little later than the release itself. For 1.37.4 the Alpine 3.24 package was built two days after the release. For most homelabs that's good enough. If you want new releases the moment they're out, or more control over which version you run, use the official vaultwarden/server Docker image instead. It's a bit more to manage, but you're no longer waiting on Alpine.

To stay current on Alpine, I schedule the helper's update to run every Sunday. It upgrades the container's Alpine packages, Vaultwarden included, and restarts Vaultwarden. In the Vaultwarden console, check the time, check that cron is running, and open the crontab:

date
rc-service crond status
# only if it isn't started:
rc-update add crond default && rc-service crond start
crontab -l
EDITOR=nano crontab -e

Keep the existing entries and add this line once:

0 4 * * 0 PATH=/usr/sbin:/usr/bin:/sbin:/bin TERM=xterm PHS_SILENT=1 /usr/bin/update > /var/log/vaultwarden-update.log 2>&1

Cron runs commands with a minimal environment, so the line sets PATH and TERM explicitly for the helper script. That's Sunday 04:00 in the container's timezone; mine uses UTC, which date shows you. Afterwards you can read the last run with tail -n 80 /var/log/vaultwarden-update.log. The log is overwritten every week and nothing notifies you when a run fails, so look at it now and then. When a security release comes out, don't wait for Sunday: run update and choose option 1.

Note that update fetches the latest version of the helper script from GitHub each time and runs it as root. If you'd rather not run a script you haven't read on a schedule, use apk -U upgrade && rc-service vaultwarden restart in the cron line instead; that's what option 1 does.

Caddy

Because of the dpkg-divert setup, package updates no longer touch your custom Caddy binary, so you update it yourself. Caddy's documentation suggests caddy upgrade for this, a command it still marks as experimental: it downloads the latest version with the same plugins from caddyserver.com's build service and replaces your binary.

caddy upgrade
caddy list-modules --skip-standard
caddy validate --config /etc/caddy/Caddyfile --envfile /etc/caddy/caddy.env
systemctl restart caddy

If you'd rather build locally again, repeat the xcaddy build from step 2, move the result to /usr/bin/caddy.custom and restart. You don't need to repeat the dpkg-divert and update-alternatives lines. A newer Caddy release may need a newer Go version than the one xcaddy installed. Either way, check that dns.providers.cloudflare is still listed and that the vault loads over HTTPS.

Using DuckDNS instead of Cloudflare

No domain? A free DuckDNS subdomain works too, with the DuckDNS plugin instead of Cloudflare's. The differences:

  • Build with xcaddy build --with github.com/caddy-dns/duckdns and check for dns.providers.duckdns.

  • Put your DuckDNS token in caddy.env as DUCKDNS_API_TOKEN=....

  • Use your DuckDNS name and dns duckdns {env.DUCKDNS_API_TOKEN} in the tls block.

  • For Option A, set your DuckDNS IP to Caddy's Tailscale IP in the DuckDNS dashboard, and don't run a DuckDNS updater script, which would overwrite it with your public IP.

I haven't run the DuckDNS variant myself; the commands follow the plugin's documentation.

Troubleshooting

  • Certificate or token error: check the token's permissions and zone, the EnvironmentFile line, and that caddy list-modules shows the Cloudflare plugin. Read the log with journalctl -u caddy --no-pager -n 80.

  • 502 Bad Gateway: Caddy can't reach Vaultwarden. Check the IP in reverse_proxy, that Vaultwarden is running on port 8000 without TLS, and the firewall rules.

  • Works away from home but not at home: the device isn't using your local DNS, or the local record points to the wrong address.

  • Doesn't sync away from home (Option A): check that Tailscale is connected on the device and that your access policy allows it to reach Caddy on TCP 443. On iOS, the Bitwarden app just keeps spinning without a clear error when Tailscale is off.

  • Shared items missing: confirm the member, check their collection access and sync again.

That's it: our own password manager for the whole family, using the normal Bitwarden apps, on hardware I control. This was one of the reasons I got into homelabbing in the first place, so it's a bit of a full-circle moment for me. The price of that control is that backups and updates are now my job, so set those up before you move your real passwords over.