Self-hosted logging and audit engine with tamper-evident, cryptographic integrity guarantees.
700
No third parties. No data exposure. No SaaS control
š¬ Got feedback or found a bug? We'd love to hear from you! Drop your feedback, feature requests, or bug reports here ā every little note helps us improve the vault š ā open the feedback form āā
š” This image contains the BE only --- not the FE or database.
TamperTrail is a developer-first, self-hosted event integrity system that makes your logs tamper-evident, encrypted, and cryptographically verifiable ā built to keep your logs where they belong: under your control.
It's built for teams who care about trust, security, and ownership ā without giving their logs to a SaaS vendor.
Free core. Pro tier with advanced capabilities coming soon!
Prerequisites: Docker Desktop (or Docker Engine + Compose plugin)
# 1. Clone the repository
git clone https://github.com/sthakur369/TamperTrail.git
cd TamperTrail
# 2. Create your own local .env from the template
cp .env.example .env
# 3. Build and start (Ensure Port 80 is available)
docker compose up -d --build
š” Note: By default, images are pulled from Docker Hub. If you prefer to use GitHub Container Registry (GHCR), edit the .env file in the project root:
IMAGE_REGISTRY=ghcr.ioand then rundocker compose up -d --buildcommand again.
That's it. Seriously.
Navigate to http://localhost in your browser. You will see the setup wizard.
āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
ā TamperTrail Setup Wizard ā
ā ā
ā Create your master admin password ā
ā to unlock the dashboard. ā
ā ā
ā Password: āāāāāāāāāāāāāāāā ā
ā ā
ā [ Complete Setup ] ā
āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
Enter a password (8+ characters), click Complete Setup, and you're in. All routes are locked by a middleware guard until this step is complete ā the system cannot be accessed without it.
Dashboard ā API Keys ā Create Key. Copy the key (shown only once).
export TAMPERTRAIL_API_KEY="vl_a1b2c3d4e5f6..."
export TAMPERTRAIL_URL="http://localhost" # your TamperTrail instance
š” For full API details, see API_REFERENCE.mdā .
One request that demonstrates every field TamperTrail accepts:
curl -X POST "$TAMPERTRAIL_URL/v1/log" \
-H "X-API-Key: $TAMPERTRAIL_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"actor": "user:[email protected]",
"action": "payment.success",
"level": "INFO",
"message": "Payment of $149.00 processed via Stripe for Pro plan upgrade.",
"target_type": "invoice",
"target_id": "inv_9f2a3b4c",
"status": "success",
"environment": "production",
"source_ip": "203.0.113.42",
"request_id": "req_abc123",
"tags": {
"payment_provider": "stripe",
"amount_usd": "149.00",
"plan": "pro"
},
"metadata": {
"card_last4": "4242",
"stripe_charge": "ch_3abc123def",
"billing_email": "[email protected]"
}
}'
// 202 Accepted
{ "status": "accepted", "message": "Log queued for processing" }
Drop this file into your project ā it's the only dependency you need:
# tampertrail_logger.py ā drop into your project, import everywhere
import os
import httpx
from typing import Optional
TAMPERTRAIL_URL = os.getenv("TAMPERTRAIL_URL", "http://localhost/v1/log")
TAMPERTRAIL_API_KEY = os.getenv("TAMPERTRAIL_API_KEY", "your-api-key-here")
HEADERS = {
"X-API-Key": TAMPERTRAIL_API_KEY,
"Content-Type": "application/json",
}
async def send_log(
actor: str, # ā
REQUIRED ā who did it (e.g. "user:[email protected]")
action: str, # ā
REQUIRED ā what happened (e.g. "order.created")
level: Optional[str] = None, # severity: DEBUG, INFO, WARN, ERROR, CRITICAL
message: Optional[str] = None, # human-readable event description
target_type: Optional[str] = None, # resource type (e.g. "order", "invoice")
target_id: Optional[str] = None, # resource ID (e.g. "ORD-1001")
status: Optional[str] = None, # outcome: "success", "failed", "200", etc.
environment: Optional[str] = None, # "production", "staging", "test"
source_ip: Optional[str] = None, # client IP address (auto-captured if omitted)
request_id: Optional[str] = None, # correlation ID ā links related logs together
tags: Optional[dict] = None, # searchable key-value pairs (visible in dashboard)
metadata: Optional[dict] = None, # š ENCRYPTED at rest, NEVER shown in UI - (Ingest sensitive data in this field)
) -> None:
"""Send a log entry to TamperTrail. Fails silently ā logging never crashes your app."""
# Build payload ā only include fields that have values
payload = {"actor": actor, "action": action}
optional = {
"level": level, "message": message, "target_type": target_type,
"target_id": target_id, "status": status, "environment": environment,
"source_ip": source_ip, "request_id": request_id,
"tags": tags, "metadata": metadata,
}
for key, value in optional.items():
if value is not None:
payload[key] = value
try:
async with httpx.AsyncClient(timeout=5.0) as client:
await client.post(TAMPERTRAIL_URL, json=payload, headers=HEADERS)
except Exception:
pass # logging should never crash your app
Use send_log() in route handlers for important business events.
š” Use
metadatafor sensitive data ā credit card info, emails, full request bodies, PII. It's encrypted at rest and never shown in the dashboard. Only for forensic audits.
# YOUR route.py file
from tampertrail_logger import send_log
@app.post("/place-order")
def place_order(order: OrderCreate, request: Request, background_tasks: BackgroundTasks):
db_order = create_order(db, order)
# background_tasks runs AFTER response is sent ā zero latency impact on your API
background_tasks.add_task(
send_log,
actor=f"user:{order.user_id}",
action="order.created",
level="INFO",
message=f"Order {order.order_id} ā {order.order_name} worth ā¹{order.price:,.0f}",
target_type="order",
target_id=order.order_id,
status="success",
environment="production",
source_ip=request.client.host,
request_id=request.state.request_id,
tags={ # ā visible & searchable in dashboard
"price": str(order.price),
"origin": order.user_location,
"destination": order.destination,
},
metadata={ # ā š encrypted, never shown in UI
"user_id": order.user_id,
"full_payload": order.model_dump(),
},
)
return {"status": "created"}
Add middleware once ā every HTTP request is logged automatically, zero code changes in routes.
The middleware below captures 30+ data points from each request. You can trim it based on your requirements:
# YOUR middleware.py file
import time, uuid, asyncio, platform, os, inspect
from starlette.middleware.base import BaseHTTPMiddleware
from fastapi.responses import JSONResponse
from tampertrail_logger import send_log
class LoggingMiddleware(BaseHTTPMiddleware):
SKIP_PATHS = {"/health", "/favicon.ico"}
async def dispatch(self, request, call_next):
request_id = str(uuid.uuid4()) # ā request_id
request.state.request_id = request_id
start = time.time()
# Execute request (catch crashes ā error tag)
error_detail = None
try:
response = await call_next(request)
status_code = response.status_code # ā status
except Exception as e:
status_code = 500
error_detail = f"{type(e).__name__}: {str(e)}" # ā error
response = JSONResponse(status_code=500, content={"detail": "Internal Server Error"})
if request.url.path in self.SKIP_PATHS:
response.headers["X-Request-ID"] = request_id
return response
# ā actor (from X-User-ID header or fallback)
user_id = request.headers.get("X-User-ID")
actor = f"user:{user_id}" if user_id else "service:my-api"
# ā client_ip, client_port (from proxy headers or direct connection)
client_ip = (request.headers.get("X-Forwarded-For", "").split(",")[0].strip()
or request.client.host)
# ā handler_file, handler_function, handler_line (introspection)
handler_file = handler_function = handler_line = None
try:
endpoint = request.scope.get("endpoint")
if endpoint:
handler_file = os.path.basename(inspect.getfile(endpoint))
handler_function = endpoint.__name__
handler_line = str(inspect.getsourcelines(endpoint)[1])
except Exception:
pass
route = request.scope.get("route")
latency_ms = round((time.time() - start) * 1000, 2) # ā latency_ms
level = "ERROR" if status_code >= 500 else ("WARN" if status_code >= 400 else "INFO")
# āā Build tags (all visible & searchable in dashboard) āāāāā
tags = {
"method": request.method, # ā HTTP method
"path": request.url.path, # ā endpoint path
"full_url": str(request.url), # ā complete URL
"scheme": request.url.scheme, # ā http or https
"http_version": request.scope.get("http_version", ""), # ā protocol version
"latency_ms": str(latency_ms), # ā response time
"client_ip": client_ip, # ā real client IP
"client_port": str(request.client.port) if request.client else "", # ā client port
"user_agent": request.headers.get("user-agent", ""), # ā browser/SDK
"host": request.headers.get("host", ""), # ā host header
"language": request.headers.get("accept-language", "").split(",")[0].strip(), # ā locale
"server_hostname": platform.node(), # ā server name
"server_os": platform.system(), # ā OS
"python_version": platform.python_version(), # ā runtime
"server_pid": str(os.getpid()), # ā process ID
}
# Optional fields (added only when data exists; spacing is only for readability)
if request.url.query: tags["query_string"] = str(request.url.query)
if request.headers.get("content-type"): tags["request_content_type"]= request.headers["content-type"]
if request.headers.get("content-length"):tags["request_bytes"] = request.headers["content-length"]
if response.headers.get("content-type"): tags["response_content_type"]= response.headers["content-type"]
if response.headers.get("content-length"):tags["response_bytes"] = response.headers["content-length"]
if request.headers.get("referer"): tags["referer"] = request.headers["referer"]
if request.headers.get("origin"): tags["origin"] = request.headers["origin"]
if request.headers.get("authorization"): tags["authenticated"] = "true" # presence only, never the token!
if handler_file: tags["handler_file"] = handler_file
if handler_function: tags["handler_function"] = handler_function
if handler_line: tags["handler_line"] = handler_line
if getattr(route, "path", None): tags["route_pattern"] = route.path
if error_detail: tags["error"] = error_detail[:200]
# āā Fire log to TamperTrail āāāāāāāāāāāāāāāāāāāāāāāāāā
asyncio.create_task(send_log(
actor=actor,
action="http.request",
level=level,
message=f"{request.method} {request.url.path} ā {status_code}",
status=str(status_code),
source_ip=client_ip,
request_id=request_id,
tags=tags,
))
response.headers["X-Request-ID"] = request_id
return response
# Register: app.add_middleware(LoggingMiddleware)
All endpoints are prefixed with /v1. Authentication uses either a JWT cookie (dashboard) or X-API-Key header (programmatic access).
š” For full API details, see API_REFERENCE.mdā
Every log entry is SHA-256 hashed and chained to the previous one ā forming an unbreakable ledger. Any deletion, modification, or insertion is mathematically detectable. Run GET /v1/verify at any time to validate the entire chain. Retention-safe: monthly checkpoints bridge gaps so archiving old logs doesn't break verification.
Your logs have two data layers:
| Field | Visibility | Indexed | Use for |
|---|---|---|---|
tags | ā Visible in dashboard | ā GIN-indexed, searchable | Event context, filtering, display |
metadata | ā Never exposed in UI | ā Encrypted BYTEA blob | Sensitive forensic data |
metadata is encrypted server-side with Fernet AES-128-CBC the moment it arrives. The raw payload never touches the database. Even with read-only database access, an attacker sees only binary ciphertext. Supports key rotation and optional envelope encryption via a MASTER_KEY for integration with AWS KMS or HashiCorp Vault.
POST /v1/log responds in <10ms. Logs are written to a crash-safe Write-Ahead Log (WAL) on disk before being micro-batched into PostgreSQL by a background worker. If the server restarts mid-batch, uncommitted entries are replayed automatically ā zero data loss by design.
All traffic passes through a hardened Nginx reverse proxy. The FastAPI server is never exposed directly. Strict per-IP rate limits protect every endpoint:
Security headers included out of the box: X-Frame-Options, X-Content-Type-Options, Content-Security-Policy, HSTS, and more.
No .env files to edit. No secrets to generate manually. On first boot, TamperTrail automatically:
/dev/urandom)chmod 600 permissionsOne command. Fully production-ready.
Isolate logs by project (tenant) with two layers of enforcement: application-level tenant filtering in every query, and PostgreSQL Row-Level Security (RLS) as a hard backstop. Add team members with admin or viewer roles. Track login sessions with IP and user-agent history. License limits enforced at the API layer.
āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
ā Your Browser ā
āāāāāāāāāāāāāāāāāāāāāāāāā¬āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
ā Port 80 (only exposed port)
āāāāāāāāāāāāāāāāāāāāāāāāā¼āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
ā tampertrail-client (Nginx) ā
ā ā
ā ⢠Serves React dashboard (SPA) ā
ā ⢠Rate limiting per IP per endpoint ā
ā ⢠Security headers (CSP, HSTS, X-Frame-Options) ā
ā ⢠Reverse proxies /v1/* ā tampertrail-server ā
ā ⢠FastAPI /docs blocked from public access ā
āāāāāāāāāāāāāāāāāāāāāāāāā¬āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
ā Internal Docker network (port 8000)
āāāāāāāāāāāāāāāāāāāāāāāāā¼āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
ā tampertrail-server (FastAPI) ā
ā ā
ā ⢠Log ingestion with WAL crash recovery ā
ā ⢠SHA-256 hash chain computation ā
ā ⢠Fernet metadata encryption ā
ā ⢠JWT authentication + Argon2 password hashing ā
ā ⢠Multi-tenant RLS enforcement ā
ā ⢠Micro-batch async DB writer ā
ā ⢠Monthly partition management ā
ā ⢠Chain verification engine ā
āāāāāāāāāāāāāāāāāāāāāāāāā¬āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
ā Internal Docker network (port 5432)
āāāāāāāāāāāāāāāāāāāāāāāāā¼āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
ā tampertrail-db (PostgreSQL 16) ā
ā ā
ā ⢠audit_logs: monthly range-partitioned table ā
ā ⢠encrypted_metadata: BYTEA (Fernet ciphertext) ā
ā ⢠tags: JSONB with GIN index (fast search) ā
ā ⢠Row-Level Security on all sensitive tables ā
ā ⢠Chain checkpoints for retention-safe verification ā
āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
TamperTrail's tamper detection works like a blockchain: each entry's hash includes the previous entry's hash. Modify or delete any entry, and the chain breaks.
# Quick verification ā stops at first error
curl -H "Authorization: Bearer $TOKEN" \
"http://localhost/v1/verify"
# Deep scan ā finds ALL tampered entries across the entire vault
curl -H "Authorization: Bearer $TOKEN" \
"http://localhost/v1/verify/deep"
// All clear
{
"valid": true,
"checked": 10482,
"message": "All 10,482 log entries verified successfully. Chain integrity intact."
}
// Tampering detected
{
"valid": false,
"checked": 4201,
"first_error_at": "2026-01-15T14:22:03.491Z",
"message": "Chain break detected: row prev_hash does not match previous row hash. Possible tampering before 2026-01-15T14:22:03Z."
}
š” Free core edition available. Pro tier (extended limits and advanced capabilities) in development!
TamperTrail runs on a license key system. Without a license key, the Free tier applies.
| Limit | Free | Pro (Coming Soon) |
|---|---|---|
| Dashboard users | 1 | TBD |
| Projects (tenants) | 1 | TBD |
| Environments per project | 2 | TBD |
| Log retention | 30 days | Extended retention |
| Log ingestion | Unlimited | TBD |
| API keys | Unlimited | TBD |
Licenses are RS256-signed JWTs. Apply via Admin ā Settings ā License Key in the dashboard. Expiry is graceful ā existing data is never deleted, only creation of new resources above free-tier limits is blocked.
TamperTrail is Self-Hosted Proprietary Software.
By downloading and using this software, you agree to the terms outlined in the LICENSEā file.
Copyright Ā© 2026 TamperTrail. All rights reserved.
Content type
Image
Digest
sha256:249b35228ā¦
Size
91.2 MB
Last updated
7 months ago
docker pull sthakur369/tampertrail-server