Skip to Content

Vaultwarden Bitwarden Self-Host: Production Deployment with Docker Compose, NGINX, and PostgreSQL

Build a hardened, production-ready password manager using Vaultwarden with an external PostgreSQL database and NGINX reverse proxy on Ubuntu.

SQLite works fine for personal Vaultwarden instances. But when you are managing credentials for a team, you need a database that handles concurrency gracefully, a reverse proxy you understand, and a deployment you can audit. This guide covers a production-grade Vaultwarden Bitwarden self-host setup using Docker Compose, PostgreSQL, and NGINX on Ubuntu.

If you are looking for a quicker start, our 15-minute quick-start guide gets you running with SQLite and minimal configuration. For Caddy-based deployments, see our Caddy production guide. This post focuses on the NGINX + PostgreSQL stack for teams that already standardize on those tools.

What You Need Before Starting

This is a production-oriented deployment. Confirm you have the following before proceeding:

  • Ubuntu 22.04 or 24.04 LTS server with at least 1 GB RAM and 10 GB disk
  • Docker Engine 24.x+ and Docker Compose v2 installed
  • A domain or subdomain with DNS A/AAAA records pointing to your server
  • A valid TLS certificate (Let's Encrypt via certbot is recommended)
  • Basic familiarity with PostgreSQL, NGINX, and Docker networking
  • UFW or another firewall configured to allow ports 22, 80, and 443

PostgreSQL adds a small resource overhead compared to SQLite, but the gains in reliability and backup tooling are worth it for any multi-user deployment.

Project Structure and Docker Compose

Create a dedicated directory for the project. Keeping everything isolated makes backups and version control easier:

sudo mkdir -p /opt/vaultwarden
sudo chown $USER:$USER /opt/vaultwarden
cd /opt/vaultwarden

Create a docker-compose.yml that defines PostgreSQL and Vaultwarden services. Use a Docker network so containers communicate internally without exposing ports to the host:

services:
  db:
    image: postgres:16-alpine
    container_name: vaultwarden-db
    restart: unless-stopped
    volumes:
      - db-data:/var/lib/postgresql/data
    environment:
      POSTGRES_USER: vaultwarden
      POSTGRES_PASSWORD: ${DB_PASSWORD}
      POSTGRES_DB: vaultwarden
    networks:
      - vaultwarden
    healthcheck:
      test: ['CMD-SHELL', 'pg_isready -U vaultwarden -d vaultwarden']
      interval: 10s
      timeout: 5s
      retries: 5

  vaultwarden:
    image: vaultwarden/server:1.35.4
    container_name: vaultwarden
    restart: unless-stopped
    depends_on:
      db:
        condition: service_healthy
    environment:
      DATABASE_URL: ${DATABASE_URL}
      WEBSOCKET_ENABLED: 'true'
    env_file:
      - .env
    volumes:
      - vw-data:/data
    networks:
      - vaultwarden
    ports:
      - '127.0.0.1:8080:80'

volumes:
  db-data:
  vw-data:

networks:
  vaultwarden:
    name: vaultwarden

We bind Vaultwarden to 127.0.0.1:8080 so it is only reachable through NGINX. The database has no exposed ports at all — only Vaultwarden can reach it over the internal Docker network.

Environment Configuration

Create an .env file with database credentials and domain settings. Generate a strong password before writing the file:

# Generate a strong password
DB_PASSWORD=$(openssl rand -hex 32)
echo "Database password: $DB_PASSWORD"

# Write the .env file
cat > .env <

Never commit this file to version control. The ADMIN_TOKEN grants access to the /admin panel where you manage users and diagnostics. Treat it like a root password.

NGINX Reverse Proxy and SSL

Vaultwarden requires HTTPS for all client connections. NGINX handles TLS termination and forwards traffic to the container. Here is a minimal site configuration:

server {
    listen 80;
    server_name vault.example.com;
    return 301 https://\$server_name\$request_uri;
}

server {
    listen 443 ssl http2;
    server_name vault.example.com;

    ssl_certificate /etc/letsencrypt/live/vault.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/vault.example.com/privkey.pem;
    ssl_trusted_certificate /etc/letsencrypt/live/vault.example.com/chain.pem;

    location / {
        proxy_pass http://127.0.0.1:8080;
        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;

        proxy_buffering off;
        proxy_request_buffering off;
        client_max_body_size 128M;
    }

    location /notifications/hub {
        proxy_pass http://127.0.0.1:8080;
        proxy_set_header Upgrade \$http_upgrade;
        proxy_set_header Connection "upgrade";
    }
}

The /notifications/hub block is essential for WebSocket-based live sync. Without it, password changes on one device will not appear on others until the next manual sync.

Obtain certificates with certbot:

sudo certbot --nginx -d vault.example.com
sudo systemctl reload nginx

Starting the Stack and First Login

Launch the containers and verify they start cleanly:

docker compose up -d
docker compose ps
docker compose logs --tail 30

Watch the logs for PostgreSQL initialization and Vaultwarden migration messages. If Vaultwarden exits with a database error, confirm the DATABASE_URL format and that the database service passed its health check.

Once the stack is healthy, open your Bitwarden client, select Self-hosted, and enter your domain:

Server URL: https://vault.example.com

Create your master account while signups are enabled, then disable them in .env and restart:

sed -i 's/SIGNUPS_ALLOWED=true/SIGNUPS_ALLOWED=false/' .env
docker compose up -d

Backups and Maintenance

PostgreSQL Backups

With PostgreSQL, you get robust backup tools out of the box. Schedule a daily dump:

#!/bin/bash
BACKUP_DIR="/opt/backups/vaultwarden"
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
mkdir -p $BACKUP_DIR

docker exec vaultwarden-db pg_dump -U vaultwarden vaultwarden \
  | gzip > $BACKUP_DIR/vaultwarden_$TIMESTAMP.sql.gz

find $BACKUP_DIR -name '*.sql.gz' -mtime +14 -delete

Updating Vaultwarden

Updates are pull-and-recreate. PostgreSQL migrations run automatically:

docker compose pull
docker compose up -d
docker compose logs --tail 20

Tips and Troubleshooting

Database connection refused

Verify the DATABASE_URL uses the service name db (not localhost) and that both services share the vaultwarden Docker network. If you changed the network name in Compose, update the connection string accordingly.

WebSocket sync fails silently

Check that NGINX forwards /notifications/hub with the Upgrade and Connection headers. Cloudflare users may need to disable WebSocket compression or enable it explicitly in network settings.

Admin panel returns 404

The admin panel requires ADMIN_TOKEN to be set. If you added it after the first start, restart the container. The panel is at /admin — no trailing slash needed.

SMTP test emails never arrive

Test credentials independently with swaks. Gmail and Microsoft 365 often require app-specific passwords rather than your account password.

Attachment uploads fail

Increase client_max_body_size in NGINX and verify the /data volume has enough free space. Vaultwarden stores attachments on disk, not in the database.

Next Steps

You now have a production-grade Vaultwarden Bitwarden self-host deployment with PostgreSQL persistence, NGINX TLS termination, and automated backups. This stack handles teams, shared collections, and audit requirements that SQLite cannot.

For related deployment patterns, explore our other guides:

Need help hardening your deployment, integrating with your identity provider, or migrating from a cloud password manager? Contact our team for enterprise Vaultwarden consulting and managed infrastructure services.

Windmill Self-Host Setup: High Availability, Multi-Workspace Governance, Custom Runtimes, and Disaster Recovery
Architect a production-grade Windmill deployment with replicated servers, isolated workspaces, custom worker runtimes, and a bulletproof backup strategy.