Skip to Content
Deployment guideAdvanced setup

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 web

docker-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.

VariableMeaning
SITE_DOMAINThe hostname Caddy serves, with automatic HTTPS. :80 for plain HTTP (IP address, or behind your own proxy).
ACME_EMAILEmail address given to Let’s Encrypt for certificate notices. Empty when SITE_DOMAIN is :80.
SITE_URLThe 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_TYPEmainnet or testnet. Decides which chain rows the API serves. Never change it on a running install.
AES_ENCRYPTION_KEY64 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_KEYHex 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_HOSTpostgres for the bundled container; a hostname for an external server.
POSTGRES_PORTDefault 5432.
POSTGRES_USERDefault recv.
POSTGRES_PASSWORDGenerated by the installer for the bundled database.
POSTGRES_DBDefault recv.
POSTGRES_SSLtrue or false. Use true for most managed Postgres services.
REDIS_PASSWORDGenerated 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_WEBHOOKStrue by default. false stops every webhook delivery server-wide. Useful on a staging copy of a production database.
IMAGE_TAGImage 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.2

Volumes

VolumeContents
postgres-dataThe bundled database. Not used with an external Postgres.
redis-dataRedis append-only file. Safe to lose; caches and rate-limit counters rebuild.
caddy-dataCertificates and ACME account. Losing it means a new certificate request on next start.
caddy-configCaddy’s runtime config.
api-logsAPI log files.
api-uploadsUploaded 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/health

Leave 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.

  1. Set SITE_DOMAIN=:80 and ACME_EMAIL= in .env.
  2. Change the caddy port mapping to your chosen port, for example '8080:80', and remove the 443 mappings.
  3. Set SITE_URL to the public HTTPS address your proxy serves.
  4. Forward that address to http://127.0.0.1:8080 on the server, passing the Host header 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-orphans

To 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.backup

Restore 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 stop

To start the stopped containers again:

cd /opt/recvfund docker compose -p recvfund --profile db start

down 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 -d

Without --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.

Caution

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
Last updated on