Skip to content

Cwork

Open-source HR information system — people, hiring, leave, attendance, payroll, and an HR assistant that actually knows your policies.

Built for Thai labour practice. Designed to be self-hosted.

English · ภาษาไทย · Overview page

CI License Backend Web Mobile

Signing in with two-factor authentication on the English console, then the approvals inbox, employee directory, leave, attendance, a closed payroll run, candidates, KPIs and the activity log

Real console, real second factor, real payroll run — recorded against the company npm run db:seed builds, by a script in this repository, on the English interface. Thai is the default and one switch turns the whole console English (CW-016). Higher-quality MP4 · the Thai take.


Why Cwork

Six problems a Thai business pays for every month, and how Cwork answers each one — rarely with a single feature, usually with several working together. The overview page tells the same story with screenshots.

1 · An HR bill that grows with every hire

Per-seat pricing means every hire raises the bill, the contract renews every year, and your employee data lives on somebody else's servers.

  • No licence fee, no per-seat fee. Apache-2.0: twenty employees or two thousand cost the same — the server.
  • One machine. Three Docker commands. PostgreSQL alone does the work of a queue, a lock service and a rate-limit store, so there is no Redis or Kafka to run.
  • Your data, in your database. Back it up, move it, or leave; nothing locks you in.
  • Room to grow. More API replicas are one setting, not another service.

2 · Payroll you hold your breath over

A spreadsheet one person understands, progressive tax, social security and several overtime rates — one mistake costs money, a correction run and trust.

  • Thai law as tested code. Withholding tax, social security, provident fund and Labour Protection Act overtime, as pure functions with worked examples.
  • Whoever prepares a run cannot approve it, enforced by the API.
  • No export until the figures reconcile. Every run in the period approved, and the payslips adding up to the run totals to the baht (CW-044).
  • Approvers see why the total moved. The optional assistant narrates the variance against last period, and a test fails on any figure it invents (CW-040).
  • Payslips you can explain a year later, with employer contributions kept apart from deductions.
  • Not yet: the PND 1, social-security and bank-transfer files.

3 · Buddy punching, and sites with no signal

  • Accounts bound to a device. The first phone binds; a punch from another is flagged, and only HR can move the binding, audited (CW-024).
  • Flag for review, never refuse. Punches outside the fence or with a suspicious location are accepted and flagged — whoever turned up can always clock in.
  • Offline punches kept safe. An encrypted on-device queue, rooted-device detection, and late deliveries that need confirming before they count (CW-025).
  • Rosters that lateness is measured against, assignable a department at a time (CW-010).
  • A summary for managers instead of a list of flags: flagged punches grouped by location, so a fence drawn too tight reads differently from fraud (CW-039).

4 · Requests stuck waiting for a signature

  • One approval engine, nine request types — leave, overtime, expenses, attendance corrections, resignations, requisitions, offers, payroll runs and document requests.
  • Routes that follow the organisation — line manager, department head, role or named person, with amount thresholds and delegation.
  • Approve from a phone. Email and push go out through a transactional outbox that retries, backs off and dead-letters.
  • Leave balances that cannot go negative. Pending days are reserved on filing; weekends and public holidays are never counted.
  • Self-service certificates, issued as PDFs with a verification code (CW-008).

5 · Employee data leaks, and PDPA exposure

  • National IDs and bank accounts encrypted at rest and masked to the last four digits.
  • A password alone is not a session for anyone who can read them or run payroll.
  • An audit trail nobody can rewrite, append-only by database trigger.
  • Keep only what you need — consent on application, candidates erased after 12 months, leavers redacted once past retention (CW-015).
  • Nothing leaks through the logs. No salary, national ID, bank account, token or query string reaches a log line, and an e2e test proves it (CW-050).
  • Uploads scanned for malware before they are stored.

6 · Systems that do not talk to each other

  • Applicant to employee in one click — requisition, public careers page, auto-graded assessment, scorecards, and an offer that becomes the employee record.
  • Fairer reviews — weighted KPIs, check-ins and calibration by HR.
  • Benefits that flow into payroll without re-keying (CW-009).
  • Offboarding with a clearance checklist and exit interview.
  • Thai and English throughout, web and app (CW-016).

What you get

Three deployables, one database:

