Skip to main content
Version: 0.11

Install TokenTimer self-hosted

Choose an install path

PathBest forPrerequisites
Docker ComposeSingle host, fastest startDocker and Docker Compose
Kubernetes (Helm)Production clustersKubernetes >= 1.29, Helm >= 3.14

Both paths deploy the same components: the REST API (Node.js, port 4000), the React dashboard, PostgreSQL, and a set of six workers (alert discovery, alert delivery, weekly digest, auto sync, endpoint/SSL check, CertOps maintenance).

Option A - Docker Compose

Step 1 - Clone and configure

cd tokentimer-core/deploy/compose
cp .env.example .env

Step 2 - Edit .env

Minimal required configuration (production-safe baseline):

# Core runtime
NODE_ENV=production
SESSION_SECRET=replace_with_a_long_random_value

# Database
DB_HOST=postgres
DB_PORT=5432
DB_NAME=tokentimer
DB_USER=tokentimer
DB_PASSWORD=replace_with_secure_password

# Initial admin bootstrap (first start only)
ADMIN_EMAIL=admin@your-company.com
ADMIN_PASSWORD=ChangeThisSecurePassword123!
ADMIN_NAME=Administrator

# Public URLs (what users/browsers should use)
APP_URL=http://localhost:5173
API_URL=http://localhost:4000

# Required only if you plan to connect a CertOps agent. Compose enables CertOps
# by default (CERTOPS_ENABLED:-true); without these two keys the CertOps UI
# appears but agent registration and job dispatch fail closed.
# Generate each with: openssl rand -hex 32
# CERTOPS_SIGNING_ENCRYPTION_KEY=
# CERTOPS_REGISTRATION_ENCRYPTION_KEY=
# Or set CERTOPS_ENABLED=false if you do not use CertOps at all.

Optional but common additions: SMTP sender identity (SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS, FROM_EMAIL, FROM_EMAIL_NAME) and host port remaps (API_PORT, DASHBOARD_PORT; keep APP_URL/API_URL in sync if you remap).

warning

If you run locally on http://localhost with NODE_ENV=production, authentication can fail because secure session cookies are not persisted or sent by the browser on plain local HTTP. Put HTTPS in front of the API and dashboard (reverse proxy with TLS), or use NODE_ENV=development for local non-TLS testing. As a last-resort local troubleshooting option only, set SESSION_COOKIE_SECURE_LOCALHOST_OVERRIDE=true.

note

If the API sits behind a reverse proxy or load balancer, set TRUST_PROXY_HOPS to the number of proxy hops (the Compose default is 2, covering LB plus reverse proxy; set 0 when nothing is in front).

Step 3 - Start all services

# A) Build from local source (default)
docker compose up -d

# B) Use prebuilt images (override file)
# Optional vars in .env: TT_IMAGE_REGISTRY, TT_IMAGE_OWNER, TT_IMAGE_TAG
docker compose -f docker-compose.yml -f docker-compose.images.yml up -d

The Compose stack starts PostgreSQL, runs database migrations as a one-shot migrations service, then starts the API, the dashboard, and the six worker containers (worker-discovery, worker-delivery, worker-weekly-digest, worker-auto-sync, worker-endpoint-check, worker-certops).

Step 4 - Verify

# View logs
docker compose logs -f

# Check health
curl http://localhost:4000/health

# View running services
docker compose ps

You should see: all services Up (API and dashboard with healthy healthchecks), and the health endpoint responding with:

{
"status": "healthy",
"timestamp": "2026-03-19T12:00:00.000Z",
"uptime": 123.456,
"environment": "production"
}

Open http://localhost:5173 in your browser to reach the dashboard.

Option B - Kubernetes with Helm

The chart deploys the API and dashboard as Deployments, the workers as CronJobs, and PostgreSQL as a CloudNativePG Cluster by default (an existing external PostgreSQL is also supported). Optional resources (disabled by default): Ingress, HPA, PDB, ServiceMonitor, PrometheusRule, NetworkPolicy.

Step 1 - Install the CloudNativePG operator (if using in-cluster PostgreSQL)

helm repo add cnpg https://cloudnative-pg.github.io/charts
helm repo update
helm install cnpg-operator cnpg/cloudnative-pg \
--namespace cnpg-system --create-namespace \
--version 0.23.0 \
--wait

Verify the CRDs are registered:

kubectl get crd clusters.postgresql.cnpg.io

If you already have PostgreSQL, skip the operator and point the chart at your database:

postgresql:
cloudnative:
enabled: false
external:
enabled: true
host: "db.example.com"
port: 5432
database: tokentimer
username: tokentimer
password: "secret" # or use existingSecret
sslMode: require

Step 2 - Install the chart

helm install tokentimer oci://ghcr.io/tokentimerch/charts/tokentimer \
--namespace tokentimer --create-namespace \
--set config.adminEmail="admin@your-company.com" \
--set config.adminPassword="SecurePassword123!" \
--set config.sessionSecret="replace-with-long-random-value" \
--set postgresql.auth.password="your-db-password" \
--set ingress.enabled=true \
--set ingress.hosts[0].host="tokentimer.your-domain.com"

For production, prefer a values file (cp deploy/helm/values.yaml my-values.yaml, edit, then -f my-values.yaml) and pre-existing Kubernetes Secrets instead of plaintext values:

config:
existingSecret: "my-tokentimer-secrets" # see the note below for the required keys

postgresql:
external:
existingSecret: "my-db-secret" # DB_HOST, DB_PORT, DB_NAME, DB_USER, DB_PASSWORD, DB_SSL

smtp:
existingSecret: "my-smtp-secret" # all SMTP_* + FROM_* keys

Two further variants exist:

  • postgresql.auth.existingSecret supplies the credentials for the in-cluster CloudNativePG cluster. It must be of type kubernetes.io/basic-auth with username and password keys, and because it only feeds CNPG bootstrap you must also provide DB_PASSWORD to the app through config.existingSecret.
  • twilio.existingSecret supplies the Twilio credentials and template SIDs.

Setting any existingSecret makes the chart stop emitting that group's generated keys entirely, so your Secret has to be complete for that group.

config.existingSecret replaces the whole generated Secret

Setting it makes the chart skip its entire generated Secret, not just SESSION_SECRET. Your own Secret must therefore carry every key the chart would have produced:

KeyWhen required
SESSION_SECRETAlways
CERTOPS_SIGNING_ENCRYPTION_KEYWhile CertOps is enabled (config.certopsEnabled defaults to true)
CERTOPS_REGISTRATION_ENCRYPTION_KEYWhile CertOps is enabled
ADMIN_PASSWORDWhen you bootstrap an admin with config.adminEmail
DB_PASSWORDUnless the password comes from postgresql.auth.existingSecret or postgresql.external.existingSecret

The CertOps pair is the easy one to miss: CERTOPS_ENABLED still renders as "true", so the deployment comes up looking healthy and then fails closed on every agent register and job dispatch. Verify with kubectl get secret my-tokentimer-secrets -n tokentimer -o jsonpath='{.data}' before cutting over, or set config.certopsEnabled=false if you are not using CertOps.

note

The chart auto-generates SESSION_SECRET, DB_PASSWORD, and ADMIN_PASSWORD (when adminEmail is set) if you do not provide them. Generated values are preserved across helm upgrade: the chart reads them back from the existing release Secret. Set explicit values or use existingSecret when you manage secrets externally, or when you render manifests with helm template (which cannot read the existing Secret back and therefore produces a new value on each render).

CertOps requires two encryption keys in Kubernetes too

config.certopsEnabled defaults to true, and the API fails closed without CERTOPS_SIGNING_ENCRYPTION_KEY and CERTOPS_REGISTRATION_ENCRYPTION_KEY: agent registration and signed job dispatch reject with CERTOPS_REGISTRATION_ENCRYPTION_KEY_MISSING / CERTOPS_SIGNING_ENCRYPTION_KEY_MISSING, so the CertOps UI appears but no agent can ever register.

The chart generates both when unset and preserves them on upgrade like the secrets above. To set them explicitly:

config:
certopsSigningEncryptionKey: "<64 hex chars>" # openssl rand -hex 32
certopsRegistrationEncryptionKey: "<64 hex chars>"

Rotating either value makes the data it wrapped unreadable: signing keys must be re-issued and agents must re-register.

Step 3 - Wait for pods and access the dashboard

kubectl get pods -n tokentimer -w

You should see: API and dashboard pods reach Running/Ready, and the PostgreSQL cluster become healthy.

Also confirm all six worker CronJobs exist, since a missing one fails silently (nothing crashes, the work just never runs):

kubectl get cronjobs -n tokentimer

