In today's infrastructure ecosystem, credential security is non-negotiable. Recently, I dove into the challenge of implementing and migrating robust password managers (Passbolt and Vaultwarden) in Rocky Linux environments, transitioning from the traditional Docker model to the sovereignty of Podman.
If you are looking for a resilient, protected implementation that is easy to manage remotely, this guide gathers lessons learned "in the trenches" of both worlds.
⚠️ Security warnings before you start#
About the GPG private key#
The GPG private key is the sole artifact that decrypts every password stored in Passbolt.
- If the private key is lost, all data is UNRECOVERABLE. There is no reset. There is no support that can recover it.
- Store the key in a secure location off the server (encrypted USB drive, offline password vault).
- Keep encrypted backups in multiple independent physical locations.
- Never send the private key via email or chat, even internally.
What "ultimate trust" means in GPG#
GPG has 6 trust levels for keys:
| Level | Code | Meaning |
|---|---|---|
| Unknown | 2 | Trust unknown or undefined |
| Not Trusted | 3 | Key explicitly untrusted |
| Marginally Trusted | 4 | Marginal trust (requires multiple validations) |
| Fully Trusted | 5 | Fully trusted, can validate third-party signatures |
| Ultimate Trust | 6 | Absolute trust - required for Passbolt to function |
| Undefined | - | No validation (default for imported keys) |
Passbolt requires level 6 (Ultimate Trust) in the www-data user's keyring. If the level is incorrect, the container will not boot and the healthcheck returns FAIL. The value 6 in the format FINGERPRINT:6: is what the --import-ownertrust command expects.
# Check the current trust level of the key
docker exec passbolt su -s /bin/bash -c \
"gpg --homedir /var/lib/passbolt/.gnupg --list-ownertrust" www-data
# Expected output with Ultimate Trust configured:
# 3AA5C34371567BD2:6:
# ^ this "6" is Ultimate Trust
1) The initial clash: passbolt vs. vaultwarden#
Before "getting your hands dirty" with commands, you must choose the right tool for your scenario:
| Criterion | Passbolt | Vaultwarden |
|---|---|---|
| Encryption | Individual OpenPGP (Military Standard) | AES-256 (Market Standard) |
| Footprint (RAM) | Moderate (~512MB+) | Ultra Light (<100MB) |
| Complexity | High (GPG key management) | Low (Plug and Play) |
| Architecture | PHP/Go/GPG | Rust (Bitwarden API-compatible) |
| Target Audience | DevOps and Teams with Auditing | General Use and Small Projects |
| Recovery | Impossible without private key | Via database backup + master key |
Verdict: Passbolt is the logical choice for those requiring granular auditing and native PGP end-to-end encryption. Vaultwarden shines for its extreme resource efficiency and operational simplicity.
2) Backup before any operation#
# ---- Docker ----
# MariaDB database backup
docker exec passbolt-db mysqldump \
-u root -p'MARIADB_ROOT_PASSWORD' passbolt \
> /root/passbolt-db-backup-$(date +%Y%m%d-%H%M).sql
# GPG keyring backup (CRITICAL - no recovery without this)
docker cp passbolt:/var/lib/passbolt/.gnupg /root/passbolt-gnupg-backup-$(date +%Y%m%d-%H%M)
# Full data volume backup
docker run --rm \
-v passbolt_data:/data \
-v /root:/backup \
alpine tar czf /backup/passbolt-volume-$(date +%Y%m%d-%H%M).tar.gz /data
# ---- Podman ----
# MariaDB database backup
podman exec passbolt-db mysqldump \
-u root -p'MARIADB_ROOT_PASSWORD' passbolt \
> /root/passbolt-db-backup-$(date +%Y%m%d-%H%M).sql
# GPG keyring backup
podman cp passbolt:/var/lib/passbolt/.gnupg /root/passbolt-gnupg-backup-$(date +%Y%m%d-%H%M)
# Verify backup integrity
ls -lh /root/passbolt-*
# Expected: .sql file at minimum a few KB (empty DB) to MB (with data)
# .gnupg backup: a few KB (contains public key + ownertrust metadata)
3) Docker implementation: the GPG challenge#
When spinning up Passbolt via Docker, the most common issue is a Healthcheck FAIL related to OpenPGP.
The investigation (the 500 error)#
Passbolt depends on the Linux GPG engine having the key imported into the www-data user's keyring with Ultimate Trust. If the environment variables aren't perfectly aligned, the container will abort its boot sequence.
The Corrective "Ace Up Your Sleeve":
# 1. Clear residual configurations that might ignore new variables
rm /etc/passbolt/passbolt.php
su -s /bin/bash -c "bin/cake cache clear_all" www-data
# 2. Ensure 'Ultimate Trust' (Replace YOUR_FINGERPRINT)
echo "YOUR_FINGERPRINT:6:" | su -s /bin/bash -c \
"gpg --homedir /var/lib/passbolt/.gnupg --import-ownertrust" www-data
# 3. Synchronize the keyring with the database
su -s /bin/bash -c "bin/cake passbolt keyring_init" www-data
GPG verification after configuration#
# Verify that the key was imported correctly
docker exec passbolt su -s /bin/bash -c \
"gpg --homedir /var/lib/passbolt/.gnupg --list-keys" www-data
# Verify Ultimate Trust is configured (must show :6:)
docker exec passbolt su -s /bin/bash -c \
"gpg --homedir /var/lib/passbolt/.gnupg --list-ownertrust" www-data
# Check keyring permissions (should be 700 for .gnupg directory)
docker exec passbolt ls -la /var/lib/passbolt/.gnupg/
# Check ownership (should be www-data:www-data)
docker exec passbolt stat /var/lib/passbolt/.gnupg/
# Verify www-data can access the keyring (real access test)
docker exec passbolt su -s /bin/bash -c \
"ls -la /var/lib/passbolt/.gnupg/" www-data
MariaDB verification#
# Verify the database container is running
docker ps | grep passbolt-db
# Verify connection and list databases
docker exec passbolt-db mysql \
-u root -p'MARIADB_ROOT_PASSWORD' \
-e "SHOW DATABASES;"
# Confirm the Passbolt schema exists
docker exec passbolt-db mysql \
-u root -p'MARIADB_ROOT_PASSWORD' \
-e "SHOW TABLES FROM passbolt;" | head -20
4) The evolution: orchestrating passbolt with Podman on Rocky Linux#
Moving from Docker to Podman provides a daemonless model and rootless execution, significantly enhancing VPS security.
4.1 resolving privileged port conflicts#
In rootless mode, Podman cannot bind ports below 1024. The Error: rootlessport cannot expose privileged port 80 error is common. The Solution: Use high ports (e.g., 9001) and map them correctly in your podman-compose.yaml.
services:
passbolt:
image: docker.io/passbolt/passbolt:latest-ce
ports:
- "9001:80"
environment:
- APP_FULL_BASE_URL=https://mydomain.com:9001
restart: always
4.2 clearing locks and ensuring persistence#
If a container crashes abruptly, Podman might lock the metadata files (acquiring lock: file exists). The command to clear the environment is:
podman system renumber
To ensure Passbolt restarts automatically after a VPS reboot:
sudo systemctl enable podman.socket
sudo systemctl enable podman-restart.service
5) Firewall configuration on Rocky Linux#
Rocky Linux uses firewalld by default. Without opening the port, Passbolt is inaccessible from outside the server.
# Check current firewall state and active rules
sudo firewall-cmd --list-all
# Permanently open port 9001
sudo firewall-cmd --permanent --add-port=9001/tcp
sudo firewall-cmd --reload
# Confirm port was added
sudo firewall-cmd --list-ports
# Expected output: 9001/tcp
# If using iptables directly (alternative)
sudo iptables -A INPUT -p tcp --dport 9001 -j ACCEPT
sudo iptables-save > /etc/sysconfig/iptables
6) Ssl/tls and connectivity verification#
SSL certificate verification#
# Inspect certificate: validity, issuer, and subject
echo | openssl s_client -connect mydomain.com:9001 2>/dev/null \
| openssl x509 -noout -dates -issuer -subject
# Verify active TLS negotiation
curl -vI https://mydomain.com:9001/ 2>&1 | grep -iE "ssl|tls|cipher"
# Test returned HTTP status (should be 200 or 301/302 for login redirect)
curl -sk https://mydomain.com:9001/ -o /dev/null -w "HTTP Status: %{http_code}\n"
Full connectivity check#
# Verify port availability (TCP handshake)
nc -zv mydomain.com 9001
# Verify DNS resolution
dig mydomain.com +short
# Test HTTP service response (first lines of HTML)
curl -sk https://mydomain.com:9001/ | head -10
7) Post-configuration verification (full checklist)#
# --- Container Status ---
# Docker:
docker ps | grep passbolt
docker inspect --format='{{.State.Health.Status}}' passbolt
docker logs passbolt --tail 50
# Podman:
podman ps | grep passbolt
podman inspect --format='{{.State.Health.Status}}' passbolt
podman logs passbolt --tail 50
# --- Connectivity ---
curl -I https://mydomain.com:9001/
# Expected healthcheck output: "healthy"
# Expected curl output: HTTP/1.1 200 OK or 302 Found (redirect to login)
8) Elite management: connecting Podman desktop to your VPS#
A modern analyst doesn't want to rely solely on the terminal. Managing logs and status visually via Podman Desktop (on your local Linux machine) is a total game changer.
The "pro tip": root vs user in SSH#
If you deployed the service as root on the VPS, a standard unprivileged SSH user will not see the containers due to Podman's UID isolation.
Command to establish the correct connection:
# Cleaning old keys if there's a mismatch error
ssh-keygen -R mydomain.com
# Adding the system connection to the remote host
podman system connection add ovh-vps --identity ~/.ssh/id_rsa ssh://[email protected]
podman system connection default ovh-vps
9) Container updates#
# ---- Docker ----
# 1. Run backup FIRST (see Section 2)
# 2. Pull new images
docker pull passbolt/passbolt:latest-ce
docker pull mariadb:10.11
# 3. With docker-compose (recommended - preserves volumes automatically)
docker-compose pull
docker-compose up -d
# Compose recreates containers with the new image while keeping volumes
# ---- Podman ----
podman pull docker.io/passbolt/passbolt:latest-ce
podman-compose pull
podman-compose up -d
# ---- Post-update verification ----
docker inspect --format='{{.State.Health.Status}}' passbolt
# Wait 30-60s for healthcheck to return "healthy"
10) Automated monitoring#
# Create the health check script
cat > /usr/local/bin/check-passbolt.sh << 'EOF'
#!/bin/bash
# Passbolt health monitor (Docker or Podman)
# Scheduled via cron: */5 * * * * /usr/local/bin/check-passbolt.sh
RUNTIME="${1:-docker}" # docker or podman
CONTAINER="passbolt"
ADMIN_EMAIL="[email protected]"
HEALTH=$($RUNTIME inspect --format='{{.State.Health.Status}}' "$CONTAINER" 2>/dev/null)
RUNNING=$($RUNTIME ps --filter "name=$CONTAINER" --format "{{.Status}}" 2>/dev/null)
if [ "$HEALTH" != "healthy" ] || [ -z "$RUNNING" ]; then
MSG="ALERT: Passbolt status - Health: ${HEALTH:-N/A} | Running: ${RUNNING:-not found}"
echo "$MSG"
echo "$MSG" | mail -s "🚨 Passbolt Alert $(date +%Y-%m-%d\ %H:%M)" "$ADMIN_EMAIL"
fi
EOF
chmod +x /usr/local/bin/check-passbolt.sh
# Add to cron (check every 5 minutes)
(crontab -l 2>/dev/null; echo "*/5 * * * * /usr/local/bin/check-passbolt.sh docker") | crontab -
# Verify cron was added
crontab -l | grep passbolt
11) Production checklist (SRE insights)#
- Total SSL/HTTPS: Modern encryption APIs (
Web Crypto API) require a secure context. Without HTTPS, the Passbolt browser plugin will not initialize. - Key Backups: In Passbolt, if a user loses their private key file (
.asc), the data is unrecoverable. This is a security feature, not a bug. - MariaDB 10.11+: Always use the
MARIADB_prefix for environment variables (e.g.,MARIADB_ROOT_PASSWORD). - Firewall: Only open the required port (9001) in firewalld. Never expose MariaDB port 3306 publicly.
- Monitoring: Set up the automated health check (Section 10) on day one of production - not when the service goes down.
- Backups: Schedule daily MariaDB and GPG keyring backups. Test restoration periodically.
Production takeaways#
Implementing Passbolt requires understanding the subtle details of GPG keyring permissions and, when transitioning to Podman, mastering namespace names and rootless ports. Having this setup operating with visual management from your desktop transforms a password manager into a world-class infrastructure tool. And with the backup, verification, and monitoring procedures documented in this guide, you have a complete runbook - not just for installation day, but for months of secure production operation.
Was this article helpful?
Leave a quick reaction to help prioritize future technical guides:
This post is licensed under CC BY-NC.



Comments
Join the discussion below.
0 comments