Admin console React 19 + Vite. Everything HR, payroll and managers do.
Employee app Flutter. Clock in/out, leave, payslips, approvals — offline-tolerant.
API NestJS modular monolith over PostgreSQL 16.
flowchart TB
    W["Admin console<br/><i>React 19 · Vite</i>"] --> API
    M["Employee app<br/><i>Flutter · offline-tolerant</i>"] --> API
    C["Public careers page"] --> API

    API["<b>NestJS API</b> — modular monolith<br/>JWT → permissions → rate limit<br/>feature modules over pure <i>domain/</i> rules"]

    API --> DB[("PostgreSQL 16<br/><i>records · job locks · rate-limit counters · outbox</i>")]
    DB --> OUT["Transactional outbox<br/><i>at-least-once, backs off, dead-letters</i>"]
    OUT --> MAIL["SMTP"]
    OUT --> PUSH["FCM push"]
    API -. optional .-> CLAM["clamd<br/><i>scans uploads</i>"]
    API -. off by default .-> LLM["Anthropic<br/><i>HR assistant</i>"]
Loading

Everything a queue, a lock server and a cache would normally do, PostgreSQL does here: FOR UPDATE SKIP LOCKED for the outbox, advisory locks so only one replica runs each scheduled job, and a counter table for shared rate limits. That is the whole reason a second instance is a setting rather than a project.

No Redis, no message broker, no Kubernetes. Running several API instances needs one setting (THROTTLE_STORAGE=postgres) rather than another service to operate — an HRIS that needs a Kafka cluster to send a leave notification is one nobody can self-host.

One deployment serves one organisation. Every table carries organizationId and every query filters on it, but that is defence in depth inside a single install, not a tenant boundary: nothing in the test suite exercises two organisations sharing a database, so nothing should depend on it.

Try it in five minutes

git clone https://github.com/SuruchBoss/Cwork.git
cd Cwork
cp .env.example .env

Fill in the secrets .env asks for — compose refuses to start without them:

openssl rand -base64 48   # JWT_ACCESS_SECRET, JWT_REFRESH_SECRET
openssl rand -base64 32   # FIELD_ENCRYPTION_KEY
                          # plus POSTGRES_PASSWORD, anything you like

Then:

docker compose up -d --build
docker compose run --rm --build migrate            # apply migrations
docker compose run --rm migrate npm run db:seed    # demo data — evaluation only

Open http://localhost:8080.

The seed does not stop at an org chart: it runs last month's payroll through the real calculator, books leave that real approvers approved, and leaves a hiring pipeline mid-flight and two requests waiting in an inbox. Every page has something on it, because a console full of "nothing here yet" tells you nothing about whether the thing works.

As an employee dev2@cwork.example — password only
As HR hr.manager@cwork.example — plus a 2FA code

Every demo account shares one password, which db:seed generates and prints once, at the end of its output — or choose it yourself with docker compose run --rm -e SEED_PASSWORD=… migrate npm run db:seed. There is no default: a password printed here would be the same on every installation that did not change it.

Admin accounts genuinely require a second factor too, so db:seed also prints a demo TOTP secret and a QR link. Add it to any authenticator app once and every admin account works.

Migrations run through the migrate service, not exec api. The API image is pruned to production dependencies and ships no Prisma CLI. That service sits behind a compose profile, so up never starts it.

Running without Docker
# API — needs PostgreSQL 16 with the pgvector extension (the first migration
#       creates it; `pgvector/pgvector:pg16` is what CI and compose run)
cd backend
cp .env.example .env    # set DATABASE_URL and the secrets
npm install
npx prisma migrate deploy && npm run db:seed
npm run start:dev       # → http://localhost:3000

# Console
cd ../web
npm install && npm run dev    # → http://localhost:5173, proxies /api

# Mobile
cd ../mobile
flutter pub get
flutter run --dart-define=API_BASE_URL=http://10.0.2.2:3000/api/v1

The interactive API docs live at /api/docs whenever NODE_ENV is not production — which is what compose defaults it to, so set NODE_ENV=development in .env if you want them.


Installing it for a real organisation

The five minutes above are demo data: a published password, a published two-factor secret, eight fictional employees. A real install skips the seed entirely.

docker compose up -d --build
docker compose run --rm --build migrate           # apply migrations
docker compose run --rm migrate npm run db:init   # your organisation

