VPS Deployment — Versola Docs
VersolaVersola/docs
0.6.2versola.kzGitHub

VPS Deployment

Deploy Versola to a production VPS with versola-cli, end to end — reverse proxy and TLS, OpenBao secrets, the Postgres role, migrations and verification.

This page covers a real production deployment with versola-cli’s vps target: a Linux server with a native PostgreSQL, on which the CLI runs Versola’s services, their secrets store (OpenBao) and a reverse proxy in front of them. If you just want to try Versola out, see Installation instead — this page is for standing up (or redeploying) the real thing.

What the CLI sets up for you

On vps, versola-cli runs everything except the database:

  • central, auth, edge — Docker containers with network_mode: host, each bound to 127.0.0.1.
  • OpenBao — the secrets store every secret lives in. The CLI starts it and, on the first run, initializes and configures it by itself.
  • The reverse proxy — the official nginx image, with its whole configuration generated by the CLI. It routes your domain to auth/edge, serves the admin console at /central/admin/, and — unless you already run your own web server — takes ports 80/443 and gets a Let’s Encrypt certificate itself.

What stays yours:

  • PostgreSQL — a native install on the server, not a container. The CLI never creates or owns it; you create the role, database and schemas once (it prints the SQL for the role).

All of it runs as one Docker Compose project (versola-vps), so versola status, down and uninstall cover the whole deployment.

Prerequisites

  • A Linux VPS with Docker and the Docker Compose v2 plugin.
  • A native PostgreSQL 14+, and superuser access to it (sudo -u postgres psql) to create Versola’s role, database and schemas.
  • A domain for Versola (e.g. id.example.com) with a DNS A record (and AAAA, if the server has IPv6) pointing at the server.
  • Either ports 80 and 443 free (Versola’s proxy takes them and handles TLS), or your own web server already on 80/443 that will forward the domain to Versola — see Step 2.
  • versola-cli installed on the VPS itself. The vps memory check reads /proc/meminfo on the machine it runs on, and in external mode the proxy listens on the server’s 127.0.0.1 only.
  • Enough free memory: roughly 1 GiB if Versola is already running (a redeploy), roughly 3 GiB for a cold start — a little more if OpenBao isn’t up yet either.

Step 0 — install or update versola-cli

curl -fsSL https://raw.githubusercontent.com/versolauth/versola-cli/main/install.sh | sh
export PATH="$HOME/.local/bin:$PATH"   # the installer can't change the current shell

The CLI goes to ~/.local/bin. If that directory wasn’t on your PATH yet, the export line makes versola usable in this shell; also add it to your shell’s startup file (~/.bashrc, or ~/.zshrc for zsh), as the installer suggests, so new terminals find it too.

If it’s already installed, versola upgrade checks the latest release and replaces the binary in place (it refuses to touch a dev build from source).

Step 1 — run doctor

versola doctor --target vps

Confirms Docker is reachable, the Compose v2 plugin is present, and there’s enough free memory and disk space. configure/migrate/up run the same checks themselves, so this step is optional but gives you the full picture up front.

Step 2 — choose how Versola is exposed

The --proxy flag of configure/bootstrap decides where Versola’s proxy listens.

--proxy nginx (default) — Versola owns 80/443

For a server that runs nothing else on the web ports. The proxy listens on 80 and 443; with an https:// --auth-url it obtains and renews a Let’s Encrypt certificate itself (nginx’s own ACME module — no certbot), and redirects HTTP to HTTPS.

  • For TLS, --auth-url must be exactly https://<domain> — no port, no path, not an IP address or localhost. (A plain http:// URL also works, without a certificate — for testing only.)
  • DNS must already point at the server, and ports 80/443 must be reachable from the internet; configure fails early, naming the port, if something already uses one.
  • Certificates are kept in the versola-acme-vps Docker volume, so a redeploy doesn’t request a new one.
  • For a test deployment add --acme-staging: the certificate then comes from Let’s Encrypt’s staging environment — untrusted by browsers, but without production’s rate limits.

--proxy external — behind your own web server

For a server that already serves other sites on 80/443 with its own TLS. Versola’s proxy then listens only on 127.0.0.1:2821 (plain HTTP), and your web server forwards the domain to it and keeps handling TLS. It has to run on the same host and pass the right headers — for nginx:

