Skip to content
Vault-WebPublic

About

Deployment repository for the Vault Web ecosystem: clone this repo to deploy the full stack, including all required service repositories as Git submodules, Docker Compose setup, and automation scripts.

Topics

Resources

Contributing

Stars

2 stars

Watchers

0 watching

Forks

Repository files navigation

Vault Web Deploy

Deployment repository for Vault Web with Docker Compose and service submodules.

Security note: all domains, IPs, usernames, UUIDs, and secrets in this README are examples and must be replaced for real deployments.

Included Services

Main compose files:

Production Architecture (Current)

This repository is deployed with this security model:

  1. App containers run internally (HTTP only).
  2. Frontend binds to localhost only (127.0.0.1:8080) and is not directly public.
  3. Headscale Caddy (/opt/headscale) terminates public TLS on 443.
  4. vpn.example.com stays public (required for Headscale/Tailscale control-plane).
  5. vault.example.com has no public DNS record and resolves only inside VPN via Split-DNS to 100.64.0.10.
  6. passwords.example.com follows the same VPN-only pattern and proxies to Vaultwarden.
  7. Headscale Caddy proxies vault.example.com to deploy-frontend-1:80.
  8. Headscale Caddy proxies passwords.example.com to vaultwarden:80.
  9. Firewall + DOCKER-USER rules block accidental exposure of debug/admin ports.
  10. Vault user data and Vaultwarden backups run daily to an external disk using incremental snapshots.

Result:

  • vault.example.com stays HTTPS and secure-context capable.
  • passwords.example.com stays HTTPS for the Vaultwarden web vault and Bitwarden clients.
  • non-VPN users cannot resolve or reach Vault Web.
  • Headscale login endpoint remains reachable as designed.

Prerequisites

  • Debian/Ubuntu server with Docker Engine and Compose plugin
  • git, openssl, dnsmasq
  • DNS:
    • vpn.example.com -> server public IP
    • vault.example.com -> no public A/AAAA record (resolved only via Split-DNS in VPN)
    • passwords.example.com -> no public A/AAAA record (resolved only via Split-DNS in VPN)
  • Router forwards 80/tcp and 443/tcp to server

1) Deploy Stack

cd /opt
git clone --recurse-submodules https://github.com/Vault-Web/deploy.git
cd /opt/deploy
cp -n .env.example .env

Set required values in /opt/deploy/.env:

  • FRONTEND_PORT=127.0.0.1:8080
  • VAULTWARDEN_PUBLIC_URL=https://passwords.example.com
  • VAULTWARDEN_DATA_DIR=/data/vaultwarden
  • strong DB/JWT secrets
  • an Argon2id VAULTWARDEN_ADMIN_TOKEN
  • valid CLOUD_HOST_ROOT (existing host directory)

Generate the Vaultwarden admin token hash:

docker run --rm -it vaultwarden/server:1.37.3 /vaultwarden hash --preset owasp

Place the resulting PHC string in .env as a single-quoted value. Keep VAULTWARDEN_SIGNUPS_ALLOWED=false for normal operation; temporarily set it to true only while creating the first account, then set it back to false and recreate the container.

Start:

cd /opt/deploy
docker compose -f docker-compose.deploy.yml up -d --build
docker compose -f docker-compose.deploy.yml ps

Runtime external links

The frontend container writes runtime-config.local.js on startup. Set VAULTWARDEN_PUBLIC_URL and VAULT_HABITS_URL in .env; Vaultwarden is added without forwarding a Vault-Web token, while Habits keeps the existing SSO token handoff.

Apply .env changes:

cd /opt/deploy
docker compose -f docker-compose.deploy.yml build frontend
docker compose -f docker-compose.deploy.yml up -d frontend

2) Headscale Caddy Routing

Connect Headscale Caddy container to deploy network (one-time):

cd /opt/headscale
docker network connect deploy_default headscale-caddy 2>/dev/null || true

Configure /opt/headscale/Caddyfile:

{
  email {$LETSENCRYPT_EMAIL}
}

vpn.example.com {
  reverse_proxy headscale:8080
}

vault.example.com {
  reverse_proxy deploy-frontend-1:80
}

passwords.example.com {
  reverse_proxy vaultwarden:80
}

Apply:

cd /opt/headscale
docker compose up -d --force-recreate caddy
docker network connect deploy_default headscale-caddy 2>/dev/null || true
docker compose logs --tail=200 caddy

