Skip to content

How to Deploy Psono on a VPS

Updated Aug 2026

Self-host Psono Community Edition on your own VPS — Postgres, generated server keys, a hand-written settings.yaml, and Caddy in front for HTTPS.

Before you start
  • A VPS with 2 vCPU / 4 GB RAM (upstream sizes the server and its database separately — one box needs to cover both)
  • A fresh Ubuntu 26.04 server with root/sudo SSH access
  • A domain you can point at the server — the web client and the API share one hostname
  • Willingness to hand-edit a YAML config; this install has an irreducibly manual step
  • Docker Engine + Compose installed (see the base guide below)

What Psono is

Psono is an end-to-end encrypted password manager aimed at IT teams. A Django API server sits on PostgreSQL, browser extensions and a web client do the encryption on the user's side, and a separate admin portal gives you central management of users, groups, and sessions. The community edition is Apache-licensed with no user cap; single sign-on, audit logging, and centrally enforced policies live in the enterprise build.

The CE install ships as a combo image that bundles the server, the web client, and the portal behind one nginx inside a single container. That sounds simple, and the running result is: one container, one Postgres, one reverse proxy. Getting there is where the work is.

Set expectations honestly before you start. Where Vaultwarden is one container and a volume, Psono wants an external PostgreSQL you provision yourself, a generated server keypair, a hand-written settings.yaml, a client config.json, a migration step, and a promotion command to get into the portal. There is no wizard. Budget an evening rather than five minutes, and read this whole guide once before typing anything — several of the steps are much cheaper to get right the first time than to unpick.

What you get for that effort is a properly designed team vault: real end-to-end encryption, groups and sharing, API keys for automation, and an admin portal that isn't an afterthought. If you'd rather have a wizard than a config file, TeamPass or Vaultwarden will suit you better and there's no shame in that.

Server sizing

Upstream sizes a sub-100-user installation as roughly 1 GB of RAM for the server plus a separate database machine at around 1.5 GB — the recommendation assumes you split the two. On a single VPS you're paying for both halves out of one budget, so 4 GB is the sane figure, with 2 GB being the point below which Postgres and Django start competing for the same page cache.

CPU matters less than you'd expect. Psono's cryptography happens in the client, so the server is mostly doing database reads, session checks, and API responses. Two vCPUs is plenty for a team; the memory line is the one to watch.

Disk is modest: the database holds encrypted blobs and metadata, not files. Twenty gigabytes is comfortable, and most of that is images, logs, and your own backups rather than the data itself.

If you outgrow the single box, the natural next step is exactly the split upstream assumes: move Postgres to its own instance and point settings.yaml at it. Nothing in this guide's layout blocks that later. A Hetzner instance in the 4 GB tier is the value pick; Kamatera lets you size RAM and CPU independently, which suits a workload that wants memory more than cores; Vultr is the option when you want a specific region and a fast rebuild. 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, start with Docker & Compose on Ubuntu — it's the base layer for every app on this site.

Confirm the firewall exposes only SSH and the web ports:

sudo ufw status verbose

OpenSSH, 80/tcp, 443/tcp allowed, everything else denied. Neither Postgres nor the Psono container should ever be reachable from the internet — the reverse proxy is the only public surface.

Point DNS now. Create an A record for your hostname (say psono.example.com) pointing at the server's public IP, and confirm it resolves with dig +short psono.example.com. The hostname goes into three separate config values before you're done, so having it settled first genuinely saves time.

Create the directories the container will mount configuration from:

sudo mkdir -p /opt/docker/psono /opt/docker/psono-client

Provision PostgreSQL first

Psono will not start without a database, and unlike most Compose stacks it does not bring one along — the CE install assumes Postgres is already accepting connections before the app is ever run. So do that first, deliberately:

docker run -d --name psono-db --restart=unless-stopped \
  -e POSTGRES_DB=psono -e POSTGRES_USER=psono -e POSTGRES_PASSWORD=CHANGEME \
  -v psono-db:/var/lib/postgresql/data postgres:16

Replace CHANGEME with something you generate (openssl rand -base64 32) and keep it — it goes into settings.yaml in a moment. Note what this command does not do: it publishes no port. The database is reachable over Docker's default bridge network from other containers on the same host and from nowhere else, which is what you want.

Confirm it's up and healthy before moving on:

docker logs psono-db --tail 20

You're looking for the "database system is ready to accept connections" line. Then find the address the app will use to reach it:

docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' psono-db

