Gemini_Generated_Image_rhtenyrhtenyrhte

Self-Hosted Password Management with Vaultwarden: From Compose File to Mobile App

I’ve been meaning to self-host my password manager for a while, and this week I finally moved it onto my homelab properly. This post walks through how I deployed Vaultwarden (the lightweight, Bitwarden-compatible server written in Rust) in Docker, put it behind my reverse proxy, got email and mobile push notifications working, and sorted out backups. I’ve also included the handful of gotchas that tripped me up along the way, so hopefully they don’t trip you up too.

My setup

For context, here’s what my homelab looks like for this project:

  • Proxmox hosting several Docker LXCs, with stacks managed through Dockge and living under /opt/stacks/ with bind mounts
  • proteus – the LXC that runs my auth and monitoring services (and now Vaultwarden)
  • sabre – the LXC running NPMPlus (with CrowdSec) as my reverse proxy
  • Cloudflare for DNS, AdGuard Home for local DNS, and Proxmox Backup Server for backups

Throughout this post I’ll use vault.example.com as the domain, 192.168.1.121 for the Docker host and 192.168.1.120 for the reverse proxy. Swap in your own.

Hardware requirements (spoiler: basically none)

Vaultwarden is a single Rust binary backed by SQLite. For a personal or family instance it sits at around 10–50 MB of RAM and is effectively idle on CPU. The heavy key-derivation work for your master password happens on your devices, not the server. The database for a few users with thousands of entries is only a few MB. It runs happily on a Raspberry Pi, so I didn’t bother bumping my LXC’s resources at all. Compare that with the official Bitwarden server, which wants 2 GB+ of RAM.

Step 1: Generate a hashed admin token

Vaultwarden has an admin panel at /admin. Rather than a plaintext token, give it an Argon2 hash:

docker run --rm -it vaultwarden/server /vaultwarden hash --preset owasp

Enter a strong password (this is what you’ll type at /admin), and it spits out a string beginning with $argon2id$.... Keep the password somewhere safe outside the vault you’re about to build.

Step 2: The Docker Compose stack

In Dockge I created a new stack called vaultwarden with this compose file:

services:
  vaultwarden:
    image: vaultwarden/server:latest
    container_name: vaultwarden
    restart: unless-stopped
    ports:
      - "8222:80"
    volumes:
      - ./data:/data
    environment:
      DOMAIN: "https://vault.example.com"
      SIGNUPS_ALLOWED: "true"          # flip to false after creating your account
      INVITATIONS_ALLOWED: "true"
      SHOW_PASSWORD_HINT: "false"
      ADMIN_TOKEN: "${ADMIN_TOKEN}"
      TZ: "Africa/Johannesburg"
      LOG_LEVEL: "warn"
      # SMTP
      SMTP_HOST: "mail.example.com"
      SMTP_PORT: "465"
      SMTP_SECURITY: "force_tls"
      SMTP_FROM: "[email protected]"
      SMTP_FROM_NAME: "My Vault"
      SMTP_USERNAME: "${SMTP_USERNAME}"
      SMTP_PASSWORD: "${SMTP_PASSWORD}"
      # Mobile push notifications
      PUSH_ENABLED: "true"
      PUSH_INSTALLATION_ID: "${PUSH_ID}"
      PUSH_INSTALLATION_KEY: "${PUSH_KEY}"

All the secrets live in the stack’s .env file (Dockge has an editor for it right under the compose file):

ADMIN_TOKEN='$argon2id$v=19$m=65540,t=3,p=4$...your-hash...'
[email protected]
SMTP_PASSWORD='your-mailbox-password'
PUSH_ID=your-installation-id
PUSH_KEY=your-installation-key

A couple of notes on that:

  • Single quotes are your friend. Inside single quotes in the .env, Compose treats everything literally: $, #, !, spaces, the lot. That matters for both the Argon2 hash and any password with “funny” characters. The only character that breaks it is a single quote inside the value itself.
  • If you paste the hash straight into the compose file instead, you have to double every $ to $$, or Compose will mangle it and your admin login will silently fail. Keeping it in .env avoids that headache entirely.
  • No port 3012 needed. Older guides tell you to expose a separate WebSocket port. Modern Vaultwarden serves live sync on the main port, so ignore that advice.

