Skip to content
nptmpl @ nguyenpanda.com v1.1.0

Documentation

Deploy, configure and operate your nptmpl registry.

๐Ÿš€ 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:

  1. --config <path> (CLI argument)
  2. $NPTMPL_CONFIG_PATH (environment variable)
  3. $XDG_CONFIG_HOME/nptmpl/config.yaml, else ~/.config/nptmpl/config.yaml
  4. ~/.nptmpl/config.yaml
  5. ./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

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 to chown the storage volume, then drops to uid 1000 (nptmpl) via gosu. The server process itself never runs as root.
  • HEALTHCHECK polls /health every 30s. docker compose ps shows the result.
  • Named volumes inherit /app/store ownership 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