{"slug": "show-hn-vaire-self-hosting-postgres-through-the-one-glorious-cli", "title": "Show HN: Vaire, Self-Hosting Postgres Through the One Glorious CLI", "summary": "MBV Labs is preparing the first v1 alpha of Vaire, a single CLI that turns a fresh Debian 13 or Ubuntu 26.04 VPS into a hardened PostgreSQL 18 server with PgBouncer, nginx, TLS certificates and WireGuard access, with no server control daemon, web UI or open control API port. As of 2026-10-19 the tool is not yet tagged or published, though phases 0–5 have live qualification evidence covering backups, disaster recovery, protected writes, observability, local bootstrap and automatic SSH-lockdown revert. Alpha formats are not frozen, and MBV Labs warns a later alpha may require a rebuild and restore; followers, extensions and multi-node support remain future work.", "body_md": "One CLI that turns a fresh VPS into a hardened PostgreSQL 18 server, and then stays the only way in.\n\nFrom your laptop (or from a coding agent), `vaire` takes a new Debian 13 or Ubuntu 26.04 server, locks it down, installs PostgreSQL 18 with PgBouncer and nginx, issues its TLS certificate, and connects you to it over WireGuard. Each database you create gets its own tuned PostgreSQL cluster with a size and resource limits, the way a managed provider gives each database its own instance. Every later operation goes through the same binary, over SSH, inside WireGuard. There is no server control daemon, web UI or open control API port; an operator-side shared tunnel daemon keeps concurrent CLI commands on one WireGuard connection per context.\n\n**Status (2026-10-19):** preparing the first v1 alpha; not yet tagged or published. Phases 0–5 have live qualification evidence, including backups, disaster recovery, protected writes, observability, local bootstrap and automatic SSH-lockdown revert. Known coverage limits are recorded in [manual testing](https://github.com/mbvlabs/vaire/blob/master/docs/MANUAL-TESTING.md). Followers, extensions and multi-node remain future work. See [the roadmap](https://github.com/mbvlabs/vaire/blob/master/docs/MASTER.md#7-roadmap-and-status) and [draft alpha notes](https://github.com/mbvlabs/vaire/blob/master/docs/RELEASE-v1.0.0-alpha.1.md). Alpha formats are not frozen; a later alpha may require a rebuild and restore.\n\n| Area | Commands | \n|---|---|\n| Setup | `vaire server init` (remote over SSH, or`--local` on the server),`--resume` | \n| Access | `vaire login` ,`vaire context list\\|show\\|use\\|remove\\|set-address` ,`vaire ssh` ,`vaire tunnel` | \n| Desired state | `vaire plan` ,`vaire apply` ,`vaire config show\\|get\\|set\\|edit\\|validate` | \n| Databases | `vaire db list\\|create\\|resize\\|drop\\|credentials\\|fork\\|protect\\|unprotect` | \n| Protection | `vaire protect init\\|status` (two-factor code),`vaire db protect\\|unprotect` | \n| SQL | `vaire sql` (multiple statements and ordered results, JSON-friendly),`vaire psql` ,`vaire console` (usql) | \n| Tuning | `vaire tune show\\|explain\\|apply` | \n| Backups | `vaire backup setup\\|now\\|list\\|verify` ,`vaire dump` ,`vaire dump list\\|get` | \n| Restores | `vaire restore <db>` (`--time` ,`--latest` ,`--set` ,`--dump` ),`vaire restore status\\|promote\\|undo\\|abort\\|cleanup\\|drill` | \n| Disaster recovery | `vaire server backup now\\|list\\|verify\\|export` (the master bundle),`vaire server init --restore` ,`vaire db recover <db>\\|--all` | \n| Jobs | `vaire job list\\|follow` | \n| Health | `vaire status [--watch]` ,`vaire doctor [--only security]` ,`vaire top` ,`vaire queries` | \n| Evidence | `vaire activity list\\|show\\|health` ,`vaire diagnostics collect\\|inspect` (local metadata-only history and portable incident files) | \n| Metrics | `vaire metrics show\\|rotate` : an authenticated`https://db1.<domain>/metrics/...` for Prometheus or Grafana Alloy, with a`targets.json` for service discovery | \n| Maintenance | `vaire maint vacuum\\|analyze\\|reindex\\|report\\|kill` (`repack` waits for Phase 7), nightly and weekly maintenance timers | \n| Operations | `vaire logs` ,`vaire secrets export` ,`vaire upgrade` | \n| Agents | `vaire commands --json` ,`vaire guide agents` ,`vaire skill install` | \n\nCommands from later phases are already in the tree and return `not implemented yet`.\n\n**New in this checkout, awaiting live qualification:** `vaire activity list|show|health` and `vaire diagnostics collect|inspect` provide local, metadata-only operation history and portable incident evidence. No logging database, external collector or raw-log export is required. See [activity and diagnostics](https://github.com/mbvlabs/vaire/blob/master/docs/ACTIVITY.md).\n\n```\n# On your laptop: install the CLI (signed release, verified with cosign)\ncurl -fsSL https://vaire.mbvlabs.com/edge/install.sh | bash\n\n# Bootstrap a fresh server (provider root key or password for first contact)\nvaire server init --host root@203.0.113.10\n\n# vaire asks a few questions, shows the DNS record to create (db1.<your domain>),\n# hardens the box, closes public SSH, installs PostgreSQL, and ends with the\n# secrets handoff.\n\nvaire db create app --size small   # a cluster of its own; prints the URLs and passwords once\nvaire sql --db app \"select now()\"\nvaire console app                # interactive usql session\nvaire console app --write        # run DDL as the database owner\n```\n\nOn another machine, `vaire login` and paste the transfer bundle from the handoff.\n\n```\n laptop / CI / agent                                     VPS db1.<domain> (the master node)\n┌───────────────────────────────┐                      ┌─────────────────────────────────────────┐\n│ vaire (operator mode)           │                      │ wg0 10.42.0.1                           │\n│  ├ context + credentials      │  WireGuard  UDP 51820│  └ sshd (port 22 reachable on wg0 only) │\n│  ├ userspace WireGuard  ══════╪═════════════════════▶│                                         │\n│  │  (no root, no wg0 needed)  │                      │ vaire (node mode), run per command:       │\n│  ├ in-process SSH  ───────────┼──── inside WG ──────▶│  ├ versioned steps + embedded scripts   │\n│  └ pgx / usql / psql ─────────┼──── inside WG ──────▶│  ├ /etc/vaire/cluster.toml (desired state)│\n└───────────────────────────────┘     (5432 / 6432)    │  ├ journal of applied steps             │\n                                                       │  └ systemd timers → vaire node run …      │\n                                                       │                                         │\n                                                       │ nginx · 2 PgBouncers · one PostgreSQL   │\n                                                       │ 18 cluster per database                 │\n                                                       └─────────────────────────────────────────┘\n```\n\n- **One binary, two roles.** On your machine`vaire` is the operator CLI. On the server the same binary runs as`vaire node …` , called over SSH for each command and by systemd timers. Operator and node versions are kept in lockstep: a mismatch is refused unless you pass`--auto-upgrade-nodes` , which pushes the matching signed build.\n- **Desired state in, convergence out.** The server and its databases are described by one TOML file on the server.`vaire plan` shows what would change and`vaire apply` converges, and it's always safe to run again. Steps have versioned IDs (`tls-v1` , and`postgres-config-v1@app` for one database's cluster), and a journal records what ran, so an upgrade only re-runs the steps that changed.\n- **vaire owns the config it writes.** Managed files are rendered from templates and checked for drift.`ALTER SYSTEM` is disabled so nothing changes behind vaire's back.\n\nEvery database is its own PostgreSQL cluster: `vaire db create app` makes the cluster `18/app` with its own data directory, socket, WAL stream, configuration, restarts, pgBackRest stanza and systemd resource limits. You only ever name the database; the cluster sits underneath. Things that belong together share one database with several schemas.\n\n```\n db1.<domain>\n┌──────────────────────────────────────────────────────────────────────┐\n│ :443 nginx (ALPN postgresql) ─┐                                      │\n│ :6432 transaction pooler ◀────┘   routes by database name            │\n│ :5432 session pooler              routes by database name            │\n│        │                                                             │\n│        ├─ app      → postgresql@18-app      large   socket .s.PGSQL.5433\n│        ├─ billing  → postgresql@18-billing  medium  socket .s.PGSQL.5434\n│        └─ staging  → postgresql@18-staging  small   socket .s.PGSQL.5435\n│                                                                      │\n│ clusters listen on Unix sockets only; nothing but the poolers faces  │\n│ the network                                                          │\n└──────────────────────────────────────────────────────────────────────┘\n```\n\n- **Sizes.** A database has a size, like a provider's plan:`nano` (256 MiB, 20 connections),`small` (512 MiB, 30),`medium` (1 GiB, 50, the default),`large` (2 GiB, 100),`xlarge` (4 GiB, 200). The size drives its tuning, its CPU weight and its connection limit.`--memory` overrides the memory, and`vaire db resize app --size large` changes it later: limits and reload-only settings change live, and a restart, when one is needed, restarts only that database.\n- **A memory budget.** The server keeps a reserve (`max(512 MiB, 25% of RAM)` ) for the OS, the poolers, nginx and vaire; the databases share the rest.`vaire tune show` prints the sheet: each database's size, limits and current use, and what's allocated or free.\n- **No overcommit by accident.**`db create` ,`db fork` and a growing`db resize` are refused when the new size would take the sizes past the budget (`memory_overcommitted` ); the error gives the numbers and the largest size that still fits, as a command to run, for example`vaire db create big2 --size small` . Nothing steps down by itself.`--allow-overcommit` goes ahead with a warning; under load the databases are then throttled rather than killed. A database PostgreSQL couldn't start at all is always refused (`would_not_start` ).\n- **Rough density.** An idle cluster's own processes cost about 19 MiB (measured on PostgreSQL 18 with a`small` database: the cgroup's`anon` plus`kernel` memory).`shared_buffers` is charged as it fills, not up front, and the page cache is reclaimable. A 2 GiB VPS takes 2`small` and 1`nano` ; a 4 GiB VPS takes 1`medium` and 3`small` ; an 8 GiB VPS takes 1`large` , 2`medium` and 2`small` .\n\nEvery server (node) has one hostname, `<node>.<domain>`; the first one is `db1.<domain>`. The database name in the URL's path picks the database, which is how PostgreSQL URLs already work, so DNS needs one record per server, not per database:\n\n```\npostgres://app_app:…@db1.example.com:6432/app?sslmode=verify-full&sslrootcert=system       app, transaction pooler\npostgres://app_app:…@db1.example.com:443/app?…&sslnegotiation=direct                         app, direct TLS on 443 (PG17+ clients)\npostgres://app_migrate:…@db1.example.com:5432/app?sslmode=verify-full&sslrootcert=system   migrations and tools, session pooler\n```\n\nCreating, forking, restoring, resizing or dropping a database never touches DNS or the certificate. Multi-node arrives in Phase 10, and moving a database to another server will change its URL's host.\n\n- **Isolation is cgroups, not a VM.** Each cluster's`MemoryHigh` (its memory plus 25%), CPU weight and I/O weight are set by systemd; a hard`memory_max` or`cpu_quota` is opt-in.`MemoryHigh` throttles, it doesn't partition. A managed provider's VM is a harder boundary.\n- **I/O weights depend on the disk scheduler.** They only work with BFQ or the kernel's`io.cost` model; on a typical virtio disk they do nothing, and`vaire tune show` says \"not enforced\".\n- **Disk space and the page cache are shared.** A database that fills the disk affects its neighbours.\n- **Density costs.** Each cluster carries its own processes (about 19 MiB idle) and buffers; a 1 GiB VPS isn't a sensible home for several databases.\n- **One server is a single point of failure** until HA (Phase 11).\n- **Recovering a lost server.** The server backs its own state up after every change: an age-encrypted master bundle in the bucket under`<prefix>/master/` (`vaire server backup list` ). When the server is lost, power the old VPS off or destroy it, create a new one, and run`vaire server init --restore --host root@<new-ip>` from your machine: the new server takes over the old one's WireGuard key, SSH host keys and certificate, so your context keeps working; then`vaire db recover --all` brings each database back from its stanza. Move the`db1.<domain>` record to the new IP, or use a provider IP that moves between servers (a Hetzner Primary IP, a floating IP) so nothing changes in DNS; a short TTL on that record shortens the wait for clients. Without a bucket, keep copies with`vaire server backup export` ; the server comes back from one with`--bundle-file` , but the databases' data doesn't: configure a bucket for production.\n\nAfter bootstrap the server has exactly these public ports open, enforced by ufw (`default deny incoming`):\n\n```\n                       Internet\n                          │\n      ┌─────────┬─────────┼──────────┬─────────────────────────┐\n      │         │         │          │                         │\n  51820/udp   80/tcp   443/tcp   6432/tcp, 5432/tcp       22/tcp\n  WireGuard   ACME     nginx     only from client_allowlist CLOSED\n      │       HTTP-01  stream    (expose_pgbouncer /       (wg0 only)\n      │         │         │       expose_postgres)\n      ▼         ▼         ▼          ▼\n ┌───────────────────────────────────────────────────────────────┐\n │ wg0 10.42.0.1 ── sshd :22, transaction pooler :6432,          │\n │                  session pooler :5432                         │\n │                  (WireGuard peers always reach these)         │\n └───────────────────────────────────────────────────────────────┘\n```\n\n- **SSH only over WireGuard.** Port 22 is allowed only`in on wg0` . Root login and password authentication are off, and only the admin user may log in, with the operator's key.\n- **Commit-confirm lockdown.** Before vaire closes public SSH, it snapshots sshd and firewall state and arms a server-side timer. If vaire doesn't confirm a working SSH-over-WireGuard session within two minutes, the server reverts on its own. A broken tunnel can't lock you out.\n- **Break-glass** is the provider console plus`vaire node unlock-ssh` , which re-opens public SSH with a password for the admin user for a fixed window. The steps are in the secrets handoff.\n- **Database ports are closed to the public by default.** WireGuard peers can always reach 6432 and 5432.`client_allowlist` with`expose_pgbouncer` /`expose_postgres` opens them to specific CIDRs. Both are PgBouncers; no PostgreSQL cluster has a TCP listener.\n- **fail2ban** watches sshd. unattended-upgrades installs security updates only, with PostgreSQL, PgBouncer and nginx held for controlled updates.\n\n```\n client (sslmode=verify-full)                          VPS\n                                ┌──────────────────────────────────────────────────┐\n ── :6432 ─────────────────────▶│ transaction pooler (TLS) ─┐                      │\n ── :5432 ─────────────────────▶│ session pooler (TLS)  ────┼─ unix socket ─▶ the  │\n                                │                           │  database's cluster  │\n ── :443  ─────────────────────▶│ nginx stream, ssl_preread │  (by database name)  │\n   (sslnegotiation=direct,      │   ALPN \"postgresql\" + allowed source             │\n    PG17+ clients)              │      → terminate TLS → transaction pooler        │\n                                │   same ALPN, other source → handshake rejected   │\n                                │   SSLRequest (no direct) → \"no TLS\" (N)          │\n                                │   anything else → HTTPS: /metrics, else 404      │\n                                │                                                  │\n ── :80   ─────────────────────▶│ nginx: /.well-known/acme-challenge/ only         │\n                                └──────────────────────────────────────────────────┘\n```\n\n- **443 needs `sslnegotiation=direct`.** nginx routes 443 by peeking at the TLS ClientHello, so it can't answer the PostgreSQL SSLRequest that clients send by default. Without`sslnegotiation=direct` , nginx answers \"no TLS here\" and closes: libpq (with`sslmode=require` or stricter) says`server does not support SSL, but SSL was required` , pgx says`server refused TLS connection` . Add`sslnegotiation=direct` (PostgreSQL 17+ libpq, pgx 5.7+), or use port 6432, which speaks the normal negotiation. A direct-TLS client from an address that isn't allowed gets its handshake rejected: libpq says`SSL error: tlsv1 unrecognized name` , pgx`remote error: tls: unrecognized name` .\n- **vaire is its own ACME client.** An hourly`vaire-certs` timer issues and renews the certificate for the node's hostname (`db1.<domain>` ) over HTTP-01, installs it for both PgBouncers, nginx and every cluster, and reloads them. Until the first certificate arrives, a placeholder certificate lets everything start.\n- The DNS record must point straight at the server (DNS only, not proxied).\n- A `self-signed` mode exists for servers without a domain. Connections are encrypted but not verified, and vaire warns about that.\n\n- Every cluster listens on its Unix socket only (`listen_addresses = ''` ), and its`pg_hba.conf` allows nothing else. The poolers terminate TLS and require it on TCP, with`scram-sha-256` . The`postgres` superuser is local-socket only.\n- `allow_alter_system = off` ,`REVOKE` of`CONNECT` and of`public` schema rights from`PUBLIC` ,`idle_in_transaction_session_timeout` , and connection and lock logging.\n- Settings are tuned per database from its size and workload profile (`vaire tune explain --db app` shows why each value was picked).\n\n```\n <db>_owner   NOLOGIN, owns the database and every object in it\n    ▲\n    │ member, SET role = <db>_owner on login\n <db>_migrate   DDL: migrations              → :5432 (session pooler; the transaction pooler refuses it)\n <db>_app       SELECT/INSERT/UPDATE/DELETE  → :6432 or :443 (transaction pooler), statement_timeout\n <db>_ro        SELECT, read-only by default → :6432 or :443 (transaction pooler), reporting and read-only clients\n\n in every cluster:\n vaire_admin        CREATEDB/CREATEROLE, not superuser (operator)\n pgbouncer_auth   may only call the auth_query functions\n vaire_monitor      pg_monitor\n```\n\nRun migrations as `migrate` and the application as `app`. The transaction pooler refuses the `migrate` role: in transaction mode, advisory locks, session `SET` and temporary tables can land on a different connection, so migrations go through the session pooler on 5432, where a client keeps its server connection for the whole session. From outside WireGuard, use `vaire tunnel --db app --target postgres` or set `expose_postgres = true`. A compromised app can change data but can't drop or change the schema. Default privileges give `app` and `ro` access to every new table without extra grants. The service passwords (`vaire_admin`, `vaire_monitor`, `pgbouncer_auth`) are the same in every cluster, so adding a database never makes your secrets handoff stale.\n\n- vaire generates server and database passwords. The optional master password for the secrets-export gate is chosen by the operator.\n- The server keeps only SCRAM verifiers, plus the two service passwords PgBouncer and the exporters need at runtime.\n- At the end of `server init` , vaire prints the**secrets handoff** once:\n  - every secret in plain text, grouped for a password manager;\n  - an ASCII-armored `age` transfer bundle (scrypt), with a generated passphrase.\n- You type `stored` to confirm you've saved it, and vaire offers to clear the screen. Nothing is written to disk unless you ask with`--secrets-out` .\n- `vaire login` on any machine takes the bundle and passphrase and sets up a**context** : credentials under`~/.config/vaire/contexts/<server>/` , files 0600.\n- Later full-document reveals (`vaire secrets export` and replacement handoffs) require a master password or a node-verified authenticator code at a real terminal. With neither configured, set one using`vaire secrets password set` or`vaire protect init` . This is a command gate, not encryption of context files at rest.\n- Releases are signed with cosign. `vaire upgrade` and node upgrades verify the signature and the sha256 before swapping binaries.\n\nA protected database refuses `db drop`, a shrinking resize, credential rotation and restore promote/undo, even with `--confirm`. Lifting the protection asks for a current 6-digit code from an authenticator app, typed at a terminal and checked on the node, so an agent with a shell can't lift it by typing a word.\n\nWriting to it takes a code too. `vaire sql --write`, `vaire psql --write`, `vaire console --write` and `vaire maint kill` ask for one when they start, before anything connects; a `psql` or `console` session then stays writable until you close it, and `vaire sql --write` asks on every run. Reads never ask. Without a terminal, with `--yes` or with `--output json`/` agent` the write is refused, so an agent gets read-only access. The node enforces it: on a protected database the `vaire_admin` login in every context can only read, and a write runs as a two-minute login role the node creates after it checks the code. Vacuum, analyze and reindex, restores into a copy and forks work as before.\n\nSet it up once with `vaire protect init`: the node makes the secret and keeps it root-only, and vaire shows it as a QR code. Once the code is confirmed, it asks which of your databases to protect (all of them picked to start with) and protects them in the same run; `--all` or `--db app` (repeatable) choose without asking. After that:\n\n```\nvaire db protect app billing     # or --all; in effect at once, no apply\nvaire db create scratch --protected\nvaire db unprotect app           # asks for a code from the authenticator app\nvaire sql --db app --write \"…\"   # on a protected database, asks for a code too\nvaire protect status             # every database, protected yes or no\n```\n\nProtecting a database is refused until `vaire protect init` has run. A code works once, and 5 wrong codes lock codes out for 15 minutes. `vaire config set clusters.app.protected true` still works too. Root on the server can still edit the files directly; this guards against agents and slips, not against someone who owns the box.\n\n```\n vaire console app\n   │\n   ├─ usql ──plaintext──▶ 127.0.0.1:<port>   (loopback only)\n   │                          │\n   │              vaire: SSLRequest + TLS handshake, verify-full\n   │                  against the node's certificate (db1.<domain>)\n   │                          │\n   └──────────── userspace WireGuard ──▶ 10.42.0.1:5432  session pooler → app\n```\n\n`vaire sql`, `vaire psql` and `vaire console` all connect over the tunnel to the session pooler on 5432 of the node that holds the database, which routes by `--db`, and verify the certificate (` verify-full`). They log in as `vaire_admin`, never as an application role, and are read-only unless `--write` is passed; with `--write` they act as `<db>_owner`, so new objects get the same owner and default grants as a migration. `vaire tunnel --db app --target postgres` forwards a local port to that pooler, which is how a migration tool on your machine reaches the database as `<db>_migrate` without opening 5432 to the internet. `vaire console` uses usql from PATH, or downloads a pinned release and checks its SHA-256. Arguments after `--` go to `psql` as they are (`vaire psql --db app -- -c \"select 1\"`); a `-c` before `--` is vaire's `--context`.\n\n```\n VPS                                                     S3-compatible bucket (R2, S3, B2, …)\n┌──────────────────────────────────────────┐           ┌──────────────────────────────────────┐\n│ app, billing, … (one cluster each)       │           │ <prefix>/pgbackrest/                 │\n│   ──WAL, continuously (≤60 s)────────────┼──────────▶│   one stanza per database:           │\n│   └─ vaire-backup-*@<db> timers ───────────┼──────────▶│   full · diff · incr sets, WAL       │\n│        Sun full, Mon–Sat diff, staggered │           │   (aes-256-cbc, zstd)                │\n│        Wed verify, monthly drill         │           │                                      │\n│   └─ scheduled per-database dumps ───────┼──────────▶│ <prefix>/dumps/<db>/  (age-encrypted)│\n└──────────────────────────────────────────┘           └──────────────────────────────────────┘\n```\n\n- **A stanza per database.** Each database's cluster has its own WAL stream, so backups, point-in-time recovery, retention and verification are all per database, in one repository with one cipher. Everything names a database:`backup list --db` ,`backup now --db` ,`restore <db>` ,`db fork <db>` .\n- **Restore window.** A database can be rewound to any second between just after its oldest backup and its last archived WAL, which is at most about a minute old. The backup sets only make restores faster; the WAL fills the gaps between them.`retention_full` is the window in weeks (default 4, and a database can have its own with`clusters.<db>.backup.retention_full` ). Retention is time-based, so extra full backups never shorten it.\n- **`vaire backup setup`** probes the bucket from your machine, generates the repository cipher (the age key for dumps already exists from`server init` ), switches every database's WAL archiving on with a reload (no restart), and takes a first full backup of each. The bucket keys, cipher and age identity form the**recovery kit** , which goes into the secrets handoff: run`vaire secrets export` afterwards. Backups are optional during`server init` ; until a bucket exists,`server init` and every`apply` warn`no_offsite_backup` .\n- **Staggered.** Each database's timers run at their own offset inside the maintenance window, and only one full, diff, verify, drill or dump runs at a time on a server.\n- **Dropped databases stay restorable for their window.**`db drop` keeps the stanza;`vaire backup list` shows it until a daily timer deletes it once the window has passed.\n- **Restoring one database doesn't touch the others.** A restore builds a new cluster from the database's stanza beside the live one, you check it, and only`promote` swaps it in. It needs free space for one copy of the database.\n- **`vaire db fork <db> [--name <new>] [--at <time>]`** makes a separate database: a physical copy of the source (from its stanza at a point in time, or from the live cluster), renamed, with its own roles and new passwords, the source's size and settings (`90-overrides.conf` included), its own stanza and a first full backup. It works without backups when there's no`--at` .\n- **Long-retention dumps.** A database that must be kept longer than the window gets scheduled`pg_dump` archives (`clusters.<db>.backup.dump_schedule` and`dump_retention` ), encrypted with age and streamed to the bucket.`vaire dump get` decrypts one,`vaire restore <db> --dump <id>` loads it, and`vaire db drop` takes a final dump first.\n- **Long work runs as detachable jobs.** Backups, verify, restores, drills and forks run as systemd units on the node. Ctrl-C detaches, and`vaire job list` /`vaire job follow <id>` re-attach.\n- **Checked, not assumed.**`vaire backup verify` checks every file of a database's stanza against its checksum, weekly by default. The monthly`vaire restore drill` restores each database into a throwaway cluster, runs`pg_amcheck` on it and compares it with the live one; each database's drill falls on its own day of the month. Both results show in`vaire backup list` .\n\nThe example rewinds `app` to just before a bad `DELETE` at 14:03 UTC. Every confirm token is `<db>/restore`, except cleanup's, which is `<db>/cleanup`.\n\n1. \n**Pick the target.** All times are UTC.`vaire backup list --db app` shows the restore window: any second between its start and the last archived WAL can be restored. Use`--time` for a point in time (RFC 3339, or relative like`-15m` ),`--latest` for the newest WAL,`--set <label>` for one backup set, or`--dump <id>` (from`vaire dump list` ) for a point older than the window.\n\n```\nvaire backup list --db app\n```\n\n2. \n**Restore into a copy.** This runs as a job on the node. Ctrl-C only detaches, and`vaire job follow <id>` re-attaches. Add`--dry-run` first to see the plan. The live`app` keeps serving throughout.\n\n```\nvaire restore app --time 2026-10-12T14:02:00Z --confirm app/restore\n```\n\n pgBackRest restores `app` 's stanza into a new cluster,`app__restoring` , which replays WAL up to the target and pauses there, read-only. Only one restore per database can be open at a time; if a previous one failed, the new one clears it.\n3. \n**Check the copy before you promote.**`restore status` compares row estimates with the live database. Query the copy directly through the session pooler to confirm it holds what you expect:\n\n```\nvaire restore status app\nvaire sql --db app__restoring \"select count(*) from users\"\n```\n\n If it's the wrong point in time, `vaire restore abort app` drops`app__restoring` (the live database was never touched), and you can restore again.\n4. \n**Promote.** The transaction pooler pauses`app` , the live cluster stops, the data directories swap places, and`app` starts on the restored data with its own configuration and limits, finishes replaying to the target and opens on a new timeline. Clients see a few seconds of downtime plus that replay; other databases aren't touched. The URL, roles and passwords don't change, so the application reconnects without any change. A full backup starts right after. The old data is kept as the stopped cluster`app__pre_restore_<timestamp>` .\n\n```\nvaire restore promote app --confirm app/restore\nvaire sql --db app \"select count(*) from users\"\n```\n\n5. \n**Undo or clean up.** If the promoted data is wrong,`undo` swaps the old data back the same way, as long as the leftover still exists, and vaire records which timeline to follow so later restores don't wander onto the abandoned one. Once you're happy,`cleanup` drops the leftover and frees its space. Run`vaire restore cleanup` without a database to list leftovers.`vaire db drop` drops a database's leftovers with it.\n\n```\nvaire restore undo app --confirm app/restore       # go back to what was live before the promote\nvaire restore cleanup app --confirm app/cleanup    # or: keep the restore, drop the old copy\n```\n\n`vaire restore app --time … --promote --confirm app/restore` does steps 2 and 4 in one go, skipping the check. Use it only when you're sure of the target. When only some rows are needed, `vaire db fork app --name app_restored --at <time>` gives you a separate database at that point instead, without touching the live one.\n\nA restore from a dump (`--dump <id>`) builds the copy from the dump instead, with the database's roles and passwords. On promote the database gets a new stanza, because its data has a new system identity; the old stanza stays in `backup list` as dropped for the rest of its window.\n\nInstalled from the distribution's apt repositories unless noted. Steps install only what is missing, and re-runs change nothing.\n\n| Package | Installed by | Why | \n|---|---|---|\n| `curl` ,`ca-certificates` ,`gnupg` ,`lsb-release` ,`jq` ,`sudo` | base packages | Basics for the steps and the PGDG repository | \n| `apt-transport-https` | base packages | Only on releases that still ship it | \n| `ufw` | base packages | Firewall: deny by default, the allow rules above | \n| `fail2ban` ,`python3-systemd` | base packages | Bans repeated SSH auth failures (reads the journal) | \n| `wireguard-tools` | base packages | The `wg0` interface the operator connects through | \n| `chrony` | base packages | Time sync (TLS and logs depend on it) | \n| `logrotate` | base packages | Rotates `/var/log/vaire` | \n| `unattended-upgrades` | base packages | Security updates only; PostgreSQL, PgBouncer and nginx are excluded | \n| `postgresql-common` | PostgreSQL packages | Adds the PGDG apt repository ( `apt.postgresql.org` ) and`pg_createcluster` , which makes each database's cluster | \n| `postgresql-18` ,`postgresql-client-18` ,`postgresql-contrib` | PostgreSQL packages (PGDG) | PostgreSQL 18; the major version is held with `apt-mark hold` | \n| `pgbouncer` | PostgreSQL packages (PGDG) | Two poolers: transaction mode on 6432 (Debian's `pgbouncer.service` ) and session mode on 5432 (`vaire-pgbouncer-session.service` ) | \n| `nginx` ,`libnginx-mod-stream` ,`ssl-cert` | TLS step | ACME HTTP-01 on 80 and ALPN routing on 443; held with `apt-mark hold` | \n| `pgbackrest` (≥ 2.59.3) | PostgreSQL packages (PGDG) | Database backups, WAL archiving and restores; held with `apt-mark hold` . 2.59.3 is the floor because earlier releases have an encryption bug | \n| `vaire` | `server init` /`--auto-upgrade-nodes` | The node binary at `/usr/local/bin/vaire` , pushed from the operator and signature-checked | \n\n| `node_exporter` 1.12.1, `postgres_exporter` 0.20.1, `pgbouncer_exporter` 0.12.1 | metrics step (upstream release binaries, sha256 pinned in vaire) | Metrics on loopback, served through nginx's authenticated `/metrics`; the same versions on Debian and Ubuntu |\n\nNo ACME client (certbot and the like) is installed: vaire issues certificates itself. vaire's bloat report embeds the estimate queries of [ioguix/pgsql-bloat-estimation](https://github.com/ioguix/pgsql-bloat-estimation) (BSD-2-Clause; `vaire version --licenses` prints the notice).\n\n| Tool | Needed for | How you get it | \n|---|---|---|\n| `vaire` | Everything | `install.sh` , cosign-verified;`vaire upgrade` afterwards | \n| WireGuard, SSH | Every remote command | Built into vaire (userspace WireGuard, in-process SSH). Nothing to install and no root needed | \n| `psql` ≥ 16 | `vaire psql` only | Your package manager; vaire offers the install command | \n| `usql` | `vaire console` only | On PATH, or vaire downloads a pinned release (v0.21.4) into `~/.cache/vaire/tools` and checks its SHA-256 | \n| `wireguard-tools` | `vaire wg export --install` only (not implemented yet) | Your package manager; for a kernel WireGuard interface instead of the built-in one | \n\n| Library | Used for | \n|---|---|\n| `spf13/cobra` | Command tree and flags | \n| Charm `huh` ,`bubbles` ,`lipgloss` | Interactive prompts and progress output | \n| `golang.org/x/crypto/ssh` | The SSH client | \n| `golang.zx2c4.com/wireguard` (wireguard-go + netstack) | The userspace WireGuard tunnel | \n| `golang.org/x/crypto/acme` | Issuing and renewing certificates on the node | \n| `jackc/pgx` | SQL over the tunnel ( `vaire sql` ,`db` ,`tune` ) | \n| `filippo.io/age` | The encrypted transfer bundle and long-retention dumps | \n| `minio/minio-go` | Bucket probe on `backup setup` , and streaming dumps to and from the bucket | \n| `sigstore/sigstore-go` | Verifying signed releases on upgrade | \n| `pelletier/go-toml` | `cluster.toml` | \n| `itchyny/gojq` | `--jq` output filtering | \n| `rsc.io/qr` | The QR code `vaire protect init` shows for the authenticator app | \n\nOperational commands support non-interactive structured JSON (`--output json`), with documented exit codes and `--dry-run` where it makes sense. Human-only secrets reveals and protected writes deliberately refuse unattended use. Destructive operations need a `--confirm <token>` that `--yes` never satisfies. `vaire commands --json` describes the whole surface, and `vaire guide agents` / `vaire skill install` load the operating guide into a coding agent.\n\n```\njust build   # bin/vaire\ngo test ./... # unit and regression tests; no local server harness\njust lint    # golangci-lint, metadata and step checks, shellcheck, actionlint\n```\n\n- [docs/MASTER.md](https://github.com/mbvlabs/vaire/blob/master/docs/MASTER.md) : goal, decisions, architecture, roadmap and current status.\n- [docs/IMPLEMENTATION.md](https://github.com/mbvlabs/vaire/blob/master/docs/IMPLEMENTATION.md) : the detailed spec for each phase.\n- [docs/RFC-cluster-per-database.md](https://github.com/mbvlabs/vaire/blob/master/docs/RFC-cluster-per-database.md) : why each database is its own cluster, and how that shapes every phase.\n- [docs/MANUAL-TESTING.md](https://github.com/mbvlabs/vaire/blob/master/docs/MANUAL-TESTING.md) : live evidence, historical failures and remaining coverage limits.\n- [docs/README.md](https://github.com/mbvlabs/vaire/blob/master/docs/README.md) : document index, including release preparation and archived work records.\n\nvaire is released under the [MIT License](https://github.com/mbvlabs/vaire/blob/master/LICENSE). Embedded third-party code keeps its own licence (see above).", "url": "https://wpnews.pro/news/show-hn-vaire-self-hosting-postgres-through-the-one-glorious-cli", "canonical_source": "https://github.com/mbvlabs/vaire", "published_at": "2026-11-02 14:45:08+00:00", "updated_at": "2026-11-02 14:52:22.533484+00:00", "lang": "en", "topics": ["ai-agents", "developer-tools", "ai-tools"], "entities": ["Vaire", "MBV Labs", "PostgreSQL 18", "PgBouncer", "nginx", "WireGuard", "Debian 13", "Ubuntu 26.04"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/show-hn-vaire-self-hosting-postgres-through-the-one-glorious-cli", "markdown": "https://wpnews.pro/news/show-hn-vaire-self-hosting-postgres-through-the-one-glorious-cli.md", "text": "https://wpnews.pro/news/show-hn-vaire-self-hosting-postgres-through-the-one-glorious-cli.txt", "jsonld": "https://wpnews.pro/news/show-hn-vaire-self-hosting-postgres-through-the-one-glorious-cli.jsonld"}}