# Post Fiat Fresh Validator Setup Guide for Agents > Purpose: end-to-end, agent-readable instructions for installing a fresh Post Fiat `postfiatd` validator on testnet, generating a validator identity, binding it to a domain, publishing domain proof, and verifying that the node is proposing. ## Codex Operator Quick Start If you want Codex to follow this guide on a server, start Codex from a terminal on the target host. Do not paste `validator-keys.json`, validator tokens, or private key material into Codex chat. For a full sudo-capable install, authenticate sudo first, then start Codex in one-shot mode so it can run the setup without per-command confirmation: ```bash sudo -v codex --yolo ``` Only use `--yolo` on a fresh or isolated validator host where Codex is allowed to make package, Docker, firewall, and file changes. Then type this into Codex, replacing the placeholders: ```text Use https://postfiat.org/validator-setup/ to install a fresh Post Fiat testnet validator on this server. Install mode: sudo. VALIDATOR_DOMAIN= SSH_PORT=22 NETWORK=testnet POSTFIATD_DIR=/opt/postfiatd Preserve existing validator keys if any exist. Do not paste private keys or validator tokens into chat. Keep key and token material on disk only. Bind admin/API ports to 127.0.0.1, keep peer port 2559 public, and do not reset firewall rules unless this is confirmed to be a fresh server. If I use GitHub Pages for the validator domain, publish .well-known/pft-ledger.toml and include .well-known in Jekyll config. Verify server_info, validator_info, consensus_info, and the public domain proof before finishing. ``` For a non-sudo Docker-only fallback, start Codex without sudo, then use this prompt instead: ```bash codex --yolo ``` ```text Use https://postfiat.org/validator-setup/ to set up a Post Fiat testnet validator if possible. Install mode: non-sudo. VALIDATOR_DOMAIN= SSH_PORT=22 NETWORK=testnet POSTFIATD_DIR=$HOME/postfiatd Do not install packages, change firewall rules, use /opt, or run sudo. Proceed only if Docker is already available to my user. Bind admin/API ports to 127.0.0.1, keep peer port 2559 public, keep key and token material on disk only, and tell me exactly which sudo-only steps remain, especially firewall and Docker service setup. If I use GitHub Pages for the validator domain, publish .well-known/pft-ledger.toml and include .well-known in Jekyll config. Verify server_info, validator_info, consensus_info, and the public domain proof before finishing. ``` ## Scope Use this guide when a user asks an LLM or automation agent to set up a new Post Fiat validator from a fresh server. Default target: - Network: `testnet` - Node role: validator - Docker image family: `agtipft/postfiatd:testnet-light-latest` - Current recommended explicit version as of 2026-05-01: `agtipft/postfiatd:testnet-light-1.0.4` Do not use older XRPL-style `3.0.0` images for Post Fiat Dynamic UNL eligibility. Official Post Fiat validator builds are `v1.0.0` or newer. ## Inputs the Agent Needs Ask for, infer, or confirm these values before beginning: - `VALIDATOR_DOMAIN`: bare domain controlled by the operator, for example `validator.example.com` or `example.com`. Do not include `https://`. - `SSH_PORT`: usually `22`. - `NETWORK`: normally `testnet`. - Where the operator will publish `https:///.well-known/pft-ledger.toml`. GitHub Pages works, including `.github.io`, but Jekyll sites need an explicit `.well-known` include. - Whether this is truly a fresh validator. If the selected `POSTFIATD_DIR`, `/opt/postfiatd`, `$HOME/postfiatd`, Docker volumes, or an existing `validator-keys.json` exists, do not delete it without explicit operator approval. Never ask the user to paste private validator keys or validator tokens into a chat transcript. Keep `validator-keys.json` and `[validator_token]` material on the target machine or in the operator's secure storage only. ## Install Mode: Sudo vs Non-Sudo Use sudo mode for production validators when possible. It can install Docker, enable the Docker service, configure firewall rules, and use the standard `/opt/postfiatd` directory: ```bash export INSTALL_MODE=sudo export POSTFIATD_DIR=/opt/postfiatd ``` Use non-sudo mode only as a Docker-only fallback on a host where Docker already works for the current user. Non-sudo mode cannot install packages, enable Docker, write to `/opt`, or configure firewall rules: ```bash export INSTALL_MODE=non-sudo export POSTFIATD_DIR="$HOME/postfiatd" ``` Set the install mode and node directory before running the commands below. If `POSTFIATD_DIR` is not set, choose the default path from `INSTALL_MODE`: ```bash if [ "${INSTALL_MODE:-sudo}" = "non-sudo" ]; then export POSTFIATD_DIR="${POSTFIATD_DIR:-$HOME/postfiatd}" else export POSTFIATD_DIR="${POSTFIATD_DIR:-/opt/postfiatd}" fi ``` ## Fresh Server Prerequisites Assume Ubuntu Server 22.04 LTS or newer. Minimum hardware: - 2 CPU cores - 4 GB RAM - 100 GB storage The server must accept inbound peer traffic on TCP `2559`. Admin/API ports such as `5005`, `6006`, and `50051` must not be open to the public internet. ## 1. Install Docker and Basic Tools Run as a sudo-capable user in sudo mode: ```bash sudo apt update sudo apt install -y docker.io docker-compose-v2 curl wget jq python3 sudo systemctl enable --now docker docker compose version ``` If `docker compose version` fails because the Ubuntu package name differs, install Docker's Compose plugin for the target distribution, then rerun the version check. In non-sudo mode, do not run package or service commands. Instead, verify Docker already works: ```bash docker info >/dev/null docker compose version ``` If either command fails in non-sudo mode, stop and ask the operator to install/enable Docker or grant Docker access. ## 2. Configure Firewall Set UFW to allow SSH and the Post Fiat peer protocol only: Only run the reset sequence on a fresh server or after confirming the host has no unrelated firewall rules. On a shared or already-running host, preserve existing service rules and add the Post Fiat peer rule instead of resetting UFW. ```bash export SSH_PORT="${SSH_PORT:-22}" sudo ufw --force reset sudo ufw default deny incoming sudo ufw default allow outgoing sudo ufw allow "${SSH_PORT}/tcp" comment 'SSH' sudo ufw allow 2559/tcp comment 'Post Fiat peer protocol' sudo ufw --force enable sudo ufw status verbose ``` Add Docker `DOCKER-USER` rate limits for peer traffic: ```bash sudo iptables -F DOCKER-USER 2>/dev/null || true sudo iptables -I DOCKER-USER -j RETURN sudo iptables -I DOCKER-USER -p tcp --dport 2559 -m state --state NEW -j DROP sudo iptables -I DOCKER-USER -p tcp --dport 2559 -m state --state NEW -m limit --limit 100/second --limit-burst 50 -j ACCEPT sudo iptables -I DOCKER-USER -p tcp --dport 2559 -m connlimit --connlimit-above 50 -j DROP sudo iptables -I DOCKER-USER -p tcp --dport 2559 -m state --state ESTABLISHED,RELATED -j ACCEPT sudo iptables -L DOCKER-USER -n -v ``` Expected security posture: - Public inbound allowed: SSH and TCP `2559` - Public inbound blocked: `5005`, `6005`, `6006`, `50051` - Local admin RPC still reachable from the server through `http://localhost:5005/` In non-sudo mode, do not change firewall rules. Record this as an operator action: ```text Required operator action: preserve SSH on the configured SSH_PORT, allow inbound TCP 2559, and keep 5005, 6005, 6006, and 50051 private. ``` ## 3. Create the Node Directory Sudo mode: ```bash export POSTFIATD_DIR="${POSTFIATD_DIR:-/opt/postfiatd}" sudo mkdir -p "$POSTFIATD_DIR/logs" sudo chown -R "$USER":"$USER" "$POSTFIATD_DIR" cd "$POSTFIATD_DIR" ``` Non-sudo mode: ```bash export POSTFIATD_DIR="${POSTFIATD_DIR:-$HOME/postfiatd}" mkdir -p "$POSTFIATD_DIR/logs" cd "$POSTFIATD_DIR" ``` Download the compose file in either mode: ```bash wget https://raw.githubusercontent.com/postfiatorg/postfiatd/main/scripts/docker-compose-external-validator.yml -O docker-compose.yml ``` Bind admin/API ports to loopback as defense in depth. The peer protocol on TCP `2559` must remain publicly reachable, but RPC/admin ports should not be published on all interfaces even if the firewall is correct: ```bash python3 - <<'PY' from pathlib import Path path = Path("docker-compose.yml") text = path.read_text() for old, new in { ' - "5005:5005"': ' - "127.0.0.1:5005:5005"', ' - "6005:6005"': ' - "127.0.0.1:6005:6005"', ' - "6006:6006"': ' - "127.0.0.1:6006:6006"', ' - "50051:50051"': ' - "127.0.0.1:50051:50051"', }.items(): text = text.replace(old, new) path.write_text(text) PY docker compose config --format json | python3 -c ' import json, sys ports = json.load(sys.stdin)["services"]["postfiatd"].get("ports", []) by_target = {int(port["target"]): port for port in ports} for port in (5005, 6005, 6006, 50051): host_ip = by_target.get(port, {}).get("host_ip") print(f"{port} host_ip:", host_ip) if host_ip != "127.0.0.1": raise SystemExit(f"{port} is not bound to 127.0.0.1") peer = by_target.get(2559, {}) print("2559 host_ip:", peer.get("host_ip", "0.0.0.0")) if peer.get("host_ip") == "127.0.0.1": raise SystemExit("peer port 2559 must not be loopback-only") ' ``` Create `.env`: ```bash cat > .env < ./validator-token.block chmod 600 ./validator-token.block grep -q '^\[validator_token\]$' ./validator-token.block ``` Do not inline the token through a shell variable. Multi-line validator tokens are easy to corrupt. ## 7. Inject the Validator Token into `postfiatd.cfg` Use a file-based replace so there is exactly one `[validator_token]` section: ```bash cd "$POSTFIATD_DIR" docker cp postfiatd:/etc/postfiatd/postfiatd.cfg ./postfiatd.cfg awk ' /^\[validator_token\]$/ {skip=1; next} /^\[[^]]+\]$/ {skip=0} !skip {print} ' ./postfiatd.cfg > ./postfiatd.cfg.new printf '\n' >> ./postfiatd.cfg.new cat ./validator-token.block >> ./postfiatd.cfg.new printf '\n' >> ./postfiatd.cfg.new test "$(grep -c '^\[validator_token\]$' ./postfiatd.cfg.new)" -eq 1 docker cp ./postfiatd.cfg.new postfiatd:/etc/postfiatd/postfiatd.cfg docker compose restart ``` After restart, remove master key material from the container: ```bash docker exec postfiatd rm -rf /root/.ripple ``` Keep these files secure and private: - `$POSTFIATD_DIR/validator-keys.json` - `$POSTFIATD_DIR/validator-keys.domain.json` - `$POSTFIATD_DIR/validator-token.block` - `$POSTFIATD_DIR/set-domain-output.txt` Prefer moving private key/token files off the validator host after configuration. ## 8. Publish Domain Attestation On the domain's website, publish: `https:///.well-known/pft-ledger.toml` Contents: ```toml [[VALIDATORS]] public_key = "" attestation = "" ``` Example generation on the validator host: ```bash cat > ./pft-ledger.toml <.github.io`, publish the proof from the repository root: ```bash cd /path/to/.github.io mkdir -p .well-known cp "$POSTFIATD_DIR/pft-ledger.toml" .well-known/pft-ledger.toml ``` If the site is built by Jekyll, ensure dot directories are included: ```yaml # _config.yml include: - .well-known ``` Commit, push, and wait for the Pages build/deploy to finish: ```bash git add _config.yml .well-known/pft-ledger.toml git commit -m "Publish Post Fiat validator proof" git push ``` Verify public access: ```bash curl -fsS "https://${VALIDATOR_DOMAIN}/.well-known/pft-ledger.toml" ``` Verify the public proof matches the generated values: ```bash curl -fsS "https://${VALIDATOR_DOMAIN}/.well-known/pft-ledger.toml" -o ./pft-ledger.public.toml grep -F "public_key = \"$PUBLIC_KEY\"" ./pft-ledger.public.toml grep -F "attestation = \"$ATTESTATION\"" ./pft-ledger.public.toml ``` ## 9. Verify Node Health Check software version, server state, and the active validator public key: ```bash docker exec postfiatd curl -s http://localhost:5005/ -X POST \ -H "Content-Type: application/json" \ -d '{"method": "server_info", "params": [{}]}' \ | PUBLIC_KEY="$PUBLIC_KEY" python3 -c ' import json, os, sys expected_public_key = os.environ["PUBLIC_KEY"] info = json.load(sys.stdin)["result"]["info"] print("build_version:", info.get("build_version")) print("server_state:", info.get("server_state")) print("pubkey_validator:", info.get("pubkey_validator")) print("peers:", info.get("peers")) print("complete_ledgers:", info.get("complete_ledgers")) if info.get("server_state") != "proposing": raise SystemExit("validator is not proposing yet") if info.get("pubkey_validator") != expected_public_key: raise SystemExit("pubkey_validator does not match generated public key") ' ``` Expected for a healthy validator after sync: ```text build_version: 1.0.4 server_state: proposing pubkey_validator: ``` `server_state` may temporarily be `disconnected`, `connected`, `syncing`, or `full` while the node starts and catches up. If `server_state` is `full` but not `proposing`, inspect `validator_info`, `consensus_info`, and token configuration before declaring the node healthy. Check validator domain and manifest sequence: ```bash docker exec postfiatd curl -s http://localhost:5005/ -X POST \ -H "Content-Type: application/json" \ -d '{"method": "validator_info"}' \ | VALIDATOR_DOMAIN="$VALIDATOR_DOMAIN" PUBLIC_KEY="$PUBLIC_KEY" python3 -c ' import json, os, sys expected_domain = os.environ["VALIDATOR_DOMAIN"] expected_public_key = os.environ["PUBLIC_KEY"] d = json.load(sys.stdin)["result"] print("domain:", d.get("domain", "")) print("seq:", d.get("seq", "")) print("master_key:", d.get("master_key", "")) if d.get("domain") != expected_domain: raise SystemExit("validator_info domain mismatch") if d.get("master_key") != expected_public_key: raise SystemExit("validator_info master_key mismatch") if int(d.get("seq", 0)) < 1: raise SystemExit("validator manifest sequence is missing") ' ``` Expected: ```text domain: seq: 1 or higher master_key: ``` For a fresh validator, `seq` is commonly `1`; later domain changes, signing key rotations, or manifest updates can increase it. Check consensus participation: ```bash docker exec postfiatd curl -s http://localhost:5005/ -X POST \ -H "Content-Type: application/json" \ -d '{"method": "consensus_info"}' \ | python3 -c ' import json, sys info = json.load(sys.stdin)["result"]["info"] for key in ("validating", "proposing", "synched"): print(f"{key}:", info.get(key)) if info.get(key) is not True: raise SystemExit(f"consensus_info {key} is not true") ' ``` Expected healthy values include: ```text validating: True proposing: True synched: True ``` ## 10. Upgrade Procedure After Fresh Install For unpinned `latest` setups: ```bash cd "$POSTFIATD_DIR" docker compose pull docker compose up -d ``` For pinned setups, edit `docker-compose.yml` to the recommended tag, then run the same pull/up sequence. Always verify after an upgrade: ```bash docker exec postfiatd curl -s http://localhost:5005/ -X POST \ -H "Content-Type: application/json" \ -d '{"method": "server_info", "params": [{}]}' \ | python3 -m json.tool | grep -E '"server_state"|"build_version"' ``` ## Troubleshooting ### `domain` is empty in `validator_info` The validator token in `/etc/postfiatd/postfiatd.cfg` probably does not contain the domain manifest. Re-run `validator-keys set_domain`, replace the token using the file-based method, and restart. ### Token errors in logs Inspect logs: ```bash docker compose logs --tail=200 | grep -Ei 'invalid|token|fatal|validator' ``` Most token errors come from corrupted line breaks. Replace the token from a file, not an inline shell variable. ### Node is `full` but not `proposing` Check that `[validator_token]` exists exactly once: ```bash docker cp postfiatd:/etc/postfiatd/postfiatd.cfg ./postfiatd.cfg.check grep -n '^\[validator_token\]$' ./postfiatd.cfg.check ``` Then inspect `validator_info` and `consensus_info`. ### Admin ports are reachable from the internet Fix the firewall immediately. Only SSH and TCP `2559` should be publicly reachable unless the operator has deliberately placed admin APIs behind a secure private network. ### The operator already has validator keys Do not run `validator-keys create_keys`. Use the existing secure `validator-keys.json`, copy it into `/root/.ripple/validator-keys.json` only temporarily, then run `validator-keys set_domain` or `validator-keys create_token` as needed. ## Agent Safety Checklist - Never delete the selected `POSTFIATD_DIR`, `/opt/postfiatd`, `$HOME/postfiatd`, Docker volumes, or existing validator keys unless the operator explicitly asks for a destructive reset. - Never paste `validator-keys.json` or `[validator_token]` into chat. - Never expose RPC/admin ports publicly. - Always publish the public key and attestation at `https:///.well-known/pft-ledger.toml`. - Always verify `server_state`, `build_version`, `validator_info.domain`, and consensus participation. - For Dynamic UNL readiness, prefer official Post Fiat `v1.0.0+` builds and keep the node upgraded.