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.xyz is 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 (or plumb-worker, etc.). Consider pm2 install pm2-logrotate to keep them bounded.
  • Postgres backups: pg_dump plumb > backup-$(date +%F).sql on a daily cron, offsite the file.
  • Monitoring: a simple external curl of /readyz every 5 minutes from a second box — any non-200 pages you.
  • Secret rotation: PLUMB_SIGNER_KEY_ED25519 rotation is supported — set the new one, move the old one into PLUMB_SIGNER_HISTORICAL_KEYS_JSON, and past receipts stay verifiable.

Opting out of individual pieces

  • Don't want on-chain settlement? Leave PLUMB_SETTLEMENT_SIGNER_PRIVKEY blank and the checkpoint scheduler will error on startup. Or set it and simply not run plumb-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: true on any chat turn. No memsync rows get written.

Plumb's primitives are orthogonal — you can run any subset.