Important:

  • If you run --force-recreate again, reconnect headscale-caddy to deploy_default afterwards.
  • If dial tcp: lookup deploy-frontend-1 ... no such host appears, the network attach step is missing.
  • If Vaultwarden clients reject the server URL, verify that passwords.example.com has a valid HTTPS certificate and that VAULTWARDEN_PUBLIC_URL matches it.

3) Split-DNS (central, production path)

On server (root):

cat >/etc/dnsmasq.d/10-headscale-splitdns.conf <<'EOF'
bind-dynamic
interface=tailscale0
listen-address=127.0.0.1,100.64.0.10
no-resolv
address=/vault.example.com/100.64.0.10
address=/passwords.example.com/100.64.0.10
server=1.1.1.1
server=1.0.0.1
cache-size=10000
EOF

dnsmasq --test
systemctl restart dnsmasq
ss -lupn | grep ':53'
dig +short vault.example.com @127.0.0.1
dig +short vault.example.com @100.64.0.10

Set Headscale DNS in /opt/headscale/config/config.yaml:

dns:
  magic_dns: true
  base_domain: vpn.internal
  override_local_dns: true
  nameservers:
    global:
      - 1.1.1.1
      - 1.0.0.1
    split:
      example.com:
        - 100.64.0.10
  extra_records:
    - name: vault.example.com
      type: A
      value: "100.64.0.10"
    - name: passwords.example.com
      type: A
      value: "100.64.0.10"

Apply:

cd /opt/headscale
docker compose restart headscale

Client reconnect (example Linux laptop):

sudo tailscale up --reset --login-server=https://vpn.example.com --accept-dns=true
sudo resolvectl flush-caches
dig +short vault.example.com

4) Firewall Hardening

Baseline UFW:

sudo ufw --force reset
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow 22/tcp
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw --force enable
sudo ufw status verbose

Docker published ports hardening:

sudo iptables -F DOCKER-USER
sudo iptables -I DOCKER-USER -p tcp --dport 8080 -j DROP
sudo iptables -I DOCKER-USER -p tcp --dport 8081 -j DROP
sudo iptables -I DOCKER-USER -p tcp --dport 5433 -j DROP
sudo iptables -A DOCKER-USER -j RETURN
sudo iptables -S DOCKER-USER

5) Verification Checklist

Server:

ss -tulpen | grep -E ':80|:443|:8080|:8081|:5433'
docker compose -f /opt/deploy/docker-compose.deploy.yml ps
docker compose -f /opt/headscale/docker-compose.yml ps
curl -vk https://vpn.example.com
dig +short vault.example.com
dig +short passwords.example.com
curl -vk https://vault.example.com
curl -vk https://passwords.example.com/alive

Expected:

  • vpn.example.com responds from Caddy/Headscale.
  • vault.example.com from VPN client resolves to 100.64.0.10 and returns 200.
  • passwords.example.com from VPN client resolves to 100.64.0.10 and Vaultwarden /alive returns 200.
  • vault.example.com from public resolver (for example dig +short vault.example.com @1.1.1.1) returns no record.
  • passwords.example.com from public resolver returns no record.

Browser on VPN device at https://vault.example.com:

  • window.isSecureContext -> true
  • !!globalThis.crypto?.subtle -> true

6) Vaultwarden Operations

Vault-Web previously included an experimental in-house password manager. The deployment now uses Vaultwarden instead, because password management should rely on a mature Bitwarden-compatible client/server rather than custom portal code. Before removing an existing production password-manager database from backups, export any real entries that still need to be retained.

Vault-Web should link users to Vaultwarden; it must not receive Vaultwarden master passwords, embed the web vault in an iframe, or forward Vault-Web tokens to Vaultwarden.

Important operating rules:

  • use the dedicated HTTPS origin in VAULTWARDEN_PUBLIC_URL;
  • back up the full VAULTWARDEN_DATA_DIR before going live;
  • keep VAULTWARDEN_SIGNUPS_ALLOWED=false after the first account exists;
  • keep SHOW_PASSWORD_HINT=false;
  • store VAULTWARDEN_ADMIN_TOKEN as an Argon2id PHC hash, not plaintext;
  • keep /admin reachable only to operators who know the admin token.

Vaultwarden Backups

Vaultwarden stores its database, attachments, sends, and RSA key files under /data. In this deployment, that is the host directory from VAULTWARDEN_DATA_DIR. Losing that directory means losing the password vaults, so test backup and restore before onboarding users.

The backup script briefly stops the vaultwarden container before copying the data directory, then starts it again if it was running. This avoids copying a live SQLite database and its WAL files mid-write.

Daily backup timer example:

cat >/etc/systemd/system/backup-vaultwarden.service <<'EOF'
[Unit]
Description=Incremental backup of Vaultwarden data

