This guide provides a Ansible playbook to deploy Bitwarden with Docker Compose on Debian 10+, Ubuntu 20.04+, and RHEL 9+ compatible systems. Includes both Bitwarden Lite (personal) and Bitwarden Standard (enterprise) deployment options.
Automate Bitwarden deployment with:
ansible-bitwarden/
βββ ansible.cfg
βββ inventory/
β βββ hosts.ini
β βββ group_vars/
β βββ bitwarden.yml
βββ playbooks/
β βββ deploy-lite.yml
β βββ deploy-standard.yml
β βββ backup.yml
βββ roles/
β βββ docker-install/
β βββ bitwarden-lite/
β βββ bitwarden-standard/
β βββ nginx-proxy/
β βββ security-hardening/
βββ templates/
β βββ docker-compose-lite.yml.j2
β βββ docker-compose-standard.yml.j2
β βββ nginx.conf.j2
βββ files/
βββ ssl/
inventory/hosts.ini[bitwarden]
bw-server-01 ansible_host=192.168.1.100 ansible_user=ansible
[bitwarden:vars]
ansible_python_interpreter=/usr/bin/python3
inventory/group_vars/bitwarden.yml---
# Global variables for Bitwarden deployment
# Deployment type: 'lite' or 'standard'
bitwarden_edition: lite
# Domain configuration
bitwarden_domain: "bitwarden.example.com"
bitwarden_email: "admin@example.com"
# Installation credentials (register at https://bitwarden.com/host)
bitwarden_installation_id: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
bitwarden_installation_key: "xxxxxxxxxxxxxxxxxxxxxxxx"
# SMTP configuration
bitwarden_smtp_host: "smtp.example.com"
bitwarden_smtp_port: 587
bitwarden_smtp_from: "bitwarden@example.com"
bitwarden_smtp_username: "smtp-username"
bitwarden_smtp_password: "smtp-password"
bitwarden_smtp_ssl: false
# Database configuration (Lite only)
bitwarden_db_provider: sqlite # sqlite, postgresql, mysql
# For external databases:
# bitwarden_db_connection_string: "Host=db.example.com;Database=bitwarden;Username=bw;Password=secret"
# SSL/TLS configuration
bitwarden_ssl_enabled: true
bitwarden_ssl_certificate_path: "/etc/ssl/certs/bitwarden.crt"
bitwarden_ssl_certificate_key_path: "/etc/ssl/private/bitwarden.key"
# Backup configuration
bitwarden_backup_enabled: true
bitwarden_backup_dir: "/backup/bitwarden"
bitwarden_backup_retention_days: 7
# Resource limits
bitwarden_memory_limit: "1G" # Lite: 1G, Standard: 4G
bitwarden_cpu_limit: "1.0" # Lite: 1.0, Standard: 2.0
# Security settings
bitwarden_fail2ban_enabled: true
bitwarden_firewall_enabled: true
bitwarden_automatic_updates: true
playbooks/deploy-lite.yml---
- name: Deploy Bitwarden Lite
hosts: bitwarden
become: true
gather_facts: true
vars:
bitwarden_edition: lite
app_root: /opt/bitwarden-lite
app_port: 8080
pre_tasks:
- name: Validate Ansible version
ansible.builtin.assert:
that:
- ansible_version.full is version('2.14', '>=')
fail_msg: "Ansible 2.14 or higher is required"
- name: Check OS compatibility
ansible.builtin.assert:
that:
- ansible_os_family in ['Debian', 'RedHat']
- ansible_distribution_major_version | int >= 10
fail_msg: "Unsupported OS: {{ ansible_distribution }} {{ ansible_distribution_major_version }}"
roles:
- role: docker-install
tags: ['docker', 'prerequisites']
- role: security-hardening
tags: ['security', 'hardening']
- role: bitwarden-lite
tags: ['bitwarden', 'application']
- role: nginx-proxy
tags: ['nginx', 'reverse-proxy']
post_tasks:
- name: Verify Bitwarden is running
ansible.builtin.uri:
url: "http://127.0.0.1:{{ app_port }}/health"
return_content: true
register: health_check
retries: 5
delay: 10
until: health_check.status == 200
- name: Display deployment information
ansible.builtin.debug:
msg: |
Bitwarden Lite deployment completed!
URL: https://{{ bitwarden_domain }}
Version: Latest ({{ ansible_date_time.date }})
Next steps:
1. Access the web vault at https://{{ bitwarden_domain }}
2. Create your admin account
3. Configure organization settings
4. Enable 2FA for all users
roles/docker-install/tasks/main.yml:
---
- name: Install Docker on Debian/Ubuntu
ansible.builtin.apt:
name:
- docker.io
- docker-compose-plugin
- git
- curl
state: present
update_cache: true
cache_valid_time: 3600
when: ansible_os_family == "Debian"
tags: ['docker']
- name: Install Docker on RHEL family
ansible.builtin.dnf:
name:
- docker
- docker-compose-plugin
- git
- curl
state: present
when: ansible_os_family == "RedHat"
tags: ['docker']
- name: Enable and start Docker service
ansible.builtin.service:
name: docker
state: started
enabled: true
tags: ['docker']
- name: Add user to docker group
ansible.builtin.user:
name: "{{ ansible_user }}"
groups: docker
append: true
tags: ['docker']
- name: Create Docker configuration directory
ansible.builtin.file:
path: /etc/docker
state: directory
mode: '0755'
tags: ['docker']
- name: Configure Docker daemon with security options
ansible.builtin.copy:
dest: /etc/docker/daemon.json
mode: '0644'
content: |
{
"log-driver": "json-file",
"log-opts": {
"max-size": "10m",
"max-file": "3"
},
"userland-proxy": false,
"no-new-privileges": true
}
notify: Restart Docker
tags: ['docker', 'security']
handlers:
- name: Restart Docker
ansible.builtin.service:
name: docker
state: restarted
roles/bitwarden-lite/tasks/main.yml:
---
- name: Create application directory
ansible.builtin.file:
path: "{{ app_root }}"
state: directory
mode: '0755'
owner: "{{ ansible_user }}"
group: "{{ ansible_user }}"
tags: ['bitwarden']
- name: Create data directory
ansible.builtin.file:
path: "{{ app_root }}/data"
state: directory
mode: '0750'
owner: "{{ ansible_user }}"
group: "{{ ansible_user }}"
tags: ['bitwarden']
- name: Deploy Docker Compose file
ansible.builtin.template:
src: docker-compose-lite.yml.j2
dest: "{{ app_root }}/docker-compose.yml"
mode: '0640'
owner: "{{ ansible_user }}"
group: "{{ ansible_user }}"
notify: Restart Bitwarden
tags: ['bitwarden']
- name: Create environment file
ansible.builtin.template:
src: bitwarden-lite.env.j2
dest: "{{ app_root }}/.env"
mode: '0600'
owner: "{{ ansible_user }}"
group: "{{ ansible_user }}"
notify: Restart Bitwarden
tags: ['bitwarden']
- name: Start Bitwarden Lite
ansible.builtin.command: docker compose up -d
args:
chdir: "{{ app_root }}"
register: compose_up
changed_when: "'Creating' in compose_up.stdout or 'Starting' in compose_up.stdout"
tags: ['bitwarden']
- name: Wait for Bitwarden to be ready
ansible.builtin.uri:
url: "http://127.0.0.1:{{ app_port }}/health"
return_content: true
register: result
retries: 10
delay: 15
until: result.status == 200
tags: ['bitwarden']
roles/bitwarden-lite/templates/docker-compose-lite.yml.j2:
version: "3.8"
services:
bitwarden-lite:
image: ghcr.io/bitwarden/lite:latest
container_name: bitwarden-lite
restart: unless-stopped
ports:
- "127.0.0.1:{{ app_port }}:8080"
volumes:
- ./data:/etc/bitwarden
environment:
# Domain configuration
BW_DOMAIN: {{ bitwarden_domain }}
# Installation credentials
BW_INSTALLATION_ID: "{{ bitwarden_installation_id }}"
BW_INSTALLATION_KEY: "{{ bitwarden_installation_key }}"
# Database configuration
BW_DB_PROVIDER: {{ bitwarden_db_provider }}
{% if bitwarden_db_connection_string is defined %}
BW_DB_CONNECTION_STRING: "{{ bitwarden_db_connection_string }}"
{% endif %}
# SMTP configuration
{% if bitwarden_smtp_host is defined %}
BW_SMTP_HOST: {{ bitwarden_smtp_host }}
BW_SMTP_PORT: "{{ bitwarden_smtp_port }}"
BW_SMTP_SSL: "{{ bitwarden_smtp_ssl | lower }}"
BW_SMTP_FROM: {{ bitwarden_smtp_from }}
BW_SMTP_USERNAME: {{ bitwarden_smtp_username }}
BW_SMTP_PASSWORD: {{ bitwarden_smtp_password }}
{% endif %}
# User permissions
PUID: "1000"
PGID: "1000"
# Security hardening
security_opt:
- no-new-privileges:true
# Resource limits
deploy:
resources:
limits:
cpus: '{{ bitwarden_cpu_limit }}'
memory: {{ bitwarden_memory_limit }}
reservations:
cpus: '0.25'
memory: 256M
# Health check
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
# Logging
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
networks:
default:
driver: bridge
roles/security-hardening/tasks/main.yml:
---
- name: Install security packages
ansible.builtin.package:
name:
- fail2ban
- ufw
- unattended-upgrades
state: present
when: ansible_os_family == "Debian"
tags: ['security']
- name: Configure UFW firewall
ansible.builtin.ufw:
rule: allow
port: "{{ item }}"
proto: tcp
loop:
- '22' # SSH
- '80' # HTTP (for Let's Encrypt)
- '443' # HTTPS
when: bitwarden_firewall_enabled | default(true)
tags: ['security', 'firewall']
- name: Enable UFW
ansible.builtin.ufw:
state: enabled
policy: deny
when: bitwarden_firewall_enabled | default(true)
tags: ['security', 'firewall']
- name: Configure fail2ban for Bitwarden
ansible.builtin.copy:
dest: /etc/fail2ban/jail.d/bitwarden.local
mode: '0644'
content: |
[bitwarden]
enabled = true
port = http,https
filter = bitwarden
logpath = /opt/bitwarden-lite/data/logs/*.log
maxretry = 5
bantime = 3600
findtime = 600
when: bitwarden_fail2ban_enabled | default(true)
notify: Restart fail2ban
tags: ['security', 'fail2ban']
- name: Configure fail2ban filter for Bitwarden
ansible.builtin.copy:
dest: /etc/fail2ban/filter.d/bitwarden.conf
mode: '0644'
content: |
[Definition]
failregex = ^.*Failed login attempt.*IP: <HOST>.*$
^.*Invalid password.*IP: <HOST>.*$
ignoreregex =
when: bitwarden_fail2ban_enabled | default(true)
notify: Restart fail2ban
tags: ['security', 'fail2ban']
- name: Enable automatic security updates
ansible.builtin.template:
src: 50unattended-upgrades.j2
dest: /etc/apt/apt.conf.d/50unattended-upgrades
mode: '0644'
when:
- ansible_os_family == "Debian"
- bitwarden_automatic_updates | default(true)
tags: ['security', 'updates']
- name: Configure sysctl for security
ansible.posix.sysctl:
name: "{{ item.name }}"
value: "{{ item.value }}"
sysctl_set: true
state: present
reload: true
loop:
- { name: 'net.ipv4.ip_forward', value: '0' }
- { name: 'net.ipv4.conf.all.send_redirects', value: '0' }
- { name: 'net.ipv4.conf.default.accept_source_route', value: '0' }
- { name: 'net.ipv4.conf.all.accept_source_route', value: '0' }
- { name: 'net.ipv4.conf.all.log_martians', value: '1' }
tags: ['security', 'sysctl']
handlers:
- name: Restart fail2ban
ansible.builtin.service:
name: fail2ban
state: restarted
roles/nginx-proxy/tasks/main.yml:
---
- name: Install Nginx
ansible.builtin.package:
name: nginx
state: present
tags: ['nginx']
- name: Create Nginx configuration directory
ansible.builtin.file:
path: /etc/nginx/sites-available
state: directory
mode: '0755'
tags: ['nginx']
- name: Deploy Nginx configuration
ansible.builtin.template:
src: nginx.conf.j2
dest: /etc/nginx/sites-available/bitwarden
mode: '0644'
notify: Reload Nginx
tags: ['nginx']
- name: Enable Nginx site
ansible.builtin.file:
src: /etc/nginx/sites-available/bitwarden
dest: /etc/nginx/sites-enabled/bitwarden
state: link
notify: Reload Nginx
tags: ['nginx']
- name: Remove default Nginx site
ansible.builtin.file:
path: /etc/nginx/sites-enabled/default
state: absent
notify: Reload Nginx
tags: ['nginx']
- name: Enable and start Nginx
ansible.builtin.service:
name: nginx
state: started
enabled: true
tags: ['nginx']
- name: Test Nginx configuration
ansible.builtin.command: nginx -t
register: nginx_test
changed_when: false
tags: ['nginx']
roles/nginx-proxy/templates/nginx.conf.j2:
server {
listen 80;
server_name {{ bitwarden_domain }};
# ACME challenge for Let's Encrypt
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
# Redirect HTTP to HTTPS
location / {
return 301 https://$server_name$request_uri;
}
}
server {
listen 443 ssl http2;
server_name {{ bitwarden_domain }};
# SSL configuration
ssl_certificate {{ bitwarden_ssl_certificate_path }};
ssl_certificate_key {{ bitwarden_ssl_certificate_key_path }};
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
ssl_prefer_server_ciphers off;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
# Security headers
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# Rate limiting
limit_req_zone $binary_remote_addr zone=bitwarden_limit:10m rate=10r/s;
location / {
limit_req zone=bitwarden_limit burst=20 nodelay;
proxy_pass http://127.0.0.1:{{ app_port }};
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;
# WebSocket support
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
# Timeouts
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
}
# Health check endpoint
location /health {
access_log off;
return 200 "healthy\n";
add_header Content-Type text/plain;
}
}
playbooks/backup.yml---
- name: Backup Bitwarden
hosts: bitwarden
become: true
vars:
backup_dir: /backup/bitwarden
retention_days: 7
tasks:
- name: Create backup directory
ansible.builtin.file:
path: "{{ backup_dir }}"
state: directory
mode: '0750'
- name: Set backup filename
ansible.builtin.set_fact:
backup_filename: "bitwarden-backup-{{ ansible_date_time.date }}_{{ ansible_date_time.hour }}{{ ansible_date_time.minute }}.tar.gz"
- name: Backup Bitwarden Lite data
ansible.builtin.archive:
path:
- /opt/bitwarden-lite/data
dest: "{{ backup_dir }}/{{ backup_filename }}"
format: gz
mode: '0640'
when: bitwarden_edition == 'lite'
- name: Backup Bitwarden Standard data
ansible.builtin.archive:
path:
- /opt/bitwarden/bw-data
dest: "{{ backup_dir }}/{{ backup_filename }}"
format: gz
mode: '0640'
when: bitwarden_edition == 'standard'
- name: Backup MSSQL database
ansible.builtin.shell: |
docker exec bw-mssql /opt/mssql-tools/bin/sqlcmd \
-S localhost -U sa -P '{{ mssql_sa_password }}' \
-Q "BACKUP DATABASE [bitwarden] TO DISK = '/var/opt/mssql/backup/bitwarden.bak'"
register: db_backup
changed_when: db_backup.rc == 0
when: bitwarden_edition == 'standard'
- name: Copy database backup from container
ansible.builtin.command: docker cp bw-mssql:/var/opt/mssql/backup/bitwarden.bak {{ backup_dir }}/bitwarden-db-{{ ansible_date_time.date }}.bak
when: bitwarden_edition == 'standard'
- name: Remove old backups
ansible.builtin.find:
paths: "{{ backup_dir }}"
patterns: "bitwarden-backup-*.tar.gz"
age: "{{ retention_days }}d"
register: old_backups
- name: Delete old backup files
ansible.builtin.file:
path: "{{ item.path }}"
state: absent
loop: "{{ old_backups.files }}"
- name: Display backup status
ansible.builtin.debug:
msg: "Backup completed: {{ backup_dir }}/{{ backup_filename }}"
# Test playbook (dry-run)
ansible-playbook -i inventory/hosts.ini playbooks/deploy-lite.yml --check
# Run deployment
ansible-playbook -i inventory/hosts.ini playbooks/deploy-lite.yml
# Run specific tags
ansible-playbook -i inventory/hosts.ini playbooks/deploy-lite.yml --tags 'docker,security'
# Limit to specific hosts
ansible-playbook -i inventory/hosts.ini playbooks/deploy-lite.yml --limit bw-server-01
ansible-playbook -i inventory/hosts.ini playbooks/backup.yml
# Update to latest version
ansible-playbook -i inventory/hosts.ini playbooks/deploy-lite.yml --tags 'bitwarden' --extra-vars "force_update=true"
# Verify containers are running
ansible all -m shell -a "docker compose ps" -i inventory/hosts.ini
# Check health endpoint
ansible all -m uri -a "url=http://127.0.0.1:8080/health" -i inventory/hosts.ini
# View logs
ansible all -m shell -a "docker compose logs -f" -i inventory/hosts.ini
# Check firewall status
ansible all -m command -a "ufw status" -i inventory/hosts.ini
# Verify fail2ban
ansible all -m command -a "fail2ban-client status bitwarden" -i inventory/hosts.ini
# Check SSL certificate
ansible all -m command -a "openssl x509 -in /etc/ssl/certs/bitwarden.crt -noout -dates" -i inventory/hosts.ini
Docker not starting:
ansible all -m shell -a "systemctl status docker" -i inventory/hosts.ini
Bitwarden container failing:
ansible all -m shell -a "docker compose logs bitwarden-lite" -i inventory/hosts.ini
Nginx configuration errors:
ansible all -m command -a "nginx -t" -i inventory/hosts.ini
# Connect to target host
ssh ansible@bw-server-01
# Check Docker status
sudo systemctl status docker
# View container logs
docker compose logs -f
# Check resource usage
docker stats
Any questions?
Feel free to contact us. Find all contact information on our contact page.