db:init asks for an organisation name, a timezone and the first administrator's email, and creates exactly that: one organisation, the eight system roles, one account. Nothing else — no departments, no demo rows, nothing to clean up afterwards.

If the terminal is not where you want to type all that:

docker compose run --rm migrate npm run db:init -- --web

prints a single-use token and you finish at http://localhost:8080/setup. That token is the entire security model. Minting one needs shell access to the server, which is the one thing a stranger who finds a fresh deployment does not have — so the wizard cannot be claimed by whoever reaches it first. It lasts an hour and works once.

Either way the first administrator holds every permission there is, so Cwork makes it enrol a second factor before its first session: have an authenticator app to hand. Neither the CLI nor the wizard ever prints a TOTP secret — you enrol it yourself, once.

Running db:init a second time on a database that already has an organisation refuses, and so does db:seed: the demo data will not install itself beside a real company by accident.


What it does

The interface is Thai by default and switches to English (CW-016) — in both the console and the employee app, every label, form, status and validation message flips between the two from one switch (a header control on the web, the profile screen on mobile), and Thai stays the default. Every image below is a real screenshot of the seeded demo company on the English interface — the console in a browser, the employee app on a 390×844 phone; the Thai README shows the same screens in Thai. Names, leave types and anything else the company typed in stay Thai, as they would for a Thai company: the interface is translated, its content is not. They are taken by capture.mjs and capture-mobile.mjs, so they can be retaken whenever a screen changes.

Dashboard: approvals waiting, headcount, leave requests and the latest payroll run

People

Employee records with encrypted national IDs and bank accounts, an org chart, employment history, and resignation with a clearance checklist and exit interview.

Employee register Employee record

Leave

Thai statutory leave types, seniority-tiered accrual, half and hourly days, and a balance ledger that reserves pending days — so two overlapping requests cannot both fit inside one remaining day.

Friday to Monday costs two days, not four. Weekends and public holidays are excluded, not charged.

Leave

Attendance

Clock in/out with geofencing and anti-fraud flags, shift rosters, corrections, and overtime at Labour Protection Act rates.

A punch outside the fence is flagged, never rejected. An employee must always be able to prove they turned up; HR reviews the flag afterwards.

Attendance Shift roster

Payroll

Effective-dated salary, Thai withholding tax and social security, benefits, expense reimbursement — and payslips you can still explain a year later.

Whoever calculates a run cannot approve it, enforced by the API rather than by policy.

Payroll periods and runs Payroll run

Approvals

One declarative engine serves leave, overtime, expenses, attendance corrections, resignations, requisitions, offers, payroll runs and document requests. Policies resolve approvers by line manager, department head, role or named user, and carry conditions such as amount thresholds.

Approval queue

Hiring, performance, documents

Requisitions through a public careers page, PDPA-consented applications, auto-graded assessments, interviews with scorecards, and offers that convert into employee records. Review cycles with weighted KPIs and calibration. Certificates and letters employees can request for themselves.

Recruitment Performance Document requests

The HR assistant

Answers policy questions from your own documents, checks balances, and files leave and document requests — scoped so it can only ever see the asker's own data.

It is optional and off by default. Policy search falls back to PostgreSQL full-text plus trigram matching, which needs no embeddings and copes with Thai's lack of word boundaries.

The HR knowledge base, which answers policy questions with no AI provider configured

The employee app

Flutter, for everyone who never opens the console. Clock in and out with geofencing, request leave, read a payslip, approve what is waiting, ask the assistant.

Built for a phone that loses signal: punches queue in durable storage and replay on reconnect, carrying a client-generated id so a retry cannot become a second punch.

Clocked in, with the shift, the office and how far from it the punch was Leave balances and requests A manager's approvals, with approve and reject on the card Payslip breakdown

That payslip is the "explain it a year later" claim in practice: earnings, deductions, and employer contributions kept visibly separate from what came out of the employee's pay.

More screenshots — sign-in and 2FA, expenses, benefits, offboarding, org chart, audit, dark theme, phone width

Sign-in, and the second factor an admin account cannot skip

Sign in Two-factor code

Expenses, benefits and offboarding

Expense claims Benefit plans and enrolments Offboarding

Org chart and the append-only audit trail

Organisation structure Audit log

More of the employee app — sign-in, payslips, profile, and filing leave

