> ## Content Index
> Fetch the complete content index at: https://corti.com/llms.txt
> Use this file to discover other available public pages before exploring further.

# Serving TokenTelemetry, a localhost-only app on the internet, safely using just a script: a tour of tokentelemetry-caddy
- URL: https://corti.com/serving-tokentelemetry-a-localhost-only-app-on-the-internet-safely-using-just-a-script-a-tour-of-tokentelemetry-caddy/
- Published: 2026-06-22T13:20:43.000Z
- Updated: 2026-06-22T15:13:38.000Z
- Author: Sascha Corti

I host OpenClaw and Hermes on two different virtual machines in the cloud. I want to be able to view their token usage using TokenTelemetry, but the tool is built to run unauthenticated on localhost.

To get a way to securely publish the TokenTelemetry endpoint of http://localhost:3000 to the public internet using a custom domain name and automatic forwarding from HTTP to HTTPS, I wrote a Bash script that installs Caddy as a reverse proxy in front of the dashboard.

The repo can be found at [https://github.com/techpreacher/tokentelemetry-caddy](https://github.com/techpreacher/tokentelemetry-caddy?ref=corti.com).

Most self-hosted dashboards are built for one of two worlds. Either they assume  
they're sitting on a trusted LAN with no auth at all, or they ship a full  
identity stack — user tables, OAuth, sessions — that's overkill when the only  
person who ever logs in is you. [TokenTelemetry](https://tokentelemetry.com/?ref=corti.com), a  
Next.js + FastAPI app for tracking LLM token usage, lands squarely in the first  
camp: a frontend and an API that happily bind to a port and expect nobody hostile  
to be on the network.

That's fine on your laptop. It's a problem the moment you want to reach it from  
your phone, from a coworker's machine, or from anywhere that isn't `localhost`.

`tokentelemetry-caddy` is a pair of Bash scripts that close that gap without  
dragging in an auth framework. It stands TokenTelemetry up as a **localhost-only**  
service and puts [Caddy](https://caddyserver.com/?ref=corti.com) in front of it as a  
TLS-terminating reverse proxy that gates every request behind a **single bearer** 
**token**. The result: one authenticated HTTPS URL, automatic Let's Encrypt  
certificates, and nothing on the public internet except Caddy.

There is no application code in the repo — just an installer, an uninstaller, and  
docs. You copy the scripts to a fresh Ubuntu/Debian box and run them with `sudo`.

![](https://corti.com/content/images/2026/06/token-telemetry-installation-2.png)

## The shape of the deployment

```plain
                 ┌────────────────────── your server ───────────────────────┐
 Internet        │                                                          │
   │  https +    │   Caddy (:443)                                           │
   │  token  ────────►  TLS + token auth ──┬─► 127.0.0.1:3000  Next.js UI   │
   ▼             │                         └─► 127.0.0.1:8000  FastAPI API  │
                 │                             (/tokentelemetry-api/*)      │
                 └──────────────────────────────────────────────────────────┘

```

Two systemd units do the work:

- `tokentelemetry-backend.service` — FastAPI, bound to `127.0.0.1:8000`.
- `tokentelemetry-frontend.service` — Next.js production server (`next start`), bound to `127.0.0.1:3000`.

Neither is reachable from outside the machine. Caddy owns `:443`, terminates TLS,  
checks the token, and proxies the survivors to the right local port. If a request  
arrives without a valid token, it never touches the app.

![](https://corti.com/content/images/2026/06/token-telemetry-dashboard.png)

## The token gate (and the cookie trick)

The whole security model is one token, accepted three ways:

| Method               | Example                            |
| -------------------- | ---------------------------------- |
| Authorization header | Authorization: Bearer <token>      |
| Query parameter      | https://your.domain/?token=<token> |
| Cookie               | tt\_token=<token>                  |

The header form is for API clients and scripts. The query-param form is for the  
first browser visit. The cookie form is what makes the browser experience  
bearable — and it's the clever bit.

A naive query-param gate forces you to append `?token=…` to *every* URL, which  
breaks the instant you click an internal link. The Caddy block solves this: when  
a request arrives with a valid `?token=`, Caddy responds with a one-year  
`tt_token` cookie:

```caddy
@hasquerytoken expression `{http.request.uri.query.token} == "__TOKEN__"`
header @hasquerytoken +Set-Cookie "tt_token=__TOKEN__; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=31536000"

```

So you visit `https://your.domain/?token=<token>` exactly once. After that the  
cookie authenticates every subsequent navigation, and the token drops out of the  
URL bar. The app's own hardcoded absolute paths (`/settings`, etc.) just work,  
because the whole site is served from the domain root rather than a subfolder.

One deliberate hole: static Next.js bundles under `/_next/*` and `/icon.svg*` are  
served **without** auth. They're just JS and CSS — no data — and gating them  
would mean the browser couldn't load the login-less assets it needs to render the  
page that *does* check the token.

```caddy
@assets path /_next/* /icon.svg*
handle @assets {
    reverse_proxy localhost:__FRONTEND_PORT__
}

```

The block is written by a **literal** (single-quoted) heredoc so Caddy's own  
`{…}` and backticks survive Bash unscathed, then `sed` substitutes the real  
domain, token, and ports into `__DOMAIN__`\-style placeholders. It's a tidy way to  
template a config full of characters the shell would otherwise mangle.

## Why production build, not `next dev`

Step 3 of the install is the one that looks like a detail but is actually a  
hard-won lesson:

```bash
NEXT_PUBLIC_API_BASE="https://$DOMAIN/tokentelemetry-api" \
  node node_modules/.bin/next build
# …then serve with `next start`

```

The script builds the frontend for production and serves it with `next start`,  
rather than running `next dev`. Turbopack's dev mode is unreliable headless — on  
some servers it panics processing `globals.css` and returns blank 500s. A  
prebuilt `.next/` served by `next start` sidesteps that entirely.

There's a consequence worth internalizing: `NEXT_PUBLIC_API_BASE` is baked into  
the bundle **at build time**, not read at runtime. It points the frontend at the  
token-gated `/tokentelemetry-api` path on your public domain. Because it's  
compiled in, **changing the domain means rebuilding** — which is exactly what  
`--update` mode is for.

## The installer dance

The trickiest part of the deploy isn't TLS or tokens — it's wrangling  
TokenTelemetry's own installer. The official one-liner  
(`curl -fsSL https://tokentelemetry.com/install.sh | bash`) does two things: it  
installs dependencies, and then it launches its own dev servers and keeps  
running. We want the first half and not the second.

The script's solution:

1. Run the installer under `setsid` in the background, as the target user, with output redirected to `~/.tt-install.log`.
2. Poll for the two completion stamp files the installer drops once deps are in:  
`backend/venv/.requirements.sha` and `frontend/node_modules/.package-json.sha`,  
with a 15-minute deadline for cold npm/pip installs.
3. Once both stamps exist, `pkill` the installer's process tree — `setsid` put it  
in its own process group precisely so the whole thing can be signaled cleanly.

```bash
deadline=$(( $(date +%s) + 900 ))
until [ -f "$BACKEND_STAMP" ] && [ -f "$FRONTEND_STAMP" ]; do
  sleep 5
  [ "$(date +%s)" -ge "$deadline" ] && die "installer did not finish…"
done
pkill -INT -u "$TT_USER" -f "$INSTALL_DIR"  # then -KILL after a grace period

```

We harvest the dependency install and discard the dev servers, then bring up our  
own production systemd units instead.

## The dry-run invariant

The single most important design rule in this repo: **every system-mutating** 
**command goes through a wrapper, so `--dry-run` is always honest.** You can preview  
an entire install — every `apt-get`, every file write, every `systemctl` — on a  
machine you'd never want to actually touch:

```bash
./deploy-tokentelemetry-caddy.sh --dry-run

```

Three helpers carry this:

- `run <cmd…>` — execute, or print `[dry-run] would: …`.
- `as_user "<string>"` — run as the target user via a login shell (so `node`/`npm` are on `PATH`), or print it.
- `emit <path>` — write a file from a heredoc, or report the write *while still consuming stdin* so the surrounding heredoc still parses.

A bare `apt-get` or a raw `>` redirect to a system path would silently corrupt the  
dry-run preview, so the discipline is: when you add a step, wrap it. The one  
deliberate exception is `.env` loading, which only sets shell variables and  
changes nothing — so it runs even under `--dry-run`, because the preview needs the  
real domain and ports to be meaningful.

## Configuration without a config format

Settings come from `TT_*` environment variables — `TT_DOMAIN`, `TT_TOKEN`,  
`TT_FRONTEND_PORT`, `TT_BACKEND_PORT`, `TT_USER`, `TT_HERMES` — or from a `.env`  
file sitting next to the script. Setting `TT_DOMAIN` (and optionally `TT_TOKEN`)  
makes the whole deploy non-interactive.

The `.env` loader is intentionally *not* `source` or `set -a`. It parses only  
validated `KEY=VALUE` pairs, never executes the file, and — crucially —  
**environment wins over the file**: any key already set in the environment is  
skipped. That precedence lets you override a single value per run without editing  
the file:

```bash
sudo TT_TOKEN="$(openssl rand -hex 32)" ./deploy-tokentelemetry-caddy.sh
# this TT_TOKEN beats whatever .env says

```

The loader is duplicated verbatim in both scripts on purpose — each is meant to be  
copied to a server and run standalone, with no shared library to carry along. The  
real `.env` is gitignored (it holds the token); `.env.example` is the committed  
template.

## Update and uninstall

`--update` is the safe-rebuild path. It reuses the existing domain, token, and  
Caddy config untouched, derives the baked-in API base from the existing frontend  
unit (or `TT_DOMAIN`), upgrades only already-installed apt packages, runs  
`git pull --ff-only`, refreshes pip and npm deps, rebuilds the production  
frontend, and restarts the services.

The uninstaller reverses the install: it stops and removes the two systemd units  
and deletes the `$DOMAIN { … }` block from the Caddyfile with an `awk` range that  
relies on the deploy script writing a top-level block whose closing `}` sits in  
column 0\. It **never** touches shared prerequisites — Node, Caddy, python3 may be  
used by other things on the box — and leaves the install directory in place unless  
you pass `--purge`. Both scripts back up the Caddyfile (`.bak.<epoch>`) before any  
edit.

## When to reach for this

This pattern is a good fit when:

- You have a **single-tenant** internal tool — a dashboard, an admin panel, a  
metrics UI — that has no auth of its own and you don't want to build any.
- You want it reachable over **real HTTPS** from anywhere, with certificates that  
renew themselves, not a self-signed cert and a VPN.
- A **shared bearer token** is an acceptable security boundary. One secret, no  
user accounts, no session store.
- You're on a **Debian-family box with systemd** and can point a DNS A/AAAA record  
at it (required before Caddy can get a certificate).

It's the wrong tool if you need per-user accounts, role-based access, audit logs  
of *who* did what, or anything where a single shared token is too coarse. The  
token is the only thing protecting the dashboard — and because it can ride in a  
query string, it can land in browser history and server logs, so treat the  
`?token=…` URL as a secret and rotate it (edit `/etc/caddy/Caddyfile`,  
`systemctl reload caddy`) if it leaks.

## The takeaway

`tokentelemetry-caddy` is a small, opinionated answer to a common question: *how* 
*do I get a localhost-only app onto the internet without rewriting it?* The answer  
it encodes — bind the app to loopback, let a reverse proxy own TLS and a single  
token, and make every destructive step previewable — generalizes well beyond  
TokenTelemetry. Swap the install steps and the two upstream ports, keep the Caddy  
token gate and the dry-run discipline, and you have a reusable recipe for exposing  
*any* trusted-network app as one authenticated HTTPS endpoint.