This is the sequel to my post on setting up a Zcash full node with 'Zebra', with the help of Claude. In short, I run a Zcash full node on a home server that is publicly accessible at marinade.duckdns.org, and I use Claude as my AI pair programmer to help build and operate the whole stack.
Why did I do all this with Claude? Because I am not skilled in programming, DevOps or security. Despite this, I wanted to contribute to the Zcash network. With Claude, coding simply involved conversing via prompts. Nevertheless, common sense is fundamental.
Why lightwalletd?
Zcash light wallets (Zingo, YWallet, Nighthawk, etc.) don't sync the full chain. They rely on a compact block server — a gRPC service that strips transactions down to just the data needed to detect your own shielded funds. That server is lightwalletd.
My original plan was to use Zaino, a newer Rust-based indexer. I even set it up, got it syncing, and wrote monitoring scripts for it. Not sure if it was the new Zebra version shipped v4.2.0 with NU6.1 but Zaino was not able to sync and broke several times.
Claude's verdict after reviewing both changelogs:
"Zaino's gRPC interface doesn't yet handle the NU6.1 block format Zebra 4.2.0 produces. lightwalletd is the reference implementation — it's what the Zcash Foundation runs, it tracks Zebra releases closely, and it's a pre-built Go binary you can swap in today."
So that's what we did. The whole migration took one afternoon.
Architecture

