Coldtill Get Coldtill

Guide: projects, domains and a private dashboard

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

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:

    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:

    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.

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:

  1. Create a free account at 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. 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, 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.

  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:

    (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:

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

    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, 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):

    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:

    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:

    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:

(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:

@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. 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). 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.