Mobile sign-in Payslips Profile, with the language switch Filing a leave request

Dark theme, and the console at phone width

Dark theme Phone width


A few decisions worth knowing about

Business rules are pure functions. Leave arithmetic, attendance derivation, Thai tax, KPI scoring and assessment grading live in domain/ directories with no database, no framework and no I/O. That is why 323 domain tests run in under ten seconds — and why "why was I charged 2.5 days?" is answered by reading one function instead of a query plan.

Salary is never prorated by attendance coverage. A monthly-salaried employee is paid the full month minus explicit unpaid leave and absence. Days that simply have not been closed out — future dates, or a clock-in rollout still in progress — must not reduce pay. Getting this wrong silently shorts people, which is the worst class of payroll bug.

A fresh deployment cannot be claimed by whoever finds it first. Between docker compose up and the moment setup finishes, an install is reachable and unowned — and a scanner sweeping the port beats the person still reading startup logs. So there is no "if no organisation exists, let anyone through": the first administrator is created either by a CLI that needs a shell on the server, or by a wizard holding a single-use token only that CLI can mint.

A notification is written in the same transaction as the thing it is about. Approve a leave request and the row, the balance and the notification commit or roll back together — there is no window where someone is told about a decision that was rolled back. Delivery is a separate poll over the outbox table with FOR UPDATE SKIP LOCKED, so a mail server being down cannot fail an approval.

The audit trail is append-only in the database. A trigger raises an exception on UPDATE or DELETE against audit_logs and attendance_punches. A compromised application account can add entries but cannot rewrite history.

Privileged accounts cannot sign in with a password alone. Anyone who can read national IDs, run payroll or hand out permissions needs a second factor. TOTP is implemented against RFC 6238's own test vectors rather than pulled in as a dependency, and a code cannot be spent twice even inside its validity window.

Uploads are scanned before they are stored. Résumés arrive from a public careers page — the least trusted input the system takes. The bytes go to clamd first, so malware is never written anywhere for a later change to expose. And a scanner that is not working is never a pass: unreachable, timed out, or a reply that cannot be parsed all leave the file held and undownloadable.

The assistant's tools take no employee id. Every one resolves the subject from the authenticated principal, so there is no parameter a prompt injection could set to read someone else's payslip. The blast radius of a fully compromised model is bounded by what that user could already see. See ADR-0004.

Ecosystem

Cwork works on its own and always will. It is also the system of record for people, attendance, leave and payroll in a group of projects by the same maintainer that touch one restaurant chain from different sides:

PaynEat POS Point of sale at a branch
PaynEat ERP Supply side: procurement, plant, distribution, stock, cost
Cwork People, attendance, leave, payroll, labour cost per cost centre
SherWhyve Investigates technical incidents across the systems above. Private, no public repository.

No system reads another's database; they integrate through versioned APIs and events. A site has the same location code in all of them (ADR-0006).

The ERP has a free edition and a paid one; the feature that will consume Cwork's labour aggregates is in the paid one. Cwork is entirely Apache 2.0 and stays that way — that split belongs to the ERP. Worth knowing before you contribute rather than after: Apache 2.0 has always allowed anyone, the maintainer included, to build a commercial product on this, and the labour data contract is a documented public interface any other consumer may implement against.

None of this is a dependency. Installing Cwork on its own gives you the whole product, and every convention adopted for the ecosystem has to earn its place in a standalone install first — the reasoning is in spec.md § Agreed direction.

Repository layout

backend/     NestJS API — modular monolith over PostgreSQL
web/         React 19 + Vite admin console
mobile/      Flutter employee app
docs/        Spec, architecture, security, data model, API, ADRs, backlog
landing/     The overview page, published to GitHub Pages
.github/     CI workflow, issue and pull-request templates

Documentation

Specification What the system does, module by module — the reference for what "correct" means
Backlog Open work, prioritised, with acceptance criteria
Architecture How the pieces fit, and what is deliberately absent
Security Auth, encryption, audit — and an honest list of gaps
Personal data What is held about people, for how long, and how to take it out
Privacy notice (ไทย) A draft notice to hand to employees
Data model Schema patterns and the hand-written SQL
API reference Endpoints, error codes, conventions
Payroll: Thai rules Tax brackets, allowances, OT multipliers
The HR assistant Tools, guardrails, data flow
Operations Deploy, backup, scheduled jobs, the Prisma drift trap
ADRs Decisions that were not obvious

