GRCpay logoGRCpay
On this page

Self-hosting GRCpay

GRCpay is built to be run by the merchant who collects the payments. That's the canonical setup, not a fallback. Two paths are covered below: connecting to a Gridcoin wallet you already operate, and standing up the whole stack from scratch with Docker Compose.

Overview

This page walks through running GRCpay on your own infrastructure. Two flavours are covered: pointing GRCpay at a Gridcoin wallet you already operate, and standing up the whole stack (wallet + GRCpay) from scratch with Docker Compose.

Both setups give you the same thing: a small REST service that your checkout (or one of the upcoming plugins) can call to mint payment addresses, and a wallet daemon on the back that stays private to your network. The whole footprint is a Node.js process and a SQLite file on the GRCpay side, plus whatever space your wallet daemon needs.

Prerequisites

You need:

  • A server with Docker installed

    Any small VPS works. GRCpay itself is tiny: <100 MB RAM, <100 MB disk for the SQLite file. A Gridcoin wallet container, if you run one alongside, is the heavier piece (a few GB of chain data once synced).

  • A Gridcoin wallet you control

    Either an existing gridcoinresearchd you already operate, or a fresh wallet you'll start in a container. GRCpay never holds funds itself. Every address it mints belongs to your wallet.

  • An RPC username and password for the wallet

    Set in the wallet's gridcoinresearch.conf as rpcuser / rpcpassword. GRCpay uses these to make JSON-RPC calls. They should be long, random, and known only to GRCpay and the wallet daemon.

  • (Optional) a public hostname and TLS certificate

    If you want a checkout to reach GRCpay over the internet, terminate TLS at a reverse proxy in front of it. We show an nginx example below.

Quick start: connect to an existing wallet

If you already run gridcoinresearchd somewhere (on the same host, on another machine in your LAN, or inside a container you set up earlier), this is the smallest possible install. One docker run and you're done.

1. Make sure your wallet accepts the connection

Open the wallet's gridcoinresearch.conf and confirm it has at minimum:

gridcoinresearch.conf
server=1
daemon=1
rpcuser=YOUR_RPC_USER
rpcpassword=YOUR_LONG_RANDOM_PASSWORD
rpcport=32748
# Allow GRCpay to reach the RPC port. Tighten this to the
# specific subnet your GRCpay container runs on. Don't use
# 0.0.0.0/0 unless you really know what you're doing.
rpcallowip=172.16.0.0/12

Restart the daemon after editing the config. The default mainnet RPC port is 32748; testnet is 32746.

2. Run GRCpay

One docker run, pointed at the wallet's host and port:

bash
mkdir -p ./grcpay-data
docker run -d \
--name grcpay \
--restart unless-stopped \
-p 127.0.0.1:7001:7001 \
-v $(pwd)/grcpay-data:/usr/src/app/data \
-e DATABASE_URL=file:../data/payment.db \
-e PORT=7001 \
-e GRC_RPC_HOST=192.168.1.50 \
-e GRC_RPC_PORT=32748 \
-e GRC_RPC_USER=YOUR_RPC_USER \
-e GRC_RPC_PASSWORD=YOUR_LONG_RANDOM_PASSWORD \
-e NODE_ENV=production \
ghcr.io/gridcat/grcpay:latest

Replace 192.168.1.50 with the hostname or IP where your wallet listens. If the wallet runs on the same host as Docker, use host.docker.internal instead. On Linux you also need --add-host=host.docker.internal:host-gateway.

3. Confirm it's running

bash
curl http://localhost:7001/status
# Expected response (200 OK):
# { "data": { "type": "status", "attributes": {
# "name": "grcpay", "version": "1.0.0"
# } } }

Tail the container logs (docker logs -f grcpay) on the first run. You should see “Connected to the gridcoin wallet”. If you see “Connecting to the gridcoin wallet…” repeating forever, the RPC connection is failing. Check the host, port, credentials, and rpcallowip.

Full stack: wallet + GRCpay via Docker Compose

If you don't already run a Gridcoin wallet, the cleanest option is to put both the wallet and GRCpay in the same Docker Compose file. They share an internal network, the wallet's RPC stays unreachable from outside, and you can bring the whole thing up or down with one command.

Project layout

bash
grcpay-server/
├── docker-compose.yaml
├── .env # RPC credentials, ignored by git
├── grcpay-data/ # created on first run, holds payment.db
└── wallet-data/ # created on first run, holds wallet.dat + chain