server {
    server_name id.example.com;
    listen 443 ssl;
    # ssl_certificate ... — your existing TLS setup

    client_max_body_size 8m;

    location / {
        proxy_pass http://127.0.0.1:2821;
        proxy_http_version 1.1;
        proxy_set_header Host $http_host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header Connection "";
    }
}
  • Host — passed through as-is. Versola doesn’t trust it: the issuer, redirect URLs and the DPoP htu all come from --auth-url, not from request headers.
  • X-Forwarded-For — trusted only from 127.0.0.1; Versola restores the real client address from it and uses it for rate limiting.
  • X-Forwarded-Proto — the scheme the browser used; standard for a proxy that terminates TLS (Versola itself takes the scheme from --auth-url).
  • client_max_body_size 8m — the admin console uploads themes and keys; your server must allow the same body size as Versola’s proxy.

All of Versola’s routing (which path goes to auth and which to edge, the admin console) is inside Versola’s proxy — your web server forwards everything for the domain, nothing more.

Step 3 — configure

versola configure vps <version> \
  --auth-url https://id.example.com \
  --postgres-host 127.0.0.1:5432
# add --proxy external for Step 2's second option

--auth-url must be exactly the origin browsers use — scheme and host (plus a port only if it’s not the default one, and never in nginx mode). The CLI rejects paths and query strings (and lowercases the host), because a mismatch would silently break redirects and passkeys.

configure checks the machine, pulls ghcr.io/versolauth/versola-tools:<version> to generate this release’s configuration and admin console, writes the proxy’s configuration, and resolves every secret against OpenBao. It starts none of Versola’s own services and touches no database — it’s safe to run repeatedly. Like migrate and up, on vps it asks for confirmation first.

OpenBao is set up automatically. On the first run configure starts the versola-openbao-vps container and provisions it: initialization with a single unseal key, unseal, a KV v2 secrets engine, AppRole authentication, and a policy limited to Versola’s own secrets. It keeps two files in ~/.versola/openbao/:

  • vps.json — the AppRole credentials the CLI reads and writes secrets with. They are also printed on every run, secret ID included — keep that in mind before pasting configure output anywhere;
  • vps-admin.json — OpenBao’s root token and unseal key. OpenBao comes back sealed after every restart; configure recreates the container if it was stopped and unseals it with this key. Copy both values somewhere safe, off the server — neither can be recovered if lost.

Every secret — signing keys, session keys, the Postgres password, the admin bootstrap password — is generated once, stored in OpenBao, and reused by every later configure. The services never talk to OpenBao at runtime: configure writes the resolved values next to the generated configuration, and OpenBao only needs to be up while you run it.

Prefer to run OpenBao yourself? See Managing OpenBao yourself.

Step 4 — create the Postgres role, database and schemas

On the first configure, the Postgres password is generated, so the role can only be created after that run. configure prints the exact statements:

Postgres: this was the first configure against this OpenBao, so a new password was
generated for Versola's Postgres role and stored there. Set it on the Postgres server
before `versola migrate` -- as the postgres superuser (e.g. sudo -u postgres psql):

  CREATE ROLE "versola_app" WITH LOGIN PASSWORD '…';   -- if the role doesn't exist yet
  ALTER ROLE "versola_app" WITH PASSWORD '…';          -- if it already exists

As the Postgres superuser, run the CREATE ROLE line (or ALTER ROLE, if you created the role earlier with a password of your own), then the database and one schema per service:

CREATE DATABASE auth OWNER versola_app;
\c auth
CREATE SCHEMA IF NOT EXISTS auth    AUTHORIZATION versola_app;
CREATE SCHEMA IF NOT EXISTS central AUTHORIZATION versola_app;
CREATE SCHEMA IF NOT EXISTS edge    AUTHORIZATION versola_app;

Run them one at a time — pasting a block that mixes psql meta-commands (\c) with SQL leads to confusing parse errors. The three services share one database but each has its own schema; don’t skip the schemas — each service keeps its migration history inside its schema, and two services sharing one fail with FlywayValidateException.

The password is printed only on that first run; later runs reuse the stored one.

Step 5 — back up the database (redeploys)

Migrations cannot be rolled back. On a fresh install there’s nothing to back up yet; before every later migrate, take a dump:

(umask 077; sudo -u postgres pg_dump -Fc auth > ~/versola-backup-$(date +%Y%m%d-%H%M%S).dump)

pg_dump runs as postgres but writes to stdout, so the file is created by your own shell — in your home directory, owned by you, readable only by you (umask 077: the dump holds password hashes and encrypted secrets). Store a copy somewhere durable, not just on the server.

Step 6 — migrate

versola migrate

