Deployment options for the x402 Cardano Payment Facilitator.
- Docker and Docker Compose (for containerized deployment)
- OR Node.js 20+ and pnpm (for bare metal)
- Blockfrost API key -- register at blockfrost.io
- Funded Cardano wallet (seed phrase or private key stored in a restrictive file)
- Redis 7+ (provided via Docker Compose or external)
The facilitator reads configuration from config/config.json, validated at startup using Zod schemas. If any required field is missing or invalid, the process exits with a descriptive error.
Copy the example to get started:
cp config/config.example.json config/config.jsonSee config/config.example.json for the full structure with production defaults.
| Field | Description | Example |
|---|---|---|
chain.network |
Cardano network | "Preview", "Preprod", "Mainnet" |
chain.blockfrost.projectId |
Blockfrost API key | "previewXXX..." |
chain.facilitator.signerMode |
Current root facilitator signer mode. Only "local-file" is implemented today |
"local-file" |
chain.facilitator.seedPhraseFile |
0600 file containing facilitator wallet seed phrase, mounted from local ./secrets in production Compose |
"/run/secrets/cardano402-facilitator.seed" |
chain.facilitator.privateKeyFile |
0600 file containing facilitator wallet private key, mounted from local ./secrets in production Compose |
"/run/secrets/cardano402-facilitator.skey" |
chain.redis.host |
Redis hostname | "localhost" or "redis-prod" |
| Field | Default | Description |
|---|---|---|
server.port |
3000 |
HTTP listen port |
server.host |
"0.0.0.0" |
HTTP listen address |
server.trustProxy |
2 for Cloudflare + nginx production deployments |
Numeric trusted-proxy hop count for X-Forwarded-* so rate limits and logs use the client IP without trusting arbitrary forwarded chains |
logging.level |
"info" |
Log level (debug, info, warn, error) |
logging.pretty |
false |
Pretty-print logs (enable for development) |
env |
"development" |
Environment (development, production) |
rateLimit.global |
100 |
Requests per minute (global) |
rateLimit.sensitive |
20 |
Requests per minute (/verify, /settle, /status, /upload, /demo/run, /demo/status) |
rateLimit.windowMs |
60000 |
Rate limit window in milliseconds |
metrics.bearerToken |
(none) | Bearer token for GET /metrics (required in production; minimum 32 characters) |
chain.blockfrost.tier |
"free" |
Blockfrost plan tier |
chain.cache.utxoTtlSeconds |
60 |
UTXO cache TTL |
chain.verification.confirmationMode |
"confirmed_only" |
Settlement response policy; allow_mempool is lower assurance |
chain.verification.minConfirmations |
6 |
Cardano confirmation depth before reporting confirmed |
chain.verification.requireNonce |
true |
Require spec nonce UTXO anti-replay field |
chain.redis.port |
6379 |
Redis port |
chain.redis.password |
(none) | Redis password (enable in production) |
chain.redis.db |
0 |
Redis database index |
sentry.dsn |
(none) | Sentry DSN for error tracking |
sentry.environment |
(none) | Sentry environment tag |
sentry.tracesSampleRate |
0.1 |
Sentry performance trace sample rate |
storage.backend |
"fs" |
Storage backend ("fs" or "ipfs") |
storage.fs.dataDir |
"./data/files" |
Local file storage directory |
storage.ipfs.apiUrl |
"http://localhost:5001" |
IPFS Kubo API endpoint |
Follow these steps to deploy on the Cardano Preview testnet:
- Create a Blockfrost account at blockfrost.io
- Create a project for the "Cardano Preview" network
- Copy the project ID into
config.chain.blockfrost.projectId - Generate or use an existing 24-word seed phrase for the facilitator wallet and store it in a
0600file - Fund the wallet via the Cardano Testnet Faucet
- Request at least 10 ADA for facilitator operations
- Set the network to
"Preview"inconfig.chain.network - If enabling the live demo, use a separate Preview/Preprod wallet via
demo.seedPhraseFile; production rejects inline demo seed material inconfig.json
The facilitator requires the MAINNET=true environment variable to connect to mainnet. This is a safety guardrail that prevents accidental mainnet usage during development. Mainnet also requires facilitator signing material to come from chain.facilitator.seedPhraseFile or chain.facilitator.privateKeyFile by default; inline seedPhrase / privateKey values in config.json are rejected unless CARDANO402_ALLOW_MAINNET_INLINE_SIGNING_KEY=true is set.
Without it, attempting to use "Mainnet" as the network will cause a startup error:
Mainnet connection requires explicit MAINNET=true environment variable
File-based Mainnet credentials are still a hot-wallet model. Mainnet
local-file signing also requires
CARDANO402_ALLOW_MAINNET_LOCAL_FILE_SIGNER=true so operators must explicitly
acknowledge the interim risk. For high-value Mainnet operation, use the signer
isolation target described in
mainnet-signer-isolation.md; until that remote
or hardware-backed signer exists, keep only limited operational funds in the
facilitator wallet.
chain.facilitator.signerMode is explicit so monitoring can report signer
posture. The only supported value today is "local-file"; remote policy signer
config should not be added until the signer provider boundary exists.
Start Redis and IPFS for local development:
cp config/config.development.example.json config/config.json
mkdir -p secrets
docker compose --profile development up -dEdit config/config.json, set chain.blockfrost.projectId, create
secrets/cardano402-preview.seed, and run
chmod 600 secrets/cardano402-preview.seed before starting the server.
Then run the facilitator locally with hot reload:
pnpm dev-
Create production config
Copy
config/config.example.jsontoconfig/config.jsonand set:envto"production"server.trustProxyto a numeric hop count when deployed behind nginx/Cloudflare on the same host (1for nginx only,2for Cloudflare plus nginx)logging.prettytofalsechain.redis.hostto"redis-prod"(Docker Compose service name)chain.redis.passwordto your Redis passwordchain.blockfrost.projectIdto your Blockfrost keychain.facilitator.seedPhraseFileto a0600file containing your facilitator wallet seed phrase
Production Compose mounts
./secretsread-only at/run/secrets, matching the exampleseedPhraseFilepaths:mkdir -p secrets nano secrets/cardano402-facilitator.seed chmod 600 secrets/cardano402-facilitator.seed
If the example
demosection is kept, create the separatesecrets/cardano402-demo-preview.seedfile with0600permissions. If the live demo is disabled, remove thedemosection fromconfig/config.json. -
Set Redis password
export REDIS_PASSWORD=your-secure-password -
Start the production stack
docker compose --profile production up -d
-
Verify the deployment
curl http://localhost:3000/health
Expected response:
{"status":"healthy","version":"1.0.0",...}
| Profile | Service | Port | Description |
|---|---|---|---|
| (default) | redis |
6379 | Dev Redis (no auth) |
| (default) | ipfs |
5001, 8080 | IPFS node (API + gateway) |
production |
facilitator |
127.0.0.1:3000 | Facilitator server |
production |
redis-prod |
127.0.0.1:6380 | Production Redis (with auth) |
The production profile includes a health check on redis-prod -- the facilitator waits for Redis to be healthy before starting. Production Compose fails fast when REDIS_PASSWORD is unset, passes NODE_ENV=production, MAINNET, and the Mainnet local-file hot-wallet acknowledgement into the facilitator, runs Redis with AOF and maxmemory-policy noeviction for settlement dedup safety, mounts ./secrets read-only at /run/secrets, mounts ./data for stored files, runs the facilitator with a read-only root filesystem plus /tmp tmpfs, and binds ports to loopback for local reverse-proxy access.
Build and run the image manually:
docker build -t cardano402 .
docker run -p 3000:3000 -v ./config:/app/config:ro cardano402Image details:
- Base: Node.js 20 on Alpine Linux
- User: Non-root (
appuser:1001) - Size: ~180 MB
- Health check: Built-in (
wgetto/healthevery 30s)
There is no auto-deploy on merge. The VPS is reachable only over Tailscale and SSH is closed to the public internet — adding a GitHub-Actions deploy key would require widening the firewall to GitHub's runner IP ranges, which is a worse security posture than bash deploy.sh from a tailnet-attached laptop. See operations.md § Manual deploy procedure for the canonical runbook.
CI (.github/workflows/ci.yml) still runs on every push and PR — lint, typecheck, test, build, docker build, security audit. It only runs inside the GitHub-Actions runner and makes no outbound SSH connection.
If auto-deploy ever becomes desirable again, the right approach is the Tailscale GitHub Action, which attaches the runner to your tailnet for the deploy duration without opening any public port. Deferred until there's actual need.
If you prefer running without Docker:
# Install dependencies
pnpm install --frozen-lockfile
# Build TypeScript
pnpm build
# Start the server
node dist/index.jsRequires an external Redis instance. Set chain.redis.host and chain.redis.port in your config to point to your Redis server.
| Method | Path | Description |
|---|---|---|
GET |
/health |
Health check (dependency status) |
GET |
/supported |
Supported payment methods and facilitator address |
POST |
/verify |
Verify a payment transaction |
POST |
/settle |
Submit a transaction for settlement |
POST |
/status |
Check transaction confirmation status |
POST |
/upload |
Payment-gated file upload |
GET |
/files/:cid |
Download a file by content ID |
For operational monitoring, log analysis, Sentry error tracking, Redis monitoring, and common issue recovery, see the Operations Runbook.
- Config file contains secrets (API keys, seed phrase) -- never commit
config/config.jsonto version control - Bind-mount config in Docker with the
:ro(read-only) flag - Enable Redis authentication in production (
chain.redis.password) - Rate limiting is configured by default (100 req/min global, 20 req/min on sensitive endpoints)
- Non-root container -- the Docker image runs as
appuser:1001 - Token registry is hardcoded as a security gate -- adding new tokens requires a code change and review
- Mainnet signing isolation -- file-based credentials reduce accidental
leakage but do not isolate signing from a compromised web process; see
mainnet-signer-isolation.md