Localisation

The UI ships in Thai and English — switched from the header, Thai by default (CW-016) — and payroll implements Thai rules — PIT withholding, social security, Labour Protection Act overtime. Nothing in the architecture is Thailand-specific: the tax rule set is data (THAI_TAX_RULES_2026), leave types are configuration, and overtime multipliers are per-organisation settings. The English locale added a dependency-free i18n layer keyed on the English source string, so a third language is a catalogue rather than a code change; adding another jurisdiction means a new rule set and that translation pass, not a rewrite.

Status

Working and verified end to end — sign-in through payroll. 373 backend unit tests, 46 web, 35 mobile, plus a 242-check end-to-end suite that drives the real API over HTTP in CI, and the console exercised in a real browser against the live API.

Second-factor authentication, upload scanning, shared rate limiting and email/push delivery are all in place. What is left is tracked in the backlog, P0 first.

Still not production-ready without work. Before running real payroll, read the gaps in docs/security.md. In short: no ภ.ง.ด.1 filing export, issued documents are not rendered as PDFs, the employee app cannot register for push yet, retention is a policy you have to enforce by hand (how), the interface covers only Thai and English, and this code has never had a penetration test.

The Thai payroll and social-security rules have not been reviewed by anyone qualified. They were written from published sources and unit-tested against hand-worked examples, which proves the code computes what its author believed the rules to be — not that the belief is right. Check the figures against your own before a real run. If you have the standing to review them properly, issue #36 is open for it.

Versioning

Releases are tagged and recorded in CHANGELOG.md. Install a tag rather than main if you want to be able to say which version you are running — the tag list is the authority on what exists:

git clone --branch <tag> https://github.com/SuruchBoss/Cwork.git

This is 0.x, and breaking changes are allowed. They will be in the changelog with what to do about them, but there is no long-term support branch and no promise of a painless upgrade path until 1.0. Migrations are forward-only; take a database backup before applying any.

1.0 means the schema and the API are stable enough to promise that. It is not close, and pretending otherwise would be the more expensive mistake.

How this was built

The code in this repository was written by an AI agent, working under direction, over a small number of days. Several commits add thousands of lines at once. That is visible in git log to anyone who looks, and it changes what you should check before trusting any of it — so it is said here rather than left to be inferred.

What was not generated: the architecture, the security model, the priorities, and the arguments behind them. Those were decided by a person, recorded as they were settled, and are readable in spec.md § Agreed direction and in the ADRs. Where a ticket's own premise turned out to be wrong, the correction is written down in the backlog's Done table next to what was built — that record is the point.

What it implies about review:

  • Every suite passes, in CI, on every push — 373 backend unit tests, 46 web, 35 mobile, 242 end-to-end checks against the real API over HTTP.
  • The decisions are documented and the reasoning is recoverable.
  • No independent human has read every line. Tests passing and a design being defensible are not the same thing as a review, and this has had the first two and not the third.

The history has not been rewritten to look otherwise, and it will not be. Rewriting it would be the actual dishonesty.

Contributing

Contributions welcome — see CONTRIBUTING.md. The backlog is the list of open work, with the ones marked good first issue called out; they tend to be leave-policy variants, payroll edge cases, or a jurisdiction other than Thailand.

Commits in a pull request from a fork need a sign-off (git commit -s) under the Developer Certificate of Origin — a statement that you had the right to submit the change, checked in CI. There is no CLA: a contributor licence agreement is a barrier in front of a one-line fix, and the DCO answers the question a CLA is usually reached for. The accepted consequence is that the licence cannot realistically be changed later, since that would need every contributor's agreement. Apache-2.0 is the answer, for good.

Licence

Apache 2.0. Copyright 2026 Suruch Chakrapeesirisuk; see also NOTICE.

Every source file starts with its copyright line and an SPDX identifier (SPDX-License-Identifier: Apache-2.0), checked in CI; applied database migrations are exempt. Outside contributions are signed off under the Developer Certificate of Origin — see CONTRIBUTING.md.

About

Self-hosted HR system built for Thai labour practice — people, leave, attendance, payroll, approvals, employee app. NestJS · React · Flutter · PostgreSQL

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages