Coldtill Get Coldtill

Self-hosting guide

Paste into Claude Code, Codex, Cursor or your favourite coding agent. View Markdown

Coldtill runs on a server you control. This guide covers the decisions around the install: what server to rent, how to keep the dashboard off the public internet, backups, updates, and a checklist to work through before you take real payments. The commands themselves are in Install and operations.

Choosing a server

Coldtill is light. PostgreSQL and four small Go processes run comfortably on:

Minimum Recommended
CPU 2 vCPU 2–4 vCPU
Memory 2 GB 4 GB
Disk 20 GB SSD 40 GB SSD or more
OS Any Linux with Docker Ubuntu 24.04 LTS or Debian 12

Both amd64 and arm64 are supported. A small VPS from Hetzner, OVH, DigitalOcean, Vultr or Linode is plenty; you don't need a dedicated machine. Things that matter more than size:

  • A provider firewall. Pick a host that lets you filter traffic before it reaches the server (Hetzner Cloud Firewall, DigitalOcean Cloud Firewall, AWS security groups). See Firewall.
  • Snapshots or backups at the provider level, in addition to your own database dumps.
  • A provider that allows crypto payment processing in its terms. Most do; check before you sign up.
  • Location. Put the server close to your shop if you can. Coldtill doesn't need to be near any chain.

Don't run Coldtill on the same machine as your shop or anything else exposed to the internet. A compromise of the shop shouldn't reach the hot wallets.

Before you install

  1. Create a non-root user with SSH keys. Disable password logins in /etc/ssh/sshd_config (PasswordAuthentication no, PermitRootLogin prohibit-password) and run sudo systemctl reload ssh.
  2. Turn on automatic security updates: sudo apt install unattended-upgrades and sudo dpkg-reconfigure -plow unattended-upgrades.
  3. Pick the public hostname customers will see on the checkout page, for example pay.example.com, and point a DNS A record at the server. We recommend proxying it through Cloudflare; see Point the subdomain at the server.
  4. Set up email. On mainnet Coldtill needs SMTP for payout approvals and update notices. Any transactional provider works (Postmark, Mailgun, Amazon SES, Resend, or your own mail server).

Then follow Install and First-run setup.

Keep the dashboard private

The checkout page and the merchant API must be public. The login page and the dashboard should not be: they are how money gets moved, so the fewer people who can reach them, the better. Coldtill always needs two-factor codes for sensitive actions, but there's no reason to let the whole internet try passwords. Choose one of these.

The strongest setup combines Tailscale for signing in with a Cloudflare rule that blocks the dashboard on every public name. Guide: projects, domains and a private dashboard walks through it step by step, including DNS, certificates behind Cloudflare, and adding projects with their own domains.

Option 1: a separate checkout domain (simplest)

Give each project a checkout domain such as pay.example.com, and keep the dashboard on a different name that you don't publish, such as admin.example.com. On the checkout domain Coldtill serves only the checkout, the merchant API and static files; the login page and /api/dash answer 404.

Then restrict the dashboard name with one of the options below, or at least a Cloudflare WAF rule.

Option 2: Cloudflare in front

Put the hostname behind Cloudflare's proxy (orange cloud), set SSL/TLS to Full (strict), and tell Caddy to trust Cloudflare's client-IP header, as described in Behind Cloudflare. Then add protection for the dashboard paths:

  • Cloudflare Access (Zero Trust, free for small teams): create a self-hosted application for the dashboard hostname, or for the paths /login, /api/dash and / on the main hostname, and allow only your team's email addresses. Leave /pay/, /api/checkout/, /api/v1/, /assets/, /branding/ and /healthz open.

  • Or a WAF custom rule that blocks everything except your office IPs:

    (http.host eq "pay.example.com"
      and not starts_with(http.request.uri.path, "/pay/")
      and not starts_with(http.request.uri.path, "/api/checkout/")
      and not starts_with(http.request.uri.path, "/api/v1/")
      and not starts_with(http.request.uri.path, "/assets/")
      and not starts_with(http.request.uri.path, "/branding/")
      and http.request.uri.path ne "/healthz"
      and http.request.uri.path ne "/favicon.svg"
      and not ip.src in {203.0.113.10})
    

    Action: Block. Replace 203.0.113.10 with your own addresses.

Cloudflare only helps if attackers can't skip it, so also restrict ports 80 and 443 to Cloudflare's ranges at the firewall.

Option 3: Tailscale (dashboard only on your private network)

Tailscale gives your laptop and the server a private network. You can serve the dashboard only there, and leave the public hostname for checkout and the API alone.

  1. Install Tailscale on the server and your devices: curl -fsSL https://tailscale.com/install.sh | sh, then sudo tailscale up.

  2. Add a private entrance to Caddy. Copy deploy/Caddyfile to deploy/Caddyfile.local and append:

    http://:8081 {
     encode zstd gzip
     reverse_proxy api:8080 {
         header_up Host pay.example.com
         header_up Origin ^https://coldtill\.your-tailnet\.ts\.net$ https://pay.example.com
         header_up X-Forwarded-Proto https
         header_up X-Forwarded-For {client_ip}
     }
    }
    

    Coldtill only accepts requests whose origin is its own site URL, so the entrance presents itself as the public name. The Origin line rewrites only requests that come from your tailnet address. Don't replace it with a fixed value: that would switch off the cross-site request check for the private entrance.

  3. Publish the port on localhost only, in deploy/docker-compose.override.yml:

    services:
      caddy:
        ports:
          - "127.0.0.1:8081:8081"
        volumes:
          - ./Caddyfile.local:/etc/caddy/Caddyfile:ro
    
  4. Start it: docker compose up -d caddy, then sudo tailscale serve --bg http://127.0.0.1:8081. The dashboard is now at https://<machine>.<your-tailnet>.ts.net, with a certificate Tailscale manages.

  5. Block the dashboard on the public name with a Cloudflare WAF rule (Option 2) or a checkout domain (Option 1).

