A small team can run its own password manager and its own uptime monitor on a single cheap VPS: Vaultwarden for shared logins and API keys, Uptime Kuma to tell you when your AI agent endpoint stops answering, and Caddy in front of both for HTTPS. It is an afternoon of work. It is also real responsibility, because you now run the thing that holds your passwords.
This is adjacent to the AI posts on this site for a reason. Anyone self-hosting an agent ends up with a pile of API keys, and a monitor is how you learn the agent died at 2 a.m. I ran this exact stack on 2026-10-09 in a sandbox with Docker 29.8.2 and Compose v5.6.0: Vaultwarden server 1.37.4 (web vault 2026.6.0), Uptime Kuma 2.5.5 and caddy:2. I used .localhost names, so Caddy issued certificates from its own internal CA rather than from Let's Encrypt. I did not test real public certificates, a real client login, or a long-running instance.
Should you self-host a password manager at all?
Maybe not. Decide before you build.
| Situation | Better choice |
|---|---|
| Solo, no one else needs the vault | A managed password manager. Less to break and nobody to lock you out of. |
| Small team, comfortable patching a server, wants control | Self-hosted Vaultwarden is reasonable. |
| You need support, audit reports or compliance evidence | Paid managed plan from a vendor. Vaultwarden is unofficial. |
| Nobody will check the server monthly | Don't self-host secrets. |
Vaultwarden's startup banner calls it an unofficial Bitwarden implementation, and the Bitwarden clients talk to it. The honest trade-off: you gain control and a very small footprint (idle memory below), and you lose a vendor who answers the phone. If the VPS dies and your backup is bad, the passwords are gone.
Do the server basics first. The VPS hardening checklist covers SSH keys, the firewall and the Docker port trap, and this stack follows its main rule: only the reverse proxy publishes ports.
What does the stack look like?
Three containers: Caddy, Vaultwarden, Uptime Kuma. Create a folder with compose.yaml, Caddyfile and .env.
.env (use your own subdomains; both need A records pointing at the VPS):
VAULT_DOMAIN=vault.yourdomain.com
STATUS_DOMAIN=status.yourdomain.com
SIGNUPS_ALLOWED=true
Caddyfile:
{$VAULT_DOMAIN} {
encode zstd gzip
reverse_proxy vaultwarden:80
}
{$STATUS_DOMAIN} {
encode zstd gzip
reverse_proxy uptime-kuma:3001
}
compose.yaml:
services:
caddy:
image: caddy:2
restart: unless-stopped
ports:
- "80:80"
- "443:443"
- "443:443/udp"
environment:
VAULT_DOMAIN: ${VAULT_DOMAIN:?set VAULT_DOMAIN in .env}
STATUS_DOMAIN: ${STATUS_DOMAIN:?set STATUS_DOMAIN in .env}
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
depends_on:
- vaultwarden
- uptime-kuma
vaultwarden:
image: vaultwarden/server:${VAULTWARDEN_VERSION:-latest}
restart: unless-stopped
environment:
DOMAIN: https://${VAULT_DOMAIN:?set VAULT_DOMAIN in .env}
SIGNUPS_ALLOWED: ${SIGNUPS_ALLOWED:-false}
volumes:
- vw_data:/data
# No ports: only Caddy is reachable from outside.
uptime-kuma:
image: louislam/uptime-kuma:${KUMA_VERSION:-2}
restart: unless-stopped
volumes:
- kuma_data:/app/data
# No ports: only Caddy is reachable from outside.
volumes:
caddy_data:
caddy_config:
vw_data:
kuma_data:
This follows the shape of the Vaultwarden wiki's own Compose-with-Caddy example (Caddy publishes 80, 443 and 443/udp, Vaultwarden publishes nothing, DOMAIN is the HTTPS URL, data lives under /data). I added Uptime Kuma, named volumes and the ${VAR:?} guards.
I ran docker compose config --quiet (clean), then with no .env, which printed the three guard errors:
error while interpolating services.caddy.environment.STATUS_DOMAIN: required variable STATUS_DOMAIN is missing a value: set STATUS_DOMAIN in .env
error while interpolating services.caddy.environment.VAULT_DOMAIN: required variable VAULT_DOMAIN is missing a value: set VAULT_DOMAIN in .env
error while interpolating services.vaultwarden.environment.DOMAIN: required variable VAULT_DOMAIN is missing a value: set VAULT_DOMAIN in .env
Then I ran docker compose up -d and checked it end to end. docker ps showed that only Caddy published ports:
stack-caddy-1: 0.0.0.0:80->80/tcp, 0.0.0.0:443->443/tcp, 0.0.0.0:443->443/udp, 2019/tcp
stack-uptime-kuma-1: 3001/tcp
stack-vaultwarden-1: 80/tcp
Vaultwarden answered through Caddy over HTTPS (curl -k because the certificate was from Caddy's local CA):
$ curl -sk https://vault.localhost/alive
"2026-10-09T06:09:45.429666Z"
$ curl -s -o /dev/null -w "%{http_code} %{redirect_url}" http://vault.localhost/
308 https://vault.localhost/
Plain HTTP redirects to HTTPS, and /alive returns a timestamp. Uptime Kuma, before any account existed, answered with a redirect to its database setup page:
302 https://status.localhost/setup-database
Two things to take from that. First, version 2 of Uptime Kuma asks you to choose a database on first run, so budget a minute for it. Second, until you finish setup, anyone who reaches that URL can do it. Create your account immediately after up -d, or keep the status domain out of DNS until you have.
How do you lock it down after the first account?
Vaultwarden's SIGNUPS_ALLOWED is true in the Compose file's .env for the first start only. The wiki's guidance is to change it to false after your account exists. Do this:
docker compose up -dwithSIGNUPS_ALLOWED=true.- Open
https://vault.yourdomain.com, create your account with a long master password, and save it somewhere that isn't this vault. - Edit
.env, setSIGNUPS_ALLOWED=false, rundocker compose up -dagain (Compose recreates the container because the value changed).
I confirmed that the environment value reached the recreated container (printenv SIGNUPS_ALLOWED printed false). I did not drive a real registration through a Bitwarden client, so I can't show you the "registration disabled" screen. Do that check yourself with a private browser window.
Also note: a variable exported in your shell overrides the .env file. I tested the switch with a shell variable. On your server, edit the file so the setting survives the next login.
The admin page is off unless you set ADMIN_TOKEN, and this stack doesn't. If you turn it on, the wiki recommends storing an Argon2 hash made with docker run --rm -it vaultwarden/server /vaultwarden hash, not the plain token, and doubling every $ as $$ when you put the hash in a Compose environment: block. For a small team I'd leave it off and manage users by invitation.
Pin the image once you're live. latest is fine for a test, but a password manager should change when you decide, after reading release notes. Set VAULTWARDEN_VERSION to a version tag in .env and update on purpose. Security fixes are the reason to update promptly, so subscribe to the project's releases.
How much RAM does it need?
Very little. After startup with no monitors configured and no vault data, docker stats showed:
| Container | Memory | CPU |
|---|---|---|
| Vaultwarden | 9.5 MiB | 0.00% |
| Caddy | 13.7 MiB | 0.00% |
| Uptime Kuma | 106.4 MiB | 0.00% |
That's an idle snapshot of a fresh stack, not a load test. Uptime Kuma will grow with the number of monitors and the check rate, and I didn't measure that. The takeaway is that this stack doesn't need a big server. My unmeasured guess is that it can share a 2 vCPU, 4 GB machine with a light n8n instance, but check docker stats on your own box before you stack more on it. For sizing a box that also runs local models, see private ChatGPT with Ollama and Open WebUI: VPS sizing.
If you want somewhere to run it, any small VPS works; I've used Hostinger for other guides, for instance the KVM 2 plan. Some links on this page are affiliate links; they cost you nothing and I only link tools I'd mention anyway. Hetzner, DigitalOcean and Linode run the same Compose file, and my longer notes on one provider are in the Hostinger VPS review.
How do you back it up, and does the restore work?
A backup you haven't restored is a hope. Vaultwarden's wiki lists what matters: db.sqlite3 and attachments/ are required, sends/ is optional, config.json and the rsa_key files are recommended (losing the keys logs everyone out and invalidates pending invitations). It also says to keep at least one off-site copy, encrypt backups because they can contain the admin token and signing keys, and not to rely on VM snapshots alone. The Docker image has no sqlite3 or cron, but there is a built-in /vaultwarden backup command for versions that support it. Mine did, and the output was Backup to 'data/db_20261009_061014.sqlite3' was successful. Note that it writes into the same /data volume, so it protects you from a corrupt database, not from a lost disk. The tarball below therefore contains both the live db.sqlite3 (which may have a -wal file beside it) and the consistent db_*.sqlite3 copy; if you ever restore, prefer the db_* copy and follow the wiki's WAL warning further down.
This script does the full job. I ran it from the Compose folder:
#!/usr/bin/env bash
# Back up Vaultwarden and Uptime Kuma data volumes to ./backups on the host.
# Run from the folder that holds compose.yaml. Copy ./backups off the server afterwards.
set -euo pipefail
OUT="${BACKUP_DIR:-$PWD/backups}"
STAMP="$(date +%F-%H%M)"
mkdir -p "$OUT"
vol_of() { # volume name mounted at $2 in container of service $1
docker inspect -f "{{range .Mounts}}{{if eq .Destination \"$2\"}}{{.Name}}{{end}}{{end}}" \
"$(docker compose ps -q "$1")"
}
# 1. Vaultwarden: consistent SQLite copy first (built-in command), then tar the whole data dir.
docker compose exec -T vaultwarden /vaultwarden backup
VW_VOL="$(vol_of vaultwarden /data)"
docker run --rm -v "$VW_VOL":/data:ro -v "$OUT":/out busybox:1.37 \
tar czf "/out/vaultwarden-$STAMP.tgz" -C /data .
# 2. Uptime Kuma: stop for a few seconds so the database file is not mid-write.
KUMA_VOL="$(vol_of uptime-kuma /app/data)"
docker compose stop uptime-kuma
docker run --rm -v "$KUMA_VOL":/data:ro -v "$OUT":/out busybox:1.37 \
tar czf "/out/uptime-kuma-$STAMP.tgz" -C /data .
docker compose start uptime-kuma
chmod 600 "$OUT"/*.tgz
ls -l "$OUT"
Output:
-rw------- 1 root root 168 Oct 9 06:10 uptime-kuma-2026-10-09-0610.tgz
-rw------- 1 root root 24188 Oct 9 06:10 vaultwarden-2026-10-09-0610.tgz
The Uptime Kuma archive is 168 bytes because I hadn't finished its setup, so there was nothing in it. Stopping Kuma for the copy is my own precaution, not something I found in its docs; a few seconds of missed checks is a fair price. The archives are plain tarballs. Encrypt them (age, gpg or your backup tool) before they leave the server, then copy them off with rsync or object storage.
The restore rehearsal I did: create a new empty volume, unpack the tarball into it, start a fresh Vaultwarden container on that volume and run its health check. It started on server version 1.37.4 and /healthcheck.sh returned a timestamp. Honest limit: my vault was empty, so that proves the files restore and the server boots, not that your data is intact. On your own server, do it once with real entries and log in with a client. The wiki also warns that when restoring a .backup file you should stop Vaultwarden and delete any leftover db.sqlite3-wal first, or corruption can occur.
Mind the NFS warning too: Uptime Kuma's README says NFS-backed storage is not supported, so use a local volume as above.
What should Uptime Kuma watch for an AI app?
Uptime Kuma's README lists these monitor types: HTTP(s), TCP, HTTP(s) Keyword, HTTP(s) JSON Query, WebSocket, Ping, DNS Record, Push, Steam Game Server and Docker Containers. For a small AI stack, four are enough:
- HTTP(s) on your agent's
/healthroute. Matches the health check you already built for zero-downtime deploys. - Keyword monitor on a page that should contain a known string. Catches the case where the server answers 200 with an error page.
- Push monitor for jobs that should run on a schedule, such as the backup script above, or a nightly n8n workflow. The job calls a URL when it succeeds; silence means failure. This is the one most people forget, and it's how you learn a backup stopped a month ago.
- Certificate expiry. The README lists certificate info as a feature but gives no detail, so check in the UI what it alerts on before you rely on it.
Add a notification channel before anything else. The README lists Telegram, Discord, Gotify, Slack, Pushover and SMTP email among 90-plus services. A monitor with no alert is a dashboard nobody opens.
Then fix the blind spot: if this VPS goes down, so does its monitor. Run a second Uptime Kuma, or any external uptime check, on a different provider, whose only job is to watch this one. For agent-specific monitoring, tracing and cost, see AI agent observability. Uptime Kuma tells you something is up or down, not whether the answers are good.
What can go wrong?
| Symptom | Likely cause | Fix |
|---|---|---|
| Caddy never gets a certificate | DNS not pointing at the VPS, or ports 80/443 blocked | Check the A record and the provider firewall |
| Someone else created your Kuma account | Setup page was reachable before you finished | Re-create the volume and finish setup straight after up -d |
| New user registers on your vault | SIGNUPS_ALLOWED still true | Set false, recreate, test in a private window |
| Backups exist but won't restore | Never rehearsed, or only the database was saved | Back up the whole data folder and rehearse |
| You can't log in after a rebuild | Lost the master password; there is no reset | Store recovery info separately from the vault |
Update on a schedule: pull new images monthly, read the release notes first, and take a backup before every update. If you want to compare proxies for this stack, read nginx vs Caddy vs Traefik.
What I did not verify
- Public certificate issuance (the test used Caddy's internal CA on
.localhostnames). - A real Bitwarden client login, registration being refused after
SIGNUPS_ALLOWED=false, and the Vaultwarden admin page. - Uptime Kuma monitors, alerts and push URLs, since I stopped at its setup page.
- Long-run memory, upgrade behavior and multi-user performance.
- A restore with real vault contents.



