# 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](https://coldtill.com/docs/install.md).

## 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](#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](https://coldtill.com/docs/guides/domains-and-dashboard.md#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](https://coldtill.com/docs/install.md#install) and [First-run setup](https://coldtill.com/docs/install.md#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](https://coldtill.com/docs/guides/domains-and-dashboard.md) 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](https://coldtill.com/docs/configuration.md#checkout-domains) 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](https://coldtill.com/docs/configuration.md#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:

  ```text
  (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](#firewall).

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

[Tailscale](https://tailscale.com) 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:

   ```text
   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`:

   ```yaml
   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:

  ```bash
  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](https://coldtill.com/docs/install.md#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](https://coldtill.com/docs/install.md#nodes-and-networks).

## Backups

Run the nightly database dump from [Backups](https://coldtill.com/docs/install.md#backups), and copy the dumps off the server: a backup
on the same disk doesn't survive losing the server. For example, with
[restic](https://restic.net) to any S3-compatible bucket:

```bash
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](https://coldtill.com/docs/updates.md). To upgrade, take a database dump, then follow
[Upgrades](https://coldtill.com/docs/install.md#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](https://coldtill.com/docs/install.md#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](https://coldtill.com/docs/security.md) and the mainnet checklist in
      Audit preparation.

---

Source: https://coldtill.com/docs/self-hosting/ · All Coldtill docs: https://coldtill.com/llms.txt
