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.
Find the server's public IP address. It's in your provider's control panel, or run this on the server:
curl -4 https://ifconfig.me; echo curl -6 https://ifconfig.me; echoCreate the DNS records where your domain's DNS is hosted:
Type Name Value ApayThe server's IPv4 address AAAApayThe server's IPv6 address, if it has one Check it from your own machine. It can take a few minutes:
dig +short pay.example.comWithout Cloudflare's proxy this prints your server's address. With it, it prints Cloudflare addresses (
104.x,172.6xor188.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 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:
- Create a free account at cloudflare.com, choose Add a domain, and enter
your domain (
example.com). - 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.
- In DNS → Records, add the
A(andAAAA) record from the table above with Proxy status: Proxied (the orange cloud). - 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.
- On the server, tell Caddy to trust Cloudflare's visitor-IP header, as described in Behind Cloudflare. Without it, rate limits treat all visitors as one.
- Give Caddy a certificate Cloudflare accepts, as described in the next section.
- Restrict ports 80 and 443 to Cloudflare's addresses at the 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 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.
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.comto cover every subdomain), and create it.Save both parts on the server in
deploy/certs/(example.com.pemfor the certificate,example.com.keyfor the private key), then runchmod 600 deploy/certs/*.key. Cloudflare only shows the key once.Repeat for each domain in a different Cloudflare zone, for example
shop-two.com. Each zone needs its own certificate.Copy
deploy/Caddyfiletodeploy/Caddyfile.localand 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:(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 }Mount both in
deploy/docker-compose.override.yml:services: caddy: volumes: - ./Caddyfile.local:/etc/caddy/Caddyfile:ro - ./certs:/etc/caddy/certs:roRun
docker compose up -d caddy, then check each name:curl -sI https://pay.shop-two.com/healthzshould returnHTTP/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 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.
Install Tailscale on the server and sign in:
curl -fsSL https://tailscale.com/install.sh | sh sudo tailscale upInstall it on your laptop and phone too, and sign in with the same account. In the admin console, turn on MagicDNS and HTTPS certificates.
Add a private entrance to Caddy. Append this block to
deploy/Caddyfile.local(copydeploy/Caddyfileto it first if it doesn't exist). Replacepay.example.comwith your site URL's hostname, andcoldtill.your-tailnet.ts.netwith the server's Tailscale name (tailscale statusshows it):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
Originline 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.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:roKeep any volumes you already have there, such as
./certs.Start it, then hand the port to Tailscale:
cd deploy && docker compose up -d caddy sudo tailscale serve --bg http://127.0.0.1:8081The 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:
(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/andhttps://pay.example.com/loginshow Cloudflare's block page.https://pay.example.com/healthzreturnsok.- 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:
@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
403responses 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.
- In the dashboard, open the project menu at the top of the sidebar and choose + New project….
- Enter a name, for example the business or shop name, and click Create. The dashboard switches to the new project and opens its settings.
- 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:
- Point the name at the server. Add an
Arecord (andAAAAif you use IPv6) forpayin that domain's DNS, proxied through Cloudflare as described in Point the subdomain at the server. Set SSL/TLS to Full (strict) in that zone too. - Let Caddy answer for it. Either add the name to
COLDTILL_CADDY_ADDRESSindeploy/.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 ownCaddyfile.local(see Certificates behind Cloudflare). Then runcd deploy && docker compose up -d caddy. - Check the server answers:
curl -s https://pay.shop-two.com/healthzshould printok. - Set it on the project. With the project selected, open Project settings → Checkout domain, enter
pay.shop-two.comwithouthttps://, and save with a two-factor code. - Check the result:
https://pay.shop-two.com/loginreturns 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.
- 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>/healthzreturns 200. -
COLDTILL_TRUSTED_PROXIESandCOLDTILL_CLIENT_IP_HEADERare set for Cloudflare. - Ports 80 and 443 only accept Cloudflare's addresses (provider firewall or
DOCKER-USER). - The dashboard opens on the Tailscale address.
-
/loginis 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.