If Caddy and tailscale serve both want port 443, start Caddy first: run sudo tailscale serve reset, docker compose up -d --force-recreate caddy, then the tailscale serve command again.

Firewall

Allow only what you need: SSH (22) and HTTP/HTTPS (80, 443). Behind Cloudflare, allow 80 and 443 only from Cloudflare's ranges.

Docker bypasses ufw. Ports published by Docker Compose are opened in iptables ahead of ufw's rules, so ufw allow from <range> to any port 443 does not stop anyone else reaching Caddy. Use one of these instead:

  • Your provider's firewall (recommended). It filters before traffic reaches the server, so Docker can't override it. Allow 22 from your addresses, and 80 and 443 from Cloudflare's ranges, or from anywhere if you aren't using Cloudflare.

  • The DOCKER-USER iptables chain, which Docker consults before its own rules. For example, to allow only Cloudflare to reach published ports:

    for r in $(curl -fsS https://www.cloudflare.com/ips-v4); do
      sudo iptables -I DOCKER-USER -i eth0 -s "$r" -p tcp -m multiport --dports 80,443 -j RETURN
    done
    sudo iptables -A DOCKER-USER -i eth0 -p tcp -m multiport --dports 80,443 -j DROP
    sudo apt install iptables-persistent && sudo netfilter-persistent save
    

    Replace eth0 with your public interface (ip route get 1.1.1.1 shows it), and repeat with ip6tables for IPv6.

Check from another machine: curl -sk --resolve pay.example.com:443:<server-ip> https://pay.example.com/healthz should time out when you're behind Cloudflare.

Accounts and two-factor

  • Enable two-factor on the root account before anything else, and on every admin.
  • Give staff the lowest role that works: developer for integration work, read_only for support.
  • Store recovery details for the root account (password, two-factor seed) offline, with the cold wallet seed.

Keys and wallets

  • Cold wallets hold everything swept. Use a hardware wallet or a multisig (Safe on EVM, Squads on Solana). Coldtill never needs their keys; it only needs their addresses.
  • Hot wallets only pay gas and approved payouts. Keep them topped up with small amounts, never a balance you can't afford to lose.
  • The hot-wallet passphrase is stored in deploy/secrets/hot_passphrase by default so sweeps survive a restart. If you'd rather type it on every start, see Running without a stored passphrase.
  • Check the first deposit address the dashboard derives against your own wallet before sharing any payment link. Do the same after every change to deposit wallets.

Nodes

Coldtill trusts RPC nodes to report deposits. Public endpoints are rate limited and fine for testing only. For mainnet, use your own nodes or paid providers, and add at least two per network so failover works. See Nodes and networks.

Backups

Run the nightly database dump from Backups, and copy the dumps off the server: a backup on the same disk doesn't survive losing the server. For example, with restic to any S3-compatible bucket:

sudo apt install restic
export RESTIC_REPOSITORY=s3:https://s3.example.com/coldtill-backups RESTIC_PASSWORD_FILE=$HOME/.restic-pass
restic init
restic backup $HOME/backups
restic forget --keep-daily 14 --keep-monthly 6 --prune

Add the restic backup and restic forget lines to the same cron job as the dump.

Keep a copy of deploy/.env and deploy/secrets/ in encrypted offline storage. Without .env, two-factor and webhook secrets in the database can't be read. Test a restore on a spare server at least once.

Updates

The dashboard checks for new releases once a day and shows a banner to admins. Security fixes are flagged and emailed to admins. See Updates. To upgrade, take a database dump, then follow Upgrades.

Monitoring

  • Point an uptime monitor (UptimeRobot, Better Stack, Healthchecks.io) at https://pay.example.com/healthz.
  • Alert on "level":"ERROR" in the logs, and on the messages listed in Monitoring.
  • Watch hot wallet balances. When they run dry, sweeps stop.
  • Subscribe to webhooks for payout.needs-review in your own tooling.

Go-live checklist

Work through this before switching a shop to mainnet.

  • SSH keys only; password login and root password login disabled.
  • Provider firewall (or DOCKER-USER rules) allows only 22 and 80/443, and 80/443 only from Cloudflare if you use it.
  • Dashboard reachable only through Cloudflare Access, Tailscale or your IP allow-list; the public name returns 404 or 403 for /login.
  • Cloudflare SSL/TLS set to Full (strict), with COLDTILL_TRUSTED_PROXIES and COLDTILL_CLIENT_IP_HEADER set.
  • Two-factor enabled on every account; no unused admin accounts.
  • Mainnet install (--mode mainnet) with SMTP configured; a test payout approval email arrived.
  • Cold wallets are hardware or multisig; the first derived deposit address matches your wallet.
  • At least two paid or self-hosted RPC nodes per enabled network.
  • Payout limits and auto-approve amounts set low enough that a stolen API key can't drain much.
  • Nightly dumps copied off-site; .env and secrets/ stored offline; a restore has been tested.
  • Uptime monitor on /healthz; log alerts on errors.
  • A small real payment on each network went through checkout, sweep and webhook end to end.
  • You've read Security and the mainnet checklist in Audit preparation.