Self-hosted secrets managers compared
Which self-hosted secrets manager belongs on one VPS: OpenBao, Infisical, SOPS with age, systemd credentials, or a locked down env file, and what each costs.
What a self-hosted secrets manager does that a password manager does not
A self-hosted secrets manager hands credentials to processes. A password manager hands credentials to people. Everything else follows from that one difference. A password manager is unlocked by a human who is present and paying attention. A secrets manager has to give your application a database password at 03:00 when nobody is awake.
The failure modes are different in a way that matters. A locked password manager is an inconvenience: you type the master password again. A sealed secrets manager is an outage: every service that restarts while it is sealed comes up without credentials and stays down. Running Vaultwarden as your own password manager solves the human problem well. It does not solve the machine problem, and it was never built to.
The realistic options for one server fall into two groups. OpenBao and Infisical are services: an API, a database, TLS (transport layer security), a login step, and a process you now have to keep alive. SOPS with age, systemd credentials and Docker secrets are files: encrypted at rest, decrypted by something already running, with nothing extra to monitor.
Here is the honest answer up front. For a single box with one or two people on it, the file based options are usually correct. An OpenBao that nobody unseals properly and nobody rotates is worse than a mode 600 env file, because it adds a moving part and a backup you will get wrong, and it buys you no rotation you were not already doing by hand.
Is a mode 600 env file good enough?
Often, yes. The threat it defends against is another user on the box reading your database password. Unix file permissions do that, and they do it before the network is up.
sudo install -d -m 750 -o root -g myapp /etc/myapp
sudo install -m 640 -o root -g myapp /dev/null /etc/myapp/env
sudoedit /etc/myapp/envCheck it from both sides:
sudo -u myapp cat /etc/myapp/env
sudo -u nobody cat /etc/myapp/envThe first prints the file. The second prints cat: /etc/myapp/env: Permission denied, because nobody is not in the myapp group and the file carries no world bits. That is the whole security model, and it is a real one.
The leak is what happens next. A systemd unit with EnvironmentFile= copies those values into the process environment, and the process environment is readable.
[Service]
User=myapp
EnvironmentFile=/etc/myapp/env
ExecStart=/usr/local/bin/myappsudo cat /proc/$(pgrep -n myapp)/environ | tr '\0' '\n'That prints your secrets in cleartext, because /proc/<pid>/environ is readable by root and by the user the process runs as. A crash reporter that attaches the environment to a report sees the same thing. So does any tool running under the same account, which is why keeping secrets out of AI agents starts with getting them out of the environment. Pair the file with a dedicated low privilege service user so that "the user the process runs as" is not root.
SOPS with age: encrypted secrets you can commit to git
SOPS (secrets operations) encrypts the values in a YAML or JSON file and leaves the keys in cleartext. age is a small encryption tool that gives you one key pair and no key server. Together they let you commit secrets.enc.yaml next to your code, and git diff still tells you which setting changed without telling a reader what it changed to.
age is packaged in Ubuntu 24.04. SOPS is not, so take the .deb from the release page. Version 3.13.3 was current as of August 2026.
sudo apt update && sudo apt install -y age
curl -LO https://github.com/getsops/sops/releases/download/v3.13.3/sops_3.13.3_amd64.deb
sudo apt install -y ./sops_3.13.3_amd64.deb
sops --versionGenerate a key pair. age-keygen writes the private key into the file and prints the public key, so you will see a line beginning Public key: age1....
mkdir -p ~/.config/sops/age
age-keygen -o ~/.config/sops/age/keys.txt
chmod 600 ~/.config/sops/age/keys.txt
age-keygen -y ~/.config/sops/age/keys.txtPut the public key in .sops.yaml at the root of the repository, so you never have to remember the recipient on the command line.
creation_rules:
- age: age1s3cqcks5genc6ru8chl0hkkd04zmxvczsvdxq99ekffe4gmvjpzsedk23csops encrypt secrets.yaml > secrets.enc.yaml
sops decrypt secrets.enc.yamlA rule with no path_regex matches everything, which is what you want at first. If you add one later, write it to match the file you pass to sops, because rules are checked against the input path and not against the file you redirect the output into.
At runtime, hand the values to one process and nothing else:
sops exec-env secrets.enc.yaml './myapp'sops exec-env decrypts in memory and sets the values in the child process environment, so no plaintext is written to disk. The environment caveat from the previous section still applies to that child.
Two things bite people here. The error Failed to get the data key required to decrypt the SOPS file under systemd almost always means SOPS looked in the wrong home directory, because a unit does not inherit your HOME. Set the path explicitly with Environment=SOPS_AGE_KEY_FILE=/etc/sops/age.txt in the unit. Separately, editing .sops.yaml does not re-encrypt anything that already exists: adding a colleague's public key affects new files only, so run sops updatekeys secrets.enc.yaml on each existing file. If your configuration already runs through Ansible, encrypting the same values with Ansible Vault reaches the same place without a second tool.
systemd credentials: secrets that never reach the environment
Ubuntu 24.04 ships systemd 255, so this needs no install. systemd-creds encrypts a secret on the host, and systemd decrypts it into a private directory that only the one service can read.
sudo systemd-creds setup
sudo install -d -m 700 /etc/myapp
echo -n 'hunter2' | sudo systemd-creds encrypt --name=db_password - /etc/myapp/db_password.cred[Service]
User=myapp
LoadCredentialEncrypted=db_password:/etc/myapp/db_password.cred
ExecStart=/usr/local/bin/myappThe service reads the value from a file called db_password inside the directory named by $CREDENTIALS_DIRECTORY. The value is not in the environment, so /proc/<pid>/environ shows nothing useful, and the plaintext never lands on the root filesystem.
Verify the file decrypts before you point a unit at it:
sudo systemd-creds decrypt /etc/myapp/db_password.cred -Know which key encrypted it, because that decides whether your backup is any use. The default --with-key=auto uses the TPM2 (trusted platform module version 2) chip when one is present and usable, and the host key otherwise. Most VPS instances have no TPM2.
systemd-analyze has-tpm2no means the host key was used, and that key lives in /var/lib/systemd/credential.secret, readable only by root. Restore db_password.cred onto a fresh VPS without that file and nothing decrypts it, ever. Copy credential.secret into the same backup, or keep the plaintext somewhere you can still reach.
Docker secrets: files under /run/secrets
Compose reads a file from the host and mounts it into the container at /run/secrets/<name>.
services:
app:
image: myapp:latest
environment:
DB_PASSWORD_FILE: /run/secrets/db_password
secrets:
- db_password
secrets:
db_password:
file: ./db_password.txtdocker compose exec app cat /run/secrets/db_password
docker compose exec app env | grep -i passwordThe first prints the secret. The second prints only DB_PASSWORD_FILE=/run/secrets/db_password, which is the point: the value is never in the container environment, so it does not show up in docker inspect output. Many official images already expect this shape, and the Postgres image reads POSTGRES_PASSWORD_FILE exactly this way.
Be clear about what this is. Outside Swarm mode there is no encryption at any layer: ./db_password.txt is a plaintext file on the host, and its only protection is its mode and its owner. Set both yourself, because Compose will happily mount a world readable file without complaining. The wider set of tradeoffs against the plain env_file shortcut is in the guide to Compose env files and secrets.
What OpenBao and Vault really cost to run
OpenBao is the Linux Foundation fork of HashiCorp Vault, started after HashiCorp relicensed Vault under the Business Source License in 2023. OpenBao stays under MPL 2.0 (Mozilla Public License). Release 2.6.2 was current as of August 2026. Almost everything below applies to Vault too, because the fork kept the same command surface.
docker pull docker.io/openbao/openbaoDebian and Ubuntu packages are on the OpenBao downloads page if you would rather apt managed the upgrades. The server needs a config file holding a listener and a storage backend:
listener "tcp" {
address = "127.0.0.1:8200"
tls_cert_file = "/path/to/full-chain.pem"
tls_key_file = "/path/to/private-key.pem"
}
storage "raft" {
path = "/path/to/raft/data"
node_id = "raft_node_1"
}Then start it once:
bao operator initBy default that splits the root key into 5 shares and requires 3 of them to unseal, which are the -key-shares and -key-threshold flags. It prints the shares and the initial root token once and never again.
Now the part most comparisons skip. A restarted server is a sealed server. OpenBao holds the root key in memory only, so after a restart it cannot decrypt its own storage until somebody supplies the threshold of shares. A kernel update or an out of memory kill therefore ends with a sealed server and applications that cannot log in.
On a one person VPS the Shamir split protects nothing, because all five shares end up in the same password manager belonging to the same person. Auto unseal moves the key to a trusted device or service, which on a large cloud means a managed key service and on your VPS usually means a key file sitting on the same disk as the data it protects. That is a genuine reduction in security, traded for a server that comes back on its own after a reboot. Make the trade knowingly and write down which way you went.
Infisical: a UI, a database, and a master key you still hold
Infisical is a secrets platform with a web interface, projects, environments and per user access control. Self-hosting it with Compose is short:
curl -o docker-compose.prod.yml https://raw.githubusercontent.com/Infisical/infisical/main/docker-compose.prod.yml
curl -o .env https://raw.githubusercontent.com/Infisical/infisical/main/.env.example
docker compose -f docker-compose.prod.yml up -dEdit .env before that last command. Two values must be yours, and one of them must never change afterwards:
openssl rand -hex 16
openssl rand -base64 32The first is ENCRYPTION_KEY, a 16 byte hex string. It is the key your secrets are encrypted with inside PostgreSQL, so losing it turns a perfect database backup into a pile of ciphertext, and changing it on a running instance stops existing secrets from decrypting. The second is AUTH_SECRET, a 32 byte base64 string used for sessions. SITE_URL must be the absolute URL you will really reach, protocol included, or the login redirect breaks.
Infisical fits better than OpenBao when the thing you actually need is people: a web interface for a small team and separation between environments, rather than database credentials that expire on their own. It costs you PostgreSQL, Redis and a TLS certificate, all of which you now patch and back up.
What happens when the secrets service is down and your app restarts
This question decides whether a secrets service belongs on a single box. Files are readable before the network starts. A service is not.
Reboot the box and your application and OpenBao start at the same moment. The application asks for its database password, OpenBao is still sealed, the request fails, and systemd restarts the application in a loop until a human pastes in unseal shares. Nothing is broken. Nothing is up either.
There are two honest ways to handle it. Order the units and let the application retry: After= the secrets service, plus Restart=on-failure and a RestartSec= long enough that you are not hammering the API. Or fetch at deploy time instead of at boot: render the secret into a mode 600 file or a systemd credential, so the running system depends on a file rather than on an API.
Token expiry is the same problem on a slower clock. OpenBao tokens and leases carry a time to live, so a long running process that never renews loses access at a moment unrelated to any deploy. That failure is confusing precisely because nothing changed that day.
Backing up the store itself
Every option here has a key, and a backup without that key is worthless. Write down where yours lives.
For an env file, the file is the secret, so the backup must be encrypted. For SOPS, the encrypted file can go anywhere public, and the age private key at ~/.config/sops/age/keys.txt is the thing you must not lose. For systemd credentials, back up /var/lib/systemd/credential.secret alongside the .cred files. For Infisical, take a PostgreSQL dump and store ENCRYPTION_KEY somewhere separate from it.
OpenBao with raft storage takes its own snapshot:
bao operator raft snapshot save backup.snap
bao operator raft snapshot restore backup.snapThe snapshot holds your encrypted storage, so restoring it into a fresh server still needs the unseal shares from bao operator init. A nightly job that copies snapshots to object storage while the shares are stored nowhere is a backup of nothing. Test the restore on a throwaway VPS before you depend on it.
Audit logging: who read which secret
Files give you no audit trail. The mode and the owner tell you who could read the secret. They never tell you who did. auditd with a watch on the path is the closest substitute, and it reports that a file was opened, not which value was used.
OpenBao logs every request to an audit device you enable explicitly:
bao audit enable file file_path=/var/log/openbao_audit.logTwo facts about that log change how you run the server. Most strings in requests and responses are hashed with HMAC-SHA256 and a salt, so you can match a value you already know against the log without the log itself carrying plaintext. Integers and booleans are written through in the clear, so a numeric secret gets no protection from that hashing.
Then the operational trap: OpenBao will not respond to requests when no enabled audit device can record them, and a device that fails in a blocking way makes requests hang until somebody fixes it. A full disk on /var/log takes your secrets API down by design. Give the audit log its own space and a logrotate rule on the first day, not after the first outage.
Which self-hosted secrets manager should you run?
Count machines and count people, then pick.
- One machine, one person: a mode 600 env file owned by root and read by a service user. Add systemd credentials when you want the value out of the process environment.
- One machine, two to five people, configuration already in git: SOPS with age. Each person gets a key pair, and
.sops.yamllists every public key allowed to decrypt. - Several machines, one configuration repository, no need for credentials that expire: still SOPS with age, with one recipient key per host, so a stolen host key decrypts only that host's files.
- Several machines and several teams that genuinely need database credentials with a lifetime, plus an audit trail somebody reads: OpenBao, and put an hour a month of operator time in the budget for unsealing and restore drills.
The rule underneath all four is the same. Run the smallest thing that meets a requirement you can state out loud, because a secrets manager that is down is indistinguishable from a secrets manager that is empty.
FAQ
Is a self-hosted secrets manager worth it for a single VPS?
Usually not, if you mean a service such as OpenBao or Infisical. On one box with one or two people, a mode 600 env file or a systemd encrypted credential gives the same protection against another local user, with no unseal step and no extra service to patch. A secrets service starts paying for itself once you have several machines and several people, or a real need for credentials that expire without anybody rotating them by hand.
What is the difference between a password manager and a secrets manager?
A password manager stores credentials a person types, and a human unlocks it while they are present. A secrets manager gives credentials to processes, so it has to work at 03:00 with nobody watching. The consequence is what separates them: a locked password manager makes you retype a master password, and a sealed secrets manager stops every service that restarts while it is sealed.
What happens to my apps if OpenBao is sealed after a reboot?
They cannot fetch their secrets, so they fail to start, and systemd restarts them in a loop until somebody supplies the unseal threshold, which is 3 of 5 shares by default. OpenBao keeps the root key in memory only, so every restart seals it again. Either turn on auto unseal, accepting that on a single VPS the unseal key ends up on the same disk as the data, or render secrets to a file at deploy time so that booting never depends on the API.
Can I commit SOPS encrypted files to a public repository?
The values are encrypted, so they are safe from anyone without the age private key. The keys are not encrypted: a reader can see that you hold STRIPE_SECRET_KEY and SMTP_PASSWORD, and how often each one changes. That metadata is acceptable for most projects and unacceptable for a few. Keep the age private key out of the repository, and run sops updatekeys on every existing file whenever you add or remove a recipient.