Deploy the stack and confirm http://192.168.1.121:8222 loads on your LAN before moving on.

Step 3: SMTP (cPanel-style hosting)

Email is needed for user invites, email verification and email-based 2FA. My mail is on a typical cPanel host, which offers SMTP on port 465. The key detail is that 465 uses implicit TLS, so the setting is SMTP_SECURITY: "force_tls", not starttls. If your ISP blocks outbound 465, try port 587 with starttls instead.

Also make sure SMTP_FROM matches the mailbox you’re authenticating as. cPanel mail servers generally reject or flag mail sent “from” a different address.

Step 4: Mobile push notifications

Without push, the mobile apps only pick up changes when they sync. To get instant updates:

  1. Go to bitwarden.com/host, enter an admin email and choose the United States data region.
  2. Copy the Installation ID and Installation Key into your .env as PUSH_ID and PUSH_KEY.
  3. Redeploy, then log out and back in on your phone so it registers for push.

If you pick the EU region instead, you also need to set PUSH_RELAY_URI and PUSH_IDENTITY_URI to the bitwarden.eu endpoints. With the US region the defaults just work.

Step 5: DNS and the reverse proxy

In Cloudflare I added a proxied DNS record for vault.example.com. Then in NPMPlus I created a proxy host:

  • Forward to: http → 192.168.1.121 : 8222
  • Websockets Support: on (needed for live sync)
  • Block Common Exploits: on
  • SSL: a proper certificate, with Force SSL, HTTP/2 and HSTS enabled

Locking the admin panel to the LAN

There’s no reason for /admin to be reachable from the internet. In the proxy host’s Advanced tab I added:

location /admin {
    allow 192.168.1.0/24;
    allow 192.168.0.0/24;
    deny all;
    proxy_pass http://192.168.1.121:8222;
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
}

Two things caught me here:

  • Allow every subnet you’ll admin from. My servers and my PC live on different subnets, so allowing only the server subnet would have locked me out with a 403. If your router NATs between subnets, check the NPMPlus access log to see which source IP actually arrives.
  • Your LAN traffic has to hit the proxy directly. If your PC resolves the domain via public DNS, the request goes out through Cloudflare and comes back from a public IP, which gets denied. I added a DNS rewrite in AdGuard Home pointing vault.example.com at the proxy’s LAN IP. That fixes it and makes local access snappier too.

Step 6: Create your account, then close the doors

  1. Browse to https://vault.example.com and create your account. The master password cannot be recovered, so choose it carefully.
  2. Set SIGNUPS_ALLOWED: "false" and redeploy.
  3. Log in at /admin and use SMTP → Send test email to confirm mail works.
  4. Turn on two-step login for your vault account (Settings → Security). Store that TOTP somewhere other than the vault itself, and keep the recovery code offline.

Gotcha: anything you save in the admin panel is written to data/config.json, and it overrides your compose environment variables. If you change something in compose and it “doesn’t take”, that file is why.

Step 7: Managing users

With signups closed, everything happens in the admin panel’s Users tab:

  • Invite users by email. They set their own master password from the link.
  • Deauthorize sessions to log someone out everywhere (handy for a lost phone).
  • Disable/Enable, Remove 2FA for someone locked out, or Delete.

What you can’t do is see anyone’s passwords or reset a master password, because everything is end-to-end encrypted. To protect against a forgotten password, have users set up Emergency Access. Put shared logins (family, Wi-Fi, etc.) in an Organization with Collections, so they don’t disappear with one person’s account.

If you’d rather let people self-register from your own domain only, SIGNUPS_DOMAINS_WHITELIST: "example.com" does exactly that.

Step 8: Connecting the apps

In the Bitwarden browser extension, desktop app or mobile app, pick Self-hosted on the login screen and enter your server URL. Then log in as normal. To migrate from another password manager, use Tools → Import data in the web vault.

Gotcha (a.k.a. the most embarrassing one): my first mobile login failed with a java.net.UnknownHostException. The cause? I’d typed .co.zs instead of .co.za in the server URL. If you see “Unable to resolve host”, read the hostname in the error very carefully before you start debugging DNS. Also make sure the URL you use everywhere matches the DOMAIN variable exactly, or you’ll get odd login and attachment issues later.

