Fully Self Hosted Bitwarden password manager, with Docker, Postgres and Traefik

Recently LastPass changed it’s free tier to only allow either browser of app access. This spurred me on to finish a fully self hosted setup. I have some servers sitting in my home running docker containers. This makes it easy to throw in additional containers, all behind a Traefik container that handles an SSL certificate.

You will want to populate a .env file with the used variables. This setup example will go to AWS Route 53 to validate domain ownership for SSL cert generation and will also dump the database daily to a specific location (with bonus cleanup). I would suggest you backup this dump and the Bitwarden persistent volume to somewhere remote. You will likely want to encrypt this, for obvious reasons!

This is the outcome of many hours of tweaking and troubleshooting. I hope this helps someone.

Prerequisites Link to heading

  • A DNS record that matches the below example (pass.{DOMAIN})and points to where ever you host this service.
version: "3.8"

services:
  traefik:
    image: "traefik:v2.3"
    container_name: "traefik"
    environment:
      - AWS_ACCESS_KEY_ID=${TRAEFIK_AWS_ACCESS_KEY_ID}
      - AWS_SECRET_ACCESS_KEY=${TRAEFIK_AWS_SECRET_ACCESS_KEY}
      - AWS_REGION=${AWS_REGION}
      - AWS_HOSTED_ZONE_ID=${ROUTE53_HOSTED_ZONE_ID}
    command:
      - "--log=true"
      - "--log.level=INFO" # (Default: error) DEBUG, INFO, WARN, ERROR, FATAL, PANIC
      #- "--accessLog=true"
      - "--global.sendAnonymousUsage=true"
      #- "--api.insecure=true"
      #- "--api=true"
      #- "--api.dashboard=true"
      - "--providers.docker=true"
      - "--providers.docker.endpoint=unix:///var/run/docker.sock"
      - "--providers.docker.exposedbydefault=false"
      - "--entryPoints.http.address=:80"
      - "--entryPoints.https.address=:443"
      - "--entryPoints.traefik.address=:8080"
      - "--entrypoints.https.http.tls.certResolver=dns-route53"
      - "--entrypoints.public.http.tls.certResolver=dns-route53"
      - "--entrypoints.https.http.tls.domains[0].main=*.${DOMAIN}"
      - "--certificatesresolvers.dns-route53.acme.dnsChallenge=true"
      - "--certificatesResolvers.dns-route53.acme.dnsChallenge.provider=route53"
      #- "--certificatesResolvers.dns-route53.acme.caServer=https://acme-staging-v02.api.letsencrypt.org/directory" # LetsEncrypt Staging Server
      - "--certificatesResolvers.dns-route53.acme.email=dns@${DOMAIN}"
      - "--certificatesResolvers.dns-route53.acme.storage=/letsencrypt/acme.json"
      - "--certificatesResolvers.dns-route53.acme.dnsChallenge.delayBeforeCheck=60"
      - "--certificatesResolvers.dns-route53.acme.dnsChallenge.resolvers=1.1.1.1:53,1.0.0.1:53"
    security_opt:
      - no-new-privileges:true
    ports:
      - "80:80"
      - "443:443"
      - "8080:8080"
    volumes:
      - "/var/run/docker.sock:/var/run/docker.sock:ro"
      - "/etc/localtime:/etc/localtime:ro"
      - "/root/letsencrypt:/letsencrypt"
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.traefik.rule=Host(`traefik.${DOMAIN}`)"
      - "traefik.http.services.traefik.loadbalancer.server.port=8080"
      - "traefik.http.routers.traefik.entryPoints=https"
      #- "traefik.http.routers.traefik.service=api@internal"
    restart: always

  bitwarden:
    image: "bitwardenrs/server:latest"
    container_name: "bitwarden"
    user: ${UID}:${GID}
    environment:
      DOMAIN: "https://pass.${DOMAIN}"
      DATABASE_URL: "postgresql://bitwarden:${BITWADEN_DB_PASSWORD}@bitwarden-postgres:5432/bitwarden"
      DATA_FOLDER: /data
      SIGNUPS_ALLOWED: "false"
      INVITATIONS_ALLOWED: "false"
      SHOW_PASSWORD_HINT: "false"
      ICON_BLACKLIST_NON_GLOBAL_IPS: "false"
      WEBSOCKET_ENABLED: "true" # Important for dynamic updates
      LOG_FILE: "/data/bitwarden.log"
      LOG_LEVEL: "info" # This is default, but setting it explicit
      EXTENDED_LOGGING: "true"
    volumes:
      - /tempvol/config-bitwarden:/data/ # Somewhere to persist data
      - /etc/localtime:/etc/localtime:ro
    security_opt:
      - no-new-privileges=true
    healthcheck:
      test: ["CMD", "curl", "-fL", "http://127.0.0.1:80"]
      interval: 2s
      timeout: 5s
      retries: 3
      start_period: 10s
    depends_on:
      - traefik
      - bitwarden-postgres
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.bitwarden.entrypoints=http"
      - "traefik.http.routers.bitwarden.rule=Host(`pass.${DOMAIN}`)"
      - "traefik.http.middlewares.bitwarden-https-redirect.redirectscheme.scheme=https"
      - "traefik.http.routers.bitwarden.middlewares=bitwarden-https-redirect"
      - "traefik.http.routers.bitwarden.service=bitwarden"
      - "traefik.http.services.bitwarden.loadbalancer.server.port=80"
      #
      - "traefik.http.routers.bitwarden-secure.entrypoints=https"
      - "traefik.http.routers.bitwarden-secure.rule=Host(`pass.${DOMAIN}`)"
      - "traefik.http.routers.bitwarden-secure.tls=true"
      - "traefik.http.routers.bitwarden-secure.tls.certresolver=dns-route53"
      - "traefik.http.routers.bitwarden-secure.service=bitwarden-secure"
      - "traefik.http.services.bitwarden-secure.loadbalancer.server.port=80"
      #
      - "traefik.http.routers.bitwarden-ws.entrypoints=https"
      - "traefik.http.routers.bitwarden-ws.rule=Host(`pass.${DOMAIN}`) && Path(`/notifications/hub`)"
      - "traefik.http.middlewares.bitwarden-ws-strip.stripprefix.prefixes=/notifications/hub"
      - "traefik.http.routers.bitwarden-ws.middlewares=bitwarden-ws-strip"
      - "traefik.http.routers.bitwarden-ws.tls=true"
      - "traefik.http.routers.bitwarden-ws.tls.certresolver=dns-route53"
      - "traefik.http.routers.bitwarden-ws.service=bitwarden-ws"
      - "traefik.http.services.bitwarden-ws.loadbalancer.server.port=3012"
    restart: always

  bitwarden-postgres:
    container_name: "bitwarden-postgres"
    image: "postgres:13-alpine"
    #user: ${UID}:${GID}
    environment:
      POSTGRES_USER: "bitwarden"
      POSTGRES_PASSWORD: "${BITWADEN_DB_PASSWORD}"
      POSTGRES_DB: "bitwarden"
    security_opt:
      - no-new-privileges:true
    volumes:
      - /tempvol/config-postgres-bitwarden:/var/lib/postgresql/data
      - /etc/localtime:/etc/localtime:ro
    restart: always

  bitwarden-postgres-backup:
    container_name: "bitwarden-postgres-backup"
    image: "postgres:13-alpine"
    environment:
      BACKUP_NUM_KEEP: 7
      BACKUP_FREQUENCY: "1d"
    volumes:
      - /tank/backups/databases/bitwarden:/dump
      - /etc/localtime:/etc/localtime:ro
    depends_on:
      - bitwarden-postgres
    entrypoint: |
      bash -c 'bash -s <<EOF
      trap "break;exit" SIGHUP SIGINT SIGTERM
      sleep 1s
      while /bin/true; do
        PGPASSWORD=${BITWADEN_DB_PASSWORD} pg_dump --host=bitwarden-postgres --username=bitwarden bitwarden | gzip -c > /dump/dump_\`date +%d-%m-%Y"_"%H_%M_%S\`.sql.gz
        (ls -t /dump/dump*.sql.gz|head -n $$BACKUP_NUM_KEEP;ls /dump/dump*.sql.gz)|sort|uniq -u|xargs rm -- {}
        sleep $$BACKUP_FREQUENCY
      done
      EOF'
    restart: always