Deploying¶
A fresh VPS to a running deployment, next to BTCPay. Written for a Hetzner CAX11 (4GB, ARM) because that is what it was built and budgeted for, but nothing here is Hetzner-specific.
What you need first¶
- a VPS with BTCPay already installed via btcpayserver-docker, synced, with a hot wallet on its store
- a domain for this API (
api.example.com) on Cloudflare - somewhere to send backups: another host you can SSH into. A homelab machine is ideal and free.
If BTCPay is not up yet, do that first and come back. This service is useless without it.
The ten commands¶
# 1. get the code
sudo git clone https://github.com/OliverD25/crypto-processing-api \
/opt/crypto-processing-api && cd /opt/crypto-processing-api
# 2. configuration — read .env.example, it documents every variable
sudo cp .env.example .env && sudo chmod 600 .env && sudo nano .env
# 3. the database's data directory, on the host so pgBackRest can see it
sudo mkdir -p /var/lib/crypto-processing-api/pgdata
# 4. bring it up (migrations run on start)
sudo docker compose -f deploy/docker-compose.yml up -d
# 5. configure BTCPay: store, hot wallet, webhook, scoped keys, processor
sudo docker compose -f deploy/docker-compose.yml exec api \
python /app/scripts/bootstrap_btcpay.py
# 6. copy the generated ids and secrets into .env, then restart
sudo nano .env && sudo docker compose -f deploy/docker-compose.yml up -d
# 7. mint the key your platform will use
sudo docker compose -f deploy/docker-compose.yml exec api \
python -m crypto_processing_api.cli create-api-key --name platform --scope readwrite
# 8. publish it
sudo cp deploy/nginx/api.conf.example /etc/nginx/conf.d/crypto-api.conf
sudo nano /etc/nginx/conf.d/crypto-api.conf # set your hostname
sudo nginx -t && sudo systemctl reload nginx
# 9. firewall — READ THIS SCRIPT FIRST, it ends with `ufw enable`
sudo SSH_PORT=22 sh deploy/ufw/rules.sh
# 10. check
curl -s https://api.example.com/healthz
Step 6 exists because the bootstrap cannot write your .env for you — it
prints what it created into .env.regtest.generated style output, and the
values (BTCPAY_STORE_ID, BTCPAY_API_KEY, BTCPAY_WEBHOOK_SECRET) go into
.env by hand. Do it once.
Before you point real money at it¶
Backups are not optional and not "later". See
backups.md — the argument in one line: manual USDT withdrawals
exist only in this database, so a restore from an old dump silently
un-debits money that has already left custody.
Set up continuous archiving before the first real deposit.
The .env values that actually matter¶
Everything is documented in .env.example. These are the ones that will hurt
if you get them wrong:
| Variable | Why it matters |
|---|---|
ENVIRONMENT=production |
refuses DEBUG=true, requires mainnet, requires a TronGrid key when TRON is configured |
BITCOIN_NETWORK=mainnet |
withdrawal addresses are validated against this. Wrong value, wrong chain |
POSTGRES_PASSWORD |
the database is not exposed, but generate a real one |
SEED_BTC_WITHDRAWAL_AUTO_LIMIT |
read once, at first migrate. After that the DB row is the truth |
SEED_BTC_WITHDRAWAL_DAILY_CAP |
this is your loss ceiling under a stolen API key. Set it to what you can afford to lose in a day |
PLATFORM_WEBHOOK_SECRET |
leave empty and outbound events park as pending — legitimate for a polling integration |
LIGHTNING_ENABLED |
off by default. Turning it on adds a second float, a second daily cap, and one server-level BTCPay scope — see below |
The seed values are read once. Changing them later does nothing; change the
assets row with SQL.
Turning Lightning on¶
Default is off, and off is a deployment that has never heard of Lightning: no
BTC_LN row, no Lightning scope requested, nothing to think about. Read the
README's Lightning note and security.md before changing that.
The short version is that BTC_LN is a separate float — users get a second,
non-fungible BTC balance — and enabling it makes the bootstrap request
btcpay.server.canuseinternallightningnode, the one server-level scope this
project ever asks for.
If you still want it:
# 1. make sure BTCPay itself has a Lightning node (BTCPAY_BTCLIGHTNING)
# 2. set LIGHTNING_ENABLED=true and the SEED_LN_* values in .env
# 3. re-run the bootstrap so the scope, the payment method and the
# Lightning payout processor are configured
LIGHTNING_ENABLED=true python scripts/bootstrap_btcpay.py
# 4. restart so `migrate` seeds the BTC_LN row and the registry picks it up
docker compose up -d --force-recreate api worker
Then set the caps deliberately. SEED_LN_WITHDRAWAL_DAILY_CAP is a second
loss ceiling: the BTC cap says nothing about how much can leave the channel.
Channel balance also cannot be swept to cold storage — what is committed to a
channel stays there until the channel closes — so size it as the amount you are
willing to leave permanently warm.
Turning it back off is not a matter of unsetting the variable. Once the
BTC_LN row exists, a build without the profile refuses to start, on purpose:
an asset holding user balances must not disappear because an environment
variable did. Zero the balances and disable the row deliberately, or leave it on.
Operating notes live in
runbook-ln-rebalance.md; the one to know in advance
is that low outbound liquidity shows up as withdrawals that refund themselves
with a definitive-failure proof, not as an error.
Cloudflare¶
- Add the
api.example.comA record, proxied (orange cloud). - SSL/TLS mode Full (strict) — the origin has a real certificate.
- A cache rule for
api.example.com/*with Bypass cache. The application sendsCache-Control: no-storeand nginx repeats it, but a cached balance is bad enough to be worth all three. - Optionally a WAF rate-limit rule on
/v1/*.
With deploy/ufw/rules.sh applied, 443 accepts traffic only from Cloudflare's
ranges, so learning the origin IP does not get an attacker past it. The
bitcoind port still reveals the IP; that is an accepted residual, documented in
security.md.
Verifying the install¶
curl -s https://api.example.com/healthz # process + database
curl -s https://api.example.com/readyz | jq # BTCPay, TronGrid, worker
sudo docker compose -f deploy/docker-compose.yml logs -f worker
/readyz is the interesting one. A worker component reporting degraded
means jobs have stopped, and nothing else would tell you: reads keep answering
200 while deposits quietly stop being credited.
Then do a real end-to-end with an amount you would not mind losing:
curl -s -X POST https://api.example.com/v1/deposits \
-H "Authorization: Bearer $PLATFORM_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H 'Content-Type: application/json' \
-d '{"external_user_id":"install-test","asset":"BTC"}' | jq
Send a small amount to the address, wait for a confirmation, then check
GET /v1/users/install-test/balances. Withdraw it back. Until that round trip
works, the install is not finished.
Operating it¶
- Alerts: set
NTFY_TOPIC_URLor the Telegram pair. Free, and the threat model's honest conclusion is that the security budget is how little sits in the hot wallet and how fast you see an alert. - Uptime: point a free pinger at
/healthz. - Hot wallet float: keep one to three days of payout volume. Sweep the rest
to cold storage. See
security.md. - Approval queue:
GET /v1/admin/withdrawals?status=pending_approvalneeds a human. It alerts, but check it.
Upgrading¶
cd /opt/crypto-processing-api && sudo git pull
sudo docker compose -f deploy/docker-compose.yml pull
sudo docker compose -f deploy/docker-compose.yml up -d
Migrations run on api start. Take a backup first — for a money database that is not a formality, it is the rollback plan.
When bumping the pinned BTCPay image, run python scripts/check_btcpay_compat.py
first. It asserts the endpoints and fields this service depends on, several of
which have already moved once, and the failure mode of drift is a deposit that
silently stops crediting.
Sizing¶
The whole stack fits in 4GB with bitcoind pruned, but not comfortably during initial block download.
sudo fallocate -l 2G /swapfile && sudo chmod 600 /swapfile
sudo mkswap /swapfile && sudo swapon /swapfile
echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab
Postgres is configured for shared_buffers=128MB and 50 connections; the API
runs a single uvicorn worker. On a single-tenant backend-to-backend service
that is not the bottleneck, and the memory belongs to bitcoind.
Also worth having: unattended-upgrades for security patches, and fail2ban
on SSH.