[Service]
Type=oneshot
WorkingDirectory=/opt/deploy
ExecStart=/opt/deploy/scripts/backup-vaultwarden.sh
EOF

cat >/etc/systemd/system/backup-vaultwarden.timer <<'EOF'
[Unit]
Description=Daily incremental backup timer for Vaultwarden (23:20)

[Timer]
OnCalendar=*-*-* 23:20:00
Persistent=true

[Install]
WantedBy=timers.target
EOF

systemctl daemon-reload
systemctl enable --now backup-vaultwarden.timer
systemctl start backup-vaultwarden.service

Restore example:

cd /opt/deploy
docker compose -f docker-compose.deploy.yml stop vaultwarden
scripts/restore-vaultwarden.sh /mnt/backup5tb/vaultwarden-backups/latest
docker compose -f docker-compose.deploy.yml up -d vaultwarden

7) Daily Incremental Backup of /data/vault-users (23:30)

Backup strategy

  • Snapshot-like backups using rsync --link-dest (incremental with hardlinks).
  • Retention: keep only the last 5 snapshots.
  • latest symlink always points to the newest snapshot.
  • The backup job mounts backup disk, runs backup, then unmounts.

One-time setup (server root)

Adjust disk UUID and device if needed.

If you want the disk mounted only during backup runs, do not use x-systemd.automount in /etc/fstab.

sudo -i
set -euo pipefail

DISK_DEV="/dev/sdc"
PART_UUID="REPLACE_WITH_BACKUP_PARTITION_UUID"
MNT="/mnt/backup5tb"

apt-get update
apt-get install -y rsync hdparm ntfs-3g

mkdir -p "${MNT}"

grep -q "${PART_UUID}" /etc/fstab || \
echo "UUID=${PART_UUID} ${MNT} ntfs-3g defaults,uid=0,gid=0,umask=022,nofail 0 0" >> /etc/fstab

mount "${MNT}" || true

Create backup script:

cat >/usr/local/sbin/backup-vault-users.sh <<'EOF'
#!/usr/bin/env bash
set -euo pipefail

SRC="/data/vault-users/"
MNT="/mnt/backup5tb"
DST_BASE="${MNT}/vault-users-backups"
TS="$(date +%F_%H-%M-%S)"
DST="${DST_BASE}/${TS}"
LATEST="${DST_BASE}/latest"
KEEP=5
DISK_DEV="/dev/sdc"

cleanup() {
  sync || true
  mountpoint -q "${MNT}" && umount "${MNT}" || true
  hdparm -y "${DISK_DEV}" >/dev/null 2>&1 || true
}
trap cleanup EXIT

mountpoint -q "${MNT}" || mount "${MNT}"
mkdir -p "${DST_BASE}"

if [ -L "${LATEST}" ] && [ -d "$(readlink -f "${LATEST}")" ]; then
  PREV="$(readlink -f "${LATEST}")"
  rsync -aH --delete --link-dest="${PREV}" "${SRC}" "${DST}/"
else
  rsync -aH --delete "${SRC}" "${DST}/"
fi

ln -sfn "${DST}" "${LATEST}"

find "${DST_BASE}" -mindepth 1 -maxdepth 1 -type d -printf '%P\n' \
  | sort -r | tail -n +$((KEEP+1)) | while read -r old; do
    rm -rf "${DST_BASE}/${old}"
  done
EOF

chmod +x /usr/local/sbin/backup-vault-users.sh

Create systemd service + timer:

cat >/etc/systemd/system/backup-vault-users.service <<'EOF'
[Unit]
Description=Incremental backup of /data/vault-users to external disk

[Service]
Type=oneshot
ExecStart=/usr/local/sbin/backup-vault-users.sh
EOF

cat >/etc/systemd/system/backup-vault-users.timer <<'EOF'
[Unit]
Description=Daily incremental backup timer for vault-users (23:30)

[Timer]
OnCalendar=*-*-* 23:30:00
Persistent=true

[Install]
WantedBy=timers.target
EOF

systemctl daemon-reload
systemctl enable --now backup-vault-users.timer

Run immediate test backup

systemctl start backup-vault-users.service
systemctl status backup-vault-users.service --no-pager
journalctl -u backup-vault-users.service -n 80 --no-pager
mount /mnt/backup5tb || true
ls -lah /mnt/backup5tb/vault-users-backups
umount /mnt/backup5tb || true

backup-vault-users.service is Type=oneshot; after a successful run, systemctl status usually shows inactive (dead) with status=0/SUCCESS. This is expected.

