A Proxmox LXC is the recommended home for a long-running corpus-forge
Postgres in a homelab or small-team setup: lighter than a VM,
snapshottable with vzdump, and (with the unprivileged + Tailscale
combo) reasonably isolated from the host.
This guide is specifically about hosting the Postgres database in an LXC; the corpus-forge ingester daemon can run anywhere with network access to it.
Pick a Debian or Ubuntu LTS template from the Templates page of the
Proxmox storage view:
debian-12-standard— recommended, most-tested with the PGDG repo.ubuntu-24.04-standard— fine, slightly larger base image.
Avoid *-cloudimg-amd64 variants for LXC — those are pre-configured
for cloud-init, which collides with Proxmox's container provisioning
in surprising ways.
Start point that comfortably handles the maintainer's reference 2.25M- chunk corpus (~49 GB total PG footprint at Qwen3-Embedding-8B 2048-dim):
| Resource | Value | Why |
|---|---|---|
| RAM | 16 GB | shared_buffers=4 GB + effective_cache_size=12 GB matches the tuning drop-in |
| Cores | 4 | HNSW backfill is the only CPU-heavy phase; 4 vCPUs is enough |
| Rootfs | 150 GB | 49 GB working set + WAL + 1–2 pg_dump snapshots + HNSW build headroom |
| Swap | 0 GB | Postgres performs poorly when paged out — avoid swap on the rootfs |
For smaller corpora, scale RAM down using the table in
postgres.md#tuning. The 150 GB rootfs is the
one number you should not undersize: HNSW index builds can transiently
double the on-disk footprint of the embedding table.
Unprivileged is the right default. The Phase R bootstrap script runs cleanly inside an unprivileged container — none of its steps require host kernel capabilities.
Pick Unprivileged container: Yes in the Proxmox create wizard, or
add unprivileged: 1 to the pct line if you're scripting it:
pct create 200 local:vztmpl/debian-12-standard_12.0-1_amd64.tar.zst \
--hostname corpus-forge-db \
--unprivileged 1 \
--memory 16384 --swap 0 \
--cores 4 \
--rootfs local-zfs:150 \
--net0 name=eth0,bridge=vmbr0,ip=dhcp \
--features nesting=0 \
--onboot 1Use privileged containers (--unprivileged 0) only if you
absolutely need a host-bind-mount with strict uid passthrough — almost
nothing about corpus-forge needs that.
Once the container is up:
# 1. Get a root shell.
pct enter 200
# 2. Install corpus-forge's scripts (clone the repo or copy
# scripts/postgres-bootstrap.sh + scripts/postgres-tune.sh into /root).
apt-get update && apt-get install -y git curl
git clone https://github.com/ulmentflam/corpus-forge.git /root/corpus-forge
# 3. Bootstrap Postgres + pgvector.
cd /root/corpus-forge
CF_PG_DB=corpus_forge \
CF_PG_USER=corpus_forge \
CF_PG_PASSWORD="$(openssl rand -base64 32)" \
CF_PG_CIDR=192.168.1.0/24 \
bash scripts/postgres-bootstrap.sh
# 4. Apply the tuning drop-in.
bash scripts/postgres-tune.sh --ram 16
systemctl reload postgresql
# 5. Smoke-test from outside the container.
psql -h <LXC IP> -U corpus_forge -d corpus_forge -c '\dx vector'The script's idempotency means you can re-run step 3 after a tweak
without losing anything — the role and database are skip-if-exists,
the pg_hba.conf append is grep-guarded, and the
listen_addresses edit is a sed in-place that converges on the same
value.
Proxmox unprivileged containers map UIDs through a shift, typically
+100000 — root inside the container is UID 100000 on the host. The
Postgres postgres user is UID 999 inside the container, which maps
to UID 100999 on the host. This matters in two situations:
- Host bind-mounts. If you bind-mount a host directory into the
container for Postgres data (instead of using the container's own
rootfs), the host directory must be
chown -R 100999:100999. The recommended pattern is the opposite: keep PG data on the container rootfs (or a Proxmox storage-managed mount point), not a host bind-mount. - Restoring a
vzdumparchive onto a different Proxmox host. UID mapping shifts can differ between hosts; use Proxmox'spct restorerather than untarring by hand.
The bootstrap script appends to pg_hba.conf. PG processes the file
top-down and the first matching line wins. If the default
local all postgres peer line comes before your host line, that's
fine — local and host are distinct connection types. The trap is a
broad host all all 0.0.0.0/0 reject line above your CIDR allow; that
will reject the corpus-forge client before your line is reached.
Check ordering:
sudo grep -nE '^(host|local)' /etc/postgresql/17/main/pg_hba.confMove the corpus-forge host line up if needed, then
sudo systemctl reload postgresql.
If you plan to run Docker inside the LXC (e.g. to host the
corpus-forge daemon alongside Postgres), Proxmox needs
features: nesting=1. For a pure Postgres host (this guide's main
case) leave nesting off — fewer capability surfaces exposed.
# Toggle nesting on an existing container.
pct set 200 -features nesting=1
pct reboot 200listen_addresses = '*' makes Postgres listen on every interface,
including any public IP the container has. Combine with a tight
pg_hba.conf (private CIDR or single tailnet address), and ideally
firewall the LXC at the Proxmox host level too. The script's default
is '*'; if you want the database to only accept connections on the
tailnet interface, use --no-listen and set listen_addresses
manually to the specific IP.
If you already run Tailscale on every machine that talks to corpus-forge, using the tailnet hostname as the database address is the simplest way to bind the entire mesh by a stable name.
# Inside the LXC.
curl -fsSL https://tailscale.com/install.sh | sh
tailscale up --auth-key=tskey-... --hostname=corpus-forge-db
# Confirm the tailnet IP.
tailscale ip -4
# 100.x.y.z
# Use the tailnet hostname in your corpus-forge config:
# dsn = "postgresql://corpus_forge:...@corpus-forge-db:5432/corpus_forge"
# MagicDNS resolves corpus-forge-db on every joined machine.When you run the bootstrap script, pass the tailnet CIDR
(100.64.0.0/10 covers the whole tailnet) or a tighter range that
matches your own tags / ACL setup:
CF_PG_CIDR=100.64.0.0/10 ... bash scripts/postgres-bootstrap.shThis is one option among several — bare LAN IPs, a private VPC, a WireGuard mesh you manage yourself — pick whatever you already have. The point is something that gives a stable hostname for every client.
Run two backup mechanisms in parallel — each protects against a failure mode the other doesn't.
| Layer | Tool | Schedule | Restores |
|---|---|---|---|
| Whole-LXC snapshot | Proxmox vzdump |
nightly | LXC bit-identical, including config, hostname, network |
| Logical DB dump | pg_dump -Fc |
nightly | Database into any compatible Postgres (including a different LXC) |
# On the Proxmox host — nightly vzdump.
vzdump 200 --compress zstd --mode snapshot --storage local
# /var/lib/vz/dump/vzdump-lxc-200-YYYY_MM_DD-HH_MM_SS.tar.zst
# Inside the LXC — nightly pg_dump shipped off-host.
sudo -u postgres pg_dump -Fc -d corpus_forge \
| ssh nas-host "cat > /backup/corpus-forge/$(date +%Y%m%d).dump"The vzdump archive is the answer to "the LXC is gone"; the
pg_dump is the answer to "corpus-forge wrote bad data" (you can
restore the dump into a fresh container without reverting system
configuration to last night's state).
For a single-disk Proxmox host without ZFS, also rsync the
vzdump archives off-host weekly. For a ZFS host, zfs send of the
storage pool covers the same ground at lower cost.