Self-hosting guide
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
- Create a non-root user with SSH keys. Disable password logins in
/etc/ssh/sshd_config(PasswordAuthentication no,PermitRootLogin prohibit-password) and runsudo systemctl reload ssh. - Turn on automatic security updates:
sudo apt install unattended-upgradesandsudo dpkg-reconfigure -plow unattended-upgrades. - Pick the public hostname customers will see on the checkout page, for example
pay.example.com, and point a DNSArecord at the server. We recommend proxying it through Cloudflare; see Point the subdomain at the server. - 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/dashand/on the main hostname, and allow only your team's email addresses. Leave/pay/,/api/checkout/,/api/v1/,/assets/,/branding/and/healthzopen.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.10with 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.
Install Tailscale on the server and your devices:
curl -fsSL https://tailscale.com/install.sh | sh, thensudo tailscale up.Add a private entrance to Caddy. Copy
deploy/Caddyfiletodeploy/Caddyfile.localand 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
Originline 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.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:roStart it:
docker compose up -d caddy, thensudo tailscale serve --bg http://127.0.0.1:8081. The dashboard is now athttps://<machine>.<your-tailnet>.ts.net, with a certificate Tailscale manages.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-USERiptables 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 saveReplace
eth0with your public interface (ip route get 1.1.1.1shows it), and repeat withip6tablesfor 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:
developerfor integration work,read_onlyfor 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_passphraseby 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-reviewin 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-USERrules) 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_PROXIESandCOLDTILL_CLIENT_IP_HEADERset. - 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;
.envandsecrets/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.