The compose file

docker-compose.yaml
services:
wallet:
image: boris1993/gridcoin-headless-docker:latest
container_name: grcpay-wallet
hostname: wallet
expose:
- 32749
volumes:
# Persist the chain data + wallet.dat across restarts.
- ./wallet-data:/root/.GridcoinResearch
environment:
- TZ=Etc/UTC
# Same values must end up in grcpay below — both sides
# read from .env so they stay in sync.
- RPC_USER=${GRC_RPC_USER}
- RPC_PASSWORD=${GRC_RPC_PASSWORD}
restart: unless-stopped
grcpay:
image: ghcr.io/gridcat/grcpay:latest
container_name: grcpay
depends_on:
- wallet
ports:
# Bind to localhost only — put a reverse proxy in front
# of this for any outside access.
- "127.0.0.1:7001:7001"
volumes:
- ./grcpay-data:/usr/src/app/data
environment:
- DATABASE_URL=file:../data/payment.db
- PORT=7001
- GRC_RPC_HOST=wallet
- GRC_RPC_PORT=32749
- GRC_RPC_USER=${GRC_RPC_USER}
- GRC_RPC_PASSWORD=${GRC_RPC_PASSWORD}
- NODE_ENV=production
restart: unless-stopped

The ${GRC_RPC_USER} and ${GRC_RPC_PASSWORD} come from a sibling .env file Compose reads automatically. Both the wallet container and GRCpay gets the same values, so the credentials they exchange line up:

.env
GRC_RPC_USER=ZdMxzASxPB2ucXqJhJLcR09Gk0dHQoJt
GRC_RPC_PASSWORD=I9nFmrZIVpB5nJz797fFdxLen35jjbpr

Bring it up

bash
docker compose up -d
# Watch the wallet finish initial sync — this can take a while
# the first time, especially on mainnet.
docker compose logs -f wallet
# Once the wallet is responsive, watch grcpay connect:
docker compose logs -f grcpay

On a fresh wallet, expect the “Connecting to the gridcoin wallet…” message to repeat for a few minutes while the daemon finishes its startup and initial sync. As soon as the RPC port comes up, GRCpay logs “Connected to the gridcoin wallet” and starts its job loop.

Hot/cold wallet pattern (recommended)

The earlier sections show GRCpay pointed at a single wallet, which is the simplest setup and fine for trial runs. For a real production deployment, split the wallet in two:

  • The hot wallet is what GRCpay talks to. Its only job is to mint payment addresses and briefly hold in-flight customer funds before forwarding them on. It runs on the same host as GRCpay, in the same Docker network, and never holds a meaningful balance.

  • The cold wallet (or main merchant wallet) is where the money actually lives. It can be on a different host, an air-gapped machine, or a hardware wallet, whatever your operational comfort allows. GRCpay never has its private keys and never makes RPC calls to it.

Why this matters

GRCpay's public REST surface is deliberately narrow, but the worst-case scenario for any internet-facing service is a full host compromise: a kernel exploit, a supply-chain attack on a dependency, a misconfigured SSH key, anything. If that happens, the attacker gets shell access to the box GRCpay runs on, and from there, full access to whatever wallet GRCpay is pointed at.

With a single-wallet setup, that's every GRC you've ever earned. With the hot/cold split, the attacker drains the hot wallet and walks away with only whatever happened to be mid-settlement at that moment, typically zero or a single order's worth of GRC. The blast radius shrinks from “everything” to “maybe a few minutes of revenue.”

This is the same pattern Bitcoin merchants use behind BTCPay, and it's the right default for any production payment processor.

How to set it up

  1. Run a second wallet daemon dedicated to GRCpay. In the Docker Compose example from the previous section, the wallet service is the hot wallet, so that's already correct. The thing that changes is what you do with the money once it arrives.

  2. Generate a fresh address in your cold wallet (the merchant wallet you actually want the money to land in). Save it somewhere your checkout integration can read it: a config value, an environment variable, a database row, whatever fits.

  3. In your checkout integration (or the WooCommerce plugin, once it ships), set the recipient field on every POST /wallets call to that cold-wallet address:

POST /api/wallets
{
"data": {
"type": "wallets",
"attributes": {
"amountRequired": 12.5,
"recipient": "SCxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
}
}
}

That's it. GRCpay generates a one-shot address from the hot wallet, the customer pays it, the job loop sees the incoming funds within ~10 seconds, and the walletFundedProcessor immediately forwards the balance (minus the 0.001 GRC network fee) to the cold address. The wallet record transitions new → funded → processed, the tx_out column gets the forwarding txid, and the hot wallet is back to zero.

