Skip to content

Customize UI

Jeremy Echols edited this page Jul 27, 2026 · 3 revisions

The basic challenge and fail pages are very generic and quite honestly ugly. If you need to provide a better UI, you can do so with custom templates.

You can choose to set up a TEMPLATE_PATH to point to wherever you want to store these templates, or just stick with the default: /var/local/tps/templates.

One Pair of Templates

Copy the *.go.html files from internal/templates to the top of your template path, adapt them, and you're done:

/var/local/tps/templates/challenge.go.html
/var/local/tps/templates/failed.go.html

Those two replace TPS's built-in pages for every request TPS handles, whatever the host or path. That's all most sites ever need.

Per-Host and Per-Path Templates

If one site behind TPS needs its own look, or one URL deserves its own explanation, add subdirectories. A directory directly under your template path is a hostname, and directories under that match the URL path:

/var/local/tps/templates/challenge.go.html                            <- everything
/var/local/tps/templates/front.x.edu/challenge.go.html                <- one site
/var/local/tps/templates/front.x.edu/collections/bread/challenge.go.html   <- one section

TPS picks the most specific template it can find, then falls back: the deepest matching path, then the hostname, then your top-level pair, then the built-in page. You can mix and match — a site-specific challenge page with the shared failure page is fine, since each is looked up on its own.

Note: the hostname directory is matched against the Host header, which means the public hostname, and it includes the port if the browser used one. If TPS is listening to front.x.edu and proxying to backend.x.edu, the directory is front.x.edu, never backend.x.edu. In development against http://localhost:8080/, it's localhost:8080.

The path directories match whole URL segments, and a match covers everything below it: collections/bread/ serves /collections/bread and /collections/bread/search?q=rye alike.

The <challenge-form> Placeholder

A challenge page has to do three things that have nothing to do with how it looks: load Cloudflare's Turnstile script, present a form that posts the solved token back to the URL the user asked for, and submit that form when the widget succeeds (while pinging the beacon so TPS knows a real browser ran the page).

You don't have to write any of that. Put an empty <challenge-form> element wherever the widget belongs and TPS fills it in when it loads your template:

<!DOCTYPE html>
<html>
  <head>
    <title>Just a moment</title>
    <link rel="stylesheet" href="https://example.edu/site.css">
  </head>
  <body>
    <h1>Verifying your browser</h1>
    <p>Trouble getting through? <a href="/contact">Let us know</a>.</p>

    <challenge-form></challenge-form>
  </body>
</html>

Details worth knowing:

  • The Turnstile script is added to the end of your <head> (or the top of your <body> if you have no <head>). If your template already loads challenges.cloudflare.com/turnstile/v0/api.js itself, TPS leaves it alone — loading it twice makes Turnstile render every widget twice.
  • Attributes on the element are kept, so <challenge-form class="widget"> works, as does styling challenge-form { ... } in your CSS. It's an unknown element, so browsers treat it as display: inline until you say otherwise.
  • Anything you put inside the element stays there, after the injected form. That's a decent place for a <noscript> explanation.
  • Use it once per page. Two placeholders mean two Turnstile widgets and two forms with the same id; TPS logs a warning if it sees that.
  • The generated form has the id tps-challenge-form, and the callback the widget uses is named tpsChallengeSolved. Avoid those two names in your own markup and scripts.

The core templates in internal/templates use the placeholder too, so they're a working example of the whole thing.

Hand-written challenge markup still works. If your template has no <challenge-form> element, TPS doesn't touch it at all — you're on the hook for the Turnstile script, the form (posting request_id and the cf-turnstile-response the widget produces to {{.PostAction}}), and the navigator.sendBeacon('/.tps/beacon') call. Copying the current core template and stripping the placeholder is the easy way to see what that involves.

Updating Templates

If you need to change a template, you must restart TPS in a production environment. Templates will auto-reload on change in dev, but not in production!

In dev (any GIN_MODE other than release) TPS re-reads the file on every render, so saving the file and refreshing the page is the whole loop. If you save a template that doesn't compile, TPS logs the error and keeps serving the last version that did, so a stray {{ doesn't leave you staring at a broken page while you look for the typo. In production, templates come from the binary's embedded copy or are read once at startup, and neither is re-read while TPS runs.

Clone this wiki locally