That IP is what goes in the HOST field of the database block below. (A named Docker network with service names is tidier long-term; the IP is the shortest path to a working install, and you can migrate to a Compose file once it's running.)

Generate the server keys

Psono's server needs a set of secrets — a Django secret key, an activation-link secret, a database secret, an email salt, and its own keypair. There's a generator in the image for exactly this:

docker run --rm psono/psono-combo:latest python3 ./psono/generateserverkeys.py

It prints a YAML block containing SECRET_KEY, ACTIVATION_LINK_SECRET, DB_SECRET, EMAIL_SECRET_SALT, PRIVATE_KEY, and PUBLIC_KEY.

Copy that output somewhere before you lose the terminal scrollback. These values are not recoverable — regenerating them does not recreate the old ones, and DB_SECRET in particular is what the contents of your database are encrypted against. This is the point where a lost terminal window costs you the install.

Write settings.yaml — the manual step

Here is the part there is no way around, and it's worth stating plainly rather than dressing up: you have to hand-write this file, and you have to paste the generated keys into it yourself. There is no scripted path in the CE install, no template generator, no environment-variable equivalent that covers the whole config. Set aside ten unhurried minutes.

sudo nano /opt/docker/psono/settings.yaml
# Paste the six values from generateserverkeys.py here, verbatim
SECRET_KEY: 'the-secret-key-you-generated'
ACTIVATION_LINK_SECRET: 'the-activation-link-secret-you-generated'
DB_SECRET: 'the-db-secret-you-generated'
EMAIL_SECRET_SALT: 'the-email-secret-salt-you-generated'
PRIVATE_KEY: 'the-private-key-you-generated'
PUBLIC_KEY: 'the-public-key-you-generated'

DEBUG: False
ALLOWED_HOSTS: ['*']
ALLOWED_DOMAINS: ['example.com']

# Both URLs are public-facing and must match what users type.
# The API lives under /server on the same hostname as the web client.
WEB_CLIENT_URL: 'https://psono.example.com'
HOST_URL: 'https://psono.example.com/server'

# Required for the admin portal — the default is False and the portal
# simply will not work until you set this.
MANAGEMENT_ENABLED: True

DATABASES:
    default:
        'ENGINE': 'django.db.backends.postgresql_psycopg2'
        'NAME': 'psono'
        'USER': 'psono'
        'PASSWORD': 'CHANGEME'
        'HOST': '172.17.0.2'
        'PORT': '5432'

# Email — needed for account activation and password recovery
EMAIL_FROM: 'psono@example.com'
EMAIL_HOST: 'smtp.example.com'
EMAIL_HOST_USER: 'your-smtp-user'
EMAIL_HOST_PASSWORD: 'your-smtp-password'
EMAIL_PORT: 587
EMAIL_USE_TLS: True
EMAIL_USE_SSL: False

Three things to get right:

MANAGEMENT_ENABLED: True. The default is False, and with it off the admin portal is simply not there. If you plan to administer this instance — and you do — set it now rather than rediscovering it later from a blank page.

HOST_URL ends in /server. The combo image serves the web client at the root and the API under /server on the same hostname. Getting this wrong produces a client that loads perfectly and cannot talk to anything.

HOST in the database block is the address you looked up above, and PASSWORD must match what you gave the Postgres container.

Now the client configuration, which is a separate file:

sudo nano /opt/docker/psono-client/config.json
{
  "backend_servers": [{
    "title": "Psono",
    "url": "https://psono.example.com/server"
  }],
  "base_url": "https://psono.example.com/",
  "allow_custom_server": false,
  "allow_registration": true,
  "allow_lost_password": true,
  "disable_download_bar": false,
  "remember_me_default": false,
  "trust_device_default": false,
  "authentication_methods": ["AUTHKEY"],
  "saml_provider": []
}

Leave allow_registration on for now — you need it to create the first account — and turn it off in the Hardening section once you have.

The mount trap, and why this order matters

Read this before running the next command, because it costs people an hour.

Both files are bind-mounted as files, not as directories. If a path in a -v /host/path:/container/path mount does not exist on the host, Docker creates it — as a directory. The container then finds a directory where it expected a YAML file, and fails with a mount or parse error that says nothing about the real cause.

So: settings.yaml and config.json must exist as real files before any container mounts them. That's why they were written above and not below. Confirm it before continuing:

file /opt/docker/psono/settings.yaml /opt/docker/psono-client/config.json

Both must report as text files. If either says directory, remove it (sudo rmdir /opt/docker/psono/settings.yaml) and write the file properly.

Migrate and start

With Postgres running and the config files in place, create the schema:

docker run --rm -v /opt/docker/psono/settings.yaml:/root/.psono_server/settings.yaml \
  psono/psono-combo:latest python3 ./psono/manage.py migrate

This is also your first real test of the config: a wrong database host, password, or malformed YAML shows up here, cleanly, before anything is running. A wall of Django migration output ending without an error is what success looks like.

Then start the combo container:

docker run --name psono-combo --sysctl net.core.somaxconn=65535 -d --restart=unless-stopped \
  -v /opt/docker/psono/settings.yaml:/root/.psono_server/settings.yaml \
  -v /opt/docker/psono-client/config.json:/usr/share/nginx/html/config.json \
  -v /opt/docker/psono-client/config.json:/usr/share/nginx/html/portal/config.json \
  -p 10200:80 psono/psono-combo:latest

Note that config.json is mounted twice — once for the web client and once for the portal, which is served from a /portal path inside the same nginx. That's not a typo; both need it.

The container listens on port 80 internally and is published on 10200. For a production install, tighten that publish to loopback only (-p 127.0.0.1:10200:80) so the container is reachable from the reverse proxy and from nothing else. Check it's alive:

docker logs psono-combo --tail 30
curl -I http://127.0.0.1:10200/

HTTPS + domain

Psono is a credential store, so plain HTTP is not an option — and the web client's crypto expects a secure context regardless.

Use Caddy in front of the published port. Follow Automatic HTTPS with Caddy for the full setup; the Caddyfile entry is one block, and — importantly — one hostname for both the client and the API, because the API lives under a path rather than on its own subdomain:

psono.example.com {
    reverse_proxy 127.0.0.1:10200
}

Caddy terminates TLS on 443, obtains and renews the Let's Encrypt certificate itself, and forwards everything — the web client at /, the API at /server, and the admin portal at /portal — to the one upstream. That's the whole config.

The usual container caveat: if Caddy runs as a Docker container, 127.0.0.1 inside it is the container's own loopback, not the host's. Give the Caddy service network_mode: host, or put both on a shared Docker network and proxy to the container name instead.

Finally, make sure WEB_CLIENT_URL, HOST_URL, and the two URLs in config.json all point at this exact https:// hostname. Four values, one hostname — a mismatch in any one of them produces a client that loads and then fails to log in.

First-run setup

Open https://psono.example.com. You should get the Psono web client over a valid certificate.

Register the first account through the normal sign-up form. Depending on your email configuration you may need to activate it from a link — this is where a working SMTP block earns its keep. Keep the username you used; you need it for the next command.

Then promote that account so it can reach the admin portal. This is a command-line step and only the first admin needs it; after that you manage admins from the portal itself:

docker run --rm -v /opt/docker/psono/settings.yaml:/root/.psono_server/settings.yaml \
  psono/psono-combo:latest python3 ./psono/manage.py promoteuser \
  you@example.com superuser

Now visit https://psono.example.com/portal/ and log in with the same credentials. If the portal doesn't load at all, the usual cause is MANAGEMENT_ENABLED still being False — fix settings.yaml and restart the container.

Hardening

  • Close registration once you're in. Set "allow_registration": false in config.json and restart the container. An open sign-up page on a credential store is exactly the risk it sounds like; invite users from the portal instead.

  • Set allow_custom_server to false. It's off in the config above already — leaving it on lets your hosted client be pointed at somebody else's backend, which is a phishing surface you gain nothing from.

  • Tighten ALLOWED_HOSTS. ['*'] is the permissive starting value; once the hostname is settled, narrowing it to your actual domain closes off host-header tricks.

  • Publish to loopback only. Re-create the container with -p 127.0.0.1:10200:80 if you used the plain -p 10200:80 above, so the only public path in is through Caddy on 443.

  • Encourage MFA per user. CE supports second factors on individual accounts; centrally enforced policy is an enterprise feature, so on CE it's a rule you set socially and verify by eye.

  • Keep the host tight. SSH keys only, ufw limited to 22/80/443, unattended security updates on.

Backups

Two things, and losing either one loses the vault:

  1. The PostgreSQL database.
  2. /opt/docker/psono/settings.yaml.

Upstream is explicit about the second, and it's the trap in this deployment: settings.yaml holds secrets — DB_SECRET above all — that the database contents are encrypted against. A database-only backup restores to unreadable ciphertext. Not a degraded install; unreadable. Back them up together, always, in the same archive.

# Database dump
docker exec psono-db pg_dump -U psono psono > psono-db.sql

# Both halves, encrypted, in one archive
sudo tar czf - psono-db.sql /opt/docker/psono/settings.yaml \
  /opt/docker/psono-client/config.json \
  | gpg --symmetric --cipher-algo AES256 \
      -o "psono-$(date +%F).tar.gz.gpg"

Copy the .gpg file off the server — object storage, another machine, anywhere that survives this VPS. Encrypt it without exception: the archive contains both your users' vaults and the key material for them, so a backup that leaks is worse than a database that leaks.

Automate it, and then do the part everyone skips: test a restore. Bring up a throwaway host, restore the database and drop settings.yaml back in place, and confirm you can log in and read a secret. Psono is precisely the kind of deployment where an untested backup quietly turns out to be a directory full of noise.

Upgrades

The combo image uses a floating tag, so an upgrade is a deliberate pull:

# back up first — see above
docker pull psono/psono-combo:latest

# migrations run against the same settings.yaml
docker run --rm -v /opt/docker/psono/settings.yaml:/root/.psono_server/settings.yaml \
  psono/psono-combo:latest python3 ./psono/manage.py migrate

docker stop psono-combo && docker rm psono-combo
# then re-run the `docker run --name psono-combo ...` command from above

The order matters: pull, migrate, then recreate. Your data lives in Postgres and your config lives on the host, so recreating the container is safe — the container itself is disposable. Read upstream's release notes before a major jump; this is a Django app with real schema migrations.

If you'd rather not retype the long docker run each time, that's a good reason to convert this into a small Compose file once the install is proven. The commands above are the shortest path to a working server, not the nicest thing to live with.

Troubleshooting

"Mount denied" / a config error mentioning a directory. The bind-mount trap. One of settings.yaml or config.json doesn't exist as a file, so Docker created a directory in its place. Check with file, remove the stray directory with rmdir, write the real file, and recreate the container.

migrate fails to connect to the database. Check the HOST in the DATABASES block against docker inspect output for psono-db — container IPs change when the container is recreated, which is the main argument for moving to a named network. And check the password matches what Postgres was initialized with; Postgres only applies POSTGRES_PASSWORD on a fresh data volume.

The web client loads but login fails or the API is unreachable. Nearly always a URL mismatch. HOST_URL must end in /server, and WEB_CLIENT_URL, base_url, and the backend_servers URL must all agree with the hostname Caddy serves. Your browser's network tab will show which URL it's actually calling.

The admin portal 404s or shows nothing. MANAGEMENT_ENABLED is still False, or config.json isn't mounted at the portal path. Both are in the docker run above — check that the file is mounted twice.

"I promoted a user and still can't get into the portal." Confirm you used the exact username the account was registered with, and that the container was restarted after any settings.yaml edit — the file is read at start.

Verification + next steps

You're done when you can: load https://psono.example.com over a valid certificate, register and log into an account, reach https://psono.example.com/portal/ as a promoted superuser, confirm registration is closed to the public, store and share a secret with a second user, and produce an encrypted, off-box backup of both the database and settings.yaml that you've restored at least once.

It was more work than most deploys on this site, and that's the honest trade: Psono asks for an evening of careful configuration and gives you a team vault with real end-to-end encryption and a proper admin portal, on a box you own, with no user cap. Keep the two backup halves together, pin your upgrades to a schedule, and it's quiet from there. A Hetzner instance in the 4 GB tier is the value pick; Kamatera when you want to buy memory without buying cores; Vultr when region matters. See Best VPS for self-hosting for the ranked picks.

Next steps

How to self-host PsonoBest VPS for a Password ManagerAutomatic HTTPS with CaddyRun Claude Code with Ollama on Your Own VPSDeploy Coolify on a VPSHow to Deploy AnythingLLM on a VPSHow to Deploy authentik on a VPSHow to Deploy Beszel on a VPSHow to Deploy Bitwarden on a VPSHow to Deploy Checkmate on a VPSHow to Deploy docker-mailserver on a VPSHow to Deploy Gatus on a VPSHow to Deploy Gitea on a VPSHow to Deploy Grafana on a VPSHow to Deploy Headscale on a VPSHow to Deploy Healthchecks on a VPSHow to Deploy Home Assistant on a VPSHow to Deploy Immich on a VPSHow to Deploy Jan on a VPSHow to Deploy LibreChat on a VPSHow to Deploy LocalAI on a VPSHow to Deploy Mailcow on a VPSHow to Deploy Mailu on a VPSHow to Deploy n8n on a VPSHow to Deploy NetBird on a VPSHow to Deploy Netdata on a VPSHow to Deploy Nextcloud on a VPSHow to Deploy Next.js to a VPSHow to Deploy Ollama on a VPSHow to Deploy Open WebUI on a VPSHow to Deploy OpenHands on a VPSHow to Deploy Pangolin on a VPSHow to Deploy Passbolt on a VPSHow to Deploy Plausible Analytics on a VPSHow to Deploy Stalwart on a VPSHow to Deploy Supabase on a VPSHow to Deploy TeamPass on a VPSHow to Deploy Twenty CRM on a VPSHow to Deploy Uptime Kuma on a VPSHow to Deploy Vaultwarden on a VPSHow to Deploy wg-easy on a VPSHow to Deploy Zabbix on a VPSDocker & Compose on Ubuntu 26.04Building AI Workflows with n8nInstall Open WebUI with OllamaAdding AI-Powered Insights to Plausible AnalyticsBuilding AI-Powered Apps with Supabase and pgvector

Search SelfHost Atlas

Search apps, comparisons, guides, and categories.

We use analytics cookies (Google Analytics, PostHog) to see which guides are useful. No ad networks, no cross-site tracking. See our privacy policy.