# Best practices deployment of Vaultwarden | encryption and hardening

**URL:** <https://vaultwarden.discourse.group/t/best-practices-deployment-of-vaultwarden-encryption-and-hardening/5008>\
**Category:** Help\
**Created:** [May 30, 2026, 7:19pm UTC](https://vaultwarden.discourse.group/t/best-practices-deployment-of-vaultwarden-encryption-and-hardening/5008 "2026-05-30T19:19:51Z")\
**Posts on this page:** 1\
**Page:** 1

<div class="post-metadata">

**Author:** ![JetPlane](https://avatars.discourse-cdn.com/v4/letter/j/b782af/32.png) [@JetPlane](https://vaultwarden.discourse.group/u/JetPlane)\
**Post date:** [May 30, 2026, 7:19pm UTC](https://vaultwarden.discourse.group/t/best-practices-deployment-of-vaultwarden-encryption-and-hardening/5008/1 "2026-05-30T19:19:51Z")

</div>

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:

1. TLS terminates at `Traefik`. Would anyone encrypt the traffic between `Traefik` → `Vaultwarden` and `Vaultwarden` → `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.)
2. `Traefik` already offers rate limiting. Would anyone still add [`fail2ban`](https://github.com/fail2ban/fail2ban)?
3. backup-strategy: I would write a small custom container that performs a pg\_dump every day. Any better ideas?

This is my setup

```auto
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

```auto
# 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

```auto
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

```auto
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 =)
