Production-ready blockchain node infrastructure and deployment automation. Provisions AWS, Latitude, and OVH bare metal with Terraform, configures nodes with Ansible, and secures RPC access via HAProxy (Let's Encrypt TLS, API-key auth, Route 53 DNS). Includes multi-region HA with Route 53 latency-based failover. Supports Docker and binary deployments, with included block-lag monitoring and load-testing tools.
Currently supports Ethereum, Avalanche, and Arbitrum - expanding to BNB, Optimism, Polygon, Base, Polkadot, IOTA, Celestia and more.
Ansible inventory is derived directly from Terraform state using the cloud.terraform.terraform_provider inventory plugin and Terraform ansible_host resources. The inventory plugin configuration file is auto-generated by Terraform.
- Architecture
- Supported Configurations
- Repository Structure
- Quick Start
- Ethereum Node Configurations
- Avalanche Node Configurations
- Arbitrum Node Configurations
- HAProxy (Reverse Proxy)
- HA Multi-Region (AWS)
- Documentation
- Common Commands
- Security
- Roadmap
- License
┌─────────────────────────────────────────────────────────────────────────┐
│ Deployment Flow │
└─────────────────────────────────────────────────────────────────────────┘
┌──────────────┐ ┌──────────────┐ ┌──────────────────────┐
│ Terraform │────▶│ Ansible │────▶│ Docker / Binary │
│ (Provision) │ │ (Configure) │ │ (Runtime) │
└──────────────┘ └──────────────┘ └──────────────────────┘
│ │ │
▼ ▼ ▼
AWS: VPC, EC2, OS hardening, Docker: Reth+LH, Geth+Prysm,
EBS, EIP system setup AvalancheGo, Nitro/Classic
Latitude: Import Binary: Erigon+Caplin
pre-ordered BMs
OVH: Import
pre-ordered BMs
HA Multi-Region: Route 53 latency routing + health checks
aws-ha/ stack ▶ HAProxy LBs (EIP) ▶ Backend nodes (VPC)
See: HA Multi-Region (AWS) section
| Chain | Network | Clients | Node Type | Platform | Deploy | Status |
|---|---|---|---|---|---|---|
| Ethereum | Mainnet | Reth + Lighthouse | Archive | AWS | Docker | ✅ Ready |
| Ethereum | Mainnet | Geth + Prysm | Full | AWS | Docker | ✅ Ready |
| Ethereum | Mainnet | Reth + Lighthouse | Archive | Latitude | Docker | ✅ Ready |
| Ethereum | Mainnet | Geth + Prysm | Full | Latitude | Docker | ✅ Ready |
| Ethereum | Mainnet | Erigon + Caplin | Full | Latitude | Binary | ✅ Ready |
| Ethereum | Mainnet | Erigon + Caplin | Full | AWS | Binary | ✅ Ready |
| Ethereum | Mainnet | Erigon + Caplin | Archive | Latitude | Binary | ✅ Ready |
| Ethereum | Mainnet | Erigon + Caplin | Archive | AWS | Binary | ✅ Ready |
| Ethereum | Sepolia | Erigon + Caplin | Full | Latitude | Binary | ✅ Ready |
| Ethereum | Sepolia | Erigon + Caplin | Full | AWS | Binary | ✅ Ready |
| Ethereum | Sepolia | Erigon + Caplin | Archive | Latitude | Binary | ✅ Ready |
| Ethereum | Sepolia | Erigon + Caplin | Archive | AWS | Binary | ✅ Ready |
| Ethereum | Sepolia | Geth + Prysm | Full | AWS | Docker | ✅ Ready |
| Ethereum | Sepolia | Geth + Prysm | Full | Latitude | Docker | ✅ Ready |
| Ethereum | Sepolia | Reth + Lighthouse | Archive | AWS | Docker | ✅ Ready |
| Ethereum | Sepolia | Reth + Lighthouse | Archive | Latitude | Docker | ✅ Ready |
| Avalanche | Mainnet | AvalancheGo | Full | AWS | Docker | ✅ Ready |
| Avalanche | Mainnet | AvalancheGo | Archive | AWS | Docker | ✅ Ready |
| Avalanche | Mainnet | AvalancheGo | Full | Latitude | Docker | ✅ Ready |
| Avalanche | Mainnet | AvalancheGo | Archive | Latitude | Docker | ✅ Ready |
| Avalanche | Fuji | AvalancheGo | Full | AWS | Docker | ✅ Ready |
| Avalanche | Fuji | AvalancheGo | Archive | AWS | Docker | ✅ Ready |
| Avalanche | Fuji | AvalancheGo | Full | Latitude | Docker | ✅ Ready |
| Avalanche | Fuji | AvalancheGo | Archive | Latitude | Docker | ✅ Ready |
| Arbitrum | Mainnet | arb-node (Classic) | Archive | AWS | Docker | ✅ Ready |
| Arbitrum | Mainnet | arb-node (Classic) | Archive | Latitude | Docker | ✅ Ready |
| Arbitrum | Mainnet | nitro-node | Archive | AWS | Docker | ✅ Ready |
| Arbitrum | Mainnet | nitro-node | Archive | Latitude | Docker | ✅ Ready |
| Arbitrum | Mainnet | nitro-node | Full | AWS | Docker | ✅ Ready |
| Arbitrum | Mainnet | nitro-node | Full | Latitude | Docker | ✅ Ready |
| Arbitrum | Sepolia | nitro-node | Archive | AWS | Docker | ✅ Ready |
| Arbitrum | Sepolia | nitro-node | Archive | Latitude | Docker | ✅ Ready |
| Arbitrum | Sepolia | nitro-node | Full | AWS | Docker | ✅ Ready |
| Arbitrum | Sepolia | nitro-node | Full | Latitude | Docker | ✅ Ready |
| Ethereum | Mainnet | Reth + Lighthouse | Archive | OVH | Docker | ✅ Ready |
| Ethereum | Mainnet | Geth + Prysm | Full | OVH | Docker | ✅ Ready |
| Ethereum | Mainnet | Erigon + Caplin | Full | OVH | Binary | ✅ Ready |
| Ethereum | Mainnet | Erigon + Caplin | Archive | OVH | Binary | ✅ Ready |
| Ethereum | Sepolia | Geth + Prysm | Full | OVH | Docker | ✅ Ready |
| Ethereum | Sepolia | Reth + Lighthouse | Archive | OVH | Docker | ✅ Ready |
| Ethereum | Sepolia | Erigon + Caplin | Full | OVH | Binary | ✅ Ready |
| Ethereum | Sepolia | Erigon + Caplin | Archive | OVH | Binary | ✅ Ready |
| Avalanche | Mainnet | AvalancheGo | Full | OVH | Docker | ✅ Ready |
| Avalanche | Mainnet | AvalancheGo | Archive | OVH | Docker | ✅ Ready |
| Avalanche | Fuji | AvalancheGo | Full | OVH | Docker | ✅ Ready |
| Avalanche | Fuji | AvalancheGo | Archive | OVH | Docker | ✅ Ready |
| Arbitrum | Mainnet | arb-node (Classic) | Archive | OVH | Docker | ✅ Ready |
| Arbitrum | Mainnet | nitro-node | Archive | OVH | Docker | ✅ Ready |
| Arbitrum | Mainnet | nitro-node | Full | OVH | Docker | ✅ Ready |
| Arbitrum | Sepolia | nitro-node | Archive | OVH | Docker | ✅ Ready |
| Arbitrum | Sepolia | nitro-node | Full | OVH | Docker | ✅ Ready |
HA Multi-Region (AWS):
| Chain | Network | Clients | Architecture | Platform | Status |
|---|---|---|---|---|---|
| Ethereum | Mainnet | Geth + Prysm | Multi-region active-active (Route 53 + HAProxy LB) | AWS HA | ✅ Ready |
| Ethereum | Mainnet | Reth + Lighthouse | Multi-region active-active (Route 53 + HAProxy LB) | AWS HA | ✅ Ready |
| Arbitrum | Mainnet | nitro-node (Full) | Multi-region active-active (Route 53 + HAProxy LB) | AWS HA | ✅ Ready |
| Arbitrum | Mainnet | nitro-node (Archive) | Multi-region active-active (Route 53 + HAProxy LB) | AWS HA | ✅ Ready |
| Avalanche | Mainnet | AvalancheGo (Full) | Multi-region active-active (Route 53 + HAProxy LB) | AWS HA | ✅ Ready |
| Avalanche | Mainnet | AvalancheGo (Archive) | Multi-region active-active (Route 53 + HAProxy LB) | AWS HA | ✅ Ready |
Single
terraform applydeploys both regions + DNS. See HA Multi-Region (AWS).
.
├── terraform/
│ ├── stacks/
│ │ ├── aws/ethereum/mainnet/ # AWS Ethereum Mainnet
│ │ ├── aws/ethereum/sepolia/ # AWS Ethereum Sepolia
│ │ ├── aws/avalanche/mainnet/ # AWS Avalanche Mainnet
│ │ ├── aws/avalanche/fuji/ # AWS Avalanche Fuji
│ │ ├── aws/arbitrum/mainnet/ # AWS Arbitrum Mainnet
│ │ ├── aws/arbitrum/sepolia/ # AWS Arbitrum Sepolia
│ │ ├── latitude/ethereum/mainnet/ # Latitude BM Ethereum Mainnet
│ │ ├── latitude/ethereum/sepolia/ # Latitude BM Ethereum Sepolia
│ │ ├── latitude/avalanche/mainnet/ # Latitude BM Avalanche Mainnet
│ │ ├── latitude/avalanche/fuji/ # Latitude BM Avalanche Fuji
│ │ ├── latitude/arbitrum/mainnet/ # Latitude BM Arbitrum Mainnet
│ │ ├── latitude/arbitrum/sepolia/ # Latitude BM Arbitrum Sepolia
│ │ ├── ovh/ethereum/mainnet/ # OVH BM Ethereum Mainnet
│ │ ├── ovh/ethereum/sepolia/ # OVH BM Ethereum Sepolia
│ │ ├── ovh/avalanche/mainnet/ # OVH BM Avalanche Mainnet
│ │ ├── ovh/avalanche/fuji/ # OVH BM Avalanche Fuji
│ │ ├── ovh/arbitrum/mainnet/ # OVH BM Arbitrum Mainnet
│ │ ├── ovh/arbitrum/sepolia/ # OVH BM Arbitrum Sepolia
│ │ ├── aws-ha/ethereum/mainnet/ # HA Multi-Region Ethereum (Route 53 + HAProxy LB)
│ │ ├── aws-ha/arbitrum/mainnet/ # HA Multi-Region Arbitrum
│ │ └── aws-ha/avalanche/mainnet/ # HA Multi-Region Avalanche
│ └── modules/
│ ├── compute/ # AWS EC2 instances
│ ├── compute-haproxy/ # AWS EC2 for HAProxy LB (no data EBS)
│ ├── compute-latitude/ # Latitude bare metal servers
│ ├── compute-ovh/ # OVH dedicated servers
│ ├── vrack-ovh/ # OVH vRack private networking
│ ├── network/ # AWS VPC/subnets
│ ├── ha-region/ # HA per-region composition (Ethereum)
│ ├── ha-region-arb/ # HA per-region composition (Arbitrum)
│ ├── ha-region-avax/ # HA per-region composition (Avalanche)
│ └── ha-dns/ # Route 53 latency routing + health checks
├── ansible/
│ ├── playbooks/
│ │ ├── ethereum.yml # Ethereum deployment (Docker)
│ │ ├── erigon.yml # Erigon deployment (binary build)
│ │ ├── avalanche.yml # Avalanche deployment (Docker)
│ │ ├── arbitrum.yml # Arbitrum deployment (Docker: Nitro + Classic)
│ │ ├── raid.yml # RAID-0 setup (bare metal)
│ │ ├── haproxy-lb.yml # HAProxy LB deployment (HA multi-region)
│ │ └── site.yml # Entry point (imports all chain playbooks)
│ ├── inventory/
│ │ ├── ethereum_mainnet_aws_terraform_state.yml # Auto-generated (AWS)
│ │ ├── ethereum_mainnet_latitude_terraform_state.yml # Auto-generated (Latitude)
│ │ ├── ethereum_sepolia_*_terraform_state.yml # Auto-generated (Sepolia)
│ │ ├── avalanche_mainnet_aws_terraform_state.yml # Auto-generated (AWS)
│ │ ├── avalanche_mainnet_latitude_terraform_state.yml # Auto-generated (Latitude)
│ │ ├── avalanche_fuji_*_terraform_state.yml # Auto-generated (Fuji)
│ │ ├── arbitrum_mainnet_aws_terraform_state.yml # Auto-generated (AWS)
│ │ ├── arbitrum_mainnet_latitude_terraform_state.yml # Auto-generated (Latitude)
│ │ ├── arbitrum_sepolia_*_terraform_state.yml # Auto-generated (Sepolia)
│ │ ├── *_ovh_terraform_state.yml # Auto-generated (OVH)
│ │ ├── ethereum_mainnet_aws_ha_terraform_state.yml # Auto-generated (HA)
│ │ └── group_vars/
│ │ ├── all/ # Global defaults (certbot email, data mount)
│ │ │ └── main.yml
│ │ ├── ethereum/ # Docker node config (role=ethereum) + vault secrets
│ │ │ ├── main.yml # Versions, images, ports, vault refs
│ │ │ └── vault.yml # Encrypted (ansible-vault create)
│ │ ├── erigon/ # Erigon node config (role=erigon) + vault secrets
│ │ │ ├── main.yml # Version, RPC, Caplin, vault refs
│ │ │ └── vault.yml # Encrypted (ansible-vault create)
│ │ ├── avalanche/ # Avalanche node config (role=avalanche) + vault secrets
│ │ │ ├── main.yml # Version, image, ports, vault refs
│ │ │ └── vault.yml # Encrypted (ansible-vault create)
│ │ ├── arbitrum/ # Arbitrum node config (role=arbitrum) + vault secrets
│ │ │ ├── main.yml # Versions, images, ports, L1 RPC refs, vault refs
│ │ │ └── vault.yml # Encrypted (ansible-vault create)
│ │ ├── node_archive/ # Archive node overrides (prune.mode, commitment-history)
│ │ │ └── main.yml
│ │ ├── network_mainnet/ # Mainnet network overrides
│ │ │ └── main.yml
│ │ ├── network_sepolia/ # Sepolia network overrides (chain, RPC tuning)
│ │ │ └── main.yml
│ │ ├── network_fuji/ # Fuji testnet overrides (Avalanche)
│ │ │ └── main.yml
│ │ ├── platform_latitude/ # Latitude BM platform overrides
│ │ │ ├── main.yml # SSH user, firewall, vault refs
│ │ │ └── vault.yml # Encrypted AWS creds (BM only)
│ │ ├── platform_ovh/ # OVH BM platform overrides
│ │ │ ├── main.yml # SSH user, firewall, vault refs
│ │ │ └── vault.yml # Encrypted AWS creds (BM only)
│ │ └── haproxy_lb/ # HA HAProxy LB config
│ │ └── main.yml # No Docker, health endpoint, vault refs
│ ├── roles/
│ │ ├── common/ # Shared (system setup, disk, optional Docker)
│ │ ├── raid/ # RAID-0 setup (NVMe auto-discovery or explicit disks)
│ │ ├── ethereum/ # Ethereum Docker clients (Reth+LH, Geth+Prysm)
│ │ ├── erigon/ # Erigon binary build (built-in Caplin CL)
│ │ ├── avalanche/ # Avalanche Docker (AvalancheGo)
│ │ ├── arbitrum/ # Arbitrum Docker (Nitro + Classic)
│ │ └── haproxy/ # Reverse proxy (TLS, API key auth, WebSocket)
├── examples/
│ ├── docker/ethereum/
│ │ ├── reth-lighthouse/ # Reth+Lighthouse (full & archive profiles)
│ │ └── geth-prysm/ # Geth+Prysm full node
│ └── systemd/
│ └── erigon/ # Erigon systemd service (binary, no Docker)
├── tools/
│ ├── block-lag-monitor/ # RPC block lag & latency monitoring (EVM + Avalanche)
│ └── load-test/ # JSON-RPC load testing with Paradigm's flood
├── docs/
│ ├── ansible.md # Ansible configuration guide
│ ├── ops.md # Operations guide
│ ├── terraform.md # Terraform infrastructure guide
│ └── static-inventory.md # Deploy without Terraform
├── images/ # Screenshots for documentation
└── .github/workflows/
examples/ contains standalone reference configs (Docker Compose and systemd) for testing and quick setups. Production deployments use Ansible.
tools/ contains operational utilities. See Block Lag Monitor for RPC block lag & latency monitoring and Load Test for JSON-RPC load testing with Paradigm's flood.
Naming Conventions:
- Terraform stacks:
terraform/stacks/{platform}/{chain}/{network}/- Inventory files:
ansible/inventory/{chain}_{network}_{platform}_terraform_state.yml
- HCP Terraform account (free tier available)
- Terraform >= 1.10
- Python >= 3.10
- Docker (for local testing)
- AWS: AWS CLI configured with credentials
- Latitude: Latitude.sh account + API token
- OVH: OVH account + API credentials
# Clone the repository
git clone https://github.com/danlix2000/blockchain-node-infra.git
cd blockchain-node-infra
# Create Python virtual environment
python3 -m venv .venv
source .venv/bin/activate # Linux/macOS
# .venv\Scripts\activate # Windows
# Install Python dependencies (Ansible)
pip install -r requirements.txt
# Install Ansible Galaxy collections
ansible-galaxy collection install -r ansible/requirements.yml# HCP Terraform authentication (required for all platforms)
export TF_TOKEN_app_terraform_io="your-api-token"
# Or: terraform login
# AWS credentials (required for all platforms - AWS infra + Route 53 DNS)
export AWS_ACCESS_KEY_ID="AKIA..."
export AWS_SECRET_ACCESS_KEY="..."
export AWS_REGION="us-east-1"
# Or: aws configure
# Latitude.sh credentials (for bare metal deployments)
# Note: AWS credentials above are also required for Route 53 DNS records and Certbot DNS-01 validation.
export LATITUDESH_AUTH_TOKEN="your-latitude-api-token"
# OVH credentials (for bare metal deployments)
# Note: AWS credentials above are also required for Route 53 DNS records and Certbot DNS-01 validation.
export OVH_ENDPOINT="ovh-us" # or ovh-eu, ovh-ca
export OVH_APPLICATION_KEY="your-application-key"
export OVH_APPLICATION_SECRET="your-application-secret"
export OVH_CONSUMER_KEY="your-consumer-key"
# Create credentials at: https://api.ovh.com/createToken/Note: This repo uses local execution mode - Terraform runs on your machine while TF Cloud stores state only. Set execution mode in TF Cloud: Workspace > Settings > General > Execution Mode > Local.
Each chain has its own stack directory. Pick the platform, chain, and network:
AWS:
cd terraform/stacks/aws/ethereum/mainnet # Ethereum Mainnet
cd terraform/stacks/aws/ethereum/sepolia # Ethereum Sepolia
cd terraform/stacks/aws/avalanche/mainnet # Avalanche Mainnet
cd terraform/stacks/aws/avalanche/fuji # Avalanche Fuji
cd terraform/stacks/aws/arbitrum/mainnet # Arbitrum Mainnet
cd terraform/stacks/aws/arbitrum/sepolia # Arbitrum Sepolia
cp cloud.tf.example cloud.tf
cp terraform.tfvars.example terraform.tfvars
cp nodes.tfvars.example nodes.tfvars
# Edit terraform.tfvars and nodes.tfvars for your deploymentLatitude.sh (Bare Metal):
# 1. Order servers on Latitude.sh dashboard (select plan, site, OS - no RAID)
# 2. Rename hostname in console (e.g., chi-avalanche-mainnet-full)
# 3. Copy server IDs (sv_xxx) from dashboard
cd terraform/stacks/latitude/ethereum/mainnet # Ethereum Mainnet
cd terraform/stacks/latitude/ethereum/sepolia # Ethereum Sepolia
cd terraform/stacks/latitude/avalanche/mainnet # Avalanche Mainnet
cd terraform/stacks/latitude/avalanche/fuji # Avalanche Fuji
cd terraform/stacks/latitude/arbitrum/mainnet # Arbitrum Mainnet
cd terraform/stacks/latitude/arbitrum/sepolia # Arbitrum Sepolia
cp cloud.tf.example cloud.tf
cp terraform.tfvars.example terraform.tfvars
cp nodes.tfvars.example nodes.tfvars
# Edit terraform.tfvars (set project_id, ssh_key_ids)
# Edit nodes.tfvars (set latitude_id for each pre-ordered server)OVH (Bare Metal):
# 1. Order dedicated servers on OVH console
# 2. Note service name (nsXXXXXXX.ip-XX-XX-XX.net) from dashboard
# 3. Set skip_reinstall = false for first deploy (OS install), then true
cd terraform/stacks/ovh/ethereum/mainnet # Ethereum Mainnet
cd terraform/stacks/ovh/ethereum/sepolia # Ethereum Sepolia
cd terraform/stacks/ovh/avalanche/mainnet # Avalanche Mainnet
cd terraform/stacks/ovh/avalanche/fuji # Avalanche Fuji
cd terraform/stacks/ovh/arbitrum/mainnet # Arbitrum Mainnet
cd terraform/stacks/ovh/arbitrum/sepolia # Arbitrum Sepolia
cp cloud.tf.example cloud.tf
cp terraform.tfvars.example terraform.tfvars
cp nodes.tfvars.example nodes.tfvars
# Edit terraform.tfvars (set ssh_public_key, optionally route53_zone_id for auto DNS)
# Edit nodes.tfvars (set service_name, server_model for each pre-ordered server)# Ethereum - AWS Mainnet
cd terraform/stacks/aws/ethereum/mainnet
terraform init && terraform apply -var-file=nodes.tfvars
# -> generates ansible/inventory/ethereum_mainnet_aws_terraform_state.yml
# Ethereum - Latitude Mainnet (imports pre-ordered servers, separate workspace/state)
cd terraform/stacks/latitude/ethereum/mainnet
terraform init && terraform apply -var-file=nodes.tfvars
# -> generates ansible/inventory/ethereum_mainnet_latitude_terraform_state.yml
# Avalanche - AWS Mainnet
cd terraform/stacks/aws/avalanche/mainnet
terraform init && terraform apply -var-file=nodes.tfvars
# -> generates ansible/inventory/avalanche_mainnet_aws_terraform_state.yml
# Avalanche - AWS Fuji
cd terraform/stacks/aws/avalanche/fuji
terraform init && terraform apply -var-file=nodes.tfvars
# -> generates ansible/inventory/avalanche_fuji_aws_terraform_state.yml
# Avalanche - Latitude Mainnet
cd terraform/stacks/latitude/avalanche/mainnet
terraform init && terraform apply -var-file=nodes.tfvars
# -> generates ansible/inventory/avalanche_mainnet_latitude_terraform_state.yml
# Arbitrum - AWS Mainnet (Classic + Nitro nodes)
cd terraform/stacks/aws/arbitrum/mainnet
terraform init && terraform apply -var-file=nodes.tfvars
# -> generates ansible/inventory/arbitrum_mainnet_aws_terraform_state.yml
# -> auto-wires arbitrum_classic_redirect_url on the nitro archive node
# Arbitrum - AWS Sepolia (Nitro only)
cd terraform/stacks/aws/arbitrum/sepolia
terraform init && terraform apply -var-file=nodes.tfvars
# -> generates ansible/inventory/arbitrum_sepolia_aws_terraform_state.yml
# Ethereum - OVH Mainnet (installs OS on pre-ordered servers, separate workspace/state)
# First apply with skip_reinstall = false installs OS + sets hostname + SSH key
cd terraform/stacks/ovh/ethereum/mainnet
terraform init && terraform apply -var-file=nodes.tfvars
# -> generates ansible/inventory/ethereum_mainnet_ovh_terraform_state.yml
# After successful install: set skip_reinstall = true, then terraform apply again
# Avalanche - OVH Mainnet
cd terraform/stacks/ovh/avalanche/mainnet
terraform init && terraform apply -var-file=nodes.tfvars
# -> generates ansible/inventory/avalanche_mainnet_ovh_terraform_state.yml
# Arbitrum - OVH Mainnet
cd terraform/stacks/ovh/arbitrum/mainnet
terraform init && terraform apply -var-file=nodes.tfvars
# -> generates ansible/inventory/arbitrum_mainnet_ovh_terraform_state.ymlSkip this step if you do not need HAProxy (TLS + API key auth). Nodes will still deploy and sync without HAProxy. You can add HAProxy later by completing this step and re-running the playbook with
--tags haproxy.
HAProxy requires encrypted vault files with API keys before deployment. Create one vault per role you deploy. For full details, see HAProxy Configuration or Ansible Vault Setup.
cd ansible
# Step 1: Create vault password file
echo "your-vault-password" > .vault_password
chmod 600 .vault_password
# Step 2: Create vault for Docker nodes (role = "ethereum")
uuidgen # Generate a UUID4 API key, copy the output
ansible-vault create inventory/group_vars/ethereum/vault.yml
# -> Editor opens. Add:
# ---
# vault_haproxy_api_key: "<paste-uuid4>"
# vault_haproxy_stats_password: "<choose-password>"
# Step 3: Create vault for Erigon nodes (role = "erigon")
uuidgen # Generate a DIFFERENT UUID4
ansible-vault create inventory/group_vars/erigon/vault.yml
# -> Editor opens. Add:
# ---
# vault_haproxy_api_key: "<paste-uuid4>"
# vault_haproxy_stats_password: "<choose-password>"
# Step 4: Create vault for Avalanche nodes (role = "avalanche")
uuidgen # Generate a DIFFERENT UUID4
ansible-vault create inventory/group_vars/avalanche/vault.yml
# -> Editor opens. Add:
# ---
# vault_haproxy_api_key: "<paste-uuid4>"
# vault_haproxy_stats_password: "<choose-password>"
# Step 5: Create vault for Arbitrum nodes (role = "arbitrum")
uuidgen # Generate a DIFFERENT UUID4
ansible-vault create inventory/group_vars/arbitrum/vault.yml
# -> Editor opens. Add:
# ---
# vault_haproxy_api_key: "<paste-uuid4>"
# vault_haproxy_stats_password: "<choose-password>"
# vault_arbitrum_parent_chain_url: "https://your-eth-rpc-endpoint"
# vault_arbitrum_beacon_url: "http://your-lighthouse-node:5052"
# Step 6 (Latitude/OVH BM only): Create vault for AWS credentials (Certbot Route 53)
# ansible-vault create inventory/group_vars/platform_latitude/vault.yml # Latitude BM
# ansible-vault create inventory/group_vars/platform_ovh/vault.yml # OVH BM
# -> Editor opens. Add:
# ---
# vault_haproxy_certbot_aws_access_key: "<your-iam-access-key>"
# vault_haproxy_certbot_aws_secret_key: "<your-iam-secret-key>"Use a different API key per role group.
.vault_passwordis in.gitignore- store the password securely.
Bare metal only (Latitude/OVH): Before deploying, set your trusted IPs in one file - all chains and networks use this shared list. This is required - without real IPs, UFW will block all access including SSH:
# Find your VPN/office exit IP
curl -4 ifconfig.me# ansible/inventory/group_vars/all/main.yml
firewall_allowed_ips:
- { ip: "YOUR_VPN_IP/32", comment: "VPN egress" } # Replace with real IP
- { ip: "YOUR_OFFICE_CIDR/24", comment: "Office network" } # Replace with real CIDRThe playbook validates IPs and rejects placeholders (
YOUR_*) and documentation IPs. See Bare Metal Firewall (UFW) for per-role overrides, port tables, and full details.
cd ansible
# Terraform Cloud token for inventory access
export TF_TOKEN_app_terraform_io="your-api-token"
export ANSIBLE_PRIVATE_KEY_FILE=~/.ssh/your-key.pemHost key checking is enabled. Ensure hosts are present in ~/.ssh/known_hosts before deployment.
Different node types use different Ansible playbooks. Match the playbook to the role in your nodes.tfvars:
role in nodes.tfvars |
Playbook | Clients |
|---|---|---|
ethereum |
playbooks/ethereum.yml |
Docker: Geth+Prysm, Reth+Lighthouse |
erigon |
playbooks/erigon.yml |
Binary: Erigon+Caplin |
avalanche |
playbooks/avalanche.yml |
Docker: AvalancheGo |
arbitrum |
playbooks/arbitrum.yml |
Docker: Nitro, Classic (FROZEN) |
Key rule: Run the correct playbook for each role. If your inventory has nodes with different roles, you need separate playbook runs for each role.
All nodes in the inventory use the same role. Each chain has its own inventory file and playbook:
# Ethereum - Docker nodes (Geth+Prysm, Reth+Lighthouse)
ansible-playbook -i inventory/ethereum_mainnet_aws_terraform_state.yml playbooks/ethereum.yml
# Ethereum - Erigon nodes (binary build)
ansible-playbook -i inventory/ethereum_mainnet_aws_terraform_state.yml playbooks/erigon.yml
# Avalanche - AvalancheGo (single client)
ansible-playbook -i inventory/avalanche_mainnet_aws_terraform_state.yml playbooks/avalanche.yml
# Arbitrum - Nitro + Classic (Docker)
ansible-playbook -i inventory/arbitrum_mainnet_aws_terraform_state.yml playbooks/arbitrum.ymlThe inventory file naming convention is {chain}_{network}_{platform}_terraform_state.yml, auto-generated by Terraform.
When your nodes.tfvars has nodes with different roles (e.g., a Geth+Prysm full node and an Erigon archive node), run each playbook separately. Use --limit to target the right nodes:
# Step 1: Deploy the Ethereum full node (Geth+Prysm, Docker)
ansible-playbook -i inventory/ethereum_mainnet_aws_terraform_state.yml playbooks/ethereum.yml --limit node_full
# Step 2: Deploy the Erigon archive node (binary build)
ansible-playbook -i inventory/ethereum_mainnet_aws_terraform_state.yml playbooks/erigon.yml --limit node_archiveWithout --limit, the playbook runs against all nodes in the inventory. The playbook itself targets its own role group, but using --limit is recommended to avoid unnecessary connection attempts to nodes that belong to a different role.
Deploy or reconfigure a single node without touching others:
# Terraform: provision only the erigon-arch node
terraform apply -var-file=nodes.tfvars -target='module.compute["erigon-arch"]'
# Ansible: configure only that node
ansible-playbook -i inventory/ethereum_mainnet_aws_terraform_state.yml playbooks/erigon.yml --limit node_archiveBare metal servers (Latitude, OVH) may have multiple data disks. Run RAID setup first, then the node playbook:
# Latitude example:
# Step 1: Verify connectivity
ansible -i inventory/ethereum_mainnet_latitude_terraform_state.yml all -m ping
# Step 2: Setup RAID-0 (creates /dev/md127)
ansible-playbook -i inventory/ethereum_mainnet_latitude_terraform_state.yml playbooks/raid.yml
# Step 3: Deploy nodes (choose the correct playbook for your role)
ansible-playbook -i inventory/ethereum_mainnet_latitude_terraform_state.yml playbooks/ethereum.yml # Docker nodes
ansible-playbook -i inventory/ethereum_mainnet_latitude_terraform_state.yml playbooks/erigon.yml # Erigon nodes
# OVH example:
# Step 1: Verify connectivity
ansible -i inventory/ethereum_mainnet_ovh_terraform_state.yml all -m ping
# Step 2: Setup RAID-0 (creates /dev/md127)
ansible-playbook -i inventory/ethereum_mainnet_ovh_terraform_state.yml playbooks/raid.yml
# Step 3: Deploy nodes (choose the correct playbook for your role)
ansible-playbook -i inventory/ethereum_mainnet_ovh_terraform_state.yml playbooks/ethereum.yml # Docker nodes
ansible-playbook -i inventory/ethereum_mainnet_ovh_terraform_state.yml playbooks/erigon.yml # Erigon nodesRAID disk selection (recommended workflow):
Bare metal servers often have mixed NVMe drives (e.g., 2x480GB for OS + 2x4TB for data). Auto-discovery excludes the OS disk, but may pick up wrong drives if the OS layout is non-standard. Always verify disks before running RAID:
# Step 1: After terraform apply, SSH into the server
ssh ubuntu@<server-ip>
# Step 2: Identify data disks (look for large unmounted NVMe drives)
lsblk -dpno NAME,TYPE,SIZE,MOUNTPOINT
# Example output:
# /dev/nvme0n1 disk 447.1G / <- OS (skip)
# /dev/nvme1n1 disk 447.1G <- OS mirror (skip)
# /dev/nvme2n1 disk 3.5T <- data disk (use)
# /dev/nvme3n1 disk 3.5T <- data disk (use)
# Step 3: Run RAID playbook with explicit disks for that host
# Use the hostname from terraform output (e.g., "ash-avalanche-mainnet-full")
ansible-playbook -i inventory/avalanche_mainnet_latitude_terraform_state.yml playbooks/raid.yml --limit ash-avalanche-mainnet-full \
-e '{"raid_disks":["/dev/nvme2n1","/dev/nvme3n1"]}'Auto-discovery: If
raid_disksis not passed, the role auto-discovers unmounted NVMe drives (excludes OS disk and SATA/SAS). This works for simple layouts (e.g., 2 identical data NVMe drives), but explicit disks via-eare recommended for production - especially when the server has mixed NVMe sizes.Single disk: If the server has only 1 data disk, skip
raid.yml- the common role handles single disks automatically.Why not group_vars? Each server may have different disk layouts. Passing
raid_disksvia-eextra-vars per host avoids applying a blanket disk list across all servers on a platform.
For mixed roles on bare metal, combine RAID + targeted playbooks:
# Latitude example:
ansible-playbook -i inventory/ethereum_mainnet_latitude_terraform_state.yml playbooks/raid.yml
ansible-playbook -i inventory/ethereum_mainnet_latitude_terraform_state.yml playbooks/ethereum.yml --limit node_full
ansible-playbook -i inventory/ethereum_mainnet_latitude_terraform_state.yml playbooks/erigon.yml --limit node_archive
# OVH example:
ansible-playbook -i inventory/ethereum_mainnet_ovh_terraform_state.yml playbooks/raid.yml
ansible-playbook -i inventory/ethereum_mainnet_ovh_terraform_state.yml playbooks/ethereum.yml --limit node_full
ansible-playbook -i inventory/ethereum_mainnet_ovh_terraform_state.yml playbooks/erigon.yml --limit node_archiveAvalanche uses a single client (AvalancheGo) - no separate execution + consensus layers:
# Avalanche Mainnet - AWS
ansible-playbook -i inventory/avalanche_mainnet_aws_terraform_state.yml playbooks/avalanche.yml
# Avalanche Mainnet - Latitude BM (RAID first if multiple disks)
ansible-playbook -i inventory/avalanche_mainnet_latitude_terraform_state.yml playbooks/raid.yml
ansible-playbook -i inventory/avalanche_mainnet_latitude_terraform_state.yml playbooks/avalanche.yml
# Avalanche Mainnet - OVH BM (RAID first if multiple disks)
ansible-playbook -i inventory/avalanche_mainnet_ovh_terraform_state.yml playbooks/raid.yml
ansible-playbook -i inventory/avalanche_mainnet_ovh_terraform_state.yml playbooks/avalanche.yml
# Avalanche Fuji testnet - AWS
ansible-playbook -i inventory/avalanche_fuji_aws_terraform_state.yml playbooks/avalanche.yml
# Specific node only
ansible-playbook -i inventory/avalanche_mainnet_aws_terraform_state.yml playbooks/avalanche.yml --limit node_archiveArbitrum requires vault secrets for L1 RPC endpoints before deployment. Mainnet requires Classic + Nitro archive nodes on separate hosts; Terraform auto-wires the redirect URL.
# Arbitrum Mainnet - AWS (Classic archive + Nitro archive + Nitro full)
ansible-playbook -i inventory/arbitrum_mainnet_aws_terraform_state.yml playbooks/arbitrum.yml
# Arbitrum Mainnet - Latitude BM (RAID first if multiple disks)
ansible-playbook -i inventory/arbitrum_mainnet_latitude_terraform_state.yml playbooks/raid.yml
ansible-playbook -i inventory/arbitrum_mainnet_latitude_terraform_state.yml playbooks/arbitrum.yml
# Arbitrum Mainnet - OVH BM (RAID first if multiple disks)
ansible-playbook -i inventory/arbitrum_mainnet_ovh_terraform_state.yml playbooks/raid.yml
ansible-playbook -i inventory/arbitrum_mainnet_ovh_terraform_state.yml playbooks/arbitrum.yml
# Arbitrum Sepolia - AWS (Nitro only)
ansible-playbook -i inventory/arbitrum_sepolia_aws_terraform_state.yml playbooks/arbitrum.yml
# Deploy a specific Arbitrum node only
ansible-playbook -i inventory/arbitrum_mainnet_aws_terraform_state.yml playbooks/arbitrum.yml --limit node_full
ansible-playbook -i inventory/arbitrum_mainnet_aws_terraform_state.yml playbooks/arbitrum.yml --limit node_archiveSame commands, different inventory file:
# Ethereum Sepolia - Docker nodes
ansible-playbook -i inventory/ethereum_sepolia_aws_terraform_state.yml playbooks/ethereum.yml
# Ethereum Sepolia - Erigon nodes
ansible-playbook -i inventory/ethereum_sepolia_aws_terraform_state.yml playbooks/erigon.yml
# Avalanche Fuji - AWS
ansible-playbook -i inventory/avalanche_fuji_aws_terraform_state.yml playbooks/avalanche.yml
# Arbitrum Sepolia - AWS
ansible-playbook -i inventory/arbitrum_sepolia_aws_terraform_state.yml playbooks/arbitrum.yml
# Latitude BM Sepolia - RAID + Erigon
ansible-playbook -i inventory/ethereum_sepolia_latitude_terraform_state.yml playbooks/raid.yml
ansible-playbook -i inventory/ethereum_sepolia_latitude_terraform_state.yml playbooks/erigon.yml
# Latitude BM Arbitrum Sepolia - RAID + Arbitrum
ansible-playbook -i inventory/arbitrum_sepolia_latitude_terraform_state.yml playbooks/raid.yml
ansible-playbook -i inventory/arbitrum_sepolia_latitude_terraform_state.yml playbooks/arbitrum.yml
# OVH BM Sepolia - RAID + Erigon
ansible-playbook -i inventory/ethereum_sepolia_ovh_terraform_state.yml playbooks/raid.yml
ansible-playbook -i inventory/ethereum_sepolia_ovh_terraform_state.yml playbooks/erigon.yml
# OVH BM Arbitrum Sepolia - RAID + Arbitrum
ansible-playbook -i inventory/arbitrum_sepolia_ovh_terraform_state.yml playbooks/raid.yml
ansible-playbook -i inventory/arbitrum_sepolia_ovh_terraform_state.yml playbooks/arbitrum.yml# Erigon nodes - HAProxy only
ansible-playbook -i inventory/ethereum_mainnet_aws_terraform_state.yml playbooks/erigon.yml --tags haproxy
# Docker nodes - HAProxy only
ansible-playbook -i inventory/ethereum_mainnet_aws_terraform_state.yml playbooks/ethereum.yml --tags haproxy
# Avalanche nodes - HAProxy only
ansible-playbook -i inventory/avalanche_mainnet_aws_terraform_state.yml playbooks/avalanche.yml --tags haproxy
# Arbitrum nodes - HAProxy only
ansible-playbook -i inventory/arbitrum_mainnet_aws_terraform_state.yml playbooks/arbitrum.yml --tags haproxy
# Deploy node without HAProxy
ansible-playbook -i inventory/ethereum_mainnet_aws_terraform_state.yml playbooks/erigon.yml --skip-tags haproxyFor manually provisioned servers without Terraform state (see Static Inventory Guide):
ansible-playbook -i inventory/ethereum_sepolia_hosts.yml playbooks/erigon.ymlDeploy multi-region active-active RPC with Route 53 latency-based failover. See the complete guides:
- Terraform HA Guide - Infrastructure setup, configuration, and deployment
- Ansible HA Guide - Node configuration, vault setup, and region targeting
Terraform - use -target to provision specific nodes:
terraform apply -var-file=nodes.tfvars -target='module.compute["full"]'
terraform apply -var-file=nodes.tfvars -target='module.compute["erigon-arch"]'Ansible - use --limit to target specific node groups:
# Single node group
ansible-playbook -i inventory/ethereum_mainnet_aws_terraform_state.yml playbooks/ethereum.yml --limit node_full
ansible-playbook -i inventory/ethereum_mainnet_aws_terraform_state.yml playbooks/erigon.yml --limit node_archive
# Multiple node groups
ansible-playbook -i inventory/ethereum_mainnet_aws_terraform_state.yml playbooks/ethereum.yml --limit "node_full,node_archive"| Variable | Options | Description |
|---|---|---|
role |
ethereum, erigon |
Deployment type (Docker or binary) |
execution_client |
reth, geth, erigon |
Execution layer client |
consensus_client |
lighthouse, prysm, caplin |
Consensus layer client |
node_type |
archive, full |
Node synchronization mode |
Deploys as a Lighthouse Beacon Archive with full blob retention (--prune-blobs=false, --supernode). Reth runs in default mode (no --full flag) which stores all historical state without pruning.
L2 Beacon Endpoint: Both mainnet and Sepolia Lighthouse archive nodes expose the Beacon API on port 5052 (--http-port=5052). This provides an L1 beacon endpoint for L2 chains (Arbitrum, Optimism, Base, etc.) that require historical blob data access via the /eth/v1/beacon/blob_sidecars/{block_id} API.
Checkpoint Sync: Lighthouse supports fast sync from a finalized checkpoint. This is optional but recommended as it is substantially faster than syncing from genesis. Endpoint availability varies by network:
- Mainnet:
https://mainnet.checkpoint.sigp.io(Sigma Prime, stable) - Sepolia:
http://unstable.sepolia.beacon-api.nimbus.team(Nimbus team, unstable/non-commercial). Standard Sepolia endpoints (beaconstate.info,ethpandaops.io,chainsafe.io) do not serve blobs, which Lighthouse v8.0.0+ requires for post-Deneb checkpoint sync.
| Resource | Minimum | Recommended |
|---|---|---|
| CPU | 8 vCPU | 16 vCPU (higher clock speed over core count) |
| Memory | 32 GB | 64 GB |
| Storage | 3 TB | 5+ TB (TLC NVMe recommended) |
| Resource | Minimum | Recommended |
|---|---|---|
| CPU | 4 vCPU | 8 vCPU |
| Memory | 16 GB | 32 GB |
| Storage | 1 TB | 2 TB |
Erigon v3 with built-in Caplin consensus client. Deployed as a native binary (no Docker). Single process handles both execution and consensus layers.
- Full (default): Erigon's native mode. No
--prune.modeflag needed. - Archive:
--prune.mode=archive+--experimental.commitment-history, applied viagroup_vars/node_archive/whennode_type = "archive".
| Resource | Full | Archive |
|---|---|---|
| CPU | 8+ vCPU | 16+ vCPU |
| Memory | 32+ GB | 64+ GB |
| Storage | 2+ TB | 3+ TB (RAID-0 recommended) |
| Configuration | Execution | Consensus | Total |
|---|---|---|---|
| Reth + Lighthouse (archive) | ~3 TB | ~500 GB | 5 TB |
| Geth + Prysm (full) | ~1 TB | ~200 GB | 2 TB |
| Erigon + Caplin (full) | ~1.5 TB | built-in | 2 TB |
| Erigon + Caplin (archive) | ~2.5 TB | built-in | 3 TB |
Docker nodes (Reth+Lighthouse, Geth+Prysm):
| Port | Protocol | Client | Purpose |
|---|---|---|---|
| 30303 | TCP/UDP | Reth/Geth | Execution P2P |
| 9000 | TCP/UDP | Lighthouse | Consensus P2P |
| 9001 | UDP | Lighthouse | Consensus QUIC |
| 13000 | TCP | Prysm | Consensus P2P TCP |
| 12000 | UDP | Prysm | Consensus P2P UDP |
| 8545 | TCP | All | HTTP RPC |
| 8546 | TCP | All | WebSocket RPC |
| 5052 | TCP | Lighthouse | Beacon HTTP API (L1 beacon for L2 chains) |
| 3500 | TCP | Prysm | Beacon HTTP API |
Erigon nodes (binary build, UFW firewall):
| Port | Protocol | Component | Purpose | Expose |
|---|---|---|---|---|
| 53 | TCP/UDP | System | DNS | Public |
| 30303 | TCP/UDP | Erigon | eth/68 P2P | Public |
| 30304 | TCP/UDP | Erigon | eth/69 P2P | Public |
| 42069 | TCP/UDP | Erigon | Torrent/snap sync | Public |
| 4000 | UDP | Caplin | CL discovery | Public |
| 4001 | TCP | Caplin | CL discovery | Public |
| 8545 | TCP | Erigon | HTTP RPC | Localhost |
| 8546 | TCP | Erigon | WebSocket RPC | Localhost |
| 8551 | TCP | Erigon | Engine API (JWT) | VPN only |
| 9090 | TCP | Erigon | gRPC server | VPN only |
UFW Firewall: Erigon bare metal nodes use UFW. Public ports are open to all. RPC ports (8545, 8546) bind to
127.0.0.1only - external access is via HAProxy (443). Engine API and gRPC are accessible from VPN IPs configured viafirewall_allowed_ipsingroup_vars/all/main.yml.
AvalancheGo is the official Go implementation of an Avalanche node. Unlike Ethereum, Avalanche uses a single client - no separate execution and consensus layers.
- Full (pruned): State-sync enabled, pruning enabled. Fast sync from network state.
- Archive: All pruning disabled, transaction indexing enabled. Full historical data.
Configuration is managed via JSON config files (node.json + chains/C/config.json), templated by Ansible based on node_type.
| Resource | Full | Archive |
|---|---|---|
| CPU | 8 vCPU | 16 vCPU |
| Memory | 32 GB | 64 GB |
| Storage | 1 TB | 16 TB (mainnet), 3 TB (Fuji) |
| Network | Node Type | Current Usage (Feb 2026) | Recommended |
|---|---|---|---|
| Mainnet | Full (pruned) | ~500 GB | 2 TB |
| Mainnet | Archive | ~13.5 TB | 16 TB |
| Fuji | Full (pruned) | ~500 GB | 1 TB |
| Fuji | Archive | ~2 TB | 3 TB |
| Port | Protocol | Purpose | Expose |
|---|---|---|---|
| 9650 | TCP | HTTP API / RPC | Localhost |
| 9651 | TCP/UDP | P2P staking | Public |
RPC port (9650) binds to localhost only - external access is via HAProxy (443).
Arbitrum is an L2 rollup that syncs from a sequencer feed rather than P2P peers. No inbound P2P ports are required.
Mainnet requires two nodes on separate hosts:
- Classic (FROZEN image): handles pre-Nitro history (blocks 1-~22.2M, ~700 GB). Uses
offchainlabs/arb-node:v1.4.5-e97c1a4- this image is frozen and must never be updated. - Nitro archive: handles post-Nitro history (~37 TB). Automatically redirects pre-Nitro RPC queries to the Classic node via
--execution.rpc.classic-redirect. Terraform auto-wires the IP when both nodes are in the same stack. If the classic node is external, setclassic_redirect_urlinnodes.tfvars(orarbitrum_classic_redirect_urlhost var in static inventory).
Sepolia uses Nitro only (no pre-Nitro history).
| Network | Node Key | Client | node_type | ~Disk (Feb 2026) |
|---|---|---|---|---|
| Mainnet | classic |
arb-node (FROZEN) | archive | ~700 GB |
| Mainnet | archive |
nitro-node | archive | ~37 TB |
| Mainnet | full |
nitro-node | full | ~2.1 TB |
| Sepolia | archive |
nitro-node | archive | ~20 TB |
| Sepolia | full |
nitro-node | full | ~1.7 TB |
| Node | CPU | Memory | Storage |
|---|---|---|---|
| Classic archive | 4 vCPU | 16 GB | 2 TB |
| Nitro archive (mainnet) | 32 vCPU | 128 GB | 50 TB |
| Nitro full (mainnet) | 8 vCPU | 32 GB | 5 TB |
| Nitro archive (Sepolia) | 16 vCPU | 64 GB | 25 TB |
| Nitro full (Sepolia) | 4 vCPU | 16 GB | 3 TB |
| Network | Node Type | Client | Current Usage (Feb 2026) | Recommended |
|---|---|---|---|---|
| Mainnet | Classic archive | arb-node | ~700 GB | 2 TB |
| Mainnet | Nitro archive | nitro-node | ~37 TB | 50 TB |
| Mainnet | Nitro full | nitro-node | ~2.1 TB | 5 TB |
| Sepolia | Nitro archive | nitro-node | ~20 TB | 25 TB |
| Sepolia | Nitro full | nitro-node | ~1.7 TB | 3 TB |
Snapshot init:
- Classic (pre-Nitro): Snapshots available at snapshot-explorer.arbitrum.io. Use these to speed up initial sync.
- Nitro archive (mainnet): Uses
--init.latest=archivebut archive snapshots are no longer updated since May 2024 (announcement). Only stale 2023 snapshots remain on snapshot-explorer. The node will fall back to syncing from genesis. To speed up, obtain a recent snapshot from a node operator and use--init.url=<snapshot-url>.- Nitro archive (Sepolia): Uses
--init.latest=archivebut no Sepolia archive snapshots exist. The node will sync from genesis.- Nitro full: Uses
--init.latest=prunedto auto-download a pruned snapshot fromsnapshot.arbitrum.io.
| Port | Protocol | Client | Purpose | Expose |
|---|---|---|---|---|
| 8547 | TCP | Nitro | HTTP RPC + WebSocket | Localhost |
| 8547 | TCP | Classic | HTTP RPC | Localhost |
| 8548 | TCP | Classic | WebSocket RPC | Localhost |
No inbound P2P ports needed - Arbitrum is L2 and syncs from the sequencer feed. RPC ports bind to localhost only - external access is via HAProxy (443).
Arbitrum requires two additional vault variables. Create inventory/group_vars/arbitrum/vault.yml:
ansible-vault create inventory/group_vars/arbitrum/vault.yml---
vault_haproxy_api_key: "<paste-uuid4-from-uuidgen>"
vault_haproxy_stats_password: "<choose-a-stats-password>"
vault_arbitrum_parent_chain_url: "https://your-eth-rpc-endpoint" # L1 ETH RPC
vault_arbitrum_beacon_url: "http://your-lighthouse-node:5052" # L1 beacon (port 5052)Use your own Ethereum node (Reth+Lighthouse, Geth+Prysm, or Erigon) as the parent chain and beacon URL. Sepolia Arbitrum nodes require Sepolia L1 endpoints; mainnet nodes require mainnet endpoints.
HAProxy provides secure external RPC access with TLS termination, UUID4 API key authentication, and WebSocket support. Runs on the same node as the blockchain client, proxying to localhost backends.
Features:
- Let's Encrypt TLS via Certbot + Route 53 DNS-01 challenge (auto-renewal)
- API key authentication via
X-API-Keyheader or?apikey=URL parameter (file-based lookup) - WebSocket upgrade detection with extended timeouts (1h tunnel)
- HTTP to HTTPS redirect
- Optional HAProxy stats page and Prometheus metrics exporter
Prerequisites: Domain with AWS Route 53 hosted zone (required for Let's Encrypt DNS-01 challenge). Complete these steps before running the Ansible playbooks in Step 6. For the full vault reference, see Ansible Vault Setup.
Step 1: Create vault password file:
cd ansible
echo "your-vault-password" > .vault_password
chmod 600 .vault_password
.vault_passwordis in.gitignore. Store the password securely and share with team members out-of-band.
Step 2: Create per-role secrets vault (API key + stats password):
Each role group gets its own vault so different node types use different API keys. Create one vault per role you deploy:
uuidgen # Generate a UUID4 API key, copy the output
# For Docker nodes (Reth+Lighthouse, Geth+Prysm):
ansible-vault create inventory/group_vars/ethereum/vault.yml
# For Erigon nodes (Erigon+Caplin):
ansible-vault create inventory/group_vars/erigon/vault.yml
# For Avalanche nodes (AvalancheGo):
ansible-vault create inventory/group_vars/avalanche/vault.yml
# For Arbitrum nodes (Nitro + Classic):
ansible-vault create inventory/group_vars/arbitrum/vault.ymlYour editor opens for each file. Add the following variables, save, and close.
For ethereum, erigon, avalanche vaults:
---
vault_haproxy_api_key: "<paste-uuid4-from-uuidgen>"
vault_haproxy_stats_password: "<choose-a-stats-password>"For the arbitrum vault (requires two additional L1 connection secrets):
---
vault_haproxy_api_key: "<paste-uuid4-from-uuidgen>"
vault_haproxy_stats_password: "<choose-a-stats-password>"
vault_arbitrum_parent_chain_url: "https://your-eth-rpc-endpoint" # L1 ETH RPC
vault_arbitrum_beacon_url: "http://your-lighthouse-node:5052" # L1 beaconUse a different
uuidgenoutput for each role group. This isolates API keys so compromising one key does not expose other node types.
Step 3: Create bare metal vault (skip for AWS-only deployments):
Create an IAM user with Route 53 permissions (route53:GetChange, route53:ListHostedZones, route53:ListHostedZonesByName, route53:ChangeResourceRecordSets), then:
# Latitude BM
ansible-vault create inventory/group_vars/platform_latitude/vault.yml
# OVH BM
ansible-vault create inventory/group_vars/platform_ovh/vault.ymlYour editor opens. Add the following variables, save, and close:
---
vault_haproxy_certbot_aws_access_key: "<iam-access-key>"
vault_haproxy_certbot_aws_secret_key: "<iam-secret-key>"AWS EC2 nodes use IAM instance profiles for Certbot Route 53 - no AWS credentials needed. Only bare metal (Latitude, OVH) nodes require this vault file.
Step 4: Set deployment config (plaintext, not secrets):
Edit inventory/group_vars/all/main.yml and set the certbot email:
haproxy_certbot_email: "your-real-email@yourcompany.com"AWS:
haproxy_domainis set per-node via Terraform: adddomaininnodes.tfvarsandroute53_zone_idinterraform.tfvars. Terraform creates Route 53 A records and passeshaproxy_domainas a host variable automatically. See Terraform DNS.Bare metal (Latitude/OVH): Add
domaininnodes.tfvarsandroute53_zone_idinterraform.tfvars- Terraform creates Route 53 A records and passeshaproxy_domainto Ansible automatically (same as AWS). RequiresAWS_ACCESS_KEY_ID+AWS_SECRET_ACCESS_KEYenv vars. Without Route 53, skiproute53_zone_idand create the A record manually in your DNS provider.
Deploy (see Step 6: Configure and Deploy Services for full scenarios):
cd ansible
# Full deploy (node + HAProxy)
ansible-playbook -i inventory/ethereum_mainnet_aws_terraform_state.yml playbooks/erigon.yml
# HAProxy only (skip node rebuild)
ansible-playbook -i inventory/ethereum_mainnet_aws_terraform_state.yml playbooks/erigon.yml --tags haproxy
# Node only (skip HAProxy)
ansible-playbook -i inventory/ethereum_mainnet_aws_terraform_state.yml playbooks/erigon.yml --skip-tags haproxyVerify:
API key can be passed via X-API-Key header or ?apikey= URL parameter:
# Method 1: API key via header
curl -s https://rpc.eth.example.com/ \
-H "X-API-Key: <your-uuid4-api-key>" \
-X POST -H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","method":"eth_syncing","params":[],"id":1}'
# Method 2: API key via URL parameter
curl -s -X POST \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","method":"eth_syncing","params":[],"id":1}' \
"https://rpc.eth.example.com/?apikey=<your-uuid4-api-key>"
# Test without API key (should return 403)
curl -s https://rpc.eth.example.com/
# Test WebSocket
wscat -c wss://rpc.eth.example.com/ -H "X-API-Key: <your-uuid4-api-key>"eth_syncing returns sync progress (currentBlock, highestBlock) or false when fully synced. For continuous monitoring, use the Block Lag Monitor to track block lag, latency, and estimate sync ETA across all your nodes.
For vault management commands (edit, view, rekey), see Ansible Vault Setup.
| Port | Protocol | Purpose | Expose |
|---|---|---|---|
| 80 | TCP | HTTP (redirects to HTTPS) | Public |
| 443 | TCP | HTTPS (TLS + API key auth) | Public |
| 8404 | TCP | Stats page (localhost only) | Local |
| 8405 | TCP | Prometheus metrics (optional) | Local |
All variables have sensible defaults in roles/haproxy/defaults/main.yml. Backend ports are auto-mapped from execution client ports via group_vars:
- Erigon (
group_vars/erigon/main.yml):haproxy_backend_http_port: "{{ erigon_http_port }}"(8545) - Docker (
group_vars/ethereum/main.yml):haproxy_backend_http_port: "{{ ports.execution_http }}"(8545) - Avalanche (
group_vars/avalanche/main.yml):haproxy_backend_http_port: "{{ ports.http_api }}"(9650) - Arbitrum (
group_vars/arbitrum/main.yml):haproxy_backend_http_port: "{{ ports.http_rpc }}"(8547),haproxy_backend_ws_enabled: true
Multi-region high-availability RPC setup with automatic failover. Deploys to multiple AWS regions with a single terraform apply. Existing single-node stacks (aws/, latitude/, ovh/) remain unchanged.
Client -> rpc.eth.example.com
|
Route 53 (latency-based routing + health checks)
|
+--------+--------+
| |
us-east-1 eu-west-1
| |
HAProxy-1 (EIP) HAProxy-1 (EIP) <- HTTPS :443 /health for Route 53
HAProxy-2 (EIP) HAProxy-2 (EIP) <- API key auth, TLS termination
| |
v v
Node-1 (AZ-a) Node-1 (AZ-a) <- Backend nodes (private IP RPC)
Node-2 (AZ-b) Node-2 (AZ-b) <- No per-node HAProxy
How it works:
- Route 53 latency-based routing - one A record per region with all HAProxy IPs; returns the nearest healthy region
- Per-endpoint health checks (HTTPS
:443 /health, 30s interval) - HAProxy returns 200 if at least one backend is healthy - Per-region calculated health checks - healthy if ANY HAProxy in the region is up (OR logic)
- Automatic failover - if all HAProxies in a region fail, Route 53 routes traffic to the other region
- No NLB - Route 53 handles failover directly to HAProxy EIPs (simpler, lower cost)
- Public subnets for backend nodes (needed for P2P, avoids NAT gateway cost)
Key design decisions:
- Single Terraform state with provider aliases for multi-region (one
terraform apply) - Single Ansible inventory file for all HA nodes (use
--limitfor region targeting) - HAProxy discovers backend IPs via Ansible inventory groups (no hardcoded IPs)
- Backend nodes get chain-specific internal RPC SG (Ethereum: 8545/8546; Arbitrum: 8547; Avalanche: 9650 - all from VPC CIDR) - no per-node HAProxy
- HAProxy nodes are lightweight EC2 instances (no data EBS, always EIP)
Terraform modules:
| Module | Description |
|---|---|
compute-haproxy/ |
Lightweight EC2 for HAProxy LB (root volume only, always EIP) |
ha-region/ |
Per-region composition for Ethereum (EL+CL P2P SGs, internal RPC 8545/8546) |
ha-region-arb/ |
Per-region composition for Arbitrum (no P2P - L2, internal RPC 8547) |
ha-region-avax/ |
Per-region composition for Avalanche (P2P 9651 staking, internal RPC 9650) |
ha-dns/ |
Route 53 health checks + latency-based A records |
network/ (modified) |
internal_rpc SG gated by enable_internal_rpc (backward compatible) |
Prerequisites: Terraform Cloud account + workspace (aws-ha-ethereum-mainnet), terraform login (or TF_TOKEN_app_terraform_io env var), AWS credentials, key pairs per region.
1. Configure Terraform:
cd terraform/stacks/aws-ha/ethereum/mainnet
cp cloud.tf.example cloud.tf
# Edit cloud.tf: set your TFC organization name and workspace name
cp terraform.tfvars.example terraform.tfvars
# Edit terraform.tfvars with your valuesKey variables to fill in terraform.tfvars:
domain = "rpc.eth.example.com" # Must be within your Route 53 hosted zone
route53_zone_id = "Z0123456789ABCDEF" # aws route53 list-hosted-zones
regions = {
"us-east-1" = {
vpc_cidr = "10.100.0.0/16"
subnet_cidrs = ["10.100.0.0/24", "10.100.1.0/24"]
azs = ["us-east-1a", "us-east-1b"]
key_name = "my-key-us-east" # EC2 key pair name in us-east-1
ssh_private_key_file = "~/.ssh/my-key-us-east.pem"
allowed_ssh_cidrs = ["YOUR_VPN_IP/32"] # Restrict SSH to your IPs
node_count = 2 # Backend nodes (auto round-robin across AZs)
node_type = "full" # "full" or "archive"
node_instance_type = "m7i.2xlarge"
node_data_volume_size_gb = 3000
haproxy_count = 2 # HAProxy LBs (auto round-robin across AZs)
haproxy_instance_type = "c6i.large"
}
"eu-west-1" = { ... } # Same structure, different CIDR/AZs/key
}2. Deploy all infrastructure (single command):
terraform init && terraform apply
# Creates per region: VPC, 2x backend nodes + EBS, 2x HAProxy + EIP, security groups, IAM profiles
# Creates shared: 4x Route 53 endpoint health checks, 2x calculated region checks, 2x latency A records
# Also generates: ansible/inventory/ethereum_mainnet_aws_ha_terraform_state.yml3. Setup vault secrets and certbot email:
cd ansible
# Set certbot email (used for Let's Encrypt TLS cert registration)
# Edit inventory/group_vars/all/main.yml:
# haproxy_certbot_email: "your-real-email@yourcompany.com"
# Vault for dedicated HAProxy LB nodes (real secrets)
ansible-vault create inventory/group_vars/haproxy_lb/vault.yml
# Add:
# vault_haproxy_api_key: "your-uuid4-api-key" # generate: uuidgen
# vault_haproxy_stats_password: "your-stats-password"
# Vault for backend Ethereum nodes (empty - HAProxy is on dedicated nodes, not co-located)
# Required: Ansible loads vault files for every group a host belongs to
ansible-vault create inventory/group_vars/ethereum/vault.yml
# Add:
# vault_haproxy_api_key: ""
# vault_haproxy_stats_password: ""4. Verify connectivity to all nodes:
export TF_TOKEN_app_terraform_io="your-api-token" # needed for inventory plugin to read TF state
ansible -i inventory/ethereum_mainnet_aws_ha_terraform_state.yml all -m ping
# All nodes in both regions should return pong before proceeding5. Configure backend blockchain nodes (all regions):
ansible-playbook -i inventory/ethereum_mainnet_aws_ha_terraform_state.yml playbooks/ethereum.yml6. Configure HAProxy load balancers (all regions):
ansible-playbook -i inventory/ethereum_mainnet_aws_ha_terraform_state.yml playbooks/haproxy-lb.yml
# Issues TLS cert via Let's Encrypt + Route 53 DNS-01. Route 53 health checks go green within ~2 min.Target a specific region (optional):
ansible-playbook -i inventory/ethereum_mainnet_aws_ha_terraform_state.yml playbooks/ethereum.yml --limit region_us_east_1
ansible-playbook -i inventory/ethereum_mainnet_aws_ha_terraform_state.yml playbooks/haproxy-lb.yml --limit region_eu_west_1Same flow as Ethereum HA. Key differences:
- No inbound P2P ports - Arbitrum L2 syncs from the sequencer feed only
- RPC port 8547 - HTTP and WS on the same port (Nitro, no 8545/8546 split)
- Single process -
nitro(no EL+CL split); noexecution_client/consensus_clientvars - Instance type -
m7i.4xlargerecommended; 2.5 TB data volume for mainnet full (~37 TB archive) - Requires L1 secrets - parent chain ETH RPC + beacon endpoint with full blob history in vault
Prerequisites: TFC workspace aws-ha-arbitrum-mainnet, a running Ethereum L1 node with blob retention (Lighthouse archive --supernode --prune-blobs=false or Prysm 7.1.0+ --semi-supernode).
# 1. Terraform
cd terraform/stacks/aws-ha/arbitrum/mainnet
cp cloud.tf.example cloud.tf # Edit: TFC org + workspace "aws-ha-arbitrum-mainnet"
cp terraform.tfvars.example terraform.tfvars # Edit: domain, route53_zone_id, key_name, allowed_ssh_cidrs, regions
terraform init && terraform apply
# Also generates: ansible/inventory/arbitrum_mainnet_aws_ha_terraform_state.yml# 2. Vault secrets
cd ansible
# HAProxy LB vault - skip if already created for another chain (shared across chains)
ansible-vault create inventory/group_vars/haproxy_lb/vault.yml
# vault_haproxy_api_key: "your-uuid4" # generate: uuidgen
# vault_haproxy_stats_password: "your-stats-password"
# Arbitrum backend nodes vault (includes required L1 connection secrets)
ansible-vault create inventory/group_vars/arbitrum/vault.yml
# vault_haproxy_api_key: "" # empty - HAProxy on dedicated nodes
# vault_haproxy_stats_password: ""
# vault_arbitrum_parent_chain_url: "https://your-eth-mainnet-rpc"
# vault_arbitrum_beacon_url: "http://your-lighthouse:5052"# 3. Verify connectivity
export TF_TOKEN_app_terraform_io="your-api-token"
ansible -i inventory/arbitrum_mainnet_aws_ha_terraform_state.yml all -m ping
# 4. Deploy backend Arbitrum nodes (all regions)
ansible-playbook -i inventory/arbitrum_mainnet_aws_ha_terraform_state.yml playbooks/arbitrum.yml
# 5. Deploy HAProxy LBs (all regions)
ansible-playbook -i inventory/arbitrum_mainnet_aws_ha_terraform_state.yml playbooks/haproxy-lb.yml
# Region-specific targeting (optional)
ansible-playbook -i inventory/arbitrum_mainnet_aws_ha_terraform_state.yml playbooks/arbitrum.yml --limit region_us_east_1
ansible-playbook -i inventory/arbitrum_mainnet_aws_ha_terraform_state.yml playbooks/haproxy-lb.yml --limit region_eu_west_1Same flow as Ethereum HA. Key differences:
- P2P staking port 9651 - must be publicly reachable for validator/peer communication
- HTTP API port 9650 - C-chain JSON-RPC at
/ext/bc/C/rpc(not root/) - Single process -
avalanchego(no EL+CL split); noexecution_client/consensus_clientvars - Health check URI - HAProxy uses
/ext/bc/C/rpcautomatically (set ingroup_vars/avalanche/main.yml) - Instance type -
m7i.2xlargerecommended; 2 TB data volume for mainnet (~1.5 TB and growing)
Prerequisites: TFC workspace aws-ha-avalanche-mainnet. No L1 node required.
# 1. Terraform
cd terraform/stacks/aws-ha/avalanche/mainnet
cp cloud.tf.example cloud.tf # Edit: TFC org + workspace "aws-ha-avalanche-mainnet"
cp terraform.tfvars.example terraform.tfvars # Edit: domain, route53_zone_id, key_name, allowed_ssh_cidrs, regions
terraform init && terraform apply
# Also generates: ansible/inventory/avalanche_mainnet_aws_ha_terraform_state.yml# 2. Vault secrets
cd ansible
# HAProxy LB vault - skip if already created for another chain (shared across chains)
ansible-vault create inventory/group_vars/haproxy_lb/vault.yml
# vault_haproxy_api_key: "your-uuid4" # generate: uuidgen
# vault_haproxy_stats_password: "your-stats-password"
# Avalanche backend nodes vault
ansible-vault create inventory/group_vars/avalanche/vault.yml
# vault_haproxy_api_key: "" # empty - HAProxy on dedicated nodes
# vault_haproxy_stats_password: ""# 3. Verify connectivity
export TF_TOKEN_app_terraform_io="your-api-token"
ansible -i inventory/avalanche_mainnet_aws_ha_terraform_state.yml all -m ping
# 4. Deploy backend Avalanche nodes (all regions)
ansible-playbook -i inventory/avalanche_mainnet_aws_ha_terraform_state.yml playbooks/avalanche.yml
# 5. Deploy HAProxy LBs (all regions)
ansible-playbook -i inventory/avalanche_mainnet_aws_ha_terraform_state.yml playbooks/haproxy-lb.yml
# Region-specific targeting (optional)
ansible-playbook -i inventory/avalanche_mainnet_aws_ha_terraform_state.yml playbooks/avalanche.yml --limit region_us_east_1
ansible-playbook -i inventory/avalanche_mainnet_aws_ha_terraform_state.yml playbooks/haproxy-lb.yml --limit region_eu_west_1All HA nodes are in a single inventory file. Use --limit with these groups:
| Group | Contains | Purpose |
|---|---|---|
haproxy_lb |
All HAProxy LB nodes | Target HAProxy LB playbook |
region_us_east_1 |
All nodes in us-east-1 | Region targeting |
region_eu_west_1 |
All nodes in eu-west-1 | Region targeting |
backend_us_east_1 |
Backend nodes in us-east-1 | HAProxy template backend discovery |
backend_eu_west_1 |
Backend nodes in eu-west-1 | HAProxy template backend discovery |
ethereum / arbitrum / avalanche |
All backend nodes | Chain role group (determines which playbook to use) |
HAProxy nodes have host var haproxy_backend_group set to their region's backend group (e.g., backend_us_east_1). The HAProxy template uses this to discover backend IPs from the Ansible inventory -- no hardcoded addresses.
Run these after Ansible completes to confirm the full stack is healthy. Steps are the same for all chains - swap the domain and RPC method where noted.
1. DNS - confirm Route 53 is returning HAProxy IPs:
# Returns the nearest region's HAProxy IPs (latency-based routing)
dig rpc.eth.yourdomain.com # Ethereum
dig rpc.arb.yourdomain.com # Arbitrum
dig rpc.avax.yourdomain.com # Avalanche
dig @8.8.8.8 rpc.eth.yourdomain.com # From Google DNS (remote client view)2. HAProxy health endpoint - confirm TLS and backend connectivity:
# Get HAProxy EIPs from Terraform output (swap chain dir as needed)
cd terraform/stacks/aws-ha/ethereum/mainnet # or arbitrum/mainnet, avalanche/mainnet
terraform output us_east_1_haproxy_instances
terraform output eu_west_1_haproxy_instances
# Hit /health directly on each HAProxy EIP (same for all chains)
# 200 = at least one backend is up; 503 = all backends down
curl -sk https://<haproxy-eip>/health3. Route 53 health check status:
# List all health checks and status
aws route53 list-health-checks \
--query 'HealthChecks[*].[Id,HealthCheckConfig.IPAddress,HealthCheckConfig.Type]' \
--output table
# Get status of a specific health check
aws route53 get-health-check-status --health-check-id <id> \
--query 'HealthCheckObservations[*].[Region,StatusReport.Status]' --output table4. RPC via HA domain - confirm API key auth and routing:
API_KEY="your-api-key"
# --- Ethereum / Arbitrum (JSON-RPC at /) ---
DOMAIN="https://rpc.eth.yourdomain.com" # or rpc.arb.yourdomain.com
# Should return 403 (API key required)
curl -s -o /dev/null -w "%{http_code}" -X POST $DOMAIN \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","method":"eth_blockNumber","id":1}'
# Should return block number (hex)
curl -s -X POST $DOMAIN \
-H "Content-Type: application/json" -H "X-API-Key: $API_KEY" \
-d '{"jsonrpc":"2.0","method":"eth_blockNumber","id":1}'
# Sync status (false = fully synced)
curl -s -X POST $DOMAIN \
-H "Content-Type: application/json" -H "X-API-Key: $API_KEY" \
-d '{"jsonrpc":"2.0","method":"eth_syncing","id":1}'
# --- Avalanche (C-chain JSON-RPC at /ext/bc/C/rpc) ---
DOMAIN="https://rpc.avax.yourdomain.com"
curl -s -X POST $DOMAIN/ext/bc/C/rpc \
-H "Content-Type: application/json" -H "X-API-Key: $API_KEY" \
-d '{"jsonrpc":"2.0","method":"eth_blockNumber","id":1}'5. Compare block numbers per region (checks both regions are in sync):
API_KEY="your-api-key"
DOMAIN="rpc.eth.yourdomain.com" # swap for arb/avax domain
# Force request to a specific region's HAProxy (bypasses DNS routing)
for IP in <us-east-haproxy-1-eip> <us-east-haproxy-2-eip> <eu-west-haproxy-1-eip> <eu-west-haproxy-2-eip>; do
BLOCK=$(curl -sk -X POST https://$IP \
-H "Content-Type: application/json" \
-H "X-API-Key: $API_KEY" \
--resolve "$DOMAIN:443:$IP" \
-d '{"jsonrpc":"2.0","method":"eth_blockNumber","id":1}' | jq -r '.result')
echo "$IP -> block $((16#${BLOCK#0x}))"
done- Terraform - Infrastructure documentation
- Ansible - Configuration management guide
- Operations - Operations guide
- Block Lag Monitor - RPC block lag & latency monitoring (any EVM chain + Avalanche)
- Load Test - JSON-RPC load testing with Paradigm's flood
- Static Inventory - Deploy without Terraform (manual servers)
Working directories: Terraform runs from its stack directory (
terraform/stacks/{platform}/{chain}/{network}/). Ansible runs from theansible/directory. See Deployment Scenarios for complete end-to-end guides.
# ── Terraform ─────────────────────────────────────────────────────────────────
# Each stack has its own directory - cd into it before running commands.
cd terraform/stacks/aws/ethereum/mainnet # AWS Ethereum Mainnet
cd terraform/stacks/aws/ethereum/sepolia # AWS Ethereum Sepolia
cd terraform/stacks/aws/avalanche/mainnet # AWS Avalanche Mainnet
cd terraform/stacks/aws/avalanche/fuji # AWS Avalanche Fuji
cd terraform/stacks/aws/arbitrum/mainnet # AWS Arbitrum Mainnet
cd terraform/stacks/aws/arbitrum/sepolia # AWS Arbitrum Sepolia
cd terraform/stacks/latitude/ethereum/mainnet # Latitude Ethereum Mainnet
cd terraform/stacks/latitude/avalanche/mainnet # Latitude Avalanche Mainnet
cd terraform/stacks/latitude/arbitrum/mainnet # Latitude Arbitrum Mainnet
cd terraform/stacks/ovh/ethereum/mainnet # OVH Ethereum Mainnet
cd terraform/stacks/ovh/avalanche/mainnet # OVH Avalanche Mainnet
cd terraform/stacks/ovh/arbitrum/mainnet # OVH Arbitrum Mainnet
cd terraform/stacks/aws-ha/ethereum/mainnet # HA Multi-Region Ethereum
cd terraform/stacks/aws-ha/arbitrum/mainnet # HA Multi-Region Arbitrum
cd terraform/stacks/aws-ha/avalanche/mainnet # HA Multi-Region Avalanche
terraform init && terraform apply -var-file=nodes.tfvars # All nodes
terraform apply -var-file=nodes.tfvars -target='module.compute["full"]' # Specific node
terraform plan -var-file=nodes.tfvars # Preview
# ── Ansible ───────────────────────────────────────────────────────────────────
# All ansible commands run from the ansible/ directory.
cd ansible
export TF_TOKEN_app_terraform_io="your-api-token"
export ANSIBLE_PRIVATE_KEY_FILE=~/.ssh/your-key.pem
# Match playbook to node role:
# role = "ethereum" -> playbooks/ethereum.yml (Docker: Geth+Prysm, Reth+Lighthouse)
# role = "erigon" -> playbooks/erigon.yml (Binary: Erigon+Caplin)
# role = "avalanche" -> playbooks/avalanche.yml (Docker: AvalancheGo)
# role = "arbitrum" -> playbooks/arbitrum.yml (Docker: Nitro + Classic)
# Deploy all nodes of a role
ansible-playbook -i inventory/ethereum_mainnet_aws_terraform_state.yml playbooks/ethereum.yml
ansible-playbook -i inventory/ethereum_mainnet_aws_terraform_state.yml playbooks/erigon.yml
ansible-playbook -i inventory/avalanche_mainnet_aws_terraform_state.yml playbooks/avalanche.yml
ansible-playbook -i inventory/arbitrum_mainnet_aws_terraform_state.yml playbooks/arbitrum.yml
# Deploy specific node group
ansible-playbook -i inventory/ethereum_mainnet_aws_terraform_state.yml playbooks/erigon.yml --limit node_full
ansible-playbook -i inventory/avalanche_mainnet_aws_terraform_state.yml playbooks/avalanche.yml --limit node_archive
ansible-playbook -i inventory/arbitrum_mainnet_aws_terraform_state.yml playbooks/arbitrum.yml --limit node_archive
# Latitude BM (RAID first, then node playbook)
ansible-playbook -i inventory/ethereum_mainnet_latitude_terraform_state.yml playbooks/raid.yml
ansible-playbook -i inventory/ethereum_mainnet_latitude_terraform_state.yml playbooks/erigon.yml
ansible-playbook -i inventory/avalanche_mainnet_latitude_terraform_state.yml playbooks/raid.yml
ansible-playbook -i inventory/avalanche_mainnet_latitude_terraform_state.yml playbooks/avalanche.yml
ansible-playbook -i inventory/arbitrum_mainnet_latitude_terraform_state.yml playbooks/raid.yml
ansible-playbook -i inventory/arbitrum_mainnet_latitude_terraform_state.yml playbooks/arbitrum.yml
# OVH BM (RAID first, then node playbook)
ansible-playbook -i inventory/ethereum_mainnet_ovh_terraform_state.yml playbooks/raid.yml
ansible-playbook -i inventory/ethereum_mainnet_ovh_terraform_state.yml playbooks/erigon.yml
ansible-playbook -i inventory/avalanche_mainnet_ovh_terraform_state.yml playbooks/raid.yml
ansible-playbook -i inventory/avalanche_mainnet_ovh_terraform_state.yml playbooks/avalanche.yml
ansible-playbook -i inventory/arbitrum_mainnet_ovh_terraform_state.yml playbooks/raid.yml
ansible-playbook -i inventory/arbitrum_mainnet_ovh_terraform_state.yml playbooks/arbitrum.yml
# HAProxy only (update proxy without redeploying node)
ansible-playbook -i inventory/ethereum_mainnet_aws_terraform_state.yml playbooks/erigon.yml --tags haproxy
ansible-playbook -i inventory/avalanche_mainnet_aws_terraform_state.yml playbooks/avalanche.yml --tags haproxy
ansible-playbook -i inventory/arbitrum_mainnet_aws_terraform_state.yml playbooks/arbitrum.yml --tags haproxy
# Deploy node without HAProxy
ansible-playbook -i inventory/ethereum_mainnet_aws_terraform_state.yml playbooks/erigon.yml --skip-tags haproxy
# Dry-run (preview changes without applying)
ansible-playbook -i inventory/ethereum_mainnet_aws_terraform_state.yml playbooks/erigon.yml --check --diff
# Static inventory (no Terraform)
ansible-playbook -i inventory/ethereum_sepolia_hosts.yml playbooks/erigon.yml
# ── HA Multi-Region ─────────────────────────────────────────────────────────
# Single terraform apply deploys both regions + DNS. Single inventory file.
cd terraform/stacks/aws-ha/ethereum/mainnet # Ethereum HA
cd terraform/stacks/aws-ha/arbitrum/mainnet # Arbitrum HA
cd terraform/stacks/aws-ha/avalanche/mainnet # Avalanche HA
terraform init && terraform apply # All regions + DNS
cd ansible
# Ethereum HA
ansible-playbook -i inventory/ethereum_mainnet_aws_ha_terraform_state.yml playbooks/ethereum.yml # All backends
ansible-playbook -i inventory/ethereum_mainnet_aws_ha_terraform_state.yml playbooks/haproxy-lb.yml # All HAProxy LBs
ansible-playbook -i inventory/ethereum_mainnet_aws_ha_terraform_state.yml playbooks/ethereum.yml --limit region_us_east_1 # Region-specific
ansible-playbook -i inventory/ethereum_mainnet_aws_ha_terraform_state.yml playbooks/haproxy-lb.yml --limit region_eu_west_1 # Region-specific
# Arbitrum HA
ansible-playbook -i inventory/arbitrum_mainnet_aws_ha_terraform_state.yml playbooks/arbitrum.yml # All backends
ansible-playbook -i inventory/arbitrum_mainnet_aws_ha_terraform_state.yml playbooks/haproxy-lb.yml # All HAProxy LBs
ansible-playbook -i inventory/arbitrum_mainnet_aws_ha_terraform_state.yml playbooks/arbitrum.yml --limit region_us_east_1 # Region-specific
# Avalanche HA
ansible-playbook -i inventory/avalanche_mainnet_aws_ha_terraform_state.yml playbooks/avalanche.yml # All backends
ansible-playbook -i inventory/avalanche_mainnet_aws_ha_terraform_state.yml playbooks/haproxy-lb.yml # All HAProxy LBs
ansible-playbook -i inventory/avalanche_mainnet_aws_ha_terraform_state.yml playbooks/avalanche.yml --limit region_us_east_1 # Region-specific
# ── HA Verification ──────────────────────────────────────────────────────────
dig rpc.eth.yourdomain.com # DNS - check Route 53 returns HAProxy IPs
curl -sk https://<haproxy-eip>/health # HAProxy health (200=ok, 503=all backends down)
curl -s -X POST https://rpc.eth.yourdomain.com \
-H "Content-Type: application/json" -H "X-API-Key: $API_KEY" \
-d '{"jsonrpc":"2.0","method":"eth_blockNumber","id":1}' # Block number via HA domain
curl -s -X POST https://rpc.eth.yourdomain.com \
-H "Content-Type: application/json" -H "X-API-Key: $API_KEY" \
-d '{"jsonrpc":"2.0","method":"eth_syncing","id":1}' # Sync status (false=synced)
aws route53 get-health-check-status --health-check-id <id> \
--query 'HealthCheckObservations[*].[Region,StatusReport.Status]' --output table # Route 53 health- SSH access restricted to VPN/office IP ranges only
- Per-client P2P security groups: each node receives only the SGs for its clients (e.g.,
p2p-reth,p2p-lighthouse,p2p-avalanche) - IAM instance profile scoped to Route 53 only (Certbot DNS-01 for HAProxy TLS)
- SSH keys managed via AWS key pairs (never commit private keys)
- Optional Elastic IPs for stable endpoints
- IMDSv2 required (no IMDSv1)
UFW is enabled on all bare metal nodes (Latitude, OVH) via group_vars/platform_latitude/main.yml and group_vars/platform_ovh/main.yml. Disabled on AWS where Security Groups handle network access.
Trusted IP configuration: All chains share a single firewall_allowed_ips list in group_vars/all/main.yml. Replace YOUR_VPN_IP with real IPs before deploying:
# ansible/inventory/group_vars/all/main.yml
firewall_allowed_ips:
- { ip: "YOUR_VPN_IP/32", comment: "VPN egress" } # curl -4 ifconfig.me
- { ip: "YOUR_OFFICE_CIDR/24", comment: "Office network" }Per-role overrides are optional - uncomment erigon_firewall_allowed_ips, ethereum_firewall_allowed_ips, avalanche_firewall_allowed_ips, or arbitrum_firewall_allowed_ips in the respective group_vars if a chain needs different IPs.
Default policy: deny all incoming, allow all outgoing, then open:
| Role | Public Ports (open to all) | Restricted (trusted IPs only) |
|---|---|---|
| Ethereum | 30303/tcp+udp (EL P2P), 9000/tcp+udp (CL P2P), 9001/udp (QUIC) | RPC (8545), WS (8546), Engine (8551) |
| Erigon | 30303/tcp+udp (P2P), 42069/tcp+udp (Torrent), 4000/udp (Caplin), 4001/tcp (Caplin), 5353/udp (DNS) | RPC (8545), WS (8546), Engine (8551), gRPC (9090) |
| Avalanche | 9651/tcp (P2P staking) | HTTP API (9650) |
| Arbitrum | None (L2 - outbound sequencer feed only) | RPC (8547), WS (8548) |
| HAProxy | 80/tcp (HTTP redirect), 443/tcp (HTTPS) | Stats (8404) |
- SSH (22) restricted to
firewall_allowed_ipsonly - set your VPN/office IPs before deploying - All Docker containers use
network_mode: host- UFW is the only access control layer on bare metal
- Let's Encrypt TLS certificates via Certbot + Route 53 DNS-01 (auto-renewal every 90 days)
- UUID4 API key required for all RPC requests (validated via
X-API-Keyheader or?apikey=URL parameter) - API keys stored in
/etc/haproxy/api-keys/(file-based lookup, never inline in config) - RPC backends bind to
127.0.0.1(not accessible externally without HAProxy) - Stats page bound to localhost only by default (port 8404)
- Ethereum Mainnet archive node (Reth + Lighthouse) - Docker
- Ethereum Mainnet full node (Geth + Prysm) - Docker
- Ethereum Mainnet full node (Erigon + Caplin) - Binary build
- Ethereum Mainnet archive node (Erigon + Caplin) - Binary build
- Ethereum Sepolia full node (Erigon + Caplin) - Binary build
- Ethereum Sepolia archive node (Erigon + Caplin) - Binary build
- Ethereum Sepolia full node (Geth + Prysm) - Docker
- Ethereum Sepolia archive node (Reth + Lighthouse) - Docker
- HAProxy reverse proxy (TLS + API key auth + WebSocket)
- Avalanche Mainnet (AvalancheGo) - Full + Archive - Docker
- Avalanche Fuji testnet (AvalancheGo) - Full + Archive - Docker
- Arbitrum Mainnet (Nitro + Classic archive) - Full + Archive - Docker
- Arbitrum Sepolia (Nitro) - Full + Archive - Docker
- Monitoring stack (Prometheus, Grafana)
- Additional chains (BNB, Optimism, Base, IOTA, Polkadot, Xai, Polygon and more)
- Multi-cloud support (GCP, Azure)
- High availability (multi-region Route 53 + HAProxy LB) - Ethereum - AWS
- High availability (multi-region Route 53 + HAProxy LB) - Arbitrum - AWS
- High availability (multi-region Route 53 + HAProxy LB) - Avalanche - AWS
MIT License - See LICENSE for details.