Self-hosting Plumb
This guide is for people comfortable on a Linux server. You don't need to be a DevOps expert — if you can copy-paste into an SSH terminal and follow numbered steps, you can do this — but you will be running commands as root and editing config files. If that's not your thing, Plumb's hosted version at
plumbtech.xyzis the easier path.
What you'll end up with
A Plumb instance running on your own server, under your own domain. Your own wallets, your own users, your own data. You can point the Python SDK or any OpenAI-compatible client at your server and get the same signed-receipt behavior as the hosted version.
The whole stack runs on a single Linux VPS — no Kubernetes, no containers, no managed platforms. This guide tracks the exact setup running in prod at plumbtech.xyz. Budget about 30 minutes start to finish.
Prereqs
- VPS with a public IPv4, ~8GB RAM, ~40GB disk (comfortable; more is fine). Tested on Ubuntu 24.04 on Hostinger.
- Domain with DNS management (we use GoDaddy for
plumbtech.xyz; any registrar works). - Root or sudo on the VPS.
- Base Sepolia ETH in the deployer wallet (~0.02 ETH covers all contract deploys with margin).
1. System packages (apt, ~5 min)
# Node 22 via NodeSource
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
sudo apt install -y nodejs
# pnpm
sudo npm install -g pnpm@9.12.0
# Postgres 16 + pgvector
sudo apt install -y postgresql-16 postgresql-16-pgvector
# Redis 7
sudo apt install -y redis-server
# Caddy (official repo)
sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https curl
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list
sudo apt update && sudo apt install -y caddy
Worth confirming versions:
node -v # v22.x
pnpm -v # 9.12+
psql --version # 16.x
redis-cli ping # PONG
caddy version # v2.x
2. Clone + install
A dedicated read-only deploy key on the GitHub repo keeps your VPS out of push permissions:
# As the plumb service user (e.g., "dustin" in our setup)
ssh-keygen -t ed25519 -f ~/.ssh/id_plumb_github -N "" -C "plumb-vps-deploy"
cat ~/.ssh/id_plumb_github.pub # copy this into GitHub → repo → Settings → Deploy keys (read-only)
# Tell ssh to use that key for the github host
cat >> ~/.ssh/config <<EOF
Host github-plumb
HostName github.com
User git
IdentityFile ~/.ssh/id_plumb_github
IdentitiesOnly yes
EOF
git clone git@github-plumb:Wdustin1/plumb.git ~/plumb
cd ~/plumb && pnpm install
Build all workspaces (not strictly required — we run Node services via tsx directly — but catches dep issues):
pnpm -r build
3. Postgres
Create the app database and the explorer read-only role:
sudo -u postgres psql <<'SQL'
CREATE ROLE plumb LOGIN PASSWORD 'plumb';
CREATE DATABASE plumb OWNER plumb;
\c plumb
CREATE EXTENSION vector;
SQL
# Run migrations (0000..0005) against the new DB
cd ~/plumb
PLUMB_DEV_DB_URL='postgresql://plumb:plumb@localhost:5432/plumb' pnpm -F @plumb/db exec drizzle-kit migrate
# Migration 0004 creates the read-only role (superuser-required). Apply separately:
sudo -u postgres psql -d plumb -f ~/plumb/packages/db/migrations/0004_explorer_ro_role.sql
sudo -u postgres psql -d plumb -f ~/plumb/packages/db/migrations/0005_cursor_block_hash.sql
# Rotate the RO role password off its default
sudo -u postgres psql -d plumb -c "ALTER ROLE plumb_explorer_ro WITH PASSWORD '$(openssl rand -hex 24)';"
Grab the password you just set — you'll need it for PLUMB_EXPLORER_DB_URL.
4. Data directories
mkdir -p ~/plumb-data/hub ~/plumb-data/pipe-inputs ~/plumb-data/logs
5. Ed25519 signer key
Generate the persistent receipt signer:
node -e '
const { generateKeyPairSync } = require("node:crypto");
const { privateKey } = generateKeyPairSync("ed25519");
process.stdout.write(privateKey.export({ format: "jwk" }).d);
' > ~/plumb-data/signer-ed25519.key
chmod 600 ~/plumb-data/signer-ed25519.key
6. Env files
~/plumb/apps/gateway/.env:
NODE_ENV=production
PORT=3010
PLUMB_APP_DB_URL=postgresql://plumb:plumb@127.0.0.1:5432/plumb
PLUMB_REDIS_URL=redis://127.0.0.1:6379
PLUMB_SIWE_DOMAIN=api.plumbtech.xyz # change this to yours
PLUMB_SIWE_CHAIN_ID=84532
PLUMB_ALLOWED_ORIGINS=https://console.yourdomain,https://explorer.yourdomain,https://yourdomain
PLUMB_DEV_ADMIN_TOKEN= # leave blank in prod; routes 404
PLUMB_SIGNER_KEY_ID=srv-2026-04-prod
PLUMB_SIGNER_KEY_ED25519=<contents of ~/plumb-data/signer-ed25519.key>
PLUMB_HUB_STORAGE_DIR=/home/USER/plumb-data/hub
PLUMB_HUB_MAX_UPLOAD_BYTES=104857600
PLUMB_HUB_INFERENCE_COST_MICRO=1000
PLUMB_HUB_REGISTRY_ADDRESS=0x08097d0a9b24779c6bf1a510473ce6776efbe492
PLUMB_PIPE_INPUTS_DIR=/home/USER/plumb-data/pipe-inputs
PLUMB_PIPE_MAX_INPUT_BYTES=10485760
BASE_SEPOLIA_RPC_URL=https://sepolia.base.org
BASE_SEPOLIA_CHAIN_ID=84532
DEPLOYER_PRIVATE_KEY=0x... # your Base Sepolia deployer key
PLUMB_SETTLEMENT_SIGNER_ADDRESS=0x...
PLUMB_SETTLEMENT_SIGNER_PRIVKEY=0x...
PLUMB_WATCHER_START_BLOCK=40632389 # contract deployment block
PLUMB_PIPE_ORACLE_ADDRESS=0xc150a84e55cee332575e198ac333a28135c46035
OPENAI_API_KEY= # optional, per upstream
ANTHROPIC_API_KEY= # optional, per upstream
~/plumb/apps/explorer/.env:
NODE_ENV=production
PORT=3013
PLUMB_EXPLORER_DB_URL=postgresql://plumb_explorer_ro:<rotated-password>@127.0.0.1:5432/plumb
NEXT_PUBLIC_API_BASE=https://api.yourdomain
~/plumb/apps/console/.env:
NODE_ENV=production
PORT=3012
NEXT_PUBLIC_API_BASE=https://api.yourdomain
7. PM2 ecosystem
The repo ships ~/plumb/plumb.ecosystem.config.cjs. It reads each .env at PM2-config load time and wires the gateway + worker + console + explorer under PM2. If the file needs customization, the comments inline explain each option.
pm2 start ~/plumb/plumb.ecosystem.config.cjs --update-env
pm2 save # persist the process list so a reboot restores them
pm2 startup systemd # emits a command to enable pm2 on boot; run what it prints
8. Caddy
Append the plumb site blocks to /etc/caddy/Caddyfile:
(plumb_security_headers) {
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
X-Frame-Options "DENY"
X-Content-Type-Options "nosniff"
Referrer-Policy "strict-origin-when-cross-origin"
Permissions-Policy "accelerometer=(), camera=(), geolocation=(), microphone=(), payment=(), usb=()"
-Server
}
}
api.yourdomain {
import plumb_security_headers
reverse_proxy 127.0.0.1:3010
encode zstd gzip
}
console.yourdomain {
import plumb_security_headers
reverse_proxy 127.0.0.1:3012
encode zstd gzip
}
explorer.yourdomain {
import plumb_security_headers
reverse_proxy 127.0.0.1:3013
encode zstd gzip
}
If you're on a host where Tailscale or another service already binds 0.0.0.0:443, add bind <public-ip> to each site block so Caddy doesn't collide. See the shipped infra/caddy/Caddyfile.prod for the pattern.
Reload: sudo systemctl reload caddy. Caddy will auto-issue Let's Encrypt certs for each domain on first hit.
9. DNS
Point A records at your VPS IP:
A api → <VPS IPv4>
A console → <VPS IPv4>
A explorer → <VPS IPv4>
Plus whatever apex config your marketing site needs (if you use Vercel for it, their dashboard tells you which A + CNAME records to set).
10. Contracts
Deploy to Base Sepolia if you don't want to use the shared prod contracts:
cd ~/plumb/packages/contracts
cp .env.local.example .env.local # fill in DEPLOYER_PRIVATE_KEY, BASE_SEPOLIA_RPC_URL, PLUMB_SETTLEMENT_SIGNER_ADDRESS
bash scripts/deploy-base-sepolia.sh # PLMB + Settlement
bash scripts/deploy-hub-registry-base-sepolia.sh
bash scripts/deploy-pipe-base-sepolia.sh
Each script updates deployments/base-sepolia.json. Copy the new addresses into your apps/gateway/.env.
11. Smoke
curl https://api.yourdomain/healthz # {"ok":true,...}
curl https://api.yourdomain/readyz | jq # postgres/redis/signer all ok:true
curl https://api.yourdomain/openapi.json | jq '.info' # schema served
curl -o /dev/null -w "%{http_code}\n" https://console.yourdomain/login # 200
curl -o /dev/null -w "%{http_code}\n" https://explorer.yourdomain/ # 200
If all five give good responses, you've got a working Plumb instance. Hit https://console.yourdomain/login, connect a wallet, SIWE-sign, and you're authenticated.
Ongoing operations
- Update code:
ssh vps "cd ~/plumb && git pull && pnpm install && pm2 restart plumb-gateway plumb-worker plumb-console plumb-explorer" - Logs:
pm2 logs plumb-gateway(orplumb-worker, etc.). Considerpm2 install pm2-logrotateto keep them bounded. - Postgres backups:
pg_dump plumb > backup-$(date +%F).sqlon a daily cron, offsite the file. - Monitoring: a simple external curl of
/readyzevery 5 minutes from a second box — any non-200 pages you. - Secret rotation:
PLUMB_SIGNER_KEY_ED25519rotation is supported — set the new one, move the old one intoPLUMB_SIGNER_HISTORICAL_KEYS_JSON, and past receipts stay verifiable.
Opting out of individual pieces
- Don't want on-chain settlement? Leave
PLUMB_SETTLEMENT_SIGNER_PRIVKEYblank and the checkpoint scheduler will error on startup. Or set it and simply not runplumb-worker— receipts will still be signed, just never settled. - Don't want Pipe? Don't run the pipe oracle and don't register a
PLUMB_PIPE_ORACLE_ADDRESS. The worker's pipe-fulfiller loop will be inactive. - Don't want Memory? Don't pass
plumb_memory: trueon any chat turn. No memsync rows get written.
Plumb's primitives are orthogonal — you can run any subset.