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.

874c482b-17cb-4c23-9011-9e94b379e367

Ditch the App Password: WordPress + Microsoft 365 Email Done Right

Background

I recently set up a WordPress site for a small business that uses Microsoft 365 for email. The obvious first attempt was to use WP Mail SMTP with basic SMTP auth — set the host to smtp.office365.com, port 587, STARTTLS, drop in an app password, and call it done. That didn’t work.

The SMTP debug log told the story clearly:

535 5.7.139 Authentication unsuccessful, user is locked by your organization's security defaults policy.

Microsoft 365 tenants have Security Defaults enabled by default, which blocks basic/legacy authentication — including SMTP AUTH with app passwords — across the whole tenant. Even after disabling Security Defaults and enabling per-mailbox SMTP AUTH, the propagation delay and Microsoft’s ongoing deprecation of basic auth makes this an unreliable path. OAuth 2.0 is the right solution.


What you’ll need

  • A WordPress site
  • A Microsoft 365 account with a licensed mailbox
  • An admin account on the Microsoft 365 tenant (Global Administrator role)
  • Access to the Microsoft Entra admin centre

In my case the business had a licensed user mailbox for day-to-day email and a shared mailbox for general enquiries (e.g. [email protected]). The goal was to send WordPress emails from the shared mailbox. More on how that works below.


Step 1 — Install FluentSMTP

FluentSMTP is a free, open-source WordPress plugin that supports Microsoft 365 OAuth 2.0 at no cost — unlike WP Mail SMTP and Post SMTP, which gate the Microsoft 365 OAuth option behind a paid plan.

Install it from the WordPress plugin directory:

  1. Go to Plugins → Add New
  2. Search for FluentSMTP
  3. Install and activate
  4. Go to Settings → FluentSMTP
  5. Select Microsoft as your email provider

Before doing anything else, copy the App Callback URL shown on the settings page — you’ll need it in the next step. Leave this tab open.


Step 2 — Register an app in Microsoft Entra

FluentSMTP authenticates via an Azure app registration. You need to create one in the Microsoft Entra admin centre.

  1. Go to entra.microsoft.com and sign in with your admin account
  2. Navigate to Identity → Applications → App registrations
  3. Click + New registration
  4. Fill in the form:
    • Name: anything descriptive, e.g. WordPress Mail
    • Supported account types: select “Accounts in any organizational directory (Any Azure AD directory – Multitenant) and personal Microsoft accounts” — this is required by FluentSMTP
    • Redirect URI: set the dropdown to Web and paste the App Callback URL from FluentSMTP
  5. Click Register
  6. On the overview page, copy the Application (client) ID

⚠️ Pitfall: wrong account type

If you select single-tenant instead of multitenant, the OAuth redirect will fail with AADSTS50194: Application is not configured as a multi-tenant application and the access token box in FluentSMTP will come back empty. If this happens, go to Authentication in your app registration and change the supported account types, then try again.


Step 3 — Create a client secret

  1. In your app registration, click Certificates & secrets in the left sidebar
  2. Click + New client secret
  3. Give it a description and set expiry to 24 months
  4. Click Add
  5. Copy the Value immediately — it is only shown once and will be masked on your next visit

⚠️ Pitfall: secret expiry

The client secret will expire after 24 months and WordPress emails will silently stop sending. Set a calendar reminder for around 22 months from now to come back and renew it under Certificates & secrets.


Step 4 — Add API permissions

The app needs explicit Microsoft Graph permissions to send mail.

  1. In your app registration, click API permissions
  2. Click + Add a permission → Microsoft Graph → Delegated permissions
  3. Search for and tick each of the following:
    • Mail.Send
    • Mail.Send.Shared (needed if sending from a shared mailbox)
    • offline_access (needed for token refresh)
  4. Click Add permissions
  5. Click Grant admin consent for [your organisation] and confirm

All permissions should show a green Granted status in the configured permissions list.


Step 5 — Configure FluentSMTP and authenticate

  1. Go back to Settings → FluentSMTP in WordPress
  2. Fill in:
    • Sender Name: your business name
    • Sender Email: the address emails should come from
    • Application Client ID: the Application ID from Step 2
    • Application Client Secret: the Value from Step 3
  3. Click Authenticate with Office 365 & Get Access Token

A Microsoft login popup will appear. A few important things here:

  • Sign in with an account that has Global Administrator rights on the tenant
  • On the consent screen, tick “Consent on behalf of your organization” before clicking Accept — if you skip this, other users on the tenant won’t be covered
  • Make sure you are doing this in the same browser profile that is logged into Entra — if WordPress is open in one profile and Entra in another, it will authenticate the wrong account

After accepting, you’ll be redirected back to a page showing your access token. Copy it, paste it into the Access Token field in FluentSMTP, and click Save Connection Settings.


A note on shared mailboxes

If you want WordPress to send from a shared mailbox (e.g. [email protected]) rather than a licensed user mailbox, this is fully supported — but the OAuth authentication still happens via a licensed user account. You cannot authenticate directly as a shared mailbox since it has no password or OAuth credentials of its own.

The correct approach is:

Microsoft 365 will honour the shared mailbox as the from address as long as the licensed user has Send As permissions on the shared mailbox. You can verify this in the Microsoft 365 admin centre under Teams & groups → Shared mailboxes → [your mailbox] → Send As permissions. This is also why you need Mail.Send.Shared in your API permissions.


Step 6 — Test it

  1. Go to the Email Test tab in FluentSMTP
  2. Set From to your sender address
  3. Set Send To to an address you can check
  4. Click Send Test Email

If it goes through, you’re done. FluentSMTP hooks into WordPress’s core wp_mail() function, which means every plugin that sends email through WordPress — WPForms, WooCommerce, comment notifications, password resets — will automatically use this setup without any additional configuration.


Troubleshooting summary

ErrorCauseFix
535 5.7.139 Authentication unsuccessful, user is locked by your organization's security defaults policySecurity Defaults enabled on tenantDisable Security Defaults in Entra under Identity → Overview → Properties
AADSTS50194 — empty access token boxApp registered as single-tenantChange supported account types to Multitenant in Authentication settings
Need admin approval screenUser account is not a Global AdminClick “Have an admin account?” and sign in with an admin account
Not Found on test emailMissing API permissionsAdd Mail.Send, Mail.Send.Shared, offline_access and grant admin consent
Forbidden on test emailWrong account authenticated or consent not granted for organisationRe-authenticate, ensure same browser profile, tick “Consent on behalf of your organization”

Once it’s working, WordPress email is one less thing to worry about. The OAuth token refreshes automatically via the offline_access permission — the only maintenance required is renewing the client secret every 24 months.