I want to deploy Vaultwarden but I am unsure about the proper hardening for a production environment. The setup is: Traefik → Vaultwarden → PostgreSQL database
Specifically, I have the following questions:
- TLS terminates at
Traefik. Would anyone encrypt the traffic betweenTraefik→VaultwardenandVaultwarden→database? (I have never heard of container-to-container TLS encryption in an application but as this is sensitive communication I am not sure if relying on docker’s network isolation is best practice.) Traefikalready offers rate limiting. Would anyone still addfail2ban?- backup-strategy: I would write a small custom container that performs a pg_dump every day. Any better ideas?
This is my setup
services:
# -------------------------------------------------
# Vaultwarden – the password‑manager backend
# -------------------------------------------------
vaultwarden-svc:
image: vaultwarden/server:1.36.0
container_name: vaultwarden
# restart: unless-stopped
volumes:
- vw-data:/data:rw
environment:
TZ: "Europe/Berlin"
# database
DATABASE_URL: "postgresql://${VW_POSTGRES_USER}:${VW_POSTGRES_PASSWORD}@vaultwarden-db-container:5432/${VW_POSTGRES_DB}" # TODO: add SSL + Ca verification
# Web-UI
DOMAIN: "https://localhost:3004/"
WEB_VAULT_ENABLED: true
ADMIN_TOKEN: "${VW_ADMIN_TOKEN:-}" # hash by agon2! https://www.ciphertools.org/blogs/how-to-choose-the-right-parameters-for-argon2
# users
SIGNUPS_ALLOWED: true # Turn off public sign‑ups; invite users manually
SIGNUPS_VERIFY: false # verify e-mail address
ORG_CREATION_USERS: "all" # "admin1@example.com,admin2@example.com"
INVITATIONS_ALLOWED: true # Allows org admins to invite users, even when signups are disabled
INVITATION_EXPIRATION_HOURS: 1 # hours after which an organization invite token, emergency access invite token email verification token and deletion request token expires
EMAIL_CHANGE_ALLOWED: true
PASSWORD_HINTS_ALLOWED: false
SHOW_PASSWORD_HINT: false
# hardening
LOGIN_RATELIMIT_MAX_BURST: 10
LOGIN_RATELIMIT_SECONDS: 300 # seconds
ADMIN_RATELIMIT_MAX_BURST: 3
ADMIN_RATELIMIT_SECONDS: 600
ADMIN_SESSION_LIFETIME: 10 # minutes
HTTP_REQUEST_BLOCK_NON_GLOBAL_IPS: true
WEBSOCKET_ENABLED: false # TODO: Enforce HTTPS because TLS is terminated at the reverse proxy
# E-Mail
EMAIL_HOST: "${VW_SMTP_HOST}"
EMAIL_PORT: "${VW_SMTP_PORT}"
EMAIL_FROM: "${VW_SMTP_FROM}"
EMAIL_AUTH_USERNAME: "${VW_SMTP_USER}"
EMAIL_AUTH_PASSWORD: "${VW_SMTP_PASS}"
EMAIL_SSL: true
# 2FA
EMAIL_TOKEN_SIZE: 6
EMAIL_EXPIRATION_TIME: 300 # seconds
EMAIL_ATTEMPTS_LIMIT: 3
EMAIL_2FA_ENFORCE_ON_VERIFIED_INVITE: false # FIXME: change
## Require new device emails. When a user logs in an email is required to be sent.
REQUIRE_DEVICE_EMAIL: false # FIXME
# container / server
ROCKET_PORT: "${VW_PORT:-8000}"
labels:
traefik.enable: "true"
# network
#traefik.docker.network: "extern"
# Attach it to the *web* entrypoint (HTTP) and *websecure* (HTTPS)
traefik.http.routers.vw.entrypoints: "web,websecure"
traefik.http.routers.vw.tls: "true"
# Define the hostname/path you want to match: expose it on the root path of the host
traefik.http.routers.vw.rule: "PathPrefix(`/password`)"
# middleware
traefik.http.routers.vw.middlewares: "vaultwarden-chain@file"
traefik.http.services.vw.loadbalancer.server.port: "${VW_PORT:-8000}"
networks:
- extern
- vault-net
healthcheck:
test: ["CMD", "curl", "-f", "http://127.0.0.0:${VW_PORT:-8000}/alive"]
interval: 10s
timeout: 5s
retries: 3
start_period: 5s
depends_on:
vaultwarden-db:
condition: service_healthy
deploy:
resources:
limits:
cpus: 0.5 # Max 0.5 CPU cores
memory: 256M # Max 256 MiB RAM
reservations:
cpus: 0.2
memory: 128M
# -----------------------------------------------------------------
# PostgreSQL – dedicated, minimal‑exposure database for Vaultwarden
# -----------------------------------------------------------------
vaultwarden-db:
image: postgres:17.6-alpine
container_name: vaultwarden-db-container
restart: unless-stopped
volumes:
- vw-pg-data:/var/lib/postgresql/data:rw
environment:
POSTGRES_DB: ${VW_POSTGRES_DB}
POSTGRES_USER: ${VW_POSTGRES_USER}
POSTGRES_PASSWORD: ${VW_POSTGRES_PASSWORD}
# hardening (PostgreSQL specific)
command: >
-c max_connections=100
-c log_min_messages=warning
-c log_error_verbosity=terse
-c password_encryption=scram-sha-256
networks:
- vault-net
healthcheck:
test: ["CMD-SHELL", "pg_isready", "-U ${VW_POSTGRES_USER}", "-d ${VW_POSTGRES_DB}"]
interval: 10s
timeout: 5s
retries: 5
start_period: 5s
deploy:
resources:
limits:
cpus: 0.45
memory: 256M
reservations:
cpus: 0.05
memory: 56M
# ---------------------------------------------------------------
# traefik – reverse proxy exposing
# ---------------------------------------------------------------
traefik-svc:
image: traefik:v3.6.8
container_name: traefik
restart: unless-stopped
ports:
- "3003:3003" # HTTP entrypoint
- "3004:3004" # HTTPS entrypoint
volumes:
- "/var/run/docker.sock:/var/run/docker.sock:ro"
- "./certs/traefik:/certs:ro"
- "./traefik/traefik.yml:/traefik.yml:ro"
- "./traefik/config.yml:/config/config.yml:ro"
networks:
- extern
healthcheck:
test: ["CMD-SHELL", "/usr/local/bin/traefik healthcheck --ping"]
interval: 15s
timeout: 5s
retries: 3
start_period: 5s
# ---------------------------------------------------------------
# volumes, networks secrets
# ---------------------------------------------------------------
networks:
vault-net: # only used for inter‑service communication
driver: bridge
internal: true
extern: # exposed to the host (for HTTP/HTTPS)
volumes:
vw-data:
vw-pg-data:
With an file for the environment varibales: .env
# PostgreSQL credentials
VW_POSTGRES_DB=vaultwarden
VW_POSTGRES_USER=vaultwardenuser
VW_POSTGRES_PASSWORD=sojks
VW_ADMIN_TOKEN='$argon2i$v=19$m=32,t=2,p=4$aDAyMzJFbEJ5ZnV5QXdlbA$BVSLWulv8Lwbr23ehtIXsA'
VW_DOMAIN=https://127.0.0.0:3004/password/
VW_PORT=8000
# SMTP settings for password‑reset / invitation emails
VW_SMTP_HOST=<your SMTP host>
VW_SMTP_PORT=<your SMTP port>
VW_SMTP_FROM=<your address>
VW_SMTP_USER=<your user name>
VW_SMTP_PASS=<your password>
And the following traefik static config: ./traefik/traefik.yml
log:
level: INFO
format: common
global:
checkNewVersion: false
sendAnonymousUsage: false
entryPoints:
web:
address: ":3003"
http:
redirections: # HTTP → HTTPS redirect
entryPoint:
to: websecure
scheme: https
websecure:
address: ":3004"
http:
tls: {}
providers:
docker:
exposedByDefault: false
watch: true
file:
directory: /config
api:
insecure: false
dashboard: false
basePath: /traefik
ping: {} # Healthcheck
tls:
certificates:
- certFile: /certs/fullchain.pem
keyFile: /certs/privkey.pem
stores:
default:
defaultCertificate:
certFile: /certs/fullchain.pem
keyFile: /certs/privkey.pem
and the dynamic config (includes the rate limits): ./traefik/config.yml
http:
middlewares:
# ========================================
# PER-CLIENT RATE LIMIT
# ========================================
per-client-limit-strict:
rateLimit:
average: 100
burst: 50
period: 30s
# Group by client IP address
sourceCriterion:
ipStrategy:
# depth: 0 for direct connections
# depth: 1 if behind a single proxy/load balancer
# depth: 2 if behind multiple proxies
depth: 0
# For IPv6, group by /64 subnet to prevent rotation attacks
ipv6Subnet: 64
# If behind proxies, list their IPs to exclude
# excludedIPs:
# - 10.0.0.1
# - 10.0.0.2
vaultwarden-strip:
stripPrefix:
prefixes:
- "/password"
vaultwarden-chain:
chain:
middlewares:
- vaultwarden-strip
- per-client-limit-strict
Comments (and examples) on how to improve hardening are very welcome =)