๐ nptmpl Server Setup Guide
How to deploy, configure, and secure the nptmpl HTTP registry server. The
backend is FastAPI + SQLite, served by Uvicorn.
๐ Where Everything Lives
This is the authoritative path reference. Everything else in this guide refers back to it.
The storage root
One directory holds the entire registry. It is set by --storage (CLI),
NPTMPL_SERVER_STORAGE (Docker), or core.store_path (config file).
<storage_root>/
โโโ registry.db โ SQLite database (metadata, versions, activity log)
โโโ registry.db-wal โ write-ahead log; part of the database
โโโ registry.db-shm โ shared-memory index; part of the database
โโโ <group>/
โโโ <name>/
โโโ <version>/
โโโ data.tar.gz โ the template archive
โโโ .nptmpl โ the version's metadata descriptor
Back up the whole storage root, not just registry.db. With WAL enabled,
registry.db-wal can hold committed transactions that are not yet in the main
file. Copying registry.db alone can silently lose recent writes.
Storage root by deployment
| Deployment | Storage root | Set by |
|---|---|---|
| Docker (compose) | /app/store inside the container, backed by the nptmpl-store named volume |
NPTMPL_SERVER_STORAGE in the image |
| systemd (LIVA) | /home/nguyenpanda/.config/nptmpl/db |
--storage in nptmpl.service |
| Local CLI default | ~/.nptmpl/db |
ConfigManager.DEFAULT_STORE_PATH |
config.yaml example |
$XDG_DATA_HOME/nptmpl/db |
core.store_path |
To print the resolved path on any machine:
nptmpl path --show-store
nptmpl path --show-config
Configuration file lookup order
The first file that exists wins:
--config <path>(CLI argument)$NPTMPL_CONFIG_PATH(environment variable)$XDG_CONFIG_HOME/nptmpl/config.yaml, else~/.config/nptmpl/config.yaml~/.nptmpl/config.yaml./config.yaml(current working directory)
In the Docker image WORKDIR is /app, so mounting a file at
/app/config.yaml is picked up by rule 5 with no extra environment variable.
Repository files that matter for deployment
| Path | Purpose |
|---|---|
Dockerfile |
Two-stage image build (builder venv โ slim runtime) |
docker-compose.yml |
The deployment stack; lives at the repo root |
docker-entrypoint.sh |
Fixes volume ownership, drops root, launches the server |
.env.example |
Template for .env; copy and fill before first run |
.dockerignore |
Keeps local state (.env, *.db, templates/) out of the image |
.deploy/nptmpl.service |
systemd unit for the LIVA edge device |
.deploy/liva.sh |
rsync + remote reinstall + service restart |
config.yaml |
Local (gitignored) config for development |
๐๏ธ Deployment Options
1. Docker Compose (recommended)
cp .env.example .env
# Fill in NPTMPL_SERVER_TOKEN, NPTMPL_SESSION_SECRET, NPTMPL_ADMIN_PASS.
# Compose refuses to start if any of them is unset.
docker compose up -d --build
curl -fsS http://localhost:9090/health
Generate the two secrets with:
python3 -c "import secrets; print(secrets.token_hex(32))"
Environment variables read by the stack:
| Variable | Required | Effect |
|---|---|---|
NPTMPL_SERVER_TOKEN |
yes | Bearer token for push / rm. Without it the write API refuses all writes. |
NPTMPL_SESSION_SECRET |
yes | Signs /admin session cookies. Changing it logs everyone out. |
NPTMPL_ADMIN_PASS |
yes | Web UI admin password. |
NPTMPL_ADMIN_USER |
no (admin) |
Web UI admin username. |
NPTMPL_HOST_PORT |
no (9090) |
Host port. The container port is always 9090. |
NPTMPL_REINDEX |
no | Any non-empty value rebuilds the SQLite index from the archives on boot. |
NPTMPL_ENABLE_DOCS |
no | Any non-empty value exposes OpenAPI docs at /api/docs. |
Data safety:
docker compose down # stops the server, keeps the data
docker compose down -v # ALSO DELETES the nptmpl-store volume โ unrecoverable
Backup and restore:
# Back up the whole storage root
docker run --rm -v nptmpl-store:/store -v "$PWD":/backup alpine \
tar czf /backup/nptmpl-store-$(date +%F).tar.gz -C /store .
# Restore into a fresh volume
docker run --rm -v nptmpl-store:/store -v "$PWD":/backup alpine \
tar xzf /backup/nptmpl-store-2026-08-25.tar.gz -C /store
Container behaviour worth knowing:
- The image does not set
USER. The entrypoint starts as root only long enough tochownthe storage volume, then drops to uid 1000 (nptmpl) viagosu. The server process itself never runs as root. HEALTHCHECKpolls/healthevery 30s.docker compose psshows the result.- Named volumes inherit
/app/storeownership automatically. Bind mounts are chowned at startup instead; if the host directory is read-only the entrypoint logs a warning rather than failing silently.
2. Direct CLI
pip install nptmpl # or: uv tool install nptmpl
nptmpl server --host 0.0.0.0 --port 9090 --storage /var/lib/nptmpl-store
Useful flags: --reindex (rebuild the index from archives), --enable-docs
(expose /api/docs), --reload (development only).
3. systemd (LIVA edge device)
The unit in .deploy/nptmpl.service reads its secrets from
/etc/nptmpl/nptmpl.env so they are not world-readable in the unit file.
sudo mkdir -p /etc/nptmpl
sudo install -m 600 /dev/null /etc/nptmpl/nptmpl.env
sudo tee /etc/nptmpl/nptmpl.env >/dev/null <<'EOF'
NPTMPL_SERVER_TOKEN=...
NPTMPL_SESSION_SECRET=...
NPTMPL_ADMIN_USER=nguyenpanda
NPTMPL_ADMIN_PASS=...
EOF
sudo cp .deploy/nptmpl.service /etc/systemd/system/nptmpl.service
sudo systemctl daemon-reload
sudo systemctl enable --now nptmpl.service
systemctl status nptmpl.service
.deploy/liva.sh handles rsync, reinstall, and restart in one step.
4. Nginx reverse proxy
The server trusts X-Forwarded-For for client IPs, so the proxy must set
it or every visitor is logged as 127.0.0.1.
server {
listen 443 ssl;
server_name nptmpl.nguyenpanda.com;
location / {
proxy_pass http://127.0.0.1:9090;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Required for live traffic counters.
location /ws {
proxy_pass http://127.0.0.1:9090/ws;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 86400s;
}
}
๐ Security
| Feature | NPTMPL_SERVER_TOKEN |
NPTMPL_SESSION_SECRET |
|---|---|---|
| Protects | CLI writes (push, rm) |
Web UI (/admin, /login) |
| Mechanism | Bearer token (stateless) | Signed session cookie |
| If unset | Write API rejects everything with 403 | Falls back to the API token, then to a random key that rotates on restart |
Set both, to different values. Path traversal is blocked on every push,
download, and inspect call: groups and names must match
^[a-zA-Z0-9_-]+$ and versions must be valid semver.
๐ Analytics & Activity Logging
What is stored
Activity logging deliberately stores only data that cannot be derived from something else:
| Table | Columns | Notes |
|---|---|---|
user_logs |
ip_address, headers_json, template_group, template_name, version, downloaded_at |
headers_json is the verbatim request header set, minus cookie and authorization |
ip_geo_cache |
ip, country, region, city, isp, org, is_mobile, status, resolved_at |
A lookup cache keyed by IP, not per-request data. 90-day TTL, negative results cached too |
Browser, browser version, OS, device class, bot/CLI detection, referer and
language are not stored. They are parsed from headers_json at render time
by nptmpl.server.useragent. Geolocation is joined from ip_geo_cache.
Upgrading from an older database migrates automatically on first start: legacy
geo columns are drained into ip_geo_cache, the legacy user_agent column is
folded into headers_json, and the derivable columns are dropped. The
migration is idempotent and preserves row IDs and timestamps.
What counts as a download
A download is recorded when the archive itself is fetched:
GET /archive/download/{group}/{name}/{version}/{filename}(Web UI button)GET /api/v1/templates/{group}/{name}/download(CLI)
The CLI's preceding metadata call (?clone=true) does not count. Counting
it double-billed every clone and attributed the download to the newest version
even when the user pinned an older one.
Clones from a purely local registry never reach the server and are never counted.
๐ก API Reference
Public
| Endpoint | Purpose |
|---|---|
GET /health |
Liveness probe; returns status, version, template count, storage path |
GET /api/v1/templates |
List templates (q, lang, tag, license, author, sort) |
GET /api/v1/templates/{group}/{name} |
Template metadata |
GET /api/v1/templates/{group}/{name}/summary |
Downloads today, lifetime total, last updated, per-version counts |
GET /api/v1/templates/{group}/{name}/download |
Download an archive (v pins a version) |
GET /api/v1/stats/downloads |
Time series (range=7d\|30d\|90d\|all, bucket=day\|week\|month, group, name, start, end) |
GET /api/v1/raw/{group}/{name}/{version}?path= |
Stream a raw file from an archive |
GET /api/v1/inspect/{group}/{name}/{version}?path= |
Syntax-highlighted file content |
Authenticated โ Bearer token
| Endpoint | Purpose |
|---|---|
POST /api/v1/templates/push |
Upload a new version |
DELETE /api/v1/templates/{group}/{name}/{version} |
Delete a version |
Authenticated โ admin session
| Endpoint | Purpose |
|---|---|
GET /admin/api/logs/activity |
Activity log (q, country, group, name, start, end, limit, offset) |
GET /admin/api/logs/updates |
Publish history (q, group, start, end, limit, offset) |
GET /admin/api/stats/templates |
Per-template download totals |
๐ผ๏ธ Visual Demo Previews
Template authors declare previews via the demo field in .nptmpl:
name: my-latex-report
version: 1.0.0
author: Your Name
description: A professional LaTeX report template.
languages:
- latex
demo:
- path: demo/slide1.png
description: "Title Slide"
- path: demo/main.pdf
description: "Full Deck"
tags:
- academic
Images render inline; PDFs render in an embedded viewer. The referenced file
must exist inside the template's data.tar.gz; it is streamed straight from
the archive without extraction.
Relative paths in a template README
A template README is normally written to render on GitHub, where media/logo.png
resolves against the repository. On the registry the same file lives inside
data.tar.gz, so relative targets in <img src>, <a href> and Markdown
 / [](โฆ) are rewritten to the raw endpoint when the page is rendered:
media/assets/logo.png โ /api/v1/raw/<group>/<name>/<version>?path=media%2Fassets%2Flogo.png
This means one README works on both surfaces โ keep every asset reference
relative and it will resolve in either place. Left untouched: absolute URLs,
// protocol-relative URLs, site-absolute paths, #anchors, mailto: links,
and anything inside a fenced code block (so build instructions keep showing the
real path).
Because GitHub cannot embed a PDF inline, the recommended pattern for a compiled artefact is a preview image that links to it:
<a href="demo/main.pdf"><img src="demo/slide1.png" width="45%"></a>
GitHub shows the PNG and follows the link; the registry rewrites both and can
additionally embed the PDF through the demo field.
๐ ๏ธ Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
Every visitor logged as 127.0.0.1 |
Proxy not forwarding the client IP | Set X-Forwarded-For in nginx (see above) |
| Template list empty after a restore | Index not rebuilt from the restored archives | Restart with NPTMPL_REINDEX=1, or nptmpl server --reindex |
Link_Offline in the footer |
WebSocket not proxied | Add the /ws block to the nginx config |
Admin login loops back to /login |
NPTMPL_SESSION_SECRET rotating on restart |
Set it explicitly |
403 Server security policy error on push |
NPTMPL_SERVER_TOKEN not set on the server |
Set it and restart |
| Permission errors on a bind-mounted store | Host directory not writable by uid 1000 | sudo chown -R 1000:1000 <dir>, or use the named volume |