Receipts docs
v1.1.0
Get help
● Ship · Server

Deployment
service, HTTPS, backups.

Run Receipts as a service behind an HTTPS proxy, block countries if you need to, and back up the database. Plus your responsibilities as an operator.

Windows

The commands on this page run on the Ubuntu server. From Windows, connect to it with ssh in Windows Terminal or PowerShell, or work in WSL (Ubuntu); do not run them in cmd.

01Run it as a service (systemd)

Create /etc/systemd/system/receipts.service:

[Unit]
Description=Receipts signal marketplace
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
User=receipts
Group=receipts
WorkingDirectory=/opt/receipts
ExecStart=/usr/bin/node --disable-warning=DEP0040 dist/src/index.js
Restart=on-failure
RestartSec=5
Environment=NODE_ENV=production
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=full
ProtectHome=true

[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable --now receipts
sudo systemctl status receipts           # should say "active (running)"
journalctl -u receipts -f                # live logs; Ctrl+C to leave

The app stops cleanly on systemctl stop: it stops the background jobs, the bot and the console, then closes the database. WorkingDirectory matters: the app reads .env from there.

First start on the server

Set ADMIN_SETUP_TOKEN in .env before the first start, then claim the site and create the console owner with it (First run). Remove it from .env afterwards and run sudo systemctl restart receipts. Without a token, the one-time setup code is in journalctl -u receipts.

02HTTPS with Caddy

The app listens on plain HTTP port 8787. Caddy sits in front, gets a free Let's Encrypt certificate and renews it. Install Caddy following caddyserver.com/docs/install, open ports 80 and 443 (sudo ufw allow 80,443/tcp), then replace /etc/caddy/Caddyfile with:

signals.example.com {
    encode gzip
    reverse_proxy 127.0.0.1:8787
}
sudo systemctl reload caddy

Set TRUST_PROXY=1 so the sign-in limits use the visitor's address, and keep port 8787 closed to the internet. nginx works as well: proxy to 127.0.0.1:8787, set X-Forwarded-For, and manage the certificate yourself (for example with certbot).

03Country blocking

Admin → Access → Blocked countries blocks visitors by two-letter code (for example US GB). Blocked visitors get HTTP 451. The app does not look up countries itself: it reads a request header your proxy or CDN adds.

  • Cloudflare (proxied): adds CF-IPCountry when IP Geolocation is on. That is the default header; nothing else is needed.
  • Caddy or nginx without Cloudflare: add a GeoIP module (for example a MaxMind GeoLite2 database) that sets a header such as X-Country, and enter that name in Admin → Access → Country header.
Limits

A request without the header is let through, and if your server is reachable without Cloudflare a visitor can send any value. Treat blocking as a reasonable measure you document, not a promise. /static/ and /webhooks/ are never blocked, so Stripe can always reach you.

04Backups

Everything (accounts, signals, seals, batches, payments, settings) is in data/receipts.db; the console's settings, team and audit are in data/admin.sqlite. Back up .env (with ADMIN_SECRET_KEY) separately and privately.

The databases run in WAL mode. Do not just copy the files while the app runs; use SQLite's online backup, which is safe while running:

cd /opt/receipts
sqlite3 data/receipts.db ".backup backup.db"
sqlite3 data/admin.sqlite ".backup admin-backup.sqlite"

A nightly backup with cron (sudo crontab -u receipts -e):

15 3 * * * cd /opt/receipts && mkdir -p backups && sqlite3 data/receipts.db ".backup backups/receipts-$(date +\%F).db" && find backups -name 'receipts-*.db' -mtime +14 -delete

Copy backups off the server too. To restore: stop the app, replace the database file, delete any -wal and -shm files next to it, start the app. The owner can also download a snapshot from Operations → Data backup.

Back up

Losing the database loses the sealed records. The Bitcoin timestamps prove a batch root existed at a time, but the event bodies needed to show that proof live only in your database.