Restore procedure (manual)

Pick a snapshot folder and restore to source:

mount /mnt/backup5tb || true
SNAP="/mnt/backup5tb/vault-users-backups/2026-04-01_23-30-00"
rsync -aH --delete "${SNAP}/" /data/vault-users/
umount /mnt/backup5tb || true

Backup troubleshooting

  • rsync: command not found:
    • apt-get install -y rsync
  • Unit backup-vault-users.service not found:
    • recreate /etc/systemd/system/backup-vault-users.service, then systemctl daemon-reload
  • target is busy on unmount:
    • check with lsof +f -- /mnt/backup5tb and fuser -vm /mnt/backup5tb
  • hdparm -C /dev/sdc shows unknown:
    • common on USB enclosures; backup still works.

8) Cloud Page User Root Folder Mapping

CLOUD_HOST_ROOT is mounted as /host-cloud in container. Root folder paths stored in DB must use container path.

Correct:

UPDATE users
SET root_folder_path = '/host-cloud/alice'
WHERE username = 'alice';

Wrong (causes "Root folder does not exist..."):

  • /alice
  • /data/vault-users/alice

Quick checks:

ls -ld /data/vault-users /data/vault-users/alice
docker compose -f /opt/deploy/docker-compose.deploy.yml exec cloud-page-backend ls -ld /host-cloud /host-cloud/alice

Path consistency reference:

  • host storage path: /data/vault-users/<user>
  • Cloud Page container-visible path: /host-cloud/<user>
  • Syncthing user container-visible path: /vault-user

9) Daily Operations

Update deploy repo only:

cd /opt/deploy
git pull --ff-only
docker compose -f docker-compose.deploy.yml up -d --build --remove-orphans

Update with submodules:

cd /opt/deploy
git pull --ff-only
git submodule sync --recursive
git submodule update --init --recursive --remote
docker compose -f docker-compose.deploy.yml up -d --build --remove-orphans

Logs:

docker compose -f /opt/deploy/docker-compose.deploy.yml logs -f frontend
docker compose -f /opt/deploy/docker-compose.deploy.yml logs -f vault-web-backend
docker compose -f /opt/deploy/docker-compose.deploy.yml logs -f vaultwarden
docker compose -f /opt/headscale/docker-compose.yml logs -f caddy

10) Syncthing User Sync (Optional)

For multi-user Syncthing with per-user server folder isolation and VPN-only access, use:

11) Known Pitfalls

  • dial tcp: lookup frontend ... no such host or deploy-frontend-1 ... no such host in headscale-caddy logs:
    • missing Docker network connection (deploy_default not attached to headscale-caddy).
  • ERR_SSL_PROTOCOL_ERROR on vault.example.com:
    • Caddy route broken, certificate pending, or wrong reverse proxy target.
  • Bitwarden clients keep using bitwarden.com:
    • choose the self-hosted/server URL option and enter https://passwords.example.com.
  • Vaultwarden account creation is visible after onboarding:
    • set VAULTWARDEN_SIGNUPS_ALLOWED=false and recreate the container.
  • vault.example.com resolves to public IP on VPN client:
    • Split-DNS not applied on client; reconnect with --accept-dns=true and flush resolver cache.
  • Invalid CORS request:
    • backend CORS allowlist/pattern does not include current frontend origin.
  • WebCrypto unavailable:
    • app opened via plain HTTP or insecure context.

10) Independent Runtime Monitoring (Gatus + Heartbeats)

Gatus runs as a host-level systemd service, not in docker-compose.deploy.yml. This keeps monitoring independent from the application stack: a Compose outage does not also take down the monitor.

Network and privacy model

  • Install Gatus on the deployment host (or another independent host) and keep its dashboard bound to 127.0.0.1:8082. Access it with an SSH tunnel (ssh -L 8082:127.0.0.1:8082 <server>) or an authenticated VPN-only proxy.
  • The monitoring host must have the same VPN connectivity and Split-DNS access as a normal user for VPN-only endpoints. For example, vault.example.com should resolve to the VPN address on that host.
  • Do not add public DNS records, firewall openings, or public port forwards for VPN-only services just to make a check pass.
  • Configure every endpoint with the URLs users actually visit through the frontend/reverse proxy. Do not point checks at Docker-only service names or internal container ports.

Install Gatus

From the repository root on the server:

cd /opt/deploy
sudo GATUS_VERSION=v5.15.0 ./monitoring/gatus/install.sh
sudoedit /etc/gatus/gatus.env

