How to Deploy Passbolt on a VPS
Updated Aug 2026
Self-host Passbolt CE on your own VPS with Docker Compose, HTTPS, and SMTP — an OpenPGP team password manager where the server only holds ciphertext.
- A VPS with 2 CPU cores / 2 GB RAM / 20 GB disk (Passbolt's own published minimum)
- A fresh Ubuntu 26.04 server with root/sudo SSH access
- A domain you can point at the server — the OpenPGP login flow needs a real HTTPS origin
- A working SMTP sender — this one is a hard requirement, not a nice-to-have
- Docker Engine + Compose installed (see the base guide below)
What Passbolt is
Passbolt is a password manager built for teams, and it makes one architectural choice that shapes everything else: it is built on OpenPGP. Every secret is encrypted to each individual user's public key. The server stores ciphertext it holds no key for. Sharing a password with a colleague is not a permissions flag — it is a real cryptographic operation that re-encrypts the secret to their key, which is also why revoking access means something here.
That design has consequences you should understand before you commit. Your private key lives in your browser extension, not on the server, so the browser extension is not optional — it is the client, and it's how you log in. Account recovery is a genuine process rather than a "reset password" link. And the server needs accurate time and working email for any of it to function, for reasons the next sections spell out.
What you get in exchange is a credential vault designed around a team rather than a person: a folder tree with per-folder permissions, group-based sharing, and a model where "who can decrypt this" is answered by cryptography instead of by an ACL check in application code.
Be honest with yourself about the edition. The community edition is AGPL, free, and has no user cap — vaults, folders, groups, sharing, and the browser and mobile clients are all there. What is pro-only: single sign-on, LDAP/Active Directory provisioning, MFA policies and password policies (the org-wide enforcement, not the per-user setup), and the activity/audit log. If your reason for self-hosting is "we need SSO and an audit trail for compliance", CE will not get you there and you should price the pro licence before you build. If you want a strong team vault for a team that will manage its own users, CE is complete.
Server sizing
Passbolt publishes its own minimum, and it's the number to work from: 2 CPU cores, 2 GB RAM, 20 GB disk. That covers both containers in the official compose file — the PHP application and the MariaDB database beside it.
This is a PHP application, which means memory use scales with concurrent requests rather than with the number of secrets you store. A vault with thousands of passwords is a small database; twenty people all opening the web UI at once is what actually costs you RAM. For a small team, the published minimum is genuinely adequate. For a few dozen active users, going to 4 GB buys you headroom and costs very little.
Disk is not the constraint either — the database is small and Passbolt does not store large attachments the way a file-sync app would. The 20 GB figure is mostly about having room for the container images, the database, logs, and your own backups without ever thinking about it.
One thing worth spending money on: a host with a reliable clock and dependable uptime. Clock drift breaks GPG authentication outright (more on that below), so this is not the workload for the flakiest box on the market. A Hetzner instance in the 2–4 GB tier is the value pick, Kamatera lets you size cores and memory independently, and DigitalOcean is the low-effort option with snapshots one click away. All three are at the end.
Prepare the server
This guide assumes Docker Engine and the Compose plugin are installed, along with a
non-root deploy user, a ufw firewall, and unattended security updates. If not,
work through Docker & Compose on Ubuntu
first — it's the base layer for every app on this site.
Check the firewall exposes only SSH and the web ports:
sudo ufw status verbose
OpenSSH, 80/tcp, and 443/tcp allowed; everything else denied. The database
container is never published to the host at all — it talks to the app over the
Compose network and nothing else needs to reach it.
Now the step people skip, and the one that produces the most baffling failures:
make sure the clock is right. Passbolt authenticates users with a GPG challenge,
and a GPG exchange between a client and a server whose clocks disagree fails with
errors that look like anything except a time problem. Ubuntu runs systemd-timesyncd
by default; confirm it's actually synchronized rather than assuming it:
timedatectl status
You want System clock synchronized: yes and NTP service: active. If it isn't,
fix that before you go any further:
sudo timedatectl set-ntp true
Point DNS, too. Create an A record for your Passbolt hostname (say
passbolt.example.com) pointing at the server's public IP and wait for it to
resolve — dig +short passbolt.example.com. The base URL is baked into the
configuration and into the setup links Passbolt emails, so having the name live
before you start saves a round of reconfiguration.
Install Passbolt (Docker Compose)
Passbolt publishes an official Compose file, and — unusually and commendably — a checksum to verify it against. Use it. You are about to run a credential store; the five seconds it costs to confirm the file is the one upstream published is five seconds well spent.
mkdir ~/passbolt && cd ~/passbolt
# Passbolt CE — official compose file, checksum-verified
curl -LO https://download.passbolt.com/ce/docker/docker-compose-ce.yaml
curl -LO https://github.com/passbolt/passbolt_docker/releases/latest/download/docker-compose-ce-SHA512SUM.txt
sha512sum -c docker-compose-ce-SHA512SUM.txt
You want to see docker-compose-ce.yaml: OK. If you don't, stop and work out why
before running anything.
Before starting it, edit the file. Three changes matter:
1. Set your real base URL. The compose file ships with a placeholder:
environment:
APP_FULL_BASE_URL: https://passbolt.example.com
This must match how users actually reach the server, scheme included. It's used for every link Passbolt generates, including the setup links it emails to new users — a wrong value here produces invitations that lead nowhere.
2. Change the database passwords. The published file contains sample credentials
in both the db service and the passbolt service, and they must match each other:
db:
environment:
MYSQL_DATABASE: "passbolt"
MYSQL_USER: "passbolt"
MYSQL_PASSWORD: "a-long-random-password-you-generated"
passbolt:
environment:
DATASOURCES_DEFAULT_HOST: "db"
DATASOURCES_DEFAULT_USERNAME: "passbolt"
DATASOURCES_DEFAULT_PASSWORD: "a-long-random-password-you-generated"
DATASOURCES_DEFAULT_DATABASE: "passbolt"
Generate one with openssl rand -base64 32. Shipping the sample password on an
internet-facing vault is not a risk worth taking for the two minutes it saves.
3. Decide about ports. By default the passbolt service publishes 80 and 443
on the host and terminates TLS itself. If this box does nothing else, that's fine.
If you'd rather use Caddy — which is what the rest of this site does, and which
makes certificate renewal somebody else's problem — change the published ports to
loopback-only instead:
ports:
- "127.0.0.1:8081:80"
That's the setup the HTTPS section below assumes. Bring the stack up:
docker compose -f docker-compose-ce.yaml up -d
docker compose -f docker-compose-ce.yaml logs --tail 50
First start takes a minute or two: the app waits for MariaDB, runs its migrations,
and generates the server's OpenPGP keypair into the gpg_volume. That keypair
is not a detail you can regenerate later without pain — note now that it lives in a
named volume, and see Backups.
Also worth doing before you go to production: pin the image tag. The compose file
uses a floating tag, and upstream explicitly recommends replacing it with the
specific version you intend to run so an unrelated docker compose pull can't move
your vault to a new major version on a Tuesday.
HTTPS + domain
Passbolt over plain HTTP is not a thing you should ship. The browser extension is doing real cryptography against this origin; secure-context browser APIs, cookie handling, and the setup flow all assume TLS.
If you left the container publishing 80 and 443 directly, Passbolt's own nginx handles TLS and upstream's certificate documentation covers the options. The simpler path, and the one consistent with everything else on this site, is to put Caddy in front of the loopback port you published above. Follow Automatic HTTPS with Caddy for the full setup; the Caddyfile entry is a single block:
passbolt.example.com {
reverse_proxy 127.0.0.1:8081
}
Caddy fetches and renews the Let's Encrypt certificate on its own, terminates TLS on
443, and forwards to Passbolt on the loopback port. It also sets the
X-Forwarded-Proto header Passbolt needs to know it's being served over HTTPS —
without that, the app generates http:// links behind an HTTPS proxy and the login
flow breaks in confusing ways.
The usual container caveat applies: if Caddy runs as a Docker container rather
than on the host, 127.0.0.1 inside it is the container's own loopback. Either give
the Caddy service network_mode: host, or put Caddy in the same Compose file and
proxy to the service name (reverse_proxy passbolt:80) with no host port publish at
all.
Whatever you choose, APP_FULL_BASE_URL must match the final public URL exactly.
Configure email (SMTP) — required
Say it plainly: Passbolt does not work without email. This is not the usual "configure SMTP for notifications" step you can defer. Invitations are emailed setup links. Account recovery is emailed. The whole onboarding path for every user after you runs through the mail server. Deploy this without SMTP and you have a vault you cannot add anyone to.
Add the mail block to the passbolt service's environment:
environment:
APP_FULL_BASE_URL: https://passbolt.example.com
EMAIL_DEFAULT_FROM: "passbolt@example.com"
EMAIL_DEFAULT_FROM_NAME: "Passbolt"
EMAIL_TRANSPORT_DEFAULT_HOST: "smtp.example.com"
EMAIL_TRANSPORT_DEFAULT_PORT: "587"
EMAIL_TRANSPORT_DEFAULT_USERNAME: "your-smtp-user"
EMAIL_TRANSPORT_DEFAULT_PASSWORD: "your-smtp-password"
EMAIL_TRANSPORT_DEFAULT_TLS: "true"
Then recreate the container:
docker compose -f docker-compose-ce.yaml up -d
Use a transactional email provider rather than sending directly from the VPS — setup links that land in spam are indistinguishable, from the user's side, from a broken server. Test it with the first admin registration below: if the setup link arrives, email works.
First-run setup
The first administrator is created from the command line, not through the browser. There is no open sign-up page to race you to it, which is the right default for a credential store.
# Passbolt creates its schema on first boot, and register_user fails with
# "Table 'passbolt.users' doesn't exist" if you race it. Wait for the tables:
until docker compose -f docker-compose-ce.yaml exec -T db \
mariadb -upassbolt -pP4ssb0lt passbolt -e "select 1 from users limit 1" >/dev/null 2>&1; do
sleep 5
done
docker compose -f docker-compose-ce.yaml exec passbolt \
su -m -c "/usr/share/php/passbolt/bin/cake passbolt register_user \
-u you@example.com -f Ada -l Lovelace -r admin" -s /bin/sh www-data
The command prints a setup URL and emails the same link to the address you gave. Open it in a browser that has the Passbolt browser extension installed — the extension is what generates your OpenPGP keypair, so the setup flow cannot complete without it. You'll choose a passphrase, the extension will generate your key, and Passbolt will hand you a recovery kit to download.
Download the recovery kit and store it somewhere safe, outside Passbolt. It is your private key. Lose it and your passphrase, and there is no administrator on earth who can decrypt your secrets for you — that is the whole point of the design. This is the moment to be deliberate rather than to click through.
From there you're in. Add users the same way (either the register_user command or
the admin UI), build a folder structure that matches how your team actually splits
responsibility, and share folders to groups rather than to individuals wherever you
can — group membership is much easier to audit later than a scatter of per-secret
grants.
Hardening
-
Enable MFA for every account. CE supports per-user multi-factor setup (TOTP and hardware tokens); what CE lacks is the org-wide policy that forces it. So make it a rule you enforce socially and verify by eye until you have a pro licence — an unenforced MFA option is one people quietly skip.
-
Prefer group-based sharing. Passbolt re-encrypts secrets on every share, so large permission changes are real work for the server and real audit surface for you. Groups keep both bounded.
-
Watch the clock, continuously. NTP is not a one-time setup step here — if
timedatectlever stops reporting a synchronized clock, logins start failing. It's worth a line in whatever monitoring you already run. -
Keep the host tight. SSH keys only, no password auth,
ufwlimited to 22/80/443, unattended security updates on. The database container should stay unpublished — checkdocker psand confirm nothing maps MariaDB to a host port. -
Pin the image and update deliberately. See Upgrades.
Backups
There are two things to back up, and a backup missing either one is worthless:
- The MariaDB database — every secret, user, folder, and permission.
- The
gpg_volume— the server's OpenPGP keypair.
That second one is the trap. The server key is what the clients validate the server against during authentication. Restore the database against a freshly generated server key and you get an instance that starts cleanly and refuses every login. Back them up together, restore them together.
Dump the database with the stack running:
Substitute your own database password:
cd ~/passbolt
docker compose -f docker-compose-ce.yaml exec db \
mysqldump --user=passbolt --password=YOUR_DB_PASSWORD passbolt \
> passbolt-db.sql
Then capture the GPG volume and the JWT volume (the latter holds the keys signing API tokens — losing it logs the mobile clients out) and encrypt the lot:
docker run --rm \
-v passbolt_gpg_volume:/gpg:ro \
-v passbolt_jwt_volume:/jwt:ro \
-v "$PWD":/backup alpine \
tar czf /backup/passbolt-keys.tar.gz -C / gpg jwt
tar czf - passbolt-db.sql passbolt-keys.tar.gz docker-compose-ce.yaml \
| gpg --symmetric --cipher-algo AES256 \
-o "passbolt-$(date +%F).tar.gz.gpg"
Check your actual volume names first with docker volume ls — Compose prefixes them
with the project directory name, so they'll match ~/passbolt if that's where you
put things.
Copy the encrypted archive off the server. Then, the part everyone skips: test a restore. Bring up a throwaway instance with the restored database and GPG volume and confirm you can log in with your recovery kit. An untested backup of a password vault is a guess.
Upgrades
Because you pinned the image tag, upgrading is a deliberate edit rather than a surprise:
cd ~/passbolt
# back up first — see above
# edit docker-compose-ce.yaml to the new tag, then:
docker compose -f docker-compose-ce.yaml pull
docker compose -f docker-compose-ce.yaml up -d
docker compose -f docker-compose-ce.yaml logs --tail 50
The container runs any pending database migrations on start; watch the logs until it settles rather than assuming. Read upstream's release notes before a major version jump — this is a PHP application with a real schema, and major upgrades occasionally have an ordering requirement worth knowing about in advance.
If you left the tag floating, pull will happily move you across a major version
with no warning. Pin it.
Troubleshooting
Login fails with a GPG or authentication error. Check the clock first, every
time: timedatectl status on the server, and make sure the user's own machine isn't
badly skewed either. This is the single most common cause and it never looks like a
time problem from the error message.
Setup links or invitations never arrive. SMTP. Check the app's logs
(docker compose -f docker-compose-ce.yaml logs passbolt) for send failures,
confirm the EMAIL_TRANSPORT_DEFAULT_* values, and remember the container only
reads them at start — recreate it after an edit. Also check the spam folder before
concluding the server is broken.
Everything loads but links point at the wrong host, or the login flow loops.
APP_FULL_BASE_URL doesn't match the public URL, or the proxy isn't passing
X-Forwarded-Proto. Set the base URL to the exact https:// origin users type and
recreate the container.
The app container restarts waiting for the database. The compose file has the
app wait for MariaDB, so a loop here usually means the two DATASOURCES_DEFAULT_*
credentials don't match what the db service was initialized with. Note that
MariaDB only applies those environment values on a fresh data volume — if you
changed the password after first start, remove the database volume and start over
(you have no data yet at this point) rather than trying to reconcile it.
The browser extension can't find the server. The extension must be pointed at your instance and needs a valid certificate. A self-signed cert will fail here even where a browser would let you click through.
Verification + next steps
You're done when you can: load https://passbolt.example.com with a valid
certificate, complete the setup flow for the first admin in a browser with the
extension installed, receive that setup email (proving SMTP works), store and share a
secret with a second user, confirm timedatectl reports a synchronized clock, and
produce an encrypted, off-box backup of both the database and the GPG volume
that you've restored at least once.
After that it's a low-maintenance system with one unusual property worth remembering: its security model puts the keys with the users, so your job as the administrator is mostly to keep the server honest — right clock, valid certificate, working email, two backups that travel together. A Hetzner instance in the 2–4 GB tier is the value pick; Kamatera when you want to size cores and memory independently; DigitalOcean when you want snapshots in the same dashboard. See Best VPS for self-hosting for the ranked picks.