Documentation

Deployment, Configuration, Shift Planning & Extensions

1. Docker Images

Docker Hub

klokkme/klokk

hub.docker.com/r/klokkme/klokk

GitHub Container Registry

ghcr.io/klokk-me/klokk

ghcr.io packages

Tag Description
stable Production-ready. Recommended for self-hosting.
latest Latest release, may include recent fixes not yet promoted to stable.

2. Quick Start

The simplest way to run Klokk is with SQLite — no external database needed. For production, a volume mount is required to persist data across container restarts.

Docker run (SQLite)

docker run -d \
  --name klokk \
  -p 8080:8080 \
  -v klokk-data:/app/data \
  -e SESSION_SECRET="$(openssl rand -hex 32)" \
  -e CSRF_KEY="$(openssl rand -hex 32)" \
  -e SESSION_SECURE_COOKIE=false \
  -e BASE_URL=http://localhost:8080 \
  klokkme/klokk:stable

docker-compose.yml (SQLite)

services:
  klokk:
    image: klokkme/klokk:stable
    restart: unless-stopped
    ports:
      - "8080:8080"
    volumes:
      - klokk-data:/app/data
    environment:
      BASE_URL: "http://localhost:8080"
      SESSION_SECRET: "change-me-min-32-chars-random-string"
      CSRF_KEY: "change-me-min-32-chars-random-string"
      SESSION_SECURE_COOKIE: "false"

volumes:
  klokk-data:
After first start: Log in with username admin (email admin@localhost) and the default password, then change it immediately. See DEFAULT_ADMIN_PASSWORD.

3. Environment Variables

All configuration is done via environment variables. Variables marked Required have no default and must be set before starting Klokk.

Application

Variable Default Description
APP_ENV dev Runtime mode: production or dev. Set production for real deployments; dev relaxes some security checks.
PORT 8080 HTTP listen port inside the container.
BASE_URL http://localhost:8080 Public base URL (no trailing slash). Used for links in emails.
LOG_LEVEL warn Log verbosity: debug, info, warn, error.
LOCALES_PATH locales Path to the locale files directory (relative to the binary or absolute).

Database

Variable Default Description
DB_DRIVER sqlite Database backend: sqlite or postgres.
DB_SQLITE_PATH ./data/klokk.db Path to the SQLite file. Mount /app/data as a volume to persist it.
DB_DSN PostgreSQL connection string, e.g. postgres://user:pass@host:5432/db?sslmode=disable. Required when DB_DRIVER=postgres.
DB_MAX_OPEN_CONNS 25 Max open DB connections (PostgreSQL only).
DB_MAX_IDLE_CONNS 5 Max idle DB connections (PostgreSQL only).
DB_CONN_MAX_LIFETIME_MINUTES 5 Connection max lifetime in minutes (PostgreSQL only).

Security Required

Variable Default Description
SESSION_SECRET Secret for signing session cookies. Min 32 chars. Must be changed. Generate: openssl rand -hex 32
CSRF_KEY Secret for CSRF token signing. Min 32 chars. Must be changed. Generate: openssl rand -hex 32
SESSION_SECURE_COOKIE false Set true in production (requires HTTPS). false for HTTP/localhost.
SESSION_SAME_SITE lax Cookie SameSite policy: strict, lax, or none.
SESSION_MAX_AGE_HOURS 24 Default session lifetime in hours.

Authentication

Variable Default Description
REMEMBER_ME_ENABLED true Show "Stay logged in" checkbox on the login page.
MFA_TRUST_ENABLED true Show "Trust this device" checkbox on the MFA page.
MFA_TRUST_DAYS 30 Duration in days for remembered sessions and trusted MFA devices.
DEFAULT_ADMIN_PASSWORD Admin@Klokk1 Password for the initial admin account (username admin, email admin@localhost). Only used on first startup. Change immediately after login.

Admin Access Control