You should see six entries: tokentimer-alert-discovery, tokentimer-alert-delivery, tokentimer-weekly-digest, tokentimer-endpoint-check, tokentimer-auto-sync and tokentimer-certops. If tokentimer-certops is absent, certificates never renew automatically and offline agents are never marked offline; re-check worker.cronjobs.certops.enabled (default true).

# If using ingress
open https://tokentimer.your-domain.com

# Or port-forward
kubectl port-forward svc/tokentimer-dashboard 8080:80 -n tokentimer
open http://localhost:8080

If the admin password was auto-generated, retrieve it with:

kubectl get secret -n tokentimer tokentimer-secrets \
-o go-template='{{index .data "ADMIN_PASSWORD" | base64decode}}{{println}}'
warning

With NODE_ENV=production, the API sets the Secure flag on session cookies. Browsers will not persist or send secure cookies over plain HTTP. For anything beyond local port-forwarding, place HTTPS in front of the API and dashboard (Ingress with TLS or a reverse proxy).

Required environment variables

Minimum required for a working install (Docker Compose):

SESSION_SECRET=long-random-string # required - long random value
DB_PASSWORD=secure-db-password # required
ADMIN_EMAIL=admin@your-company.com # required - bootstraps the admin account
ADMIN_PASSWORD=secure-admin-password # required on first start; remove afterwards
APP_URL=http://localhost:5173
API_URL=http://localhost:4000

# Required whenever CertOps is enabled, which it is by default on Compose.
# Generate each with: openssl rand -hex 32
CERTOPS_SIGNING_ENCRYPTION_KEY=64-hex-chars
CERTOPS_REGISTRATION_ENCRYPTION_KEY=64-hex-chars

For every available variable (SMTP, Twilio, integrations, worker tuning, security overrides), see the Configuration Reference, the annotated deploy/compose/.env.example, and deploy/helm/values.yaml.

For Kubernetes specifically, the chart ships its own reference and ready-made values files, which cover things this page does not repeat (value precedence, existingSecret semantics, private registries and digest pinning, autoscaling and pod disruption budgets, the CertOps controller):

First login

TokenTimer creates the admin user automatically on first startup from ADMIN_EMAIL and ADMIN_PASSWORD.

  1. Navigate to your dashboard URL and open the login page.
  2. Log in with your configured ADMIN_EMAIL and ADMIN_PASSWORD.
  3. You should see your default admin workspace.
warning

After first login, remove ADMIN_PASSWORD from your .env file (or values). It is only needed for the bootstrap on first start.

Recommended next steps: invite team members (Workspace Settings, Members), add your first token, configure alert thresholds and channels in Alerts & thresholds, and configure SMTP (via System Settings UI or env vars).

Optional components

These are all disabled by default. Each needs more than its enabled flag to behave the way you would expect.

NetworkPolicy

networkPolicy.enabled=true installs a default-deny posture. It does not infer where your traffic comes from, so the namespaces must be named explicitly or the matching inbound rule is simply not emitted:

networkPolicy:
enabled: true
ingressNamespace: "ingress-nginx" # without this, no ingress-controller traffic reaches the API
monitoringNamespace: "monitoring" # without this, Prometheus cannot scrape /metrics
egress:
smtpCidrs: ["0.0.0.0/0"] # ports 25/465/587 from api and worker pods
httpsCidrs: ["0.0.0.0/0"] # port 443: OAuth/SAML, integrations, webhooks
kubeApiServerCidrs: [] # required when the CertOps controller is enabled

Egress CIDRs default to 0.0.0.0/0 to preserve out-of-the-box behavior; narrow them to your real endpoints, or set a list to [] to disable that protocol's egress entirely.

warning

Leaving ingressNamespace empty renders the API policy with ingress: [], which means the Ingress controller cannot reach the API and the app appears down even though every pod is Ready.

Autoscaling and pod disruption budgets

api:
autoscaling:
enabled: true
minReplicas: 2
maxReplicas: 5
targetCPUUtilizationPercentage: 80
podDisruptionBudget:
enabled: true
minAvailable: 1 # maxUnavailable takes precedence if you set both

ServiceMonitor

Enabling it is not enough on its own: kube-prometheus-stack only selects ServiceMonitors carrying the release label it was configured to match, so set labels as well.

monitoring:
serviceMonitor:
enabled: true
labels:
release: kube-prometheus-stack # must match your Prometheus serviceMonitorSelector
interval: 30s

