Install TokenTimer self-hosted
Choose an install path
| Path | Best for | Prerequisites |
|---|---|---|
| Docker Compose | Single host, fastest start | Docker and Docker Compose |
| Kubernetes (Helm) | Production clusters | Kubernetes >= 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).
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.
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.existingSecretsupplies the credentials for the in-cluster CloudNativePG cluster. It must be of typekubernetes.io/basic-authwithusernameandpasswordkeys, and because it only feeds CNPG bootstrap you must also provideDB_PASSWORDto the app throughconfig.existingSecret.twilio.existingSecretsupplies 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 SecretSetting 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:
| Key | When required |
|---|---|
SESSION_SECRET | Always |
CERTOPS_SIGNING_ENCRYPTION_KEY | While CertOps is enabled (config.certopsEnabled defaults to true) |
CERTOPS_REGISTRATION_ENCRYPTION_KEY | While CertOps is enabled |
ADMIN_PASSWORD | When you bootstrap an admin with config.adminEmail |
DB_PASSWORD | Unless 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.
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).
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}}'
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):
deploy/helm/README.md- the full chart referencevalues-minimal.yaml- smallest working installvalues-external-db.yaml- external PostgreSQL instead of CloudNativePGvalues-full-test.yaml- every optional component enabled
First login
TokenTimer creates the admin user automatically on first startup from ADMIN_EMAIL and ADMIN_PASSWORD.
- Navigate to your dashboard URL and open the login page.
- Log in with your configured
ADMIN_EMAILandADMIN_PASSWORD. - You should see your default admin workspace.
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.
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
| Symptom | Likely cause | Fix |
|---|---|---|
| API won't start | Database unreachable or credentials wrong | Test 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 localhost | NODE_ENV=production with plain-HTTP local URLs; the browser drops secure session cookies | Put HTTPS in front, or use NODE_ENV=development locally; last resort: SESSION_COOKIE_SECURE_LOCALHOST_OVERRIDE=true |
| No admin account exists | Admin 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 alerts | SMTP unconfigured, or alerts still queued | Check 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 fail | API_URL/APP_URL mismatch with actual public URLs | Align the URL variables with what browsers use, rebuild/restart the dashboard |
| Helm install stalls on database | CloudNativePG 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 start | Run 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 blocked | Set 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 disabled | kubectl 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
- Configuration reference - configure SMTP for email alerts, Twilio for WhatsApp, and worker tuning.
- Alerts & thresholds - set thresholds, delivery windows, and channels.
- Tokens - add your first tokens: manual entry, file import, integrations, or endpoint monitoring.
- Backup & restore - set up PostgreSQL backups before you rely on the instance.
- Monitoring & metrics - expose Prometheus metrics and wire suggested alerts.
- Enable CertOps - turn on the certificate operations layer with the
CERTOPS_ENABLEDflag.