# Guide: projects, domains and a private dashboard

One Coldtill server can take payments for several shops or businesses. Each one is a **project** with its own
wallets, API keys, webhooks, branding and checkout address. This guide covers where to point your DNS, why
Cloudflare's proxy is worth using, how to keep the login page off the public internet with Tailscale and
Cloudflare, and how to add projects and give each one its own domain.

## How the addresses fit together

A Coldtill server answers on three kinds of address:

| Address | Example | What it serves | Who should reach it |
|---|---|---|---|
| **Site URL** (`COLDTILL_SITE_URL`) | `pay.example.com` | Checkout, merchant API, and the login page and dashboard | Customers and shops; the dashboard paths blocked by Cloudflare |
| **Checkout domain** (one per project, optional) | `pay.shop-two.com` | Checkout and merchant API only. The login page and dashboard answer 404 | Customers and that project's shop |
| **Private entrance** | `coldtill.your-tailnet.ts.net` | The dashboard | Only your own devices, over Tailscale |

The site URL always serves the dashboard; there's no setting to switch it off there. You keep it private with
the private entrance plus a Cloudflare rule that blocks the dashboard paths on every public name. A checkout
domain blocks them by itself.

A typical setup for two businesses on one server:

| Name | Points at | Used for |
|---|---|---|
| `pay.example.com` | The server, through Cloudflare | Site URL; checkout and API for the first project |
| `pay.shop-two.com` | The server, through Cloudflare | Checkout domain of the second project |
| `coldtill.your-tailnet.ts.net` | The server, over Tailscale | Signing in to the dashboard |

## Point the subdomain at the server

Use a subdomain such as `pay.` rather than your shop's main domain. The shop and the payment server are
different machines, and a subdomain keeps them separate.

1. **Find the server's public IP address.** It's in your provider's control panel, or run this on the server:

   ```bash
   curl -4 https://ifconfig.me; echo
   curl -6 https://ifconfig.me; echo
   ```

2. **Create the DNS records** where your domain's DNS is hosted:

   | Type | Name | Value |
   |---|---|---|
   | `A` | `pay` | The server's IPv4 address |
   | `AAAA` | `pay` | The server's IPv6 address, if it has one |

3. **Check it** from your own machine. It can take a few minutes:

   ```bash
   dig +short pay.example.com
   ```

   Without Cloudflare's proxy this prints your server's address. With it, it prints Cloudflare addresses
   (`104.x`, `172.6x` or `188.114.x`), which is what you want.

### Use Cloudflare's proxy (recommended)

Cloudflare's free plan can sit in front of the server. Customers connect to Cloudflare, and Cloudflare connects
to your server. That gives you:

- **A hidden server address.** People see Cloudflare's addresses, not your server's, so they can't attack the
  server directly once the [firewall](https://coldtill.com/docs/self-hosting.md#firewall) only lets Cloudflare in.
- **Firewall rules in front of Coldtill**, so the login page can be blocked before a request reaches the server.
- **DDoS protection and rate limiting** for the checkout and API.

To set it up:

1. Create a free account at [cloudflare.com](https://dash.cloudflare.com), choose **Add a domain**, and enter
   your domain (`example.com`).
2. Cloudflare gives you two nameservers. Set them at the registrar where you bought the domain, replacing the
   old ones. Cloudflare copies your existing records during sign-up; check they're all there before you switch.
3. In **DNS → Records**, add the `A` (and `AAAA`) record from the table above with **Proxy status: Proxied**
   (the orange cloud).
4. In **SSL/TLS → Overview**, set the mode to **Full (strict)**. Don't use **Flexible**: it sends traffic
   between Cloudflare and your server unencrypted, and causes redirect loops with Coldtill.
5. On the server, tell Caddy to trust Cloudflare's visitor-IP header, as described in
   [Behind Cloudflare](https://coldtill.com/docs/configuration.md#behind-cloudflare). Without it, rate limits treat all visitors as one.
6. Give Caddy a certificate Cloudflare accepts, as described in the next section.
7. Restrict ports 80 and 443 to Cloudflare's addresses at the [firewall](https://coldtill.com/docs/self-hosting.md#firewall), so nobody
   can bypass Cloudflare by connecting to the server's IP.

If a domain isn't on Cloudflare, an ordinary `A` record at any DNS host works too. Caddy gets a Let's Encrypt
certificate automatically, and you skip the Cloudflare steps. You lose the protections above, so take extra care
with the [firewall](https://coldtill.com/docs/self-hosting.md#firewall) and the private dashboard.

### Certificates behind Cloudflare

With **Full (strict)**, Cloudflare checks that your server's certificate is valid for the name it's
connecting to. Caddy's automatic Let's Encrypt certificates usually work behind the proxy. But Cloudflare
security features (bot challenges, "Under Attack" mode, some firewall rules) can block Let's Encrypt's checks.
The certificate then fails to renew, and visitors see **error 525** or **526**.

The reliable option is a free **Cloudflare Origin Certificate**. It's valid for 15 years, and only Cloudflare
trusts it, which is all you need behind the proxy.

1. In Cloudflare, open the domain, go to **SSL/TLS → Origin Server → Create Certificate**. Keep the default key
   type, list the hostnames (`pay.example.com`, or `*.example.com` to cover every subdomain), and create it.
2. Save both parts on the server in `deploy/certs/` (`example.com.pem` for the certificate,
   `example.com.key` for the private key), then run `chmod 600 deploy/certs/*.key`. Cloudflare only shows the
   key once.
3. Repeat for each domain in a different Cloudflare zone, for example `shop-two.com`. Each zone needs its own
   certificate.
4. Copy `deploy/Caddyfile` to `deploy/Caddyfile.local` and give each name its own site block that uses its
   certificate. Keep the global options block at the top, and move the shared settings into a snippet:

   ```text
   (coldtill) {
   	encode zstd gzip
   	request_body {
   		max_size 4MB
   	}
   	header {
   		-Server
   		Strict-Transport-Security "max-age=31536000; includeSubDomains"
   	}
   	reverse_proxy api:8080 {
   		header_up X-Forwarded-For {client_ip}
   	}
   }

   pay.example.com {
   	tls /etc/caddy/certs/example.com.pem /etc/caddy/certs/example.com.key
   	import coldtill
   }

   pay.shop-two.com {
   	tls /etc/caddy/certs/shop-two.com.pem /etc/caddy/certs/shop-two.com.key
   	import coldtill
   }
   ```

5. Mount both in `deploy/docker-compose.override.yml`:

   ```yaml
   services:
     caddy:
       volumes:
         - ./Caddyfile.local:/etc/caddy/Caddyfile:ro
         - ./certs:/etc/caddy/certs:ro
   ```

6. Run `docker compose up -d caddy`, then check each name: `curl -sI https://pay.shop-two.com/healthz` should
   return `HTTP/2 200`.

With your own site blocks, `COLDTILL_CADDY_ADDRESS` is no longer used. When you add a domain later, add a
site block for it instead.

## Keep the dashboard private

The checkout and merchant API have to be public. The login page and dashboard don't, and they are how money
gets moved. Coldtill asks for two-factor codes for sensitive actions, but there's no reason to let the whole
internet try passwords. The strongest setup uses both of the tools below. Tailscale is how you get in, and
Cloudflare blocks the dashboard on every public name.

### Tailscale: sign in over your private network

[Tailscale](https://tailscale.com) is a free private network between your devices. The dashboard gets an
address that only your laptop and phone can open, and that address never goes through the public internet or
Cloudflare.

1. **Install Tailscale on the server** and sign in:

   ```bash
   curl -fsSL https://tailscale.com/install.sh | sh
   sudo tailscale up
   ```

   Install it on your laptop and phone too, and sign in with the same account. In the
   [admin console](https://login.tailscale.com/admin/dns), turn on **MagicDNS** and **HTTPS certificates**.

2. **Add a private entrance to Caddy.** Append this block to `deploy/Caddyfile.local` (copy `deploy/Caddyfile`
   to it first if it doesn't exist). Replace `pay.example.com` with your site URL's hostname, and
   `coldtill.your-tailnet.ts.net` with the server's Tailscale name (`tailscale status` shows it):

   ```text
   http://:8081 {
   	encode zstd gzip
   	request_body {
   		max_size 4MB
   	}
   	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 dashboard requests that come from its own site URL. That protects against cross-site
   attacks, so the entrance presents itself as the site URL. The `Origin` line only rewrites requests that
   really come from your Tailscale address. Don't replace it with a fixed value, which would switch the
   cross-site check off for this 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
   ```

   Keep any volumes you already have there, such as `./certs`.

4. **Start it**, then hand the port to Tailscale:

   ```bash
   cd deploy && docker compose up -d caddy
   sudo tailscale serve --bg http://127.0.0.1:8081
   ```

   The dashboard is now at `https://coldtill.your-tailnet.ts.net`, with a certificate Tailscale manages. Open it
   from a device signed in to Tailscale.

If Caddy and `tailscale serve` both want port 443 after a restart, start Caddy first. Run
`sudo tailscale serve reset`, then `docker compose up -d --force-recreate caddy`, then the `tailscale serve`
command again.

If the `.ts.net` name doesn't resolve on your laptop ("DNS address could not be found"), Tailscale's DNS is off
on that device. Run `sudo tailscale set --accept-dns=true`.

### Cloudflare: block the login page on every public name

Once you sign in over Tailscale, nobody needs the dashboard on the public names. A Cloudflare firewall rule
blocks it before requests reach the server, and leaves the checkout and API open.

In Cloudflare, open the domain, go to **Security → WAF → Custom rules → Create rule**, choose **Edit
expression**, and paste:

```text
(http.host in {"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")
```

Set **Action** to **Block** and deploy. Those paths are exactly what a checkout domain serves, so customers and
your shop are unaffected. Then check from a browser that isn't using Tailscale:

- `https://pay.example.com/` and `https://pay.example.com/login` show Cloudflare's block page.
- `https://pay.example.com/healthz` returns `ok`.
- A payment link from your shop still opens the checkout.

**Block it on the server too.** A Cloudflare rule only helps for traffic that goes through Cloudflare. To make
the server itself refuse the dashboard on public names, add these two lines to each public site block in
`Caddyfile.local` (or to the `(coldtill)` snippet), before `reverse_proxy`:

```text
@dashboard not path /pay/* /api/checkout/* /api/v1/* /assets/* /branding/* /healthz /favicon.svg
respond @dashboard 404
```

Leave them out of the `:8081` private entrance, or you'll lock yourself out of the dashboard.

Notes:

- **Every public name needs covering.** Add each hostname to the `{…}` list, separated by spaces. A name in a
  different Cloudflare zone (`pay.shop-two.com`) needs the same rule in that zone. Checkout domains already
  return 404 for the dashboard, but adding them to the rule costs nothing.
- **No Tailscale?** Add `and not ip.src in {203.0.113.10}` before the closing bracket, with your own IP
  addresses, to keep the dashboard reachable from your office only. Or use **Cloudflare Access** (Zero Trust,
  free for up to 50 users): create a self-hosted application for the site URL's hostname that only lets your
  team's email addresses in. Exclude the checkout paths listed above from it.
- **Bot protection and your shop.** Cloudflare's **Bot Fight Mode** and high **Security Level** settings can
  challenge requests that don't come from a browser. If your shop's server gets `403` responses from
  `/api/v1/`, or webhooks to your shop fail, add a WAF rule with action **Skip** for paths starting with
  `/api/v1/`, or turn Bot Fight Mode off for the zone. Your own laptop can be challenged too if it connects
  through a VPN or a Tailscale exit node. Sign in over the private entrance instead.

## Add a project

You need a root or admin account.

1. In the dashboard, open the project menu at the top of the sidebar and choose **+ New project…**.
2. Enter a name, for example the business or shop name, and click **Create**. The dashboard switches to the new
   project and opens its settings.
3. Set it up the same way as your first project. Every setting below belongs to the selected project:
   - **Project settings:** store name, logo, colours, support links and the **Return URL**, where customers go
     after paying.
   - **Deposit wallets:** where this project's payments go. You can reuse cold wallet addresses from another
     project, or give each business its own.
   - **API keys:** create a key for this project's shop. A key only works for its own project.
   - **Webhooks:** the URL on this project's shop that receives payment events, and its signing secret.
   - **Team:** who can see or manage this project.

Some things are shared by the whole server and set up once by an admin: user accounts, **Hot wallets** (gas and
payouts), and **Nodes and networks**.

## Give a project its own domain

Without a checkout domain, a project's payment links open on the site URL. To use the business's own name
instead, for example `pay.shop-two.com`:

1. **Point the name at the server.** Add an `A` record (and `AAAA` if you use IPv6) for `pay` in that domain's
   DNS, proxied through Cloudflare as described in
   [Point the subdomain at the server](#point-the-subdomain-at-the-server). Set SSL/TLS to **Full (strict)** in
   that zone too.
2. **Let Caddy answer for it.** Either add the name to `COLDTILL_CADDY_ADDRESS` in `deploy/.env`, comma
   separated (`COLDTILL_CADDY_ADDRESS=pay.example.com, pay.shop-two.com`), or add a site block with its
   Origin certificate if you use your own `Caddyfile.local` (see
   [Certificates behind Cloudflare](#certificates-behind-cloudflare)). Then run
   `cd deploy && docker compose up -d caddy`.
3. **Check the server answers:** `curl -s https://pay.shop-two.com/healthz` should print `ok`.
4. **Set it on the project.** With the project selected, open **Project settings → Checkout domain**, enter
   `pay.shop-two.com` without `https://`, and save with a two-factor code.
5. **Check the result:**
   - `https://pay.shop-two.com/login` returns 404. Login is never served on a checkout domain.
   - A new payment link for this project starts with `https://pay.shop-two.com/pay/`. Links sent earlier keep
     working on the old address.
6. **Update the shop.** Point the shop's API base URL at `https://pay.shop-two.com` (the merchant API works on
   the checkout domain too), and add the name to your Cloudflare block rule if you like.

Each domain can belong to one project only, and the site URL can't be used as a checkout domain.

## Checklist

- [ ] Every public name has proxied DNS records and SSL/TLS set to **Full (strict)**.
- [ ] Caddy has a certificate Cloudflare accepts for every name. `curl -sI https://<name>/healthz` returns 200.
- [ ] `COLDTILL_TRUSTED_PROXIES` and `COLDTILL_CLIENT_IP_HEADER` are set for Cloudflare.
- [ ] Ports 80 and 443 only accept Cloudflare's addresses (provider firewall or `DOCKER-USER`).
- [ ] The dashboard opens on the Tailscale address.
- [ ] `/login` is blocked or returns 404 on every public name.
- [ ] Each project has its own API key, webhook, return URL and deposit wallets.
- [ ] A test payment on each checkout domain went through checkout and the webhook reached the shop.

---

Source: https://coldtill.com/docs/guides/domains-and-dashboard/ · All Coldtill docs: https://coldtill.com/llms.txt