Internet │ │ TCP 443 (TLS 1.2/1.3) ▼ nginx ─── rate limit, fail2ban, TLS termination │ │ gRPC (plaintext, localhost only) ▼lightwalletd :9067 │ │ JSON-RPC (localhost only) ▼zebrad :8232
The key design decision: lightwalletd never touches the internet directly. nginx sits in front and handles everything: TLS, rate limiting, access logging. lightwalletd just talks to zebrad over loopback.
Step 1 — Install the binary
lightwalletd ships as a pre-built Go binary on GitHub Releases. No compilation needed.
mkdir -p ~/Programs/lightwalletdcd ~/Programs/lightwalletd LATEST=$(curl -s https://api.github.com/repos/zcash/lightwalletd/releases/latest \ | python3 -c "import json,sys; print(json.load(sys.stdin)['tag_name'])") DOWNLOAD_URL=$(curl -s https://api.github.com/repos/zcash/lightwalletd/releases/latest \ | python3 -c "import json, sysdata = json.load(sys.stdin)for a in data['assets']: if 'linux' in a['name'].lower() and 'amd64' in a['name'].lower(): print(a['browser_download_url']) break") curl -sL "$DOWNLOAD_URL" -o lightwalletdchmod +x lightwalletd./lightwalletd --version
Step 2 — Configuration
lightwalletd requires a zcash.conf file even when connecting to Zebra (which doesn't use RPC credentials). The credentials in it are ignored — the file just satisfies a startup check.
sudo mkdir -p /etc/lightwalletd /var/lib/lightwalletdsudo touch /var/log/lightwalletd.logsudo chown bloxster:bloxster /var/log/lightwalletd.log /var/lib/lightwalletd # Dummy zcash.conf (credentials ignored by zebrad)sudo tee /etc/lightwalletd/zcash.conf << 'EOF'rpcuser=lightwalletdrpcpassword=lightwalletdrpchost=127.0.0.1rpcport=8232EOF sudo tee /etc/lightwalletd/lightwalletd.yml << 'EOF'grpc-bind-addr: 127.0.0.1:9067no-tls-very-insecure: truezcash-conf-path: /etc/lightwalletd/zcash.confdata-dir: /var/lib/lightwalletdlog-file: /var/log/lightwalletd.loglog-level: 4EOF
no-tls-very-insecure: true sounds alarming. It means lightwalletd itself doesn't terminate TLS — nginx does. The name is intentionally scary so you don't expose the port without something in front of it.
Step 3 — systemd service
sudo tee /etc/systemd/system/lightwalletd.service << 'EOF'[Unit]Description=Lightwalletd - Zcash Light Wallet ServerAfter=network-online.targetWants=network-online.target [Service]Type=simpleUser=bloxsterExecStart=/home/bloxster/Programs/lightwalletd/lightwalletd \ --config /etc/lightwalletd/lightwalletd.ymlRestart=on-failureRestartSec=10Nice=-10IOSchedulingClass=realtimeIOSchedulingPriority=2StandardOutput=journalStandardError=journal [Install]WantedBy=multi-user.targetEOF sudo systemctl daemon-reloadsudo systemctl enable --now lightwalletdsudo systemctl status lightwalletd
The Nice=-10 and IOSchedulingClass=realtime give lightwalletd priority when zebrad is doing a lot of disk I/O during the initial sync. Without this, the initial block download can starve lightwalletd and it falls behind.
Step 4 — nginx reverse proxy with TLS
I already had nginx running for other things, so I added a new site config. The public endpoint is port 443 (standard HTTPS — more firewall-friendly than 50051, and wallet apps handle it fine).
limit_conn_zone $binary_remote_addr zone=lwd_conn:10m;limit_req_zone $binary_remote_addr zone=lwd_req:10m rate=30r/s; server { listen 443 ssl http2; server_name marinade.duckdns.org; ssl_certificate /etc/nginx/certs/fullchain.pem; ssl_certificate_key /etc/nginx/certs/privkey.pem; ssl_protocols TLSv1.2 TLSv1.3; ssl_ciphers HIGH:!aNULL:!MD5; # Rate limits: 20 concurrent connections, 30 req/s with burst of 50 limit_conn lwd_conn 20; limit_req zone=lwd_req burst=50 nodelay; # Timeouts client_header_timeout 10s; client_body_timeout 10s; send_timeout 30s; keepalive_timeout 30s; server_tokens off; access_log /var/log/nginx/zaino_access.log; error_log /var/log/nginx/zaino_error.log warn; location / { grpc_pass grpc://127.0.0.1:9067; grpc_read_timeout 300s; grpc_send_timeout 300s; client_max_body_size 1m; }}
Note the 300s gRPC timeouts. Light wallets do long-lived streaming calls when scanning. If you set this too low, scans time out mid-flight and the wallet app retries in a loop.
I also started with port 50051 (the conventional lightwalletd port), then migrated to 443. The reason: some residential ISPs and corporate firewalls block non-standard ports. Port 443 just works everywhere.
Step 5 — TLS certificate (Let's Encrypt + DuckDNS)
My server is behind a common home router with a dynamic IP. I use DuckDNS to keep marinade.duckdns.org pointing at my current IP, and certbot with the certbot-dns-duckdns plugin for the DNS challenge (no need to open any extra ports for certificate issuance).
pip install certbot certbot-dns-duckdns # Credentials filemkdir -p ~/.secretscat > ~/.secrets/duckdns.ini << 'EOF'dns_duckdns_token=YOUR_DUCKDNS_TOKENEOFchmod 600 ~/.secrets/duckdns.ini # Issue certcertbot certonly \ --authenticator dns-duckdns \ --dns-duckdns-credentials ~/.secrets/duckdns.ini \ --dns-duckdns-propagation-seconds 60 \ --config-dir ~/.config/letsencrypt \ --work-dir /tmp/certbot-work \ --logs-dir /tmp/certbot-logs \ -d marinade.duckdns.org \ --non-interactive --agree-tos -m your@email.com # Copy to nginxsudo cp ~/.config/letsencrypt/certs/live/marinade.duckdns.org/fullchain.pem /etc/nginx/certs/sudo cp ~/.config/letsencrypt/certs/live/marinade.duckdns.org/privkey.pem /etc/nginx/certs/sudo chmod 640 /etc/nginx/certs/privkey.pemsudo chown root:www-data /etc/nginx/certs/privkey.pemsudo nginx -t && sudo systemctl reload nginx
Auto-renewal runs via cron every Monday at 05:00. The script checks whether the cert has >30 days left before bothering certbot, then copies the new cert and reloads nginx. If it fails, I get a Telegram alert.
Step 6 — Hardening
This is the part Claude was most useful for. I asked: "What would a serious attacker do against a public gRPC endpoint, and how do we defend against each?"
The threat model for a public lightwalletd:
- Traffic flood (legitimate-looking wallet scans from many IPs) — exhaust CPU/memory
- Single IP abuse — one bot hammering the endpoint, triggering OOM
- Oversized payloads — malformed gRPC requests to trigger parsing bugs
- TLS downgrade — force weak cipher
- Service escape — process somehow escaping its confinement
fail2ban
# /etc/fail2ban/jail.d/zaino.conf[lightwalletd]enabled = trueport = 443filter = lightwalletdlogpath = /var/log/nginx/zaino_access.logmaxretry = 30findtime = 60bantime = 86400 # 24h banaction = ufw # Recidive: repeat offenders banned for 1 week[lightwalletd-recidive]enabled = truefilter = recidivelogpath = /var/log/fail2ban.logaction = ufw[port="443"]bantime = 604800 # 7 daysfindtime = 86400maxretry = 3
# /etc/fail2ban/filter.d/lightwalletd.conf[Definition]failregex = ^<HOST> .* "(GET|POST|/cash\.z\.wallet\.sdk\.rpc|/grpc) .*" (429|444|400|499) .*$ ^<HOST> .* - - \[.*\] ".*" 429 .*$ignoreregex =
The recidive jail is the key one. An IP that gets banned three times in a day gets a week-long ban automatically. No manual intervention needed.
systemd resource limits
# /etc/systemd/system/lightwalletd.service.d/limits.conf[Service]MemoryMax=8GMemorySwapMax=512MCPUQuota=90% # Syscall filtering and namespace restrictionsNoNewPrivileges=truePrivateTmp=trueProtectSystem=strictReadWritePaths=/var/lib/lightwalletd /var/log/lightwalletd.logProtectKernelTunables=trueProtectControlGroups=trueRestrictAddressFamilies=AF_INET AF_INET6 AF_UNIXRestrictNamespaces=trueSystemCallFilter=@system-service
The CPUQuota=90% leaves headroom for zebrad. If lightwalletd starts thrashing (e.g. someone triggers a pathological scan pattern), it can't fully starve the node.
ProtectSystem=strict + ReadWritePaths means the process can only write to two paths. Even if someone exploits a bug in lightwalletd, they can't write anywhere else on the filesystem.
Spike monitor
A cron job runs every minute, parses the last 60 seconds of the nginx access log, and sends a Telegram alert if:
- Any single IP exceeds 100 req/min
- Total traffic exceeds 500 req/min
This is early warning before fail2ban kicks in, and it catches distributed floods that don't trip single-IP thresholds.
Step 7 — Automated updates with rollback
The update script runs every Wednesday at 04:00. It checks the GitHub Releases API, downloads the new binary only if a new version exists, and automatically rolls back if the service doesn't start cleanly after the update.
# Pseudocode of the update flow:check current version vs latest GitHub releaseif no update → exit quietlydownload new binary to temp filestop lightwalletdbackup old binary → lightwalletd.bakinstall new binarystart lightwalletdsleep 5if service active → send Telegram "updated X → Y"else → restore .bak, restart, send Telegram "rollback"
The rollback path is what matters. An update that silently breaks the service and stays down until I notice is worse than no update at all.
Step 8 — Daily backups
lightwalletd stores its indexed data in a SQLite database under /var/lib/lightwalletd. It re-syncs from zebrad on first start, so losing it isn't catastrophic — but a re-sync takes hours. Daily backups keep the recovery time short.
# Runs daily at 02:00, keeps last 7 backupstar -czf /home/bloxster/backups/lightwalletd/lightwalletd_$(date +%Y%m%d_%H%M%S).tar.gz \ -C /var /lib/lightwalletd # Prune old backupsls -t /home/bloxster/backups/lightwalletd/lightwalletd_*.tar.gz | tail -n +8 | xargs -r rm -f
If the backup fails, Telegram alert.
What the full stack looks like now
Cron jobs├── Mon 05:00 cert-renew.sh — Let's Encrypt renewal├── Mon 09:00 zebra-report.sh — weekly Zebra status report├── Wed 03:00 zebra-update.sh — Zebra auto-update├── Wed 04:00 lightwalletd-update.sh — lightwalletd auto-update (with rollback)├── Daily 02:00 lightwalletd-backup.sh — SQLite backup (7-day retention)├── Every 5min uptime-monitor.sh — alert if any service goes down└── Every 1min nginx-spike-monitor.sh — traffic spike alert
Every failure mode sends a Telegram message. I don't check dashboards — the system pages me.
Steps 9 - Joining the community node list
Once the endpoint was stable, I submitted it to the public lightwalletd directory at https://nodus.alexxiy.top — a community-maintained list of public Zcash light wallet servers. The entry was added manually through the site's submission form, pointing to marinade.duckdns.org:443. I also opened a pull request on https://github.com/zeckrocks/zosh to get the node listed in Zosh's built-in server registry, so users of that wallet can discover it directly from the app without any manual configuration. Both submissions went through without issues — the node was synced, TLS was clean, and the gRPC endpoint responded correctly to the health checks the maintainers run before merging.
Lessons learned
1. Don't expose gRPC without a proxy in front of it.
lightwalletd has no auth, no rate limiting, no TLS by itself. nginx + fail2ban + UFW is the minimum viable defense layer.
2. Port 443 over 50051.
Conventional, but the practical benefit is real. Several users told me they couldn't connect on 50051 from their networks.
3. systemd's security directives are underused.ProtectSystem=strict, NoNewPrivileges, RestrictNamespaces, SystemCallFilter — these take five minutes to add and meaningfully shrink the blast radius if something goes wrong.
4. The rollback path is not optional.
I've had one failed update. Without the rollback logic, the service would have gone down silently at 04:00 and I'd have found out when someone complained their wallet wasn't syncing.
5. Claude is genuinely useful for threat modeling.
The hardening section came out of a 20-minute conversation where I kept asking "what else could go wrong?" and Claude kept producing specific, actionable answers. I knew most of the mitigations in isolation — Claude's value was connecting them into a coherent defense-in-depth story.
The public endpoint is marinade.duckdns.org:443. If you run a Zcash light wallet and want to point it at a community node, feel free to use it.
