Organization Schedule & Attendance Management, self-hosted, offline-first, production-ready.
Universities and organizations track attendance on paper, manage timetables in Excel, and learn of staff absences only when members complain. Organization networks are unreliable. Staff don't know where their colleagues are. Visitors have no formal check-in system.
Chronos Ledger solves all of this in a single, self-hosted, Docker-deployable stack that runs entirely on your organization intranet, no cloud subscription, no data leaving your network.
| Metric | Value |
|---|---|
| REST API endpoints | 30+ |
| Role-specific dashboards | 4 |
| Database tables | 10 |
| Offline capability | Reads cached; attendance marks queue and auto-sync |
| Setup time | < 5 minutes |
| Runtime dependencies | Docker + Docker Compose only |
| License | Apache 2.0 |
# Clone, configure, and launch everything
git clone https://github.com/Life-Experimentalist/chronos-ledger.git
cd chronos-ledger
chmod +x setup.sh && ./setup.shsetup.sh auto-detects your LAN IP, generates cryptographic secrets, and launches all services. When it finishes:
- App →
http://<your-server-ip> - API docs →
http://<your-server-ip>/docs - Login →
admin@org.internal, with the passwordsetup.shprinted (also in.envasINITIAL_ADMIN_PASSWORD)
Want to demo it to someone? ./setup.sh --build --demo boots it with sample data; docs/demo.md has the five-minute walkthrough.
curl -fsSL https://raw.githubusercontent.com/Life-Experimentalist/chronos-ledger/main/docker-compose.prod.yml \
-o docker-compose.prod.yml
export JWT_SECRET_SIGNING_KEY=$(openssl rand -hex 32)
export DB_PASSWORD=$(openssl rand -base64 24 | tr -d '/+=' | head -c 32)
export GHCR_OWNER=Life-Experimentalist
docker compose -f docker-compose.prod.yml up -dPin any release: VERSION=v1.2.0 docker compose -f docker-compose.prod.yml up -d
The compose file pulls from GHCR, which does not rate-limit anonymous pulls of a public image. The same two images go to Docker Hub as well, for anyone who would rather not type a registry prefix:
docker pull vkrishna04/chronos-ledger-backend
docker pull vkrishna04/chronos-ledger-web Clients (PWA) Organization Server
┌─────────────┐ ┌────────────────────────────────────────┐
│ Member │ │ Docker network: chronos_net │
│ Staff │──HTTPS/WSS──▶│ ┌──────────┐ ┌────────────────┐ │
│ Admin │ │ │ Nginx │────▶│ FastAPI │ │
│ Guest Kiosk │ │ │ :80/443 │ │ :8000 │ │
└─────────────┘ │ └──────────┘ └───────┬────────┘ │
│ │ │ │
│ Static files ┌───┴──────┐ │
│ (Next.js export) │PostgreSQL│ │
│ │ Redis │ │
└───────────────────────┴──────────┴────┘
Full diagrams with Mermaid charts: docs/architecture.md
Members mark attendance via GPS. The server runs a Haversine distance check, plus an altitude delta (|Δalt| <= 4m) when both the device and the room report an altitude, so that somebody a floor above or below does not register. Altitude is optional on both sides: a fix without one is judged on the horizontal radius alone. The client refuses to mark at all when the reported accuracy is worse than 30m, and the server refuses a fix coarser than GEOFENCE_ACCURACY_FACTOR times the fence radius, which by default is the same 30m on a 15m fence.
Always know where staff are, in priority order:
- Override: a
state_override:<id>value an integration writes to Redis. Chronos never writes one itself, and the tier is skipped when Redis is down. - The generated day: the room of a slot they cover, or of their own slot when nobody else covers it. OFF_SITE when that day marks them on leave.
- Weekly timetable: the room of a slot they lead, on a date the nightly job has not generated yet.
- Base station: OFF_SITE on a day of approved leave, otherwise their assigned base station.
The full rules are in docs/architecture.md.
Presence is the default state. Staff file absences rather than confirming presence. Requests route to the line manager for approval. Approved absences cascade ON_LEAVE to every affected ledger entry and fire a WebSocket notification to the staff member.
Organization Wi-Fi drops. Chronos Ledger keeps working:
- Attendance marks queue to IndexedDB and flush on reconnect (Background Sync where available, the app itself otherwise)
- Today's schedule cached locally for 12 hours
- Class reminders fire up to 20 minutes before start, even with the app closed, via
periodicsyncin the service worker
JWT-authenticated persistent connections. Guest handshake requests, absence approvals, and ledger state changes arrive in milliseconds, no polling.
Drop a member-centric CSV on the Admin dashboard. One upload creates/updates users, activity offerings, master timetable slots, and member registrations atomically and idempotently.
| Role | Path | Core capabilities |
|---|---|---|
| Super Admin | /admin/dashboard |
CSV import, cycle management, proxy assignment, user provisioning |
| Unit Admin | /admin/dashboard |
Absence approvals, ledger overrides for own unit |
| Staff | /staff/dashboard |
Availability switcher, attendance matrix, absence requests, guest desk |
| Member | /member/dashboard |
Live timeline, geofenced self-mark, staff locator, offline queue |
| Guest | /guest/kiosk |
No visitor login, check-in form, real-time staff notification |
| Layer | Technology |
|---|---|
| Backend | Python 3.11 · FastAPI 0.141 · SQLAlchemy 2 · Alembic · APScheduler |
| Auth | PyJWT 2.14 · bcrypt 5.0 (no CVE-affected packages) |
| Database | PostgreSQL 17 |
| Cache / PubSub | Redis 7.4 |
| Frontend | Next.js 16 App Router · React 19 · TypeScript · Tailwind CSS |
| State | Zustand · React Hook Form · Zod |
| PWA | @ducanh2912/next-pwa (Workbox) · IndexedDB (idb) · Web Push (VAPID) |
| Reverse proxy | Nginx 1.27 |
| Packaging | uv (Python) · npm (Node.js) |
| Container | Docker 24 · Docker Compose 2.20 |
| CI/CD | GitHub Actions · GHCR · Docker Hub |
| Releases | Release Please (semver, CHANGELOG) |
chronos-ledger/
├── .github/
│ ├── workflows/
│ │ ├── ci.yml # Lint, tests, type-check, build, Trivy, publish, release
│ │ ├── cd.yml # Build & push to GHCR + Docker Hub (main + releases)
│ │ └── release.yml # Release Please semver releases (called by ci.yml)
│ └── dependabot.yml # Automated dep updates (pip, npm, Docker, Actions)
├── assets/
│ ├── logo.svg # Vector wordmark
│ ├── Logo.png # Raster logo
│ └── Icon.png # App icon
├── backend/ # FastAPI application (uv managed)
│ ├── pyproject.toml
│ ├── alembic/ # DB migrations
│ └── app/
│ ├── core/ # Config, DB, security, Redis, WebSocket manager
│ ├── models/ # SQLAlchemy ORM (9 tables)
│ ├── schemas/ # Pydantic request/response models
│ ├── api/v1/ # Route handlers per domain
│ ├── services/ # Business logic (geo-fence, RSVP, ingestion)
│ └── cron/ # Nightly ledger generator (APScheduler)
├── frontend/ # Next.js 16 PWA (npm managed)
│ ├── public/
│ │ ├── manifest.webmanifest
│ │ └── sw-custom.js # Background sync + push + periodicsync
│ └── src/
│ ├── app/ # App Router pages per role
│ ├── components/ # Role-scoped UI components
│ ├── hooks/ # useWebSocket, useGeolocation, useAuth, useScheduleNotifications
│ ├── lib/ # Axios API client, IndexedDB helpers, auth utils
│ └── store/ # Zustand: auth + notifications
├── nginx/
│ ├── nginx.conf # Reverse proxy + static serving + security headers
│ └── Dockerfile # Multi-stage: Next.js build → nginx image (for GHCR)
├── docs/
│ ├── openapi.yaml # Static OpenAPI 3.1 contract (all 30+ endpoints)
│ ├── architecture.md # Mermaid system diagrams
│ ├── data-model.md # Mermaid ERD (all 9 tables)
│ ├── flows.md # Sequence diagrams (attendance, RSVP, guest, cron)
│ ├── api.md # Human-readable API reference with examples
│ └── deployment.md # Deployment guide with topology diagram
├── bin/
│ ├── chronos_intranet_autodiscover.sh
│ └── chronos_maintenance_vault.sh
├── docker-compose.yml # Local dev / self-build (builds from source)
├── docker-compose.prod.yml # Production (pulls from GHCR, no build)
├── setup.sh # One-command setup script
├── .env.example # Environment template
└── version.txt # Source of truth for semver (managed by Release Please)
cd backend
uv sync # install deps (including dev)
uv run alembic upgrade head # apply migrations
uv run uvicorn app.main:app --reload --port 8000API: http://localhost:8000 · Swagger: http://localhost:8000/docs
Swagger and ReDoc follow DOCS_ENABLED, which is unset here, so they are
on in development and off under APP_ENV=production.
cd frontend
npm install
NEXT_PUBLIC_API_URL=http://localhost:8000/api/v1 \
NEXT_PUBLIC_WS_URL=ws://localhost:8000/ws \
npm run devApp: http://localhost:3000
Pull Request ──▶ ci.yml ──▶ ruff format + ruff check
├──▶ pytest
├──▶ eslint + tsc + next build
├──▶ Trivy scan (critical fails the build)
└──▶ Docker build check (no push)
Push to main ──▶ ci.yml (all of the above)
├──▶ every gate green ──▶ cd.yml
│ └──▶ Push to GHCR + Docker Hub
│ (sha tag + latest)
└──▶ every gate green ──▶ release.yml
└──▶ Release Please opens
or updates a release PR
Merge release PR ──▶ ci.yml ──▶ release.yml ──▶ GitHub Release created
├──▶ CHANGELOG.md updated
├──▶ version.txt bumped
└──▶ cd.yml (version tag) ──▶ GHCR + Docker Hub, vX.Y.Z
Published images, one build pushed to both:
| Registry | Backend | Web |
|---|---|---|
GHCR (canonical, what docker-compose.prod.yml pulls) |
ghcr.io/life-experimentalist/chronos-ledger-backend |
ghcr.io/life-experimentalist/chronos-ledger-web |
| Docker Hub | vkrishna04/chronos-ledger-backend |
vkrishna04/chronos-ledger-web |
Both carry the same tags: latest, main, a short commit sha, and vX.Y.Z on a
release. GHCR is the one the compose file points at because it does not
rate-limit anonymous pulls of a public image; Docker Hub does. A fork that sets
neither DOCKERHUB_NAMESPACE nor DOCKERHUB_TOKEN publishes to GHCR alone.
Every image carries an SBOM and build provenance, plus a Sigstore-signed SLSA attestation naming the workflow and commit it was built from:
gh attestation verify oci://ghcr.io/life-experimentalist/chronos-ledger-backend:latest \
--owner Life-Experimentalistdocs/faq.md has the Docker Hub form and the buildkit attestations that ride along inside the image itself.
| Variable | Required | Description |
|---|---|---|
JWT_SECRET_SIGNING_KEY |
yes | 64-char hex: openssl rand -hex 32. Production refuses to start on the placeholder or on anything under 32 characters. |
DB_PASSWORD |
yes | PostgreSQL password |
ORG_TIMEZONE |
recommended | IANA name of where the organization is, such as Asia/Kolkata. Decides what "today" means. Defaults to UTC, which is what a container runs. An offset is refused. |
LABEL_STAFF and five more |
optional | What the interface calls the engine's six nouns: LABEL_STAFF, LABEL_MEMBER, LABEL_ACTIVITY, LABEL_UNIT, LABEL_LEAD, LABEL_CYCLE. Blank means the engine's own neutral word (Staff, Member, Activity, Unit, Lead, Cycle). Labels only, never the schema and never the API, so they are safe to change on a running instance. There are no domain presets on purpose. docs/vocabulary.md explains why, and what labels never change. |
PASSWORD_MIN_LENGTH |
optional | Shortest password a person may choose, default 12. Applies to the first-login change and to any account an admin creates with a typed password. Generated passwords are random and not measured against it. Refused below 8. |
RATE_LIMIT_ENABLED |
optional | A budget per caller on sign-in, the visitor kiosk and the calendar feed. On by default. Counted in Redis, and if Redis is unreachable the limits stop applying rather than the requests failing. |
RATE_LIMIT_LOGIN_PER_IP |
optional | Failed sign-ins per calling address per 15 minutes, default 10. A correct password costs nothing. 0 turns it off. |
RATE_LIMIT_LOGIN_PER_EMAIL |
optional | Failed sign-ins per account per 15 minutes, default 5. 0 turns it off. |
RATE_LIMIT_GUEST_CHECKIN |
optional | Visitor check-ins per kiosk account per hour, default 300. 0 turns it off. |
RATE_LIMIT_CALENDAR_FEED |
optional | Calendar feed fetches per feed token per hour, default 60. 0 turns it off. |
CSV_UPLOAD_MAX_MB |
optional | Largest CSV POST /ingestion/upload-csv accepts, in MB, default 25. A bigger file is refused with 413 before anything is imported. nginx refuses requests over 25 MB on its own, so going past 25 means raising client_max_body_size in nginx/nginx.conf too. |
GEOFENCE_ACCURACY_FACTOR |
optional | How coarse a member's location fix may be, as a multiple of the session's fence radius, default 2 (30 m on the default 15 m radius). A mark whose reported accuracy is coarser gets 400. 0 turns the check off. A mark that reports no accuracy is not refused for it. |
GUEST_RETENTION_DAYS |
optional | How many days a visitor check-in is kept, default 0, which keeps them forever. Each one holds the visitor's name and phone number. Set a number of days, such as 90, and a job at 03:30 in ORG_TIMEZONE deletes the older check-ins every day, whether or not anyone acted on them. Refused below 0. |
DOCS_ENABLED |
optional | Whether /docs, /redoc and /openapi.json are served. Blank follows APP_ENV: off in production, on everywhere else. Set true to publish them from a production instance anyway. |
FORWARDED_ALLOW_IPS |
optional | Which upstream addresses uvicorn believes X-Forwarded-For from, default *. The app container publishes no ports, so nginx is the only way in. The sign-in limit needs it to tell callers apart from the proxy, and the proxy overwrites that header rather than appending to it, so a caller cannot forge an address. |
VAPID_PUBLIC_KEY |
recommended | Web Push: npx web-push generate-vapid-keys |
VAPID_PRIVATE_KEY |
recommended | Web Push |
VAPID_CONTACT_EMAIL |
recommended | Admin contact for push service |
NEXT_PUBLIC_API_URL |
dev only | Baked in at build time; defaults to /api/v1 in GHCR image |
NEXT_PUBLIC_WS_URL |
dev only | Defaults to /ws in GHCR image |
APP_CORS_ORIGINS |
prod | Comma-separated allowed origins |
GHCR_OWNER |
prod only | GitHub org/username for GHCR image pull |
VERSION |
prod only | Image tag to deploy (default: latest) |
See .env.example for the full template.
The Alembic seed migration creates one super-admin. It seeds a hash of a
random string it throws away, so the account exists, nothing can log into
it, and this repository publishes no password for anyone to find. What the
first password is comes from your .env:
| Field | Value |
|---|---|
admin@org.internal |
|
| Password | INITIAL_ADMIN_PASSWORD from your .env |
setup.sh generates that password and prints it once, in its summary. It is
applied on boot while the account is still waiting for a password of its own,
and does nothing once you have logged in and chosen one. Until you have, the
account is refused by every endpoint except the password change itself, so
the first login is the only thing it can do: use Admin Portal → Profile
→ Change Password, or the Onboarding Wizard, which opens on its own.
| Document | Description |
|---|---|
docs/openapi.yaml |
OpenAPI 3.1 contract, all endpoints, schemas, enums |
docs/architecture.md |
System topology, module deps, location resolution, WebSocket lifecycle |
docs/data-model.md |
ERD for all 10 database tables |
docs/flows.md |
Sequence diagrams: attendance, RSVP, guest handshake, ledger cron |
docs/api.md |
Human-readable API reference with examples |
docs/deployment.md |
Deployment guide, cycle rollover, TLS, scaling |
docs/hardware.md |
Hardware requirements, load test results, when to add replicas |
docs/vocabulary.md |
What an instance calls things, and which value sets are closed |
docs/faq.md |
FAQ & troubleshooting, setup, auth, CSV import, geofencing, CI/CD |
docs/quickstart.md |
Call the REST API from any language: auth, keys, paging, idempotency |
docs/roadmap.md |
Planned releases 0.13 to 0.15 with scope and acceptance checks |
docs/distribution.md |
Planned installer, channels, drift handling and signing |
docs/sdks.md |
Planned TypeScript, Python, Go and Rust client libraries |
CONTRIBUTING.md |
Development setup, commit conventions, PR checklist |
CODE_OF_CONDUCT.md |
Community standards and enforcement |
SECURITY.md |
Vulnerability reporting and disclosure policy |
CHANGELOG.md |
Release history (managed by Release Please) |
Telemetry is off by default, a stock build sends nothing anywhere, and the ghcr.io image is built from nginx/Dockerfile with those same defaults. Nothing in a default install reaches a host you do not run.
If you turn it on, Chronos Ledger reports anonymous, aggregate view counts to a CFlair-Counter instance, a privacy-first, self-hostable counter. No IP addresses, usernames, or session data are collected or transmitted.
| Event | Project key |
|---|---|
| Landing page visits | chronos-ledger-landing |
| Admin dashboard logins | chronos-ledger-app |
Set both at build time, either is enough to keep it off:
NEXT_PUBLIC_TELEMETRY_ENABLED=true
NEXT_PUBLIC_TELEMETRY_ENDPOINT=https://counter.example.internal # a CFlair-Counter you runAn empty endpoint disables the pings regardless of the enabled flag.
Per-browser opt out: once enabled, Admin Dashboard → Overview → Privacy & Telemetry toggles it off for that browser. The preference is stored locally and persists across sessions.
Contributions are welcome. Please:
- Fork the repo and create a feature branch.
- Follow Conventional Commits so Release Please can generate the changelog.
- Open a PR: CI runs automatically (lint, tests, type-check, build, Trivy scan).
- All checks must pass before merge.
Commit examples:
feat: add unit-level attendance export to CSV
fix: resolve geofence false positive on altitude boundary
perf: cache staff location resolver result in Redis
security: upgrade cryptography to 44.0.1
Copyright 2026 Chronos Ledger Contributors
Licensed under the Apache License, Version 2.0.