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:
- Vaultwarden Self-Hosting Guide: Deploy Your Own Bitwarden Password Manager in 15 Minutes
- Production Guide: Deploy Vaultwarden with Docker Compose + Caddy on Ubuntu
- Production Guide: Deploy Vaultwarden with Docker Compose + NGINX + PostgreSQL on Ubuntu
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.