Pinning images (private registries, air-gapped, reproducible deploys)

Image tags default to the chart's appVersion. For a mirrored registry or a byte-for-byte reproducible deploy:

global:
imageRegistry: "registry.internal.example.com"
imagePullSecrets:
- name: my-registry-credentials

api:
image:
digest: "sha256:..." # takes precedence over tag; from the release manifest

Upgrading

Docker Compose

Which commands apply depends on which image source you deployed in Step 3.

Build from local source (the default: app services declare build: with no image:, so there is nothing to pull):

git pull
docker compose up -d --build

# Verify health
curl http://localhost:4000/health

Prebuilt images (the docker-compose.images.yml override):

docker compose -f docker-compose.yml -f docker-compose.images.yml pull
docker compose -f docker-compose.yml -f docker-compose.images.yml up -d

# Verify health
curl http://localhost:4000/health

Kubernetes

helm upgrade tokentimer oci://ghcr.io/tokentimerch/charts/tokentimer -f my-values.yaml -n tokentimer

# Monitor rollout
kubectl rollout status deployment/tokentimer-api -n tokentimer

Upgrade notes:

  • API and dashboard Deployments roll automatically on helm upgrade (checksum annotations change when config, SMTP, Twilio, or DB values change).
  • CronJob spec changes apply on the next scheduled worker run.
  • Pods read ConfigMap/Secret env vars only at start; a rollout is required after changing config.baseUrl, config.apiUrl, or SSO env.
  • Back up the database before major upgrades:
# Docker Compose
docker compose exec postgres pg_dump -U tokentimer tokentimer > backup.sql

# Kubernetes (CloudNativePG; adjust the pod name to the current primary)
kubectl get pods -n tokentimer -l cnpg.io/cluster=tokentimer-pg
kubectl exec -n tokentimer tokentimer-pg-1 -- \
pg_dump -U tokentimer tokentimer > backup.sql

See Backup and restore for retention, external databases, and PVC behavior on uninstall.

Troubleshooting

SymptomLikely causeFix
API won't startDatabase unreachable or credentials wrongTest with psql postgresql://tokentimer:password@localhost:5432/tokentimer; check docker compose logs api, or kubectl logs -n tokentimer deployment/tokentimer-api
Login returns 401 on localhostNODE_ENV=production with plain-HTTP local URLs; the browser drops secure session cookiesPut HTTPS in front, or use NODE_ENV=development locally; last resort: SESSION_COOKIE_SECURE_LOCALHOST_OVERRIDE=true
No admin account existsAdmin bootstrap skipped (no ADMIN_EMAIL/ADMIN_PASSWORD on first start, or DISABLE_ADMIN_BOOTSTRAP set)Set the bootstrap variables and restart the API once; remove ADMIN_PASSWORD afterwards
Workers not sending alertsSMTP unconfigured, or alerts still queuedCheck worker logs (docker compose logs worker-discovery worker-delivery, or kubectl logs -n tokentimer -l app.kubernetes.io/component=alert-delivery); verify SMTP env (docker compose exec api env filtered on SMTP_/FROM_EMAIL, or kubectl exec -n tokentimer deployment/tokentimer-api -- env); inspect the alert_queue table for pending rows
Dashboard loads but API calls failAPI_URL/APP_URL mismatch with actual public URLsAlign the URL variables with what browsers use, rebuild/restart the dashboard
Helm install stalls on databaseCloudNativePG operator not installed (default in-cluster PostgreSQL requires it)Install the CNPG operator first, or switch to postgresql.external
Settings changed but behavior unchanged (Kubernetes)Pods still hold the old environment; env vars are read at startRun a rollout restart of the affected Deployments
App looks down but all pods are Ready (Kubernetes)networkPolicy.enabled=true without ingressNamespace, so the API policy renders ingress: [] and the Ingress controller is blockedSet networkPolicy.ingressNamespace to your Ingress controller's namespace; see Optional components
Certificates never renew, offline agents stay active (Kubernetes)The certops worker CronJob is missing or disabledkubectl get cronjobs -n tokentimer should list tokentimer-certops; check worker.cronjobs.certops.enabled

Uninstalling

# Docker Compose (keeps data)
docker compose down
# ... including volumes
docker compose down -v

# Kubernetes
helm uninstall tokentimer -n tokentimer
# CloudNativePG PVCs are retained by default; delete manually if no longer needed

Next steps