Best practices deployment of Vaultwarden | encryption and hardening

I want to deploy Vaultwarden but I am unsure about the proper hardening for a production environment. The setup is: TraefikVaultwardenPostgreSQL database

Specifically, I have the following questions:

  1. TLS terminates at Traefik. Would anyone encrypt the traffic between TraefikVaultwarden and Vaultwardendatabase? (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?
  3. 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 =)