Applies each service’s own schema migrations (auth, central and edge each own their schema) using one throwaway container that ships all three services’ migrations and exits when done. No server starts. The run is recorded under ~/.versola, which status then shows. The services never migrate on their own startup — they validate their schema and refuse to start against an out-of-date one.

Also available:

versola migrate --service auth         # migrate one service at a time
versola migrate --dry-run              # check without applying

In Versola releases up to and including 0.6.2, --dry-run reports migrations that are merely pending as a validation failure (“Detected resolved migration not applied to database”) — that is a known issue of the dry run, not a problem with your database; plain versola migrate applies them normally.

Step 7 — up

versola up

Starts the stack and waits until it’s actually serving: central first (auth and edge sync their configuration from it), then auth and edge, then the reverse proxy last — and finally checks that a request through the proxy reaches auth.

In nginx mode with HTTPS, if the certificate isn’t issued within about two minutes (DNS not propagated yet, port 80 not reachable), up prints a warning instead of failing — the services are up, and nginx keeps retrying the certificate; check docker logs versola-proxy.

On success it prints the address, the admin login and the file holding ADMIN_BOOTSTRAP_PASSWORD. That password is temporary, valid for 24 hours: the first login with it asks you to set a permanent one. If it expires before you log in, restart auth (docker restart versola-auth) for a fresh 24-hour one. In external mode it also reminds you to point your web server at 127.0.0.1:2821.

Or all at once: bootstrap

versola bootstrap vps <version> --auth-url … --postgres-host … [--proxy external] runs Steps 3, 6 and 7 in one go. On a first run that generates the Postgres password, it stops before migrating (with a non-zero exit code) so you can run the SQL from Step 4 — then continue with versola migrate and versola up.

Step 8 — verify

versola status
Deployed version: 0.6.2 (target: vps)
Migrations applied: 2026-09-27 17:11:51

NAME                  IMAGE                                       …   STATUS
versola-auth          ghcr.io/versolauth/versola-auth:0.6.2       …   Up 2 minutes
versola-central       ghcr.io/versolauth/versola-central:0.6.2    …   Up 3 minutes
versola-edge          ghcr.io/versolauth/versola-edge:0.6.2       …   Up 2 minutes
versola-openbao-vps   openbao/openbao:2.5.4                       …   Up 10 minutes
versola-proxy         nginx:1.30-alpine                           …   Up 1 minute

(The table is docker compose ps for the deployment, shortened here.)

Then from outside:

AUTH_URL=https://id.example.com   # exactly the --auth-url you passed to configure
curl -s -o /dev/null -w "%{http_code}\n" "$AUTH_URL/.well-known/openid-configuration"   # 200

and log in to the admin console at <auth-url>/central/admin/ before considering the deploy done.

Redeploying later

A new version is the same three steps with the new version number and the same flags as the first deployment — configure doesn’t remember them. In particular keep --proxy external if you used it: without it the proxy falls back to the default nginx mode and configure stops on your web server’s ports 80/443.

versola upgrade                     # keep the CLI current
# back up the database (Step 5)
# drop --proxy external if your first deployment used the default nginx mode
versola configure vps <new-version> --auth-url https://id.example.com --postgres-host 127.0.0.1:5432 --proxy external
versola migrate
versola up

configure regenerates the configuration, proxy and admin console from that release; secrets are reused from OpenBao. configure and migrate don’t touch the running services, so the old version keeps serving while migrations run — the only interruption is up recreating the containers.

Rolling back

If the new release only added migrations and the previous version runs fine on the newer schema, going back is just versola configure vps <previous-version> … with your usual flags, then versola up (install the matching CLI from the releases page if needed).

If the migrations must be undone, restore the database before starting the previous version — never start it against a schema only the new release understands:

versola down                                     # nothing may write while restoring
sudo -u postgres psql -c "DROP DATABASE auth;"
sudo -u postgres psql -c "CREATE DATABASE auth OWNER versola_app;"
sudo -u postgres pg_restore -d auth < ~/versola-backup-<timestamp>.dump   # the file is opened by your shell, not by postgres
# drop --proxy external if your first deployment used the default nginx mode
versola configure vps <previous-version> --auth-url https://id.example.com --postgres-host 127.0.0.1:5432 --proxy external
versola migrate                                  # no-op: the dump already has the previous schema
versola up

Anything written between the dump (Step 5) and the rollback is lost — which is why the dump is taken right before migrate.

Managing OpenBao yourself

If you run your own OpenBao setup, pass --setup-openbao to configure/bootstrap: the CLI then never uses OpenBao’s admin API, and you provision it and store AppRole credentials yourself. Unsealing after a restart is then also yours.

