How the data layer goes live: home’s services reached through a WireGuard tunnel, and the API on a VPS behind Caddy. Why it’s shaped this way is chunk 26’s plan (Home, Tunnel and VPS). Mainnet only: preprod stays on Koios, and the preprod server at home is another project’s.
Every file here is a template. <…> marks a value to fill in on the machine. Keys, passwords, addresses and database names are never committed.
| Where | What | Files |
|---|---|---|
| Home | cardano-node, db-sync and Postgres, the Seedelf Kupo, Ogmios, cardano-submit-api. Stock software only. | home/ |
| The tunnel | WireGuard, dialled out from home: 10.88.0.2 is home, 10.88.0.1 the VPS |
home/wg0.conf, edge/wg0.conf |
| The VPS | seedelf-data-api behind Caddy. Every line of custom code runs here. |
edge/ |
https://mainnet.<domain>, and DNS names the VPS only.DATA_ORIGINS.cargo build --release -p seedelf-data-api from seedelf-data/.Postgres. The seedelf_reader role exists since 2026-10-08; home/seedelf_reader.sql makes it again. Its TCP keepalives date from 2026-10-09 (the tunnel drill): without them, a session the tunnel cut off kept its slot for about 2 hours, and the API was refused the rest of its pool.
listen_addresses, and the logging settings: home/postgresql-seedelf.conf.pg_hba.conf: home/pg_hba-seedelf.conf.home/postgresql-after-network.conf, so Postgres binds after the network and wg0 are up. After a reboot on 2026-10-09 it bound localhost alone.systemctl daemon-reload, then restart Postgres.The Seedelf Kupo (home/kupo_seedelf.service; on the server, --log-level-http-server Warning is a drop-in, kupo_seedelf.service.d/http-log.conf), cardano-submit-api (--config home/tx-submit-mainnet-config.yaml --port 8090) and Ogmios (port 1337, which serves another project too) listen on every address: the LAN for building locally, and wg0 for the VPS. The router forwards none of their ports, and home/nftables-seedelf.conf lets only the VPS in through wg0.
db-sync runs the options in home/db-sync-insert-options.json. They’ve been right since 2026-10-09, when offchain_vote_data was turned on. Check them again after every db-sync upgrade: 13.7.0.1 added that option switched off, and vote metadata stopped with no error.
db-sync’s extra indexes are in home/db-sync-indexes.sql:
Run it as the role db-sync writes with. It skips any index already there, and builds the rest without holding up db-sync. Its head has the command that lists every index with its size, and the one that finds a build that failed.
The tunnel:
wg genkey | tee private | wg pubkey > public, as root, in /etc/wireguard/.home/wg0.conf as /etc/wireguard/wg0.conf, mode 0600.wg0 alone, so the LAN keeps its rules:
home/nftables-seedelf.conf as /etc/nftables.d/seedelf.conf, and home/seedelf-nftables.service. Then systemctl daemon-reload and systemctl enable --now seedelf-nftables.nftables.service on this box: Ubuntu’s flushes the whole ruleset when it stops, and its stock /etc/nftables.conf flushes it when it starts. That would take ufw’s rules with them, and Docker’s where it runs.ufw too: ufw allow in on wg0 from 10.88.0.1 to any port 5432,1443,1337,8090 proto tcp comment 'Seedelf VPS'.systemctl enable --now wg-quick@wg0. It requires seedelf-nftables, so it never comes up unfiltered.Power. NUT on the UPS shuts down in this order: db-sync, Postgres, Kupo, then the node. An unclean stop costs a long ledger replay.
wireguard-tools, nftables, vnstat, postgresql-client (for pg_isready), and Caddy.
402 Payment Required on 2026-10-09, so Caddy is the .deb from its GitHub release: check it with grep ' caddy_<version>_linux_amd64.deb$' caddy_<version>_checksums.txt | sha512sum -c -, then apt install ./caddy_<version>_linux_amd64.deb.systemctl disable --now caddy until step 4: its default site would answer on port 80.edge/wg0.conf;systemctl enable --now wg-quick@wg0.net.ipv4.ip_forward stays 0.edge/nftables.conf replaces every rule on the machine.
nft -c -f nftables.conf.systemd-run --unit=seedelf-nft-rollback --on-active=180 /bin/sh -c 'nft flush ruleset; ufw --force enable'.ufw is on (DigitalOcean’s droplet had it), ufw --force disable and systemctl disable ufw./etc/nftables.conf, then systemctl enable nftables and systemctl restart nftables.systemctl stop seedelf-nft-rollback.timer.pg_isready -h 10.88.0.2;curl http://10.88.0.2:1443/health;curl http://10.88.0.2:1337/health (Ogmios, which must listen on every address, not only the LAN’s);curl -X POST http://10.88.0.2:8090/api/submit/tx (an error, which proves it answers)./usr/local/bin/seedelf-data-api;edge/seedelf-data.env as /etc/seedelf-data/env, mode 0600;edge/seedelf-data-api.service, then systemctl enable --now seedelf-data-api;curl 127.0.0.1:8099/health answers 200 with "source":"db-sync"..github/workflows/data-layer-deploy.yml:
useradd --system --create-home --home-dir /home/deploy --shell /bin/sh deploy, with /home/deploy/.ssh its own, mode 0700;edge/seedelf-data-deploy as /usr/local/sbin/seedelf-data-deploy, root’s, mode 0755;edge/sudoers-seedelf-deploy as /etc/sudoers.d/seedelf-deploy, mode 0440, after visudo -cf passes it;ssh-keygen -t ed25519):
edge/deploy-authorized_keys’s prefix, as /home/deploy/.ssh/authorized_keys (deploy’s, 0600);DEPLOY_SSH_KEY secret of the repository’s data-layer environment, then deleted;main alone, and two variables. DEPLOY_HOST is the API’s name. DEPLOY_KNOWN_HOSTS is ssh-keyscan -t ed25519 <the name>, checked against ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub on the VPS;ssh -n -i <the key> deploy@<the name> status answers, and any other command gets the script’s usage.Nothing is public yet: no DNS name leads here until step 4.
Home’s node is a stake pool’s relay, and it must keep its inbound peers: there’s no money for a second mainnet node. So the node and db-sync reach the internet from the home IP, as they always have, and the tunnel carries only the API’s calls to home.
Hiding the home IP from them would gain nothing: a registered relay publishes it already, in the pool’s certificate on chain and to every peer. What a wallet’s user meets is the VPS alone. Their address stops there, and DNS names only the VPS.
What’s left to see: someone determined could tie the API to the relay, by sending a transaction through the API and watching which relay announces it first. The worst they can do with that is attack an IP that’s public already, and wallets fall back to Koios.
Until 2026-10-09 this step moved the node’s and db-sync’s traffic into the tunnel, failing closed. It was dropped once it was clear the node is the pool’s relay: a node that only reaches out through the VPS is no relay. Its files (seedelf-tunnel-routes.service, nftables-seedelf-egress.conf, the VPS’s forwarding and NAT) are in git’s history, should the relay ever move off this box.
mainnet.<domain>, to the VPS.edge/Caddyfile as /etc/caddy/Caddyfile, then systemctl reload caddy. It gets its certificate by itself.curl https://mainnet.<domain>/health answers 200, private, public, submit and egress all ok, and node connected, its age under 3 minutes (the time since the last block, usually under one).CORS: a preflight from the wallet’s origin is answered with it, and any other origin gets no Access-Control-Allow-Origin, so a browser refuses it (the methods and max-age lines still come):
curl -si -X OPTIONS https://mainnet.<domain>/api/v1/tip \
-H 'Origin: chrome-extension://<ID>' -H 'Access-Control-Request-Method: GET' | grep -i access-control
POST /api/v1/account_info sent at once from one address end in a 429 with Retry-After. Send them in parallel (xargs -P 20): one at a time, the refill keeps up. From the VPS to its own public name is a third machine;Sec-Fetch-Mode: no-cors, or from another Origin, gets 403: a web page can’t spend the API’s traffic.journalctl -u caddy -u seedelf-data-api --since -10min shows neither the client’s address nor the path. The only addresses there are Let’s Encrypt’s validators (served key authentication), at issuance and renewal.On home, Postgres’s log holds no statement, and Kupo’s no request path.
mainnet.<domain> and nothing that leads home.The drills (Verification):
| Stop | Expect |
|---|---|
| Kupo | nothing changes: it’s the spare |
| Postgres | within 6 s (two failed reads of its tip), /health says "source":"kupo" and the public routes answer 503; the wallet goes to Koios for the public side |
| db-sync alone | the private index moves to Kupo once db-sync is 60 slots behind it; the public routes answer 503 once its tip is 3 minutes old |
| Ogmios | the submit part 503 within 4 s (two failed reads of its /health); the wallet sends and evaluates through Koios. The reads don’t change |
| the node | the submit part 503 at the next read of Ogmios’s /health once it says disconnected (2 s), every part within 3 minutes; the wallet goes to Koios for everything. Started again, the submit part stays 503 until the node has caught up (/health’s node age under 3 minutes), so a spend of a newer UTxO isn’t refused as spent |
| the tunnel | every part 503 within 6 s, and a submit 503 within 3 s (it can’t connect); the relay carries on |
| home’s power | the same, then a clean start in NUT’s order |
| the VPS | the wallet goes to Koios |
The wallet goes back to the API at its first reading after a 5-minute hold, and a retry that fails starts the hold again. Postgres keeps answering on 10.88.0.2 across a restart of the tunnel. The drills as run, with their times, are in chunk 26c’s plan.
main, and approve the run when GitHub asks.
deploy builds main and installs it, keeping the running binary as .prev. It checks /health, which must answer as well as before, and the store’s CORS line, and puts the old binary back by itself if the new one fails.rollback swaps the two, and a second one undoes it. status says what’s running.rollback can take a route, or a request’s shape, away from a released wallet, against the release rule that a wallet the store ships never meets an older API. Its checks pass all the same: they read /health and CORS alone. A route the old build lacks answers 404, and a request it takes in another shape answers 400: either holds the wallet’s whole public part on Koios for 5 minutes, and again at its first such request after each hold. After 1.5.0’s deploy, .prev is the build deployed at #302’s merge: it has credential_txs and account_reward_history but takes the rewards history only newest first, so it answers 1.5.0’s Rewards history with a 400, and each open of it sends the wallet’s public reads, Public activity’s included, to Koios for 5 minutes. Roll back past it only as a stopgap, and fix forward..deb the same way, checked.home/db-sync-insert-options.json.home/db-sync-indexes.sql again: a new database has only db-sync’s own indexes.api/data/token-decimals.json, built in) along with the wallet’s own token list, then deploy.