Step 9: Backups

Everything important lives in ./data: db.sqlite3 (and its -wal/-shm files), attachments/, sends/, the rsa_key* files and config.json.

One thing not to do is rsync or rclone the live SQLite files. I’ve been bitten by that before: it copies files one at a time while they’re changing, and you end up with an inconsistent database.

Because my stacks use bind mounts inside an LXC, my existing Proxmox Backup Server jobs already capture the whole thing. A snapshot-mode backup is a crash-consistent, point-in-time copy of the filesystem, and SQLite in WAL mode is designed to survive exactly that. PBS also supports file-level restore, so I can pull just the data folder back without rolling back every other service on the host. Things worth doing if you go this route:

  • Use Snapshot mode, which needs ZFS or LVM-thin storage under the LXC.
  • Test a file-level restore once and check db.sqlite3 is there.
  • Occasionally do an encrypted Export vault from the web vault and keep it somewhere separate, in case your backup server is down on the day you need it.

If you don’t have PBS, the ttionya/vaultwarden-backup container is a nice alternative. It takes a consistent SQLite backup, zips it with a password and ships it anywhere rclone can reach, including S3-compatible storage.

Updating

Updating is just hitting Update on the stack in Dockge to pull the latest image. Take a backup before major version bumps, and glance at the Vaultwarden release notes on GitHub every so often.

Wrapping up

All in, this was one of the smoother self-hosting projects I’ve done. Vaultwarden is tiny, the Bitwarden clients are excellent, and it slots neatly into an existing Docker + reverse proxy setup. The gotchas were all small (quoting in .env, the TLS mode for port 465, subnet rules on the admin lock, and one very silly typo), but each one could eat an afternoon if you didn’t know to look for it. Hopefully this saves you that afternoon.

Gemini_Generated_Image_3s11sn3s11sn3s11

Ditching Spotify: Self-Hosting My Own Music Streaming Service with Navidrome

I’ve been slowly replacing subscription services in my homelab with self-hosted alternatives, and music streaming was the next one on the list. This post walks through how I set up Navidrome, an open-source, Subsonic-API-compatible music server, on my Proxmox homelab — including the NFS permissions rabbit hole I fell into along the way, and the client apps I landed on to actually listen to the thing.

Why Navidrome?

Navidrome is a lightweight, open-source music server written in Go. It indexes a personal music library and serves it up through a clean web player, plus it’s compatible with the Subsonic API — which means there’s a huge ecosystem of third-party client apps (desktop, iOS, Android) that can connect to it. No subscription, no algorithmic playlists, no losing access to an album because a licensing deal expired. Just your own library, streamed on your own terms.

It’s also refreshingly light on resources. The server’s job is mostly scanning files for metadata, serving a web UI, and streaming bytes — it doesn’t decode or “play” audio itself, the client device does that. The only time it gets CPU-hungry is if you configure on-the-fly transcoding (e.g. converting FLAC down to a lower bitrate for a bandwidth-constrained mobile client), and even then it’s only for the duration of that one stream. A single CPU core and well under a gigabyte of RAM is plenty for a home setup.

Where to run it: container host vs. NAS

My music library lives on my NAS, so the first question was whether to run Navidrome directly on the NAS itself (to be “close to the storage”) or on one of my Docker LXC hosts. Since the server’s CPU/RAM footprint is so small, there was no real performance case for co-locating it with the storage — reading static files over the network is negligible overhead for this use case. I went with an existing Docker host instead, so it could slot into my regular container-management workflow (I use Dockge) rather than being managed separately on the NAS’s own app system.

Mounting the NFS share — the hard way, then the right way

My music share lives on a NAS device, exported over NFS. My container host is an unprivileged LXC on Proxmox, and I wanted to avoid loosening the container’s isolation just to get an NFS client working natively inside it — unprivileged containers map root to a non-root UID on the host, and mounting filesystems from inside them tends to run into AppArmor restrictions and UID-mapping headaches.

