Skip to content

How to Deploy Psono on a VPS

Updated Sep 2026

verified on Ubuntu 26.04 · Aug 2026
We earn commissions when you shop through the links below. Full disclosure →

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)
Need a box for this guide? Kamatera's free tier lets you spin one up now.Start free on Kamatera → (opens in new tab)

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 a solid choice; 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
Where to host itaffiliate disclosure
Hetzner Cloudrun it on
2 vCPU · 4 GB RAM · 80 GB SSD · $23.59/mo
Get Hetzner Cloud (opens in new tab)
Kamaterafree trial
1 vCPU · 1 GB RAM · 20 GB SSD · $4.00/mo
Start free on Kamatera → (opens in new tab)
Vultralso works on
1 vCPU · 1 GB RAM · 25 GB SSD · $5.00/mo
Get Vultr (opens in new tab)

Paid link — we earn a commission if you shop through it.

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 a solid choice; 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 Psono →More self-hosted password manager tools →Best VPS for a Password Manager →Automatic HTTPS with Caddy →Run Claude Code with Ollama on Your Own VPS →Deploy Coolify on a VPS →How to Deploy Actual Budget on a VPS →How to Deploy AnythingLLM on a VPS →How to Deploy Appwrite on a VPS →How to Deploy Audiobookshelf on a VPS →How to Deploy Authelia on a VPS →How to Deploy authentik on a VPS →How to Deploy Baserow on a VPS →How to Deploy Beszel on a VPS →How to Deploy Bitwarden on a VPS →How to Deploy BookStack on a VPS →How to Deploy CapRover on a VPS →How to Deploy Checkmate on a VPS →How to Deploy Directus on a VPS →How to Deploy docker-mailserver on a VPS →How to Deploy Docmost on a VPS →How to Deploy Dokku on a VPS →How to Deploy Dokploy on a VPS →How to Deploy Firefly III on a VPS →How to Deploy Forgejo on a VPS →How to Deploy Gatus on a VPS →How to Deploy Ghostfolio on a VPS →How to Deploy Gitea on a VPS →How to Deploy GitLab on a VPS →How to Deploy GlitchTip on a VPS →How to Deploy Grafana on a VPS →How to Deploy Graylog on a VPS →How to Deploy Headscale on a VPS →How to Deploy Healthchecks on a VPS →How to Deploy Home Assistant on a VPS →How to Deploy Immich on a VPS →How to Deploy Jan on a VPS →How to Deploy Jellyfin on a VPS →How to Deploy Karakeep on a VPS →How to Deploy Keycloak on a VPS →How to Deploy Leantime on a VPS →How to Deploy LibreChat on a VPS →How to Deploy Linkwarden on a VPS →How to Deploy LocalAI on a VPS →How to Deploy Mailcow on a VPS →How to Deploy Mailu on a VPS →How to Deploy Matomo on a VPS →How to Deploy Mattermost on a VPS →How to Deploy Meilisearch on a VPS →How to Deploy Memos on a VPS →How to Deploy n8n on a VPS →How to Deploy Navidrome on a VPS →How to Deploy NetBird on a VPS →How to Deploy Netdata on a VPS →How to Deploy Nextcloud on a VPS →How to Deploy Next.js to a VPS →How to Deploy Nginx Proxy Manager on a VPS →How to Deploy NocoDB on a VPS →How to Deploy ntfy on a VPS →How to Deploy Ollama on a VPS →How to Deploy Open WebUI on a VPS →How to Deploy OpenHands on a VPS →How to Deploy OpenObserve on a VPS →How to Deploy OpenProject on a VPS →How to Deploy Outline on a VPS →How to Deploy Pangolin on a VPS →How to Deploy Paperless-ngx on a VPS →How to Deploy Passbolt on a VPS →How to Deploy Plane on a VPS →How to Deploy Plausible Analytics on a VPS →How to Deploy Pocket ID on a VPS →How to Deploy PocketBase on a VPS →How to Deploy Prometheus on a VPS →How to Deploy Radarr on a VPS →How to Deploy Rocket.Chat on a VPS →How to Deploy SigNoz on a VPS →How to Deploy Sonarr on a VPS →How to Deploy Stalwart on a VPS →How to Deploy Stirling-PDF on a VPS →How to Deploy Supabase on a VPS →How to Deploy Synapse on a VPS →How to Deploy Taiga on a VPS →How to Deploy TeamPass on a VPS →How to Deploy Tinyauth on a VPS →How to Deploy Traefik on a VPS →How to Deploy Trilium on a VPS →How to Deploy Twenty CRM on a VPS →How to Deploy Umami on a VPS →How to Deploy Uptime Kuma on a VPS →How to Deploy Vaultwarden on a VPS →How to Deploy Vikunja on a VPS →How to Deploy wg-easy on a VPS →How to Deploy Wiki.js on a VPS →How to Deploy Zabbix on a VPS →How to Deploy Zitadel on a VPS →How to Deploy Zulip on a VPS →Docker & Compose on Ubuntu 26.04 →Building AI Workflows with n8n →Install Open WebUI with Ollama →Adding AI-Powered Insights to Plausible Analytics →Building AI-Powered Apps with Supabase and pgvector →

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