The order: run versola configure vps … --setup-openbao once — it starts the versola-openbao-vps container and then stops with “no OpenBao credentials stored”, which is expected — do the steps below, store the credentials with versola secrets login, and run the same configure again. TLS is disabled on this OpenBao (it listens only on 127.0.0.1:8200), but the bao CLI defaults to https, so every command needs BAO_ADDR:

# 1. Initialize — save BOTH values printed; neither is recoverable if lost.
docker exec -it -e BAO_ADDR=http://127.0.0.1:8200 versola-openbao-vps \
  bao operator init -key-shares=1 -key-threshold=1

# 2. Unseal — again after every container restart.
docker exec -it -e BAO_ADDR=http://127.0.0.1:8200 versola-openbao-vps \
  bao operator unseal <unseal key>

# 3–4. KV v2 and AppRole.
docker exec -it -e BAO_ADDR=http://127.0.0.1:8200 -e BAO_TOKEN=<root token> versola-openbao-vps \
  bao secrets enable -path=secret kv-v2
docker exec -it -e BAO_ADDR=http://127.0.0.1:8200 -e BAO_TOKEN=<root token> versola-openbao-vps \
  bao auth enable approle

# 5. A policy limited to Versola's own secrets.
docker exec -i -e BAO_ADDR=http://127.0.0.1:8200 -e BAO_TOKEN=<root token> versola-openbao-vps \
  bao policy write versola-vps - <<'EOF'
path "secret/data/versola/vps/*" {
  capabilities = ["create", "read", "update"]
}
EOF

# 6. An AppRole bound to it (a long-lived credential for an unattended tool).
docker exec -it -e BAO_ADDR=http://127.0.0.1:8200 -e BAO_TOKEN=<root token> versola-openbao-vps \
  bao write auth/approle/role/versola-vps \
    token_policies="versola-vps" token_ttl=1h token_max_ttl=4h \
    secret_id_ttl=0 token_num_uses=0

# 7. The credentials the CLI needs.
docker exec -it -e BAO_ADDR=http://127.0.0.1:8200 -e BAO_TOKEN=<root token> versola-openbao-vps \
  bao read auth/approle/role/versola-vps/role-id
docker exec -it -e BAO_ADDR=http://127.0.0.1:8200 -e BAO_TOKEN=<root token> versola-openbao-vps \
  bao write -f auth/approle/role/versola-vps/secret-id

Then store them — the secret ID is asked for in a masked prompt, so it never lands in shell history:

versola secrets login vps http://127.0.0.1:8200 <role-id>

Writing the policy from Windows? Save the HCL file as ASCII, not the default UTF-8 — a UTF-8 BOM breaks OpenBao’s parser with illegal char at 1:1. Write it locally, docker cp it in, then bao policy write versola-vps /path/inside/container.hcl.

Changing a stored secret

Secrets live at secret/versola/vps/{auth,central,edge}. Use bao kv patch — kv put replaces the whole path and would wipe every other key there — with the root token from ~/.versola/openbao/vps-admin.json:

docker exec -it -e BAO_ADDR=http://127.0.0.1:8200 -e BAO_TOKEN=<root token> versola-openbao-vps \
  bao kv patch -mount=secret versola/vps/auth SOME_KEY='<value>'

Then run versola configure vps <current-version> with your usual flags and versola up. POSTGRES_PASSWORD is shared by all three services and must be identical under auth, central and edge — configure refuses to continue if they disagree.

Troubleshooting

configure fails because port 80 or 443 is in use. Something (usually a web server) already listens there. Either stop it, or use --proxy external behind it.

OpenBao doesn’t start after a reboot, or is sealed. Your running services aren’t affected — they don’t need OpenBao at runtime. The next configure recreates and unseals it. With --setup-openbao, unseal it yourself (bao operator unseal).

“container name already in use”. A container with one of Versola’s fixed names exists under another Compose project. Check with docker ps and docker compose ls, stop and remove those containers by name, then rerun.

502 through your web server (external mode). Check that the proxy answers locally: curl -H 'Host: id.example.com' http://127.0.0.1:2821/.well-known/openid-configuration. If it does, the problem is in your web server’s forwarding; if it doesn’t, check versola status and docker logs versola-proxy.

The admin console shows 404 right after up. Reload once — the first request right after startup can hit a service that’s still warming up.

image not found. Check the version format — tags have no leading v: 0.6.2, not v0.6.2. Published versions are listed on the versola-tools package page.