Advanced setup
This page documents what the installer produces so you can reproduce or repair it by hand. The stack runs two application images, recvfund-api and recvfund-web, which are public on GitHub Container Registry (ghcr.io/recv-fund/recvfund-api and ghcr.io/recv-fund/recvfund-web, tags X.Y.Z per release, latest for the newest release), or built on the server only with the --source option.
Internet ──► caddy :80/:443 ──┬── /api/* → api:3001 (the server: API, watchers, sweeps, payouts, webhooks)
└── /* → web:3000 (dashboard and /payments checkout)
redis internal only
postgres internal only, profile "db" (or an external Postgres)Files on the server
/opt/recvfund/
├── .env # secrets and connection values, mode 600
├── docker-compose.yml # the stack below
├── Caddyfile # routing and TLS
├── install.log # appended by every installer run
└── docker-compose.build.yml # only with --source: build overrides for api and webdocker-compose.yml
services:
caddy:
image: caddy:2-alpine
restart: unless-stopped
ports:
- '80:80'
- '443:443'
- '443:443/udp'
environment:
SITE_DOMAIN: ${SITE_DOMAIN}
ACME_EMAIL: ${ACME_EMAIL}
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy-data:/data
- caddy-config:/config
depends_on:
- web
- api
api:
image: ghcr.io/recv-fund/recvfund-api:${IMAGE_TAG:-latest}
restart: unless-stopped
environment:
NODE_ENV: production
PORT: 3001
NETWORK_TYPE: ${NETWORK_TYPE}
SITE_URL: ${SITE_URL}
AES_ENCRYPTION_KEY: ${AES_ENCRYPTION_KEY}
ADMIN_API_KEY: ${ADMIN_API_KEY}
SEND_WEBHOOKS: ${SEND_WEBHOOKS:-true}
POSTGRES_HOST: ${POSTGRES_HOST}
POSTGRES_PORT: ${POSTGRES_PORT}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_SSL: ${POSTGRES_SSL:-false}
REDIS_HOST: redis
REDIS_PORT: 6379
REDIS_PASSWORD: ${REDIS_PASSWORD}
volumes:
- api-logs:/repo/apps/api/logs
- api-uploads:/repo/apps/api/uploads
depends_on:
redis:
condition: service_healthy
healthcheck:
test: ['CMD', 'wget', '-qO-', 'http://127.0.0.1:3001/api/v1/health']
interval: 30s
timeout: 5s
retries: 3
web:
image: ghcr.io/recv-fund/recvfund-web:${IMAGE_TAG:-latest}
restart: unless-stopped
environment:
NODE_ENV: production
# Same origin as the browser: Caddy routes /api/* to the api service.
NEXT_PUBLIC_API_URL: /api/v1
API_INTERNAL_URL: http://api:3001/api/v1
depends_on:
- api
postgres:
image: postgres:16-alpine
profiles: ['db']
restart: unless-stopped
environment:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}
volumes:
- postgres-data:/var/lib/postgresql/data
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}']
interval: 5s
timeout: 3s
retries: 20
redis:
image: redis:7-alpine
restart: unless-stopped
command: >
redis-server --requirepass ${REDIS_PASSWORD} --appendonly yes
volumes:
- redis-data:/data
healthcheck:
test: ['CMD', 'redis-cli', '-a', '${REDIS_PASSWORD}', 'ping']
interval: 10s
timeout: 3s
retries: 5
volumes:
caddy-data:
caddy-config:
postgres-data:
redis-data:
api-logs:
api-uploads:The installer accepts amd64 and arm64 hosts. The postgres service is behind the db profile: it starts only when you pass --profile db, which the installer does for a bundled database.
Caddyfile
{
email {$ACME_EMAIL}
}
{$SITE_DOMAIN} {
encode zstd gzip
handle /api/* {
reverse_proxy api:3001
}
handle {
reverse_proxy web:3000
}
}When SITE_DOMAIN is a hostname, Caddy obtains and renews a Let’s Encrypt certificate. When it is :80, the stack serves plain HTTP.
Environment variables
Everything in .env. The installer writes all of them; edit by hand only if you know what each value does.
| Variable | Meaning |
|---|---|
SITE_DOMAIN | The hostname Caddy serves, with automatic HTTPS. :80 for plain HTTP (IP address, or behind your own proxy). |
ACME_EMAIL | Email address given to Let’s Encrypt for certificate notices. Empty when SITE_DOMAIN is :80. |
SITE_URL | The public address of this install: used in payment links, emails and the dashboard. Initial value only; editable later under Settings → Site URL, which is stored in the database. |
NETWORK_TYPE | mainnet or testnet. Decides which chain rows the API serves. Never change it on a running install. |
AES_ENCRYPTION_KEY | 64 hex characters. Encrypts every secret in the database, including hot wallet private keys and webhook secrets. Generate with openssl rand -hex 32. Never change it after first start. Back it up off the server. |
ADMIN_API_KEY | Hex string. Authenticates the two platform-configuration administration endpoints of the API (x-api-key header). The installer generates it but does not use it. Not a project API key. |
POSTGRES_HOST | postgres for the bundled container; a hostname for an external server. |
POSTGRES_PORT | Default 5432. |
POSTGRES_USER | Default recv. |
POSTGRES_PASSWORD | Generated by the installer for the bundled database. |
POSTGRES_DB | Default recv. |
POSTGRES_SSL | true or false. Use true for most managed Postgres services. |
REDIS_PASSWORD | Generated by the installer. Redis is only reachable inside the compose network. It holds caches and rate-limit counters; queues and job state are in Postgres. |
SEND_WEBHOOKS | true by default. false stops every webhook delivery server-wide. Useful on a staging copy of a production database. |
IMAGE_TAG | Image version to run. The installer writes the newest released version (for example 0.1.2); --image-tag overrides it. --update changes it. |
Every other setting (sweep triggers, payment expiry, rate limits, SMTP, site URL) lives in the database and is edited in the dashboard, not in .env.
A complete example for a bundled database and plain HTTP:
SITE_DOMAIN=:80
ACME_EMAIL=
SITE_URL=http://203.0.113.10
NETWORK_TYPE=testnet
AES_ENCRYPTION_KEY=<64 hex characters>
ADMIN_API_KEY=<hex>
POSTGRES_HOST=postgres
POSTGRES_PORT=5432
POSTGRES_USER=recv
POSTGRES_PASSWORD=<generated>
POSTGRES_DB=recv
POSTGRES_SSL=false
REDIS_PASSWORD=<generated>
SEND_WEBHOOKS=true
IMAGE_TAG=0.1.2Volumes
| Volume | Contents |
|---|---|
postgres-data | The bundled database. Not used with an external Postgres. |
redis-data | Redis append-only file. Safe to lose; caches and rate-limit counters rebuild. |
caddy-data | Certificates and ACME account. Losing it means a new certificate request on next start. |
caddy-config | Caddy’s runtime config. |
api-logs | API log files. |
api-uploads | Uploaded project logos. Back it up with the database. Replacing the API container keeps it. |
Starting by hand
cd /opt/recvfund
docker compose -p recvfund --env-file .env --profile db pull
docker compose -p recvfund --env-file .env --profile db up -d --wait postgres redis
docker compose -p recvfund --env-file .env --profile db run --rm api node dist/src/db/migrate
docker compose -p recvfund --env-file .env --profile db up -d --remove-orphans
curl -s http://127.0.0.1/api/v1/healthLeave out --profile db, and start only redis in the second command, when POSTGRES_HOST points at an external server. With --source, add -f docker-compose.yml -f docker-compose.build.yml and replace pull with build --pull.
The API refuses to start with unapplied migrations, which is why migrate runs first.
External Postgres
Point POSTGRES_HOST, POSTGRES_PORT, POSTGRES_USER, POSTGRES_PASSWORD, POSTGRES_DB and POSTGRES_SSL at a Postgres 16 server, and start the stack without --profile db. The user needs to own the database: migrations create the recv and recv_ops schemas and their tables. Allow the server’s IP in the database firewall.
The installer sets this up when you choose external at the database prompt, or pass --external-db with the --pg-* options. It tests the connection before writing any file.
Behind your own proxy
If another proxy or load balancer already owns ports 80 and 443, run the stack on plain HTTP on a different host port and forward to it.
- Set
SITE_DOMAIN=:80andACME_EMAIL=in.env. - Change the
caddyport mapping to your chosen port, for example'8080:80', and remove the443mappings. - Set
SITE_URLto the public HTTPS address your proxy serves. - Forward that address to
http://127.0.0.1:8080on the server, passing theHostheader through.
The installer does steps 1 to 3 for you with --http-port 8080 --site-url https://pay.example.com. Add --http-bind 127.0.0.1 when the proxy runs on the same server, so port 8080 isn’t reachable from outside. Without --site-url, it asks for the site URL (with --yes, it uses http://<server-ip>:8080). The dashboard shows the site URL under Settings → Site URL; correct it there so payment links and emails carry the address your proxy serves.
Updating by hand
--update does the following; run it yourself if you prefer.
cd /opt/recvfund
docker compose -p recvfund --env-file .env --profile db pull
docker compose -p recvfund --env-file .env --profile db up -d --wait postgres redis
docker compose -p recvfund --env-file .env --profile db run --rm api node dist/src/db/migrate
docker compose -p recvfund --env-file .env --profile db up -d --remove-orphansTo pin a version, set IMAGE_TAG in .env before pulling. Keep .env otherwise identical across updates: a changed AES_ENCRYPTION_KEY or NETWORK_TYPE makes existing data unreadable or mismatched.
Backups
cd /opt/recvfund
docker compose -p recvfund exec postgres pg_dump -U recv recv > backup.sql
docker run --rm -v recvfund_api-uploads:/uploads -v "$PWD":/backup alpine tar czf /backup/uploads.tgz -C /uploads .
cp .env env.backupRestore is the reverse: restore the dump into an empty database, restore the uploads archive into the api-uploads volume, put .env back, start the stack. With an external Postgres, use that service’s own backups and still keep a copy of .env.
Stopping and starting
The installer has no stop operation. Stop and start the containers with Docker Compose from the install directory. Run the commands with sudo if your user isn’t in the docker group. Leave out --profile db when you use an external Postgres.
To stop every container and keep all data:
cd /opt/recvfund
docker compose -p recvfund --profile db stopTo start the stopped containers again:
cd /opt/recvfund
docker compose -p recvfund --profile db startdown removes the containers but keeps the volumes, .env and the database. Bring the stack back with up -d:
cd /opt/recvfund
docker compose -p recvfund --profile db down
docker compose -p recvfund --profile db up -dWithout --profile db, these commands skip the bundled Postgres container, so stop leaves it running. Every container has restart: unless-stopped. A container you stopped stays stopped after a reboot, and one you didn’t stop starts again with Docker.
Don’t add -v to down. It deletes the volumes, including the bundled
database. --reset also deletes everything; see
Script usage.
Day-two commands
cd /opt/recvfund
docker compose -p recvfund ps
docker compose -p recvfund logs -f --tail=100 api
docker compose -p recvfund restart api
curl -s http://127.0.0.1/api/v1/health