What the hot wallet's balance should look like

On a healthy production deployment, plotting the hot wallet's balance over time should look like a sawtooth: near-zero baseline, brief spikes when customers pay, dropping straight back down within seconds. If you ever see the baseline drift upward (funds accumulating that aren't being forwarded), that's a signal something is wrong: the cold address is invalid, the forwarding flow hit an error, or you've got wallets stuck in error state. Check the db_logs table or the container logs.

Configuration reference

Every GRCpay setting can be passed as an environment variable. Defaults come from config.json and src/config.ts inside the container; env vars override anything in those files.

VariableStatusDefaultDescription
DATABASE_URL
Required
file:../data/payment.dbSQLite file location. The path is resolved relative to prisma/schema.prisma inside the container, so '../data' lands in /usr/src/app/data — which is where the data volume should be mounted.
PORT
Required
7001HTTP port GRCpay listens on.
GRC_RPC_HOST
Required
Hostname or IP of the gridcoinresearchd RPC interface. In a Compose setup, this is the wallet service name (e.g. "wallet"). For an existing wallet on the same host, use host.docker.internal.
GRC_RPC_PORT
Required
32748RPC port. 32748 = mainnet, 32746 = testnet.
GRC_RPC_USER
Optional
RPC username. Must match rpcuser in the wallet's gridcoinresearch.conf. Optional but strongly recommended: GRCpay will start without it for dev convenience, but the wallet will refuse RPC calls without matching credentials.
GRC_RPC_PASSWORD
Optional
RPC password. Same constraints as GRC_RPC_USER above.
LIFE_SPAN
Optional
7200How long a payment wallet stays open before it gets marked expired, in seconds. Default is 2 hours.
JOBS_INTERVAL
Optional
10How often the background job loop runs (balance updates → funded detection → expiry → forwarding → refunds), in seconds. A tick is skipped if the previous run is still in flight, so long RPC batches can never overlap onto themselves.
LATE_PAYMENT_WINDOW
Optional
604800How long after a wallet reaches a terminal state (processed / refunded / norefund) GRCpay still watches it for late-arriving customer payments, in seconds. Inside the window, any GRC that trickles in from a stale checkout page or a saved address is detected and refunded to the sender automatically. Outside it, funds sent to a stale address stay in the hot wallet for manual sweep. Default 7 days, past which every reasonable browser cache or checkout session is assumed gone.
LATE_PAYMENT_CHECK_INTERVAL
Optional
3600How often the late-payment sweep runs, in seconds. Deliberately separate from (and much slower than) JOBS_INTERVAL because late payments are an edge-case rescue path, not a latency-sensitive flow. Default once an hour. Set to 0 to disable the sweep entirely, which is useful if you don't want GRCpay auto-touching terminal wallets at all and prefer to sweep the hot wallet manually.
MAX_REFUND_ATTEMPTS
Optional
5How many times a refund RPC call is allowed to fail before GRCpay gives up. Applies to both overpayment refunds (funded processor) and late-payment refunds. Past the cap the funded processor falls back to forwarding the full received balance so the merchant payout is never blocked indefinitely; the late-payment processor leaves the funds in the hot wallet and stops retrying.
REFUND_RETRY_BASE_DELAY
Optional
30Exponential backoff base for refund retries, in seconds. After failure N the next attempt is gated by base * 2^(N-1), so with the default 30s the intervals are 30s, 1m, 2m, 4m, spanning ~7.5 minutes before MAX_REFUND_ATTEMPTS trips. The window exists so a real human operator has time to unlock a locked wallet (the usual root cause of a refund RPC failure) before GRCpay declares the refund hopeless.
MIN_CONFIRMATIONS
Optional
2Minimum number of block confirmations a received tx must have before it counts toward a wallet's settled balance. The balance updater queries the daemon at both MIN_CONFIRMATIONS and 0-conf on every tick: the confirmed portion lands in amountRecieved and gates the new → funded flip, while the unconfirmed delta is reported separately as amountPending so integrators can surface a "waiting for N confirmations" state. Default 2 matches standard e-commerce hardening against same-block reorgs. Lower to 1 on private or trusted chains; set to 0 to accept 0-conf payments (faster but unsafe against reorgs).
RATE_LIMIT_WALLET_CREATE_PER_MIN
Optional
10Per-IP rate limit on POST /wallets, in requests per minute. Keeps wallet creation cheap to abuse-proof: each call mints a fresh gridcoin address and burns an RPC slot, so the default is deliberately tight. Legitimate merchant checkout flows rarely exceed a handful per minute per IP, so raise it only if you have a specific reason.
RATE_LIMIT_WALLET_READ_PER_MIN
Optional
300Per-IP rate limit on GET /wallets/:address, in requests per minute. Needs to be generous because the WooCommerce plugin polls every 5 seconds per open checkout page (12 req/min per tab), and multiple concurrent customers behind the same NAT share one bucket. Default 300 covers roughly 25 concurrent customers on a shared IP with headroom.
RATE_LIMIT_WALLET_DELETE_PER_MIN
Optional
10Per-IP rate limit on DELETE /wallets/:address (merchant-initiated cancellation), in requests per minute. Same envelope as creation: rarely used in normal operation, tight by default to blunt abuse.
RATE_LIMIT_QR_PER_MIN
Optional
120Per-IP rate limit on GET /wallets/:address/qr, in requests per minute. This endpoint is intentionally public (for <img> embedding on checkout pages), so it has its own bucket separate from the token-gated read endpoint. The QR renderer is cheap but not free; 120/min/IP is comfortable for real use without leaving an amplification target wide open.
RATE_LIMIT_RATES_PER_MIN
Optional
60Per-IP rate limit on GET /rates and /rates/:currency, in requests per minute. Server-side cached for five minutes, so requests beyond the cache TTL hit CoinGecko rather than GRCpay; the limiter mostly exists to discourage polling storms.
RPC_BREAKER_THRESHOLD
Optional
5Circuit breaker failure threshold for the Gridcoin RPC client. After this many consecutive failures (timeouts or errors), the breaker opens and fast-fails every subsequent RPC call without touching the wallet daemon for RPC_BREAKER_COOLDOWN_MS. One probe call goes through after the cooldown; success closes the breaker, failure reopens it with a fresh cooldown. Set to 0 to disable the breaker entirely, which is useful for dev or when troubleshooting an unrelated issue. The per-call 30s timeout still applies regardless.
RPC_BREAKER_COOLDOWN_MS
Optional
30000How long (in milliseconds) the RPC breaker stays open before it lets a probe call through. Matches the per-call RPC timeout budget by default: five failed 30s calls already cost the job loop 2.5 minutes, so the breaker blocks further requests for at least one more cycle before we retry. Raise this if your wallet daemon takes longer to recover from the kind of failure you're seeing; lower it only in dev.
TRUST_PROXY_HOPS
Optional
1Number of reverse-proxy hops Express should trust when reading the client IP from X-Forwarded-For. The per-IP rate limiters read req.ip, so getting this wrong either (a) trusts forged X-Forwarded-For values when GRCpay is directly exposed, letting an attacker rotate fake IPs to bypass the limit, or (b) buckets every request under nginx's loopback address when deployed behind a real proxy. Default 1 assumes a single trusted upstream (the usual nginx-in-front deployment). Set to 0 if GRCpay is directly exposed on a public port with no reverse proxy. Set to 2+ if there are multiple proxies (e.g., Cloudflare → nginx → GRCpay).
NODE_ENV
Optional
(unset)Set to "production" in deployed environments. Quiets the verbose per-request HTTP access log and disables a few dev-only behaviours.