Set the URLs in /etc/gatus/gatus.env. The example file is monitoring/gatus/gatus.env.example. It includes the user-facing Vault Web and Cloud Page UI URLs, a Cloud Page backend probe, Vaultwarden, Vault Habits and the WebSocket route wss://vault.example.com/ws-chat, matching docker/nginx/default.conf. Replace every example host with the real route in your installation.

The Cloud Page backend probe deliberately requests a nonexistent Secure Send token through /cloud-api/public/secure-sends/<probe-token>/meta. A healthy backend returns the expected 404 response body Secure Send link is unavailable; this proves the request reached the Cloud Page application rather than merely receiving Angular's SPA shell. If the backend or its database is unavailable, the expected status/body check fails.

To verify the backend monitor on the deployment host, stop only the Cloud Page backend and wait for the next Gatus interval:

cd /opt/deploy
sudo docker compose -f docker-compose.deploy.yml stop cloud-page-backend
# The "Cloud Page backend" endpoint should become unhealthy.
sudo docker compose -f docker-compose.deploy.yml start cloud-page-backend

After the backend is healthy again, Gatus should report the endpoint as healthy on a subsequent check.

Enable the service:

sudo systemctl enable --now gatus
sudo systemctl status gatus --no-pager
sudo journalctl -u gatus -n 100 --no-pager
# From your workstation, open an SSH tunnel:
ssh -L 8082:127.0.0.1:8082 <server>
# Then visit http://127.0.0.1:8082 in your browser.

Gatus restarts on failure and runs checks every minute. A failed condition marks the endpoint unhealthy. Review the Gatus status page/logs and investigate the affected reverse proxy, VPN/DNS route or application. Do not expose private services to the public internet to satisfy a check.

Optional external host and backup heartbeats

Create separate monitors at healthchecks.io for host uptime and the backup job. Keep generated UUIDs secret. The scripts in monitoring/healthchecks are no-ops when their UUID environment variable is absent, so this integration remains optional.

Create /etc/default/vault-web-heartbeats with mode 0600 and set:

HC_HOST_UUID=replace-with-host-check-uuid
HC_BACKUP_UUID=replace-with-backup-check-uuid

Install the host timer using the example units:

sudo install -m 0755 /opt/deploy/monitoring/healthchecks/host-heartbeat.sh /usr/local/sbin/vault-web-host-heartbeat
sudo install -m 0644 /opt/deploy/monitoring/healthchecks/host-heartbeat.service.example /etc/systemd/system/vault-web-host-heartbeat.service
sudo install -m 0644 /opt/deploy/monitoring/healthchecks/host-heartbeat.timer.example /etc/systemd/system/vault-web-host-heartbeat.timer
sudo systemctl daemon-reload
sudo systemctl enable --now vault-web-host-heartbeat.timer

For backup monitoring, install the hook and attach it to the existing systemd backup service with an ExecStartPost drop-in. systemd runs ExecStartPost only after the backup command exits successfully, so failed backups never send a success heartbeat:

sudo install -m 0755 /opt/deploy/monitoring/healthchecks/backup-heartbeat.sh /usr/local/sbin/vault-web-backup-heartbeat
sudo install -d -m 0755 /etc/systemd/system/backup-vault-users.service.d
sudo tee /etc/systemd/system/backup-vault-users.service.d/healthchecks.conf >/dev/null <<'EOF'
[Service]
EnvironmentFile=/etc/default/vault-web-heartbeats
ExecStartPost=/usr/local/sbin/vault-web-backup-heartbeat
EOF
sudo systemctl daemon-reload

The required EnvironmentFile passes HC_BACKUP_UUID from /etc/default/vault-web-heartbeats into the hook. The hook runs after the existing backup script, including its retention and verification steps. Do not call it at backup start: a later backup failure must not be reported as success. Test the configured path with a successful backup run, then confirm the backup monitor's last-ping time updates in healthchecks.io. Set its grace period longer than the normal backup duration; missed pings trigger its configured email/webhook alerts.

Troubleshooting

  • All VPN endpoints fail: check VPN connectivity and Split-DNS (getent hosts vault.example.com).
  • One endpoint fails: open the same user-facing URL from a VPN client and inspect proxy/application logs; confirm the expected HTTP status.
  • WebSocket fails: verify the route supports an HTTP Upgrade handshake and /ws-chat matches the frontend proxy.
  • Heartbeat is late: inspect the systemd timer/service, outbound HTTPS connectivity and UUID configuration.

About

Deployment repository for the Vault Web ecosystem: clone this repo to deploy the full stack, including all required service repositories as Git submodules, Docker Compose setup, and automation scripts.

Topics

Resources

Contributing

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages