๐๏ธ Deploy to production ~1 hr โ
This is the runbook for deploying the Nimiq Simple Faucet to a real environment. For local development or testnet smoke testing, see smoke-testing.md instead.
Two shipping paths are supported and fully tested:
- Docker Compose โ single-host deploys. SQLite in a named volume today; good up to low-thousand claims/day.
- Helm / Kubernetes โ the standard path for anything production-grade. Chart lives in
deploy/helm/. Single-replica with a PVC-backed SQLite on 1.0.x.
Pick whichever fits your operational maturity.
Note on Postgres + Redis (for this release, 1.0.x): The Postgres and Redis subcharts in the Helm chart and the
postgresprofile in docker-compose are defined but NOT yet consumed by the server โ that lands in a future 1.x release (tracked in ROADMAP ยง1.3.x). Until then, SQLite is the supported backend. All references to "Postgres" below describe the 1.3.x target; the current code path refuses non-SQLiteDATABASE_URLvalues.
1. Decide how the faucet holds its key โ
This is the single most important decision. The faucet MUST be able to sign transactions; you pick where the private key lives.
| Mode | FAUCET_SIGNER_DRIVER | Key location | When to use |
|---|---|---|---|
| WASM | wasm | In the faucet process, from FAUCET_PRIVATE_KEY env var (and encrypted to disk at /data/faucet.key via FAUCET_KEY_PASSPHRASE) | Default. Simplest. Faucet connects directly to testnet/mainnet seed peers. |
| RPC | rpc | In a separate core-rs-albatross node with the wallet pre-unlocked | You already run a Nimiq node and want to centralise key custody. |
For either mode, the address is FAUCET_WALLET_ADDRESS. The faucet will refuse to start if this is unset or if it can't produce the same address from the supplied key.
RPC security note: When using
FAUCET_SIGNER_DRIVER=rpc, the faucet sends the private key and wallet passphrase to the Nimiq node viaimportRawKeyandunlockAccountRPC calls. Inside a Docker Compose network this is safe (traffic stays on the isolated bridge network), but if the RPC node is on a separate host, always use HTTPS forFAUCET_RPC_URLto protect key material in transit. Never expose the RPC port to the public internet.
2. Pre-flight checklist โ
- [ ] Secret manager selected (Vault, AWS Secrets Manager, GCP Secret Manager, sealed-secrets, or plain k8s Secret)
- [ ] For K8s: External Secrets Operator installed, OR you're comfortable maintaining a plain
Secretyourself - [ ] For K8s with TLS: cert-manager installed + ClusterIssuer configured
- [ ] Ingress controller installed (nginx, traefik, cloud-native, etc.)
- [ ] DNS A/AAAA record pointing at your cluster's ingress
- [ ] A funded Nimiq address on the correct network (main or test)
- [ ] Captcha provider account (Turnstile or hCaptcha) if you're exposing the public claim UI
- [ ] Postgres (managed RDS / Cloud SQL / self-hosted) if you expect >1 replica or >1000 claims/day
- [ ] Redis (managed ElastiCache / Memorystore) for the same case
- [ ] Backup strategy: SQLite volume snapshots OR Postgres logical dumps
3. Docker Compose path โ
Step-by-step โ
git clone https://github.com/PanoramicRum/nimiq-simple-faucet.git
cd nimiq-simple-faucet/deploy/compose
cp .env.example .envEdit .env:
FAUCET_NETWORK=main # or test
FAUCET_SIGNER_DRIVER=wasm # simplest; rpc if you have an external node
FAUCET_WALLET_ADDRESS=NQ12 ... # your funded address
FAUCET_PRIVATE_KEY=<64 hex or 12/24-word mnemonic>
FAUCET_ADMIN_PASSWORD=<16+ chars>
FAUCET_KEY_PASSPHRASE=<16+ chars> # encrypts the on-disk key blob
FAUCET_TURNSTILE_SITE_KEY=... # or FAUCET_HCAPTCHA_SITE_KEY
FAUCET_TURNSTILE_SECRET=... # matching secret
FAUCET_TLS_REQUIRED=true # KEEP AT TRUE IN PROD
FAUCET_CORS_ORIGINS=https://your-integrator.example.com # explicit list, no '*'
POSTGRES_USER=faucet
POSTGRES_PASSWORD=<generate a strong one>
POSTGRES_DB=faucetStart it:
docker compose up -dPut it behind TLS. The faucet refuses to boot on plain HTTP when FAUCET_TLS_REQUIRED=true. In production you always want this, so the compose stack should sit behind either:
- A reverse proxy on the host (Caddy, Traefik) with a Let's Encrypt cert, or
- A cloud load balancer (ALB, Cloud LB) with an ACM / managed cert
Don't publish port 8080 to the public internet directly.
Backups (Compose path) โ
The faucet-data volume contains the SQLite DB, the encrypted key blob, and the admin TOTP secret. Back it up regularly:
# Hot backup (safe; SQLite supports online backup)
docker compose exec faucet sqlite3 /data/faucet.db ".backup '/data/faucet.db.backup'"
docker cp compose-faucet-1:/data/faucet.db.backup /secure-backup/$(date +%F).db
# Also snapshot the encrypted key blob
docker cp compose-faucet-1:/data/faucet.key /secure-backup/$(date +%F).keyRotation and offsite replication are your responsibility.
4. Kubernetes / Helm path โ
Install the chart โ
The chart is published at oci://ghcr.io/panoramicrum/charts/nimiq-simple-faucet:
helm install faucet \
oci://ghcr.io/panoramicrum/charts/nimiq-simple-faucet \
--version 1.0.0 \
--namespace faucet \
--create-namespace \
-f values-prod.yamlSee deploy/helm/examples/values-prod.yaml for a production-grade starting point that this doc builds on.
Secrets (External Secrets Operator) โ
The default values assume ESO. Pre-create a ClusterSecretStore that points at your real secret manager, then populate these keys:
| Remote key | Property | Maps to env var |
|---|---|---|
faucet/admin | password | FAUCET_ADMIN_PASSWORD |
faucet/admin | totp | FAUCET_ADMIN_TOTP_SECRET |
faucet/wallet | key-passphrase | FAUCET_KEY_PASSPHRASE |
faucet/wallet | private-key | FAUCET_PRIVATE_KEY |
faucet/captcha | turnstile-secret | FAUCET_TURNSTILE_SECRET |
faucet/captcha | turnstile-site-key | FAUCET_TURNSTILE_SITE_KEY |
faucet/abuse | hashcash-secret | FAUCET_HASHCASH_SECRET |
faucet/integrators | keys | FAUCET_INTEGRATOR_KEYS |
The full list is in deploy/helm/values.yaml under secrets.external.data. You can add or remove entries there.
Secrets (no ESO โ plain k8s Secret) โ
If you don't run ESO:
# values-prod.yaml
secrets:
external:
enabled: false
values:
FAUCET_ADMIN_PASSWORD: "" # keep empty in committed files!
FAUCET_KEY_PASSPHRASE: ""
FAUCET_PRIVATE_KEY: ""
# ...Then supply real values at install time via --set-file or a second override file that is never committed and lives only on the operator's machine / in sealed-secrets:
helm install faucet โฆ -f values-prod.yaml -f values-secrets-LOCAL.yamlTLS + ingress with cert-manager โ
# values-prod.yaml
ingress:
enabled: true
className: nginx
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
nginx.ingress.kubernetes.io/proxy-body-size: "1m"
# Optional hardening:
nginx.ingress.kubernetes.io/force-ssl-redirect: "true"
hosts:
- host: faucet.example.com
paths:
- path: /
pathType: Prefix
tls:
- secretName: faucet-tls
hosts:
- faucet.example.com
config:
corsOrigins: "https://your-integrator.example.com"
tlsRequired: truePersistence and Postgres โ
SQLite is fine for up to ~1 req/sec sustained. Past that, enable Postgres:
# values-prod.yaml
persistence:
enabled: false # disable local SQLite PVC
postgresql:
enabled: true
auth:
username: faucet
database: faucet
existingSecret: faucet-postgres # created by your ESO / sealed secret
secretKeys:
adminPasswordKey: postgres-password
userPasswordKey: password
primary:
persistence:
size: 50Gi
redis:
enabled: true
auth:
existingSecret: faucet-redis
existingSecretPasswordKey: password
architecture: standalone
replicaCount: 2Migration from SQLite to Postgres: the server does not ship an automatic migration. If you have existing SQLite data to preserve, export via
sqlite3 /data/faucet.db .dump | psql $DATABASE_URLbefore switching modes. Most operators just start fresh on Postgres โ the claims table grows quickly anyway, and historic data can be archived from SQLite separately.
NetworkPolicy โ
The chart ships a commented-out NetworkPolicy scaffold. Uncomment and tune egress to only reach:
- Your Nimiq RPC endpoint (port 443 if HTTPS, 8648 if self-hosted)
- Captcha verify endpoints (
challenges.cloudflare.com,hcaptcha.com) - GeoIP provider (IPinfo API or MaxMind update endpoint, if used)
networkPolicy:
enabled: true
# see values.yaml for the starter templateScaling โ
Replicas >1 require:
- Postgres enabled (SQLite can't be shared)
- Redis enabled (for rate-limit counters and nonces)
- Session cookies signed with a stable secret (already handled by the chart)
Do not scale horizontally if you kept SQLite. Horizontal scaling with a local sqlite volume is silently corrupting in ways that are painful to debug. The chart won't stop you, but you've been warned.
5. Post-install verification โ
Once the pod is Ready:
# 1. Health
curl -fsS https://faucet.example.com/healthz
# โ "ok"
# 2. Public config (verify abuse layers are enabled as expected)
curl -s https://faucet.example.com/v1/config | jq .
# 3. Admin dashboard
open https://faucet.example.com/admin
# โ first login triggers TOTP enrolment (see docs/admin-first-run.md)
# 4. Real claim against testnet (keep the stack on testnet until you're happy)
curl -X POST https://faucet.example.com/v1/claim \
-H 'content-type: application/json' \
-d '{"address":"NQ12 ..."}'If anything in step 1-3 fails, check pod logs; most prod-run misconfigs are caught by the server's startup Zod validation (it'll crash-loop with a clear error message).
6. Hardening checklist before going live โ
- [ ]
FAUCET_TLS_REQUIRED=true - [ ]
FAUCET_CORS_ORIGINSset to an explicit CSV, not* - [ ]
FAUCET_NETWORK=main(if running mainnet) - [ ] Captcha provider configured and verified with a real claim
- [ ]
FAUCET_ADMIN_PASSWORDchanged from any default - [ ] TOTP enrolled on first login (not skipped)
- [ ] Rate limits tuned:
FAUCET_RATE_LIMIT_PER_MINUTE,FAUCET_RATE_LIMIT_PER_IP_PER_DAY - [ ] GeoIP allow/deny lists match your legal footprint (
FAUCET_GEOIP_DENY_COUNTRIES) - [ ] Backup job cronned and tested (restore-from-backup actually works)
- [ ] Alerting wired โ see health-observability.md
- [ ] Secret rotation schedule documented (TOTP, admin password, HMAC secrets, wallet key)
7. Troubleshooting โ
- Pod crashlooping with
FATAL: no TLS and TLS_REQUIRED=trueโ either front the faucet with an ingress that terminates TLS, or if you're intentionally running behind a trusted reverse proxy, setFAUCET_TLS_REQUIRED=false(not recommended). /admin/loginreturns 401 after correct password โ TOTP enrolment wasn't completed. Visit/admin/loginin a fresh browser session; it will force the enrolment flow again (the QR code is re-displayed only the first time).Driver not initializedfor minutes โ WASM client is still establishing consensus with seed peers. This can take up to 60s on cold start. If it hangs longer, check egress rules โ the pod needs outbound WSS to*.seed.nimiq.*.- Claims succeed but
statusstays atbroadcastโ this was a bug in pre-1.0 RPC driver; fixed in 1.0.0. If you see it on 1.0.0+, checkdocker logsforconfirmation failedwarnings and the node's responsiveness.
See also health-observability.md for monitoring.