Reverse proxy

GRCpay listens on plain HTTP. Don't expose port 7001 directly. Terminate TLS at a reverse proxy on the host and forward requests inward. Two common options:

nginx

/etc/nginx/sites-available/grcpay.conf
server {
listen 443 ssl http2;
server_name grcpay.example.com;
ssl_certificate /etc/letsencrypt/live/grcpay.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/grcpay.example.com/privkey.pem;
# GRCpay's REST surface lives under /api so it can share the
# hostname with the (optional) docs frontend on /.
location /api/ {
# Strip the /api prefix before forwarding — GRCpay routes
# are mounted at the root (/wallets, /status, /rates, ...).
rewrite ^/api/(.*)$ /$1 break;
proxy_pass http://127.0.0.1:7001;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
server {
listen 80;
server_name grcpay.example.com;
return 301 https://$host$request_uri;
}

Caddy

If you'd rather not deal with certbot, Caddy provisions and renews TLS automatically:

Caddyfile
grcpay.example.com {
handle /api/* {
uri strip_prefix /api
reverse_proxy 127.0.0.1:7001
}
}

Verifying it works

Once the container is up and GRCpay has connected to the wallet, run through the smoke test below. If all four steps pass, you're ready to wire a checkout up to it.

1. Status check

bash
curl -s https://grcpay.example.com/api/status | jq
# Or directly against the container:
curl -s http://localhost:7001/status | jq

2. Mint a test payment wallet

bash
RESP=$(curl -s -X POST https://grcpay.example.com/api/wallets \
-H 'Content-Type: application/vnd.api+json' \
-d '{
"data": {
"type": "wallets",
"attributes": {
"amountRequired": 0.01
}
}
}')
echo "$RESP" | jq
# Capture the address and the one-time access token for step 3.
ADDR=$(echo "$RESP" | jq -r '.data.attributes.address')
TOKEN=$(echo "$RESP" | jq -r '.data.attributes.token')

You should get back a 201 with a fresh Gridcoin address in data.attributes.address. That address came from your wallet's keypool, so it's yours, not ours. The response also contains a token field. GRCpay reveals the raw access token exactly once, right here, and only stores a SHA256 hash server-side. Stash it now, because every subsequent read or cancel on this wallet will need it in the X-Wallet-Token header.

3. Look it up

bash
# Uses $ADDR and $TOKEN captured in step 2.
curl -s "https://grcpay.example.com/api/wallets/$ADDR" \
-H "X-Wallet-Token: $TOKEN" | jq
# Status should still be "new" until something arrives.
# Without the header you'd get a 401 — GRCpay deliberately
# doesn't let unauthenticated callers probe live wallets.

4. Send a small payment to it (optional)

From any other Gridcoin wallet, send 0.01 GRC to the address from step 2. Within ~10 seconds of the transaction confirming, the wallet status should flip from new to funded and then to processed. If you supplied a recipient field at creation time, you'll also see a forwarding transaction land in that wallet.

Day-to-day operations

Updating

bash
docker compose pull grcpay
docker compose up -d grcpay
# Or, for the docker run setup:
docker pull ghcr.io/gridcat/grcpay:latest
docker stop grcpay && docker rm grcpay
# … then re-run the docker run command from above.

GRCpay applies any pending Prisma migrations automatically on boot (npx prisma migrate deploy is the first thing the entrypoint runs), so you don't have to do anything special after pulling a new image.

Backups

  • GRCpay's SQLite file

    Snapshot ./grcpay-data/payment.db on a schedule. It contains your audit log and the order ↔ address mapping. Without it you can still reconcile by hand from the chain, but it's much easier with the file.

  • The wallet's wallet.dat

    Snapshot ./wallet-data/wallet.dat on a schedule. This is your actual money. The wallet auto-refills its keypool as GRCpay calls getnewaddress, so any backup taken before a fresh batch of addresses is generated will be missing those keys. Schedule backups frequently, or pause GRCpay around the snapshot.

Logs

bash
docker logs -f grcpay
# or
docker compose logs -f grcpay

GRCpay logs every job-loop iteration, every wallet status transition, and every RPC error. The db_logs table inside the SQLite database also has a structured audit trail that's easier to query than raw container logs.

Common gotchas

  • "Connecting to the gridcoin wallet…" on loop

    The RPC handshake is failing. Almost always one of: wrong host/port, wrong rpcuser/rpcpassword, or rpcallowip not covering the source address. The wallet logs nothing for rejected connections by default; temporarily set debug=rpc in gridcoinresearch.conf to see why.

  • Forwarding fails with 'Insufficient funds'

    The wallet you forward from needs enough balance to cover the customer payment plus the network fee (default 0.001 GRC). If GRCpay is pointed at a fresh wallet that hasn't received any GRC of its own yet, top it up before going live.

  • Wallet status stuck at 'new' after payment

    Either the transaction hasn't confirmed yet (Gridcoin blocks are minted by staking nodes, so expect a few minutes of variance before a new tx lands in one), or GRCpay can't reach the wallet to refresh balances. Check the container logs for RPC errors.

  • Wallet ends up in 'error' status

    The expired-refund flow couldn't determine the original sender or the send call failed. The wallet stays parked for manual review. Query the db_logs table for that wallet_id to see what went wrong, and refund manually if appropriate.


Self-hosted Gridcoin payment facilitator. Privacy-first by design.Terms · Legal overview

Made with by @gridcat · Part of Gridcoin Club ↗ · Testnet