The cleaner pattern — and the one Proxmox setups generally recommend — is to mount the NFS share on the Proxmox host itself, then bind-mount that already-mounted path into the LXC via the container’s configuration. From the container’s point of view, it’s just a local directory; it never needs any NFS privileges of its own.

1. Mount NFS on the Proxmox host

bash

mkdir -p /mnt/music-nas
mount -t nfs <nas-ip>:/path/to/Music /mnt/music-nas

Make it persistent by adding it to /etc/fstab on the host:

<nas-ip>:/path/to/Music  /mnt/music-nas  nfs  defaults,_netdev  0  0

The _netdev flag matters — it tells systemd this is a network filesystem and to wait for networking before attempting the mount, avoiding race conditions on boot.

2. The permissions detour

This is where I lost an afternoon. The mount itself succeeded fine, but trying to list the directory as root on the Proxmox host threw a flat Permission denied — even though the NFSv4 ACL on the NAS side showed root with full control.

The culprit turned out to be how the NFS export was configured. My NAS (TrueNAS) supports two different ways of mapping incoming NFS users:

  • Maproot User/Group — maps only incoming root to a specific local user
  • Mapall User/Group — maps every incoming user, root included, to one fixed local user, regardless of what UID the client presents

My share had neither set, which meant it was falling back to root-squash behavior — incoming root gets mapped to an anonymous “nobody” user that isn’t in the ACL at all, hence the denial. Setting Mapall User/Group to a real user with proper read access to the dataset fixed it immediately.

Worth noting: after changing that setting, I had to fully unmount and remount on the Proxmox host for the change to take effect — an already-open NFS session doesn’t repropagate a permissions change on its own.

bash

umount /mnt/music-nas
mount -a
ls -la /mnt/music-nas   # should list files now, not "Permission denied"

(And once permissions were sorted, I discovered the directory was just… empty. I hadn’t actually copied any music into it yet. A good reminder to rule out the boring explanation before chasing the exotic one.)

3. Bind-mount into the LXC

With the host-side mount working, the last step was exposing that path inside the container. This is done via pct set, not through the Proxmox GUI’s “Create: Mount Point” dialog — that dialog is for carving volumes out of Proxmox-managed storage pools, not for arbitrary host-path bind mounts.

bash

pct set <VMID> -mp0 /mnt/music-nas,mp=/mnt/music-nas,ro=1

One gotcha: hotplugging a new mount point into an already-running container can fail unpredictably. If you hit a startup or permission error after adding the mount point, stop the container first, apply the config, then start it fresh:

bash

pct stop <VMID>
pct set <VMID> -mp0 /mnt/music-nas,mp=/mnt/music-nas,ro=1
pct start <VMID>

After that, shelling into the container and running ls -la /mnt/music-nas should show the library, read-only, exactly as mounted on the host.

The Docker Compose stack

With the mount working inside the container, the rest is a standard Dockge stack. I keep config in a separate .env file rather than inlining everything into the compose file — cleaner, and easier to version-control the compose file itself without secrets or environment-specific values baked in.

.env

env

ND_SCANSCHEDULE=1h
ND_LOGLEVEL=info
ND_SESSIONTIMEOUT=24h
ND_MUSICFOLDER=/music
ND_DATAFOLDER=/data
ND_ENABLETRANSCODINGCONFIG=true
ND_ENABLESHARING=true
ND_ENABLEFAVOURITES=true
ND_DEFAULTTHEME=Dark
ND_DEFAULTUILANGUAGE=en
ND_REVERSEPROXYWHITELIST=<reverse-proxy-ip>/32

docker-compose.yml

yaml

services:
  navidrome:
    image: deluan/navidrome:latest
    container_name: navidrome
    restart: unless-stopped
    ports:
      - "4533:4533"
    env_file:
      - .env
    volumes:
      - ./data:/data
      - /mnt/music-nas:/music:ro
    cpus: 1.5
    mem_limit: 768m

