Introduction

Vaultwarden is a lightweight, self-hosted alternative to Bitwarden that provides password management with minimal infrastructure overhead.

This guide demonstrates a production-ready deployment using:

  • Rocky Linux 10 (container host)
  • Podman (root-based container runtime)
  • IIS 10 (reverse proxy + TLS termination)
  • ARR + URL Rewrite
  • SMTP integration
  • Secure admin access model
  • SELinux-compliant storage design

Unlike basic tutorials, this guide includes real operational constraints, failure modes, and production hardening practices.


Architecture Overview

Components

ComponentRole
Rocky Linux 10Hosts container runtime
PodmanRuns Vaultwarden container
IIS 10Reverse proxy + TLS termination
ARR + URL RewriteHTTP forwarding engine
SMTP serverEmail delivery
SQLiteVaultwarden database

Network Design Example

SystemExample IP
IIS Reverse Proxy192.168.10.20
Vaultwarden Host192.168.10.50
Internal Network192.168.10.0/24
Public Domainvault.example.com

Traffic Flow

Internet
   ↓ HTTPS (443)
IIS Reverse Proxy (192.168.10.20)
   ↓ HTTP (8000)
Vaultwarden Container (192.168.10.50)

Rocky Linux 10 Preparation

Install Podman

sudo dnf update -y
sudo dnf install podman -y

Verify:

podman --version

Vaultwarden Data Directory (CRITICAL SECTION)

Vaultwarden stores ALL persistent data in a single directory:

/vw-data

Create directory

mkdir /vw-data
chmod 700 /vw-data

What /vw-data contains

FilePurpose
db.sqlite3Main database
rsa_key.pemEncryption keypair
attachments/File uploads
sends/Secure file sharing
tmp/Temporary data

⚠️ SELinux requirement (IMPORTANT)

On Rocky Linux 10, SELinux WILL block writes unless corrected.

Fix:

chcon -Rt container_file_t /vw-data

OR ensure Podman mount includes:

-v /vw-data:/data:Z

Why this matters

Without this:

  • RSA key generation fails
  • SQLite locks fail
  • container exits on startup

Pull Vaultwarden Image

podman pull vaultwarden/server:latest

Environment Configuration

Create:

nano /vw-data/vaultwarden.env

Production Environment File

DOMAIN=https://vault.example.com
ADMIN_TOKEN=replace_with_strong_random_value
SIGNUPS_ALLOWED=false
IP_HEADER=X-Forwarded-For
SMTP_HOST=mail.example.com
SMTP_FROM=vault@example.com
SMTP_FROM_NAME=Vaultwarden
SMTP_SECURITY=force_tls
SMTP_PORT=465
SMTP_USERNAME=vault@example.com
SMTP_PASSWORD=your_password

Configuration Behavior (IMPORTANT)

Vaultwarden config precedence

  1. Environment variables (container runtime)
  2. config.json (if present)
  3. internal defaults

⚠️ Critical rule

Changes to .env DO NOT apply to running containers.

You must recreate the container.


Starting Vaultwarden

podman run -d \
  --name vaultwarden \
  -e DOMAIN="https://vault.domain.tld" \
  -v /vw-data:/data:Z \
  --restart unless-stopped \
  -p 127.0.0.1:8000:80 \
  vaultwarden/server:latest

Container Lifecycle Reality

First run behavior

  • Generates rsa_key.pem
  • Initializes SQLite database
  • Creates folder structure

Restart behavior

  • Keeps existing data
  • DOES NOT reload env changes

IIS Reverse Proxy Configuration

Required IIS Modules

  • URL Rewrite
  • Application Request Routing (ARR)
  • WebSocket Protocol
  • IP and Domain Restrictions

Enable ARR Proxy

IIS Manager → Server → Application Request Routing Cache
→ Server Proxy Settings → Enable Proxy ✔

Web.config (Production Ready)

<?xml version="1.0" encoding="UTF-8"?>
<!--
  ============================================================================
  Vaultwarden reverse proxy : IIS + Application Request Routing
  ============================================================================

  DESIGN POSTURE: transparent proxy.

  IIS is responsible for exactly three things:
    1. Terminating TLS
    2. Gating /admin by source IP
    3. Forwarding everything to the backend, unmodified

  IIS is explicitly NOT responsible for:
    Interpreting or replacing backend responses  (broke JSON error bodies)
    Compressing responses                        (backend already gzips)
    Setting security headers the backend sets    (produces duplicates)
    Serving error pages                          (requires interception)

  SCOPE CONTRACT: this file contains only configuration sections delegated to
  site level by default in IIS. It has no dependency on server level state.
  Anything requiring applicationHost.config lives in the prerequisites script
  and must be applied first.

  XML NOTE: comments here use "=" for decoration, never runs of hyphens. The
  sequence of two hyphens is illegal anywhere inside an XML comment body and
  causes IIS to fail the whole site with HTTP 500.19.

  PREREQUISITES:
    ARR installed, proxy enabled
    WebSocket Protocol feature installed
    ARR responseBufferLimit = 0
    ARR timeout raised above the 30s default
    HTTP_X_FORWARDED_PROTO / HTTP_X_FORWARDED_HOST / HTTP_X_FORWARDED_FOR
    allowlisted as rewrite server variables

  BACKEND CONFIGURATION (Vaultwarden env / config.json):
    DOMAIN     = https://vault.yourdomain.tld     (must match the public URL exactly)
    IP_HEADER  = X-Forwarded-For

  Set IP_HEADER on the backend rather than emitting X-Real-IP here. Referencing
  a server variable that is not allowlisted causes URL Rewrite to fail every
  request with a 500. The backend setting achieves the same result with no IIS
  surface area.
  ============================================================================
-->
<configuration>
    <system.webServer>

        <!--
          ====================================================================
          REWRITE RULES
          Ordered, each terminal. Rule 2 is the only policy decision IIS makes.
          ====================================================================
        -->
        <rewrite>
            <rules>

                <!--
                  RULE 1 : Force HTTPS
                  No op if the site only binds 443. Harmless to keep.
                -->
                <rule name="ForceHttps" stopProcessing="true">
                    <match url="(.*)" />
                    <conditions>
                        <add input="{HTTPS}" pattern="^OFF$" />
                    </conditions>
                    <action type="Redirect"
                            url="https://{HTTP_HOST}/{R:1}"
                            redirectType="Permanent" />
                </rule>

                <!--
                  RULE 2 : Restrict /admin to management subnets

                  Returns a bodiless 403. No error page is served: every
                  mechanism for serving one requires httpErrors interception,
                  which is what corrupted the API error bodies.

                  IMPORTANT. {REMOTE_ADDR} is the IP of whatever connects
                  directly to IIS. If another proxy sits in front (OPNsense, a
                  load balancer, Cloudflare), this evaluates that proxy address
                  and the gate is meaningless. In that case swap the input to
                  {HTTP_X_FORWARDED_FOR} AND ensure the upstream proxy strips
                  any client supplied XFF header, or the gate is trivially
                  spoofable.

                  Subnets allowed: 172.16.28.0/24, 172.16.29.0/24,
                  172.16.30.0/24, 172.16.31.0/24, 10.20.28.0/24
                -->
                <rule name="RestrictAdmin" stopProcessing="true">
                    <match url="^admin(/.*)?$" />
                    <conditions logicalGrouping="MatchAll">
                        <add input="{REMOTE_ADDR}"
                             pattern="^(172\.16\.(2[89]|3[01])\.[0-9]{1,3}|10\.20\.28\.[0-9]{1,3})$"
                             negate="true" />
                    </conditions>
                    <action type="CustomResponse"
                            statusCode="403"
                            statusReason="Forbidden"
                            statusDescription="Forbidden" />
                </rule>

                <!--
                  RULE 3 : Proxy everything to Vaultwarden

                  Covers /notifications/hub (WebSocket) implicitly. On
                  Vaultwarden 1.30 and later the hub is on the main port, so no
                  separate rule for 3012 is needed.

                  X-Forwarded-Proto is not optional: without it Vaultwarden
                  builds http URLs behind an https frontend, and the vault
                  loads but does not populate data.

                  X-Forwarded-For is SET, not appended. Correct only when IIS
                  is the edge. If it is not, see the note on Rule 2.
                -->
                <rule name="ProxyToVaultwarden" stopProcessing="true">
                    <match url="(.*)" />
                    <serverVariables>
                        <set name="HTTP_X_FORWARDED_PROTO" value="https" />
                        <set name="HTTP_X_FORWARDED_HOST"  value="{HTTP_HOST}" />
                        <set name="HTTP_X_FORWARDED_FOR"   value="{REMOTE_ADDR}" />
                    </serverVariables>
                    <action type="Rewrite"
                            url="http://192.168.10.50:8000/{R:1}" />
                </rule>

            </rules>
        </rewrite>

        <!--
          ====================================================================
          ERROR HANDLING : do not change this element

          existingResponse="PassThrough" is the single most important line in
          this file. The IIS default is "Auto", which does NOT mean "preserve
          responses that have a body". Auto preserves a response only when the
          producing module sets the SetStatus flag, and ARR does not set it on
          proxied responses. Under Auto, IIS replaces the body of every 4xx and
          5xx from Vaultwarden with an HTML error page, so clients parsing JSON
          fail on:
              400 carrying TwoFactorProviders
              401 driving the refresh token exchange
              any API error response

          This element must be present. Omitting it does not disable error
          handling; it falls back to the "Auto" default and reintroduces the
          fault.
          ====================================================================
        -->
        <httpErrors errorMode="Custom" existingResponse="PassThrough" />

        <!--
          ====================================================================
          COMPRESSION : disabled

          Vaultwarden compresses its own responses. IIS re-compressing proxied
          content risks double encoding and has known browser side consequences
          for attachment downloads. A reverse proxy should not re-encode a body
          it did not generate.
          ====================================================================
        -->
        <urlCompression doStaticCompression="false"
                        doDynamicCompression="false" />

        <!--
          ====================================================================
          REQUEST FILTERING

          maxAllowedContentLength raised from the 28.6 MB default for
          attachments and Sends. 512 MB here; keep it at or above the backend
          own limit.

          This governs the request BODY only. Large uploads also require the
          ARR timeout raised past 30s (see the prerequisites script) or they
          will 502 mid transfer.
          ====================================================================
        -->
        <security>
            <requestFiltering removeServerHeader="true">
                <requestLimits maxAllowedContentLength="536870912" />
            </requestFiltering>
        </security>

        <!--
          ====================================================================
          RESPONSE HEADERS : minimal by design

          Only headers IIS uniquely owns are set here. Vaultwarden emits its
          own CSP, X-Content-Type-Options, X-Frame-Options and Referrer-Policy.
          IIS customHeaders APPENDS rather than replaces, so adding them here
          yields doubled values such as "SAMEORIGIN, SAMEORIGIN", which some
          browsers reject, breaking the web vault while leaving native clients
          unaffected.

          HSTS is set here because TLS terminates here and the backend has no
          way to know the connection was HTTPS.

          "preload" is deliberately absent. It is a commitment covering the
          entire apex domain and every subdomain, requires separate submission,
          and cannot be undone on any useful timescale.

          X-Frame-Options is intentionally NOT set: FIDO2 WebAuthn requires
          SAMEORIGIN rather than DENY, and the backend already handles this.
          ====================================================================
        -->
        <httpProtocol>
            <customHeaders>
                <remove name="X-Powered-By" />
                <remove name="Strict-Transport-Security" />
                <add name="Strict-Transport-Security"
                     value="max-age=31536000; includeSubDomains" />
            </customHeaders>
        </httpProtocol>

    </system.webServer>
</configuration>

WebSockets Requirement

Vaultwarden uses WebSockets for:

  • Real-time sync
  • Login session updates
  • Device notifications

Without WebSockets:

  • UI updates delay
  • Mobile sync issues

SMTP Configuration Notes

Valid values only:

ModePortSecurity
STARTTLS587starttls
SMTPS465force_tls

Invalid configuration example

SMTP_SECURITY=ssl ❌

This causes startup failure.


Admin Panel Security

Enable admin panel

ADMIN_TOKEN=strong_random_value

Access:

https://vault.example.com/admin

Security model

  • Admin is NOT user-authenticated
  • It is token-based only
  • Must never be publicly exposed

Restricting Access (Production Model)

Recommended approach

✔ Restrict entire Vaultwarden to internal network:

172.16.28.0/22

Configured via IIS:

  • IP Address and Domain Restrictions
  • Deny all unspecified clients
  • Allow internal subnet only

Backup Strategy

Backup command

tar czf vaultwarden-backup.tar.gz /vw-data

Restore procedure

systemctl stop podman-vaultwarden
tar xzf vaultwarden-backup.tar.gz -C /
systemctl start podman-vaultwarden

Common Failure Scenarios

1. Permission denied (rsa_key.pem)

Cause:

  • SELinux blocking write

Fix:

chcon -Rt container_file_t /vw-data

2. SMTP_SECURITY crash

Cause:

  • invalid value (e.g. “ssl”)

Fix:

  • use force_tls or starttls

3. ENV changes not applying

Cause:

  • container not recreated

Fix:

podman rm -f vaultwarden
podman run ...

4. Admin panel disabled

Cause:

  • missing ADMIN_TOKEN

Fix:

  • set token + recreate container

Security Recommendations

  • Disable public signups
  • Restrict admin panel to internal network
  • Enable HSTS
  • Use HTTPS only at IIS
  • Pin Vaultwarden version (avoid latest)
  • Regular backups of /vw-data

Final Thoughts

Vaultwarden combined with Podman and IIS provides a lightweight but production-capable password management system. However, its reliability depends heavily on:

  • correct SELinux configuration
  • proper container lifecycle management
  • secure reverse proxy setup
  • strict admin access control

When correctly configured, it is suitable for enterprise-grade internal deployments without requiring the complexity of the official Bitwarden stack.

Last Update: July 26, 2026

Tagged in:

, , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , , ,