Prod VM with nginx and TLS
When to use this: you want to run a production Host on a VM with nginx as a reverse proxy, TLS termination, and persistent volumes for DefraDB data, keys, and lens files.
These scenarios use Ethereum Mainnet data. Ethereum is the only officially supported chain today, but any EVM-compatible chain should work by changing the contract addresses and topic hashes to match the target chain. See the Generator chain config for details.
Topology
nginx terminates incoming HTTPS traffic and proxies GraphQL and metrics requests to the Host container. P2P traffic from Generators goes directly to the Host on port 9171, bypassing nginx. Persistent volumes on the VM keep DefraDB data, keys, and lens files across container restarts.
Prerequisites
- Docker installed on the VM.
- A
config.yamlfile with your Host configuration. - TLS certificate and key files at
~/ssl/nginx.crtand~/ssl/nginx.keyfor nginx TLS termination. - Ports 8080, 80, 443, and 9171 open externally.
Config file
This config is drawn from host-prod-setup.sh in the shinzo-host-client repo. It sets the DefraDB URL, keyring secret, P2P bootstrap peers, and the ShinzoHub endpoint. The event filter is disabled, accepting all documents:
defradb:
url: "localhost:9181"
keyring_secret: "pingpong"
p2p:
enabled: true
bootstrap_peers:
- '/ip4/35.254.135.221/tcp/9171/p2p/12D3KooWDUdHSCXBM5Wb7te6ZdWMgqddw7tJ7npWSzXK5tQgBsbT'
- '/ip4/34.57.239.57/tcp/9171/p2p/12D3KooWBAgCEJHYqzuCFEXzjsw2CnV9JqvqMgTKYDww58aCxwW5'
- '/ip4/34.134.119.63/tcp/9171/p2p/12D3KooWQQTuSQaz4HfuvnJHakkQy3PhWbKBBbS3RkmBw4ZsFkyT'
listen_addr: "/ip4/0.0.0.0/tcp/9171"
max_retries: 5
retry_base_delay_ms: 1000
reconnect_interval_ms: 60000
enable_auto_reconnect: true
store:
path: "./.defra"
block_cache_mb: 512
memtable_mb: 64
index_cache_mb: 256
num_compactors: 4
value_log_file_size_mb: 128
shinzo:
minimum_attestations: 1
start_height: 0
hub_base_url: testnet.shinzo.network:26657
cache_queue_size: 50000
batch_writer_count: 8
batch_size: 500
batch_flush_interval: 100
use_block_signatures: true
doc_worker_count: 32
doc_queue_size: 50000
event_filter:
enabled: false
mode: "allowlist"
cascade_filters: true
groups:
- name: "uniswap-v3"
enabled: false
contracts:
- name: "Uniswap V3 Router 2"
address: "0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45"
types: ["transaction", "log"]
topics:
- name: "Swap"
topic0: "0xc42079f94a6350d7e6235f29174924f928cc2ac818eb64fed8004e115fbcca67"
- name: "stablecoins"
enabled: false
contracts:
- name: "USDT"
address: "0xdAC17F958D2ee523a2206206994597C13D831ec7"
types: ["log"]
- name: "USDC"
address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"
types: ["log"]
topics:
- name: "Transfer"
topic0: "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"
pruner:
enabled: true
max_blocks: 2000
docs_per_block: 1000
interval_seconds: 30
prune_history: false
logger:
development: false
host:
lens_registry_path: "./.defra/lens"
health_server_port: 8080
open_browser_on_start: false
snapshot:
enabled: false
indexer_url: "http://35.206.105.60:8080"
historical_ranges:
- start: 24528700
end: 24528999
Compose file
This compose file is drawn from host-prod-setup.sh. It runs the Host container and an nginx container on a shared Docker network. The Host container exposes the DefraDB API on host port 443, the GraphQL Playground on 444, and P2P on 9171. nginx proxies health, registration, metrics, and GraphQL on ports 8080 and 80, and terminates TLS on 443 via the mounted certificate:
networks:
shinzo-net:
driver: bridge
services:
shinzo-host:
image: ghcr.io/shinzonetwork/shinzo-host-client:v0.6.5-ethereum-mainnet
user: "1001:1001"
mem_limit: 16g
mem_reservation: 13g
restart: unless-stopped
container_name: shinzo-host
networks:
- shinzo-net
ports:
- "443:9181"
- "444:9182"
- "9171:9171"
volumes:
- ~/data/defradb:/app/.defra
- ~/data/keys:/app/.defra/keys
- ~/data/lens:/app/.defra/lens
- ~/config.yaml:/app/config.yaml:ro
environment:
- DEFRA_URL=0.0.0.0:9181
- GOMEMLIMIT=14GiB
- LOG_LEVEL=error
- LOG_SOURCE=false
- LOG_STACKTRACE=false
- ALLOWED_ORIGINS=https://*.shinzo.network
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/metrics"]
interval: 15s
timeout: 30s
retries: 10
start_period: 120s
nginx:
image: nginx:alpine
ports:
- "8080:8080"
- "80:80"
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
- ~/ssl/nginx.crt:/etc/nginx/ssl/nginx.crt:ro
- ~/ssl/nginx.key:/etc/nginx/ssl/nginx.key:ro
depends_on:
- shinzo-host
networks:
- shinzo-net
restart: unless-stopped
nginx configuration
This nginx config is drawn from host-prod-setup.sh. It listens on 80, 443 (with TLS), and 8080, and proxies health, registration, metrics, and GraphQL requests to the Host container via a $backend variable. CORS headers are restricted to https://explorer.shinzo.network. The resolver 127.0.0.11 directive lets nginx resolve the shinzo-host service name on the Docker network:
events { worker_connections 1024; }
http {
resolver 127.0.0.11 valid=10s;
map $http_origin $cors_origin {
default "";
"https://explorer.shinzo.network" $http_origin;
}
server {
listen 80;
listen 443 ssl;
listen 8080;
server_name api.shinzo.network;
ssl_certificate /etc/nginx/ssl/nginx.crt;
ssl_certificate_key /etc/nginx/ssl/nginx.key;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
set $backend "shinzo-host";
location = /metrics {
if ($request_method = OPTIONS) {
add_header 'Access-Control-Allow-Origin' $cors_origin always;
add_header 'Access-Control-Allow-Methods' 'GET, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'Authorization, Content-Type, Accept, Origin' always;
add_header 'Access-Control-Max-Age' 3600 always;
add_header 'Vary' 'Origin' always;
return 204;
}
add_header 'Access-Control-Allow-Origin' $cors_origin always;
add_header 'Access-Control-Allow-Methods' 'GET, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'Authorization, Content-Type, Accept, Origin' always;
add_header 'Vary' 'Origin' always;
proxy_pass http://$backend:8080/metrics;
proxy_set_header Host $host;
}
location = /health {
if ($request_method = OPTIONS) {
add_header 'Access-Control-Allow-Origin' $cors_origin always;
add_header 'Access-Control-Allow-Methods' 'GET, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'Authorization, Content-Type, Accept, Origin' always;
add_header 'Access-Control-Max-Age' 3600 always;
add_header 'Vary' 'Origin' always;
return 204;
}
add_header 'Access-Control-Allow-Origin' $cors_origin always;
add_header 'Access-Control-Allow-Methods' 'GET, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'Authorization, Content-Type, Accept, Origin' always;
add_header 'Vary' 'Origin' always;
proxy_pass http://$backend:8080/health;
proxy_set_header Host $host;
}
location = /registration {
if ($request_method = OPTIONS) {
add_header 'Access-Control-Allow-Origin' $cors_origin always;
add_header 'Access-Control-Allow-Methods' 'GET, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'Authorization, Content-Type, Accept, Origin' always;
add_header 'Access-Control-Max-Age' 3600 always;
add_header 'Vary' 'Origin' always;
return 204;
}
add_header 'Access-Control-Allow-Origin' $cors_origin always;
add_header 'Access-Control-Allow-Methods' 'GET, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'Authorization, Content-Type, Accept, Origin' always;
add_header 'Vary' 'Origin' always;
proxy_pass http://$backend:8080/registration;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
}
location = /registration-app {
if ($request_method = OPTIONS) {
add_header 'Access-Control-Allow-Origin' $cors_origin always;
add_header 'Access-Control-Allow-Methods' 'GET, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'Authorization, Content-Type, Accept, Origin' always;
add_header 'Access-Control-Max-Age' 3600 always;
add_header 'Vary' 'Origin' always;
return 204;
}
add_header 'Access-Control-Allow-Origin' $cors_origin always;
add_header 'Access-Control-Allow-Methods' 'GET, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'Authorization, Content-Type, Accept, Origin' always;
add_header 'Vary' 'Origin' always;
proxy_pass http://$backend:8080/registration-app;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
}
location = /api/v0/graphql {
if ($request_method = OPTIONS) {
add_header 'Access-Control-Allow-Origin' $cors_origin always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'Authorization, Content-Type, Accept, Origin' always;
add_header 'Access-Control-Max-Age' 3600 always;
add_header 'Vary' 'Origin' always;
return 204;
}
add_header 'Access-Control-Allow-Origin' $cors_origin always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'Authorization, Content-Type, Accept, Origin' always;
add_header 'Vary' 'Origin' always;
proxy_pass http://$backend:9181/api/v0/graphql;
proxy_set_header Host $host;
}
location / {
if ($request_method = OPTIONS) {
add_header 'Access-Control-Allow-Origin' $cors_origin always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'Authorization, Content-Type, Accept, Origin' always;
add_header 'Access-Control-Max-Age' 3600 always;
add_header 'Vary' 'Origin' always;
return 204;
}
add_header 'Access-Control-Allow-Origin' $cors_origin always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'Authorization, Content-Type, Accept, Origin' always;
add_header 'Vary' 'Origin' always;
proxy_pass http://$backend:9181;
proxy_set_header Host $host;
}
}
}
What the key values mean
user: "1001:1001": The container runs as UID 1001, GID 1001. Update this to match your user and group ID on the VM (the repo script notes this). The data directories must be owned by this UID and GID.DEFRA_URL=0.0.0.0:9181: Overridesdefradb.urlso the DefraDB API binds to all interfaces instead of the loopback-only YAML default. Read inconfig/config.go. See environment variables.GOMEMLIMIT=14GiB: Go runtime soft memory limit, set below the 16g container limit. See env vars.ALLOWED_ORIGINS=https://*.shinzo.network: CORS origins the Host accepts.~/data/defradb:/app/.defra: Persistent volume for DefraDB data. See defradb store.~/data/keys:/app/.defra/keys: Persistent volume for DefraDB keyring keys. See defradb.~/data/lens:/app/.defra/lens: Persistent volume for lens WASM files. See host.~/config.yaml:/app/config.yaml:ro: Mount the config file read-only.- The
443:9181port mapping exposes the DefraDB GraphQL API on host port 443. The444:9182mapping exposes the GraphQL Playground on port 444. The playground is built with thehostplaygroundbuild tag, not controlled by env vars. - nginx terminates TLS on 443 using
/etc/nginx/ssl/nginx.crtand/etc/nginx/ssl/nginx.key(mounted from~/ssl/), and also listens on 80 and 8080.
Start the Host
Place all three files (config.yaml, docker-compose.yml, nginx.conf) in the same directory, then start:
docker compose -f docker-compose.yml up -d
Verify the Host is healthy through nginx:
curl http://localhost:8080/health
Query GraphQL over TLS on 443:
curl -s -X POST https://localhost/api/v0/graphql \
-H "Content-Type: application/json" \
-d '{"query": "{ Ethereum__Mainnet__Block(limit: 1) { number hash } }"}'
Gotchas
- The
user: "1001:1001"in the compose file must match the ownership of the data directories. If the directories are owned by root, the Host container will fail to write. Fix ownership withchown -R 1001:1001 ~/data/defradb ~/data/keys ~/data/lens. DEFRA_URLoverridesdefradb.urland is read by the Host client (config/config.go); it binds the DefraDB API to0.0.0.0:9181.LOG_LEVEL,LOG_SOURCE, andLOG_STACKTRACEappear in the originalhost-prod-setup.shcompose but are not read by the Host client and have no effect. See env vars that are not read.- The
DEFRA_KEYRING_SECRETenv var uses theDEFRA_prefix. The Generator client usesDEFRADB_KEYRING_SECRETwith theDEFRADB_prefix. If you are running both clients on the same VM, do not confuse the two env var names. See environment variables. - The shipped
config.yamlincludes severalshinzo.*keys that are not in theconfig.gostruct and are silently ignored:wait_for_gaps,max_gap_size,batch_processing_enabled,batch_max_views_per_job,batch_query_cache_size. They have been omitted from the config above. See no-op keys. - The
logger.levelfield in the shipped config is not in theLoggerConfigstruct and has no effect. It has been omitted. See logger. - The bootstrap peer IDs in this config are the three indexer peers from
host-prod-setup.sh(identical to the shippedconfig.yaml) and may be stale. Check the Shinzo Validators list for current peers. - The nginx CORS config restricts origins to
https://explorer.shinzo.network. If you need to allow other origins, add them to themapblock in the nginx config. - The Host image tag in this compose file is
ghcr.io/shinzonetwork/shinzo-host-client:v0.6.5-ethereum-mainnet, matchinghost-prod-setup.sh. The GCP local SSD scenario pins:v0.5.1, and the Watchtower setup uses:latest. Pick one tag and be consistent across your deployment.
Need help
- For onboarding and technical support, join the Shinzo Discord.
- To report a documentation bug or request a feature, open an issue in the docs repo.
- For a technical issue with the Host client, open an issue in the shinzo-host-client repo.