A couple of notes on the environment variables:

  • ND_REVERSEPROXYWHITELIST tells Navidrome which IP(s) it should trust X-Forwarded-For headers from. Without this, everything appears to come from your reverse proxy’s internal IP instead of the real client — useful for logs and rate limiting once you’re proxying externally. Set this to your reverse proxy’s actual LAN IP with a /32 suffix if it’s a single fixed host, not the generic Docker bridge range you’ll find in a lot of copy-pasted examples online (that range only applies if the proxy and the app share a Docker network, which mine don’t).
  • cpus/mem_limit aren’t strictly necessary given how light Navidrome is, but since this host also runs other services, I kept a modest cap in place as a safety net against something unexpected (like several simultaneous transcoding streams) rather than letting it compete unbounded for resources.

Reverse proxy

To access it from outside the house, I put it behind my existing reverse proxy (Nginx Proxy Manager + CrowdSec), the same pattern I use for other self-hosted services:

  • Forward to the container host’s IP on port 4533
  • Enable WebSockets support — Navidrome uses these for real-time scan progress in the admin UI, and it’ll appear to hang without this enabled
  • Add to the advanced Nginx config, to avoid buffering entire audio files before forwarding them:

nginx

proxy_buffering off;
client_max_body_size 0;
  • SSL via my usual certificate setup, force HTTPS

Getting the library in shape

Navidrome doesn’t fetch metadata or cover art from the internet — it only reads what’s already embedded in the files themselves, or sitting alongside them as folder art (cover.jpg/folder.jpg). If your files aren’t tagged, you’ll just see “Unknown Artist” everywhere, which isn’t much fun to browse.

For a messy, years-accumulated library, the fix is to run everything through MusicBrainz Picard before it ever touches the server:

  • Picard fingerprints the actual audio (via AcoustID) and matches it against the MusicBrainz database, correcting tags even when filenames are useless
  • It can fetch and embed cover art as part of the same pass
  • With “Move files when saving” enabled and a naming script configured, it will also reorganize files into a clean folder structure automatically:
%albumartist%/%album% (%date%)/%tracknumber% - %title%

Recommended structure once organized:

/Music
├── Artist Name/
│   ├── Album Name (Year)/
│   │   ├── 01 - Track Name.flac
│   │   ├── 02 - Track Name.flac
│   │   └── cover.jpg

I’d suggest pointing Picard at a local staging folder rather than the live NFS share directly — it does a lot of read/rename churn during matching, which is both faster and less fragile against local disk. Once a batch is cleaned up, rsync it over to the NAS:

bash

rsync -avh --progress /path/to/staging/ /path/to/nas-music-share/

rsync over a plain copy is worth it for a large one-shot library move — if anything interrupts partway through, it resumes cleanly instead of starting over.

Listening on the go — and going open source all the way

The server side was only half the job — the other half is picking a client app. Navidrome doesn’t ship its own mobile app, but because it speaks the Subsonic API, there’s a large ecosystem of third-party clients to choose from, most of which support downloading music for offline playback so you’re not burning mobile data every time you want to listen.

Since I’d already gone fully open-source for the server, I wanted to keep the client side open-source too:

  • Android: Ultrasonic — GPLv3, actively maintained, supports offline downloads/sync, background playback, and Android Auto.
  • iOS: Amperfy — open source, built with Navidrome/Subsonic servers specifically in mind, with good offline support and a genuinely polished UI (open-source iOS media clients aren’t always known for that).

For anyone who wants something closer to the Spotify look and feel while staying fully open source:

  • Desktop: Feishin — a React/Electron client with a modern, Spotify-esque interface, lyrics, podcasts, and scrobbling support.
  • iOS: Tempo — a native client with a clean, premium-feeling dark UI.

Setup for any of these is the same pattern: point the app at your server’s URL, log in with your Navidrome credentials, browse your library, and tap the download icon on whatever albums or playlists you want cached locally for offline listening.

Where it landed

The end result: my own music library, streamed from infrastructure I control, accessible from anywhere, with zero subscription fee and full offline support on mobile — built entirely from open-source components. The trade-off, honestly, is convenience — I don’t have a catalog of tens of millions of songs at my fingertips the way a commercial service offers. But for a library I already own and care about, having full control over it, with no risk of an album vanishing because a licensing deal lapsed, feels like a fair trade.

Next up: tackling the tagging backlog on the rest of my library with Picard. That part’s less “infrastructure project” and more “several patient evenings with a coffee,” but it’s the last piece standing between this setup and a genuinely great browsing experience.