Variable Default Description
ADMIN_IP_ALLOWLIST empty Comma-separated IPs/CIDRs allowed to access /admin/*. Empty = no restriction. X-Forwarded-For is respected for reverse proxies.
SUPER_ADMIN_IP_ALLOWLIST empty Additional allowlist for super_admin-only routes (/admin/settings, /admin/companies, /admin/audit). Stacks on top of ADMIN_IP_ALLOWLIST.

Password Policy

Variable Default Description
PASSWORD_MIN_LENGTH 12 Minimum password length.
PASSWORD_REQUIRE_UPPER true Require at least one uppercase letter.
PASSWORD_REQUIRE_LOWER true Require at least one lowercase letter.
PASSWORD_REQUIRE_NUMBER true Require at least one digit.
PASSWORD_REQUIRE_SPECIAL true Require at least one special character.
PASSWORD_MAX_HISTORY 5 Number of previous passwords to remember (prevent reuse). 0 = disabled.
PASSWORD_EXPIRY_DAYS 0 Password expiry in days. 0 = never expires.

Rate Limiting

Variable Default Description
RATE_LIMIT_LOGIN_PER_MINUTE 10 Max login attempts per IP per minute.
RATE_LIMIT_MFA_PER_MINUTE 10 Max MFA attempts per IP per minute.
RATE_API_PER_MINUTE 100 Max API / general requests per IP per minute.
AUTH_LOCKOUT_THRESHOLD 0 Failed login attempts before account lockout. 0 = disabled.
AUTH_LOCKOUT_DURATION_MINUTES 15 Account lockout duration in minutes.

Internationalisation

Variable Default Description
I18N_DEFAULT_LOCALE en Default locale for new users and the login page: en or de.
I18N_AVAILABLE_LOCALES en,de Comma-separated list of enabled locales.

Working Time Defaults

Applied to the admin seed user on first startup. Per-user values are managed in the app afterwards.

Variable Default Description
DEFAULT_WEEKLY_HOURS 40 Default weekly working hours.
DEFAULT_ANNUAL_LEAVE_DAYS 30 Default annual leave days.

Shift Planning

Shift planning itself is enabled per company in the app (Admin → Companies), not via environment variables. See Shift Planning below for the full workflow. The one server-level setting is the pre-shift push reminder:

Variable Default Description
SHIFT_REMINDER_HOURS 0 Send a push reminder this many hours before a published shift starts. Sent once per shift to employees who enabled the "Shift plan published or changed" push notification. 0 = disabled.

SMTP (Email)

Variable Default Description
SMTP_HOST empty SMTP server hostname. Leave empty to disable email sending entirely.
SMTP_PORT 587 SMTP server port. 587 for STARTTLS, 465 for implicit TLS.
SMTP_USER SMTP username.
SMTP_PASS SMTP password.
SMTP_TLS false true = implicit TLS (port 465). false = STARTTLS (port 587).
SMTP_FROM empty From address for outgoing emails. Leave empty to disable.

API & Extensions

Klokk ships a read-only public REST API under /api/v1/* (/status, /users, /clock/states, /time/entries, /companies). Requests are authenticated with an API key and authorised per scope (e.g. read:users, read:time_entries), and rate-limited to 60 requests per minute per IP.

Super-admins manage integrations under API & Extensions (/admin/extensions). Each integration bundles an API key, its scopes, and one connection mode:

  • API only — the extension calls the REST API above using its key.
  • Webhooks — Klokk sends outbound HTTP POSTs on clock-in, clock-out, and leave events.
  • WebSocket — the extension opens a persistent inbound connection at GET /api/v1/extension/connect.

Set EXTENSIONS_ENABLED=false to switch off the extension subsystem at the network level. The read-only REST API endpoints listed above remain available; only the webhook/WebSocket extension features below are disabled.

Variable Default Description
EXTENSIONS_ENABLED true Enable the extension API subsystem. When set to false:
  • /admin/extensions and all sub-routes return 404
  • /api/v1/extension/connect WebSocket endpoint is not registered
  • Outbound webhook dispatch is skipped entirely
  • No ExtensionHub goroutines are started

4. Reverse Proxy

When running Klokk behind a reverse proxy, set these two variables in addition to your proxy config:

  • Set SESSION_SECURE_COOKIE=true — required when served over HTTPS.
  • Set BASE_URL=https://yourdomain.com — no trailing slash.

nginx

server {
    listen 443 ssl;
    server_name yourdomain.com;

    # TLS config omitted — use certbot or similar

    location / {
        proxy_pass         http://localhost:8080;
        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;
    }
}

Caddy

yourdomain.com {
    reverse_proxy localhost:8080
}

Caddy automatically provisions and renews TLS certificates via Let's Encrypt — no additional TLS configuration needed.

5. Shift Planning

Shift planning lets admins and managers build weekly rosters from reusable shift templates, publish them to employees, and handle day-to-day changes through swaps, open shifts and availability — with German working-time law (ArbZG) checks built in. It is opt-in: a tenant must have it enabled, and only users on the shift-based work-time model draw their target hours from the roster.

a. Enabling shifts

A super-admin turns the feature on per company under Admin → Companies:

  • Shift planning — enables shift templates and rosters for the company.
  • Roster notice period (days) — advance notice required before a roster may be published (e.g. from a collective or works agreement). 0 = none. The planner sees a "publish by" hint and a warning once the period has passed.

Once enabled, a Shifts entry appears for admins and managers, and a My shifts entry for every employee.

b. Work-time model

Each user's daily target hours come from their work-time model:

  • Weekly — fixed/flexible weekly hours from the work schedule (the default).
  • Shift-based — target hours are derived from the published roster instead of weekly hours.
  • Inherit — use the department default, then fall back to weekly.

Set it per user under Admin → Users, or as a department default under Admin → Departments. Only shift-based users are affected by the roster; weekly users keep their schedule.

c. Templates & roster

Templates are reusable shift definitions (e.g. "Früh 06:00–14:00") created under Shifts. Each has a name, start and end time (if the end is not after the start, the shift crosses midnight, e.g. 22:00–06:00), a break in minutes (subtracted from the gross to give paid net time), a colour for the grid, and an active flag. Shifts in the statutory night window (23:00–06:00) are detected automatically for the ArbZG checks.

The roster is a weekly grid of employees × days. Assign a template per cell, add an optional note, and use Copy previous week to carry a recurring plan forward. Drafts are invisible to employees and do not affect target hours. When ready, choose Publish & notify: assignments become visible in My shifts, feed shift-based target hours, notify affected employees (email and/or push, per their preferences), and show each employee a precise change diff versus the last published version.

d. Swaps, open shifts & availability

  • Shift swaps (give-away). From My shifts an employee gives away a published shift; a colleague takes it over and a manager/admin approves the reassignment (open → claimed → approved, or rejected/withdrawn). Only an approved swap moves the assignment.
  • Open shifts. A planner offers an unassigned slot to the pool; employees apply and a manager approves, turning it into a normal assignment (open → claimed → filled).
  • Availability. Employees mark days Unavailable or Preferred (no entry = no preference). Assigning someone on a day they marked unavailable flags a conflict on the roster.

e. Employee view, calendar & reminders

My shifts shows the employee's own published roster as a week or month; they can acknowledge the plan ("I have seen my shifts") and manage availability, swaps and open-shift applications from here.

Calendar subscription. Employees can enable a calendar feed and subscribe to the generated iCalendar URL (/shifts/feed/<token>) from any calendar app; it auto-updates, and disabling it revokes the link.

Pre-shift reminders. Set SHIFT_REMINDER_HOURS (see Shift Planning under environment variables) to push a reminder that many hours before a shift starts — once per shift, to employees who enabled the "Shift plan published or changed" push notification.

ArbZG audit. Klokk checks rosters against the German Working Time Act — daily/weekly maximums, the 11-hour rest period, and night-work rules — and the Yearly ArbZG audit view gives a per-employee, full-year compliance overview.

6. Extensions

Extensions are external services that integrate with Klokk through its API & extension system: a scoped, read-only REST API, outbound webhooks, and inbound WebSocket connections. The configuration variables live in API & Extensions above; this section covers the in-app workflow and the first-party extensions.

a. In-app usage

A super-admin manages integrations under Admin → API & Extensions (/admin/extensions). Each integration bundles an API key (shown once — store it securely), the scopes it may use, and one connection mode:

  • API only — the extension polls the REST API using its key (/api/v1/status, /users, /clock/states, /time/entries, /shifts/events, /companies); rate-limited to 60/min per IP.
  • Webhooks — Klokk sends outbound HTTP POSTs on clock-in, clock-out and leave events.
  • WebSocket — the extension opens a persistent inbound connection at GET /api/v1/extension/connect.

Scopes (e.g. read:users, read:time_entries, read:companies) authorise each key; grant only what an extension needs. The public API is read-only. Setting EXTENSIONS_ENABLED=false removes the admin routes, the WebSocket endpoint and all webhooks (the plain REST API stays available).

b. Klokk Anchor

Klokk Anchor is a companion service that writes a daily, tamper-evident proof of your time-tracking data to the IOTA Rebased (Starfish) blockchain. Each event is reduced to a one-way SHA-256 commitment combined into a Merkle tree; only the daily Merkle root is anchored, so no personal data ever leaves toward the chain. Optionally adds an RFC 3161 trusted timestamp.

Prerequisites: a running Klokk with EXTENSIONS_ENABLED=true; a Klokk API key with the read:time_entries and read:companies scopes; and two 32-byte hex secrets (one for at-rest encryption, one for company-token hashing). For mainnet, a funded IOTA wallet (created in the anchor UI); Klokk's anchor smart contract is the built-in default.

Image: klokkme/anchor / ghcr.io/klokk-me/anchor (tags stable, latest). PostgreSQL is supported via DATABASE_URL.

docker-compose.yml

services:
  klokk-anchor:
    image: klokkme/anchor:stable
    restart: unless-stopped
    ports:
      - "127.0.0.1:9090:9090"
    volumes:
      - anchor-data:/app/data
    environment:
      KLOKK_BASE_URL: "https://klokk.example.com"
      KLOKK_API_KEY: "klokk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
      DATABASE_URL: "sqlite:/app/data/klokk-anchor.db"
      ENCRYPTION_KEY: "change-me-openssl-rand-hex-32"
      COMPANY_TOKEN_PEPPER: "change-me-openssl-rand-hex-32"
      IOTA_NETWORK: "testnet"
      TZ: "Europe/Berlin"
      TIMEZONE: "Europe/Berlin"

volumes:
  anchor-data:
Generate the secrets first: run openssl rand -hex 32 twice for ENCRYPTION_KEY and COMPANY_TOKEN_PEPPER. Changing ENCRYPTION_KEY after wallets exist makes them permanently unreadable.

Environment variables

Variable Default Description
KLOKK_BASE_URL RequiredBase URL of your Klokk instance.
KLOKK_API_KEY RequiredExtension API key with read:time_entries + read:companies. A company-scoped key limits the anchor to that company. Keep secret.
DATABASE_URLsqlite:data/klokk-anchor.dbSQLite path (sqlite:…) or PostgreSQL DSN (postgresql://…).
ENCRYPTION_KEY Required64-char hex (32 bytes). AES-256-GCM key for stored wallet private keys. openssl rand -hex 32.
COMPANY_TOKEN_PEPPER RequiredSecret mixed into company tokens before SHA-256 hashing, so company IDs cannot be reversed from on-chain values.
IOTA_NETWORKtestnettestnet, mainnet or localnet. IOTA_ANCHOR_PACKAGE defaults to Klokk's deployed package.
TIMEZONE / DAILY_BOOKING_TIMEUTC / 00:00IANA timezone and fixed daily booking time (HH:MM) for anchoring the previous day.
ANCHOR_EMPTY_DAYStrueAnchor a proof even on days with no entries (positive "nobody worked" evidence). One transaction per company per empty day.
TSA_URLOptional RFC 3161 timestamping authority (may be a qualified eIDAS QTSP). Best-effort; never blocks anchoring.
LEDGER_URLSComma-separated Klokk Ledger URLs to cross-check proofs during verification — an operator-independent fourth check.
ADMIN_PORT9090Port for the web UI and REST API (bind behind a reverse proxy for remote access).

c. Klokk Ledger

Klokk Ledger is an independent indexer and verification service. It reads Klokk Anchor proofs back off the public IOTA ledger, stores them, and serves a read-only verification API — so anyone can verify a proof without trusting the operator, and even if the ledger node later prunes old transactions. It needs no API key and no secrets; it only reads public data, and on first run backfills the full package history automatically.

Image: klokkme/ledger / ghcr.io/klokk-me/ledger (tags stable, latest). SQLite is pure-Go (no CGO); PostgreSQL optional.

docker-compose.yml

services:
  klokk-ledger:
    image: klokkme/ledger:stable
    restart: unless-stopped
    ports:
      - "127.0.0.1:8080:8080"
    volumes:
      - ledger-data:/app/data
    environment:
      IOTA_NETWORK: "mainnet"
      IOTA_ANCHOR_PACKAGE: "0x..."   # same package your anchor writes to
      DB_DRIVER: "sqlite"
      DB_DSN: "data/klokk-ledger.db"
      POLL_INTERVAL: "60s"

volumes:
  ledger-data:

Environment variables

Variable Default Description
IOTA_ANCHOR_PACKAGE RequiredAddress of the klokk_anchor Move package to index — the same one your anchor writes to.
IOTA_NETWORKtestnettestnet, mainnet or devnet (selects the default GraphQL endpoint; override with IOTA_GRAPHQL_URL).
DB_DRIVER / DB_DSNsqlite / data/…sqlite or postgres, with the matching file path or DSN.
LISTEN_ADDR:8080Address the HTTP API listens on.
POLL_INTERVAL60sHow often to poll the ledger for new proof events (e.g. 30s, 5m).
ADDRESSESallOptional comma-separated anchor sender addresses to index. Empty = every proof for the package.

Verification API

All endpoints are served openly. GET /api/v1/verify/{tx}?root=<expected> returns the independently-indexed proof and whether it matches the expected Merkle root — the same cross-check Klokk Anchor performs via LEDGER_URLS.

Endpoint Description
GET /api/v1/statusNetwork, package, indexed count, last index time.
GET /api/v1/verify/{tx}Verification verdict; pass ?root= to compare against the indexed root.
GET /api/v1/proofs/{tx}A single indexed proof by transaction digest.
GET /api/v1/proofsList proofs for a sender, with company_token/period filters.
GET /api/v1/companies/{token}/proofsProofs for a company token, optionally by period.