A full-stack project management platform with real-time collaboration, drag-and-drop kanban workflows, and team workspace management. Built using React, Express.js, and MongoDB in a scalable monorepo architecture.
- Trellify
- ๐ Kanban Boards - Create and manage multiple boards with customizable columns
- ๐ Card Management - Drag-and-drop cards between columns with smooth animations
- ๐ท๏ธ Card Details - Labels, due dates with reminders, checklists with progress, and editable comments
- ๐ Board Filters - Filter cards by keyword, label, member, or due status
- ๐๏ธ Archive & Restore - Archive cards and columns, then restore them to their original position
- ๐ Card History - Per-card activity log of every change
- โญ Starred & Recent Boards - Quick access to favourite and recently opened boards
- ๐ฅ Team Collaboration - Invite members to boards and assign cards to team members
- ๐ฌ Real-time Updates - Socket.io for live synchronization across all users
- ๐ Authentication & Authorization - JWT-based auth with secure user management
- ๐ Password Reset - Email-based forgot-password and reset flow
- ๐ก๏ธ Bot Protection - Cloudflare Turnstile on register, login, and forgot-password
- ๐ผ๏ธ Avatar Uploads - Cloudinary-backed profile image uploads
- โ๏ธ Background Jobs - BullMQ worker for deferred tasks: unverified-account cleanup, invitation emails, and due-date reminders
- ๐จ Theme Support - Light and dark mode with customizable themes
- ๐ Notifications - Real-time notifications for board invitations
If you don't have pnpm installed, you can install it using one of the following methods:
Using npm:
npm install -g pnpmFor more installation options, visit pnpm installation guide.
git clone https://github.com/BaoDuong254/trellify.git
cd trellifyThe project uses pnpm workspaces. Simply run from the root directory:
pnpm installThis will install all dependencies for root, apps (client & server), and packages automatically.
This project uses pnpm catalog to manage all dependency versions centrally in pnpm-workspace.yaml. Individual package.json files reference packages with "catalog:" instead of a version number - never pin versions directly in package.json.
Step 1 - Register the version in pnpm-workspace.yaml:
catalog:
# ... existing entries ...
<package-name>: <version> # e.g. dayjs: 1.11.13Step 2 - Add the dependency to the target workspace's package.json:
{
"dependencies": {
"<package-name>": "catalog:"
}
}Use "devDependencies" instead for build-time / tooling packages.
Step 3 - Sync the lockfile from the root:
pnpm installAdding an internal workspace package (e.g. @workspace/shared, @workspace/eslint) โ these are resolved locally, so they do not need a catalog entry. Just reference them directly in package.json:
{
"dependencies": {
"@workspace/shared": "workspace:*"
},
"devDependencies": {
"@workspace/eslint": "workspace:*"
}
}Then run pnpm install from the root.
Create .env files for both client and server:
Server (.env in apps/server/):
# Server configuration
PORT=3000
NODE_ENV=development
# Client configuration
CLIENT_URL=http://localhost:5173
# Database configuration
MONGODB_URI=your_mongodb_uri
DATABASE_NAME=your_database_name
# Brevo configuration
BREVO_API_KEY=your_brevo_api_key
# Admin configuration
ADMIN_EMAIL_ADDRESS=your_admin_email
ADMIN_EMAIL_NAME=your_admin_name
# JWT configuration
ACCESS_TOKEN_SECRET_SIGNATURE=your_access_token_secret
ACCESS_TOKEN_LIFE=your_access_token_life
REFRESH_TOKEN_SECRET_SIGNATURE=your_refresh_token_secret
REFRESH_TOKEN_LIFE=your_refresh_token_life
# Cookie configuration
COOKIE_MAX_AGE=your_cookie_max_age
# Cloudinary configuration
CLOUDINARY_CLOUD_NAME=your_cloudinary_cloud_name
CLOUDINARY_API_KEY=your_cloudinary_api_key
CLOUDINARY_API_SECRET=your_cloudinary_api_secret
# Redis cloud configuration
REDIS_URL=your_redis_url
# BullMQ configuration
QUEUE_PREFIX=trellify
WORKER_CONCURRENCY=5
# Turnstile configuration, use 1x0000000000000000000000000000000AA for dev mode
TURNSTILE_SECRET_KEY=your_turnstile_secret_keyClient (.env in apps/client/):
# API Configuration - server origin only, the client appends /api/v1 itself
VITE_API_ENDPOINT=http://localhost:3000
# Turnstile Site Key, use 1x00000000000000000000AA for dev mode
VITE_TURNSTILE_SITE_KEY=your-turnstile-site-keyNote
- Both apps validate their environment with Zod at startup (
apps/server/src/config/environment.ts,apps/client/src/config/env.ts) and throw on the first missing or invalid variable - a typo fails fast at boot instead of surfacing later as a broken request.VITE_API_ENDPOINTmust be the server origin only. Every API call already appends/api/v1/..., so adding the path here produces/api/v1/api/v1/....- For local development, MongoDB and Redis are external services (e.g. MongoDB Atlas and Redis Cloud) - there is no local container for either, so
MONGODB_URIandREDIS_URLmust point at real instances beforepnpm start:devwill boot. Production is different: both run in-cluster as StatefulSets, and the connection details come from the manifests ininfra/, not from these files.
The project uses Turbo for monorepo management. A single command starts three processes - the client, the API server, and the BullMQ worker:
# From root directory - runs client (5173), API server (3000) and worker in parallel
pnpm start:devOr run a single workspace. From the root directory, fe, be, shared and e2e are aliases for pnpm --filter=client, pnpm --filter=server, pnpm --filter=shared and pnpm --filter=e2e, so there is no need to change directory:
# Terminal 1 - Client only
pnpm fe start:dev
# Terminal 2 - API server only (does NOT start the worker)
pnpm be start:dev
# Terminal 3 - BullMQ worker only
pnpm be start:worker:dev
# API server with the Node inspector attached on port 9229
pnpm be start:debugNote The worker is a separate process from the API server (
apps/server/src/worker.ts). If you start onlypnpm be start:dev, the API still enqueues jobs but nothing consumes them - queued work such as unverified-account cleanup will silently never run.
The project uses a monorepo structure with shared packages. You must build in the correct order:
# Step 1: Build shared packages first (required dependencies for apps)
pnpm pkg:build
# Step 2: Build applications (client & server)
pnpm apps:build
# Step 3: Run production
pnpm start:prodProduction runs on a self-hosted k3s cluster and is deployed by GitOps โ GitHub Actions builds images, commits the new tag back to this repo, and ArgoCD reconciles the cluster to match. Nothing is deployed by SSH-ing into a machine.
1. Build โ once CI passes on a push to main, .github/workflows/build-k8s-images.yml builds apps/server/Dockerfile and apps/client/Dockerfile and pushes them to GHCR as ghcr.io/baoduong254/trellify-{server,client}.
2. Tag โ each image gets the immutable tag sha-<short-commit> alongside latest. Only the sha- tag is ever deployed, so whatever is running traces back to exactly one commit.
3. Bump โ the bump-manifest job runs kustomize edit set image against infra/trellify/overlays/prod and commits the result as chore(deploy): trellify -> sha-xxxxxxx [skip ci]. The [skip ci] marker is what stops the workflow from re-triggering itself.
4. Sync โ ArgoCD sees the new commit and rolls the Deployments forward. CI runs in parallel with the build rather than gating it; the Docker build itself runs pnpm apps:build, so code that does not compile never produces an image.
See infra/README.md for the full infrastructure reference โ cluster bootstrap, ArgoCD project layout, SealedSecrets, and the operations runbook.
| Workload | Replicas | Notes |
|---|---|---|
trellify-server |
3, HPA up to 6 | Autoscales at 70% CPU; PodDisruptionBudget keeps minAvailable: 2 |
trellify-worker |
1 | Same image as the server, running dist/worker.js - the BullMQ consumer |
trellify-client |
2 | nginx serving the built Vite bundle |
trellify-mongodb |
1 (StatefulSet) | In-cluster, 20Gi retained volume, nightly backup CronJob โ Cloudflare R2 |
trellify-redis |
1 (StatefulSet) | In-cluster, 8Gi retained volume |
All three public paths share one host: / goes to the client, /api to the server (rate limited), and /socket.io to the server with long timeouts and cookie affinity. There is no public port on the VM โ traffic arrives through an outbound-only Cloudflare tunnel.
| Secret / variable | Purpose |
|---|---|
GITHUB_TOKEN (built in) |
Pushes images to GHCR |
GITOPS_TOKEN |
Pushes the image-tag bump commit back to main |
SERVER_ENV, CLIENT_ENV |
Full .env contents, used by the CI build only |
vars.VITE_TURNSTILE_SITE_KEY |
Baked into the client image at build time |
TELEGRAM_TO, TELEGRAM_TOKEN |
Build success / failure notifications |
SONAR_TOKEN |
SonarCloud scan in the CI workflow |
Important Runtime configuration for the cluster does not come from these secrets โ it comes from
infra/trellify/base/config-server.envand the SealedSecrets ininfra/trellify/overlays/prod/. When you add a new environment variable, update those as well, or the pod fails Zod validation at startup and never becomes ready.
Before the k3s migration, production ran as a Docker Compose stack on a single VPS, deployed over SSH by .github/workflows/deploy.yml with images on Docker Hub and Portainer for container management. Those files are still in the repo: docker-compose.yml and docker-compose.portainer.yml, and remain useful for self-hosting on a single machine.
This path is no longer used for production. deploy.yml is workflow_dispatch-only, so nothing pushed to main can trigger it โ but dispatching it manually does build the Docker Hub images and redeploy the Compose stack over SSH, which keeps it usable as a fallback if the cluster is unavailable.
It needs its own secrets, none of which the k3s pipeline uses:
| Secret | Purpose |
|---|---|
DOCKERHUB_USERNAME, DOCKERHUB_PASSWORD |
Docker Hub authentication and image namespace |
HOST_VPS, USERNAME_VPS, KEY_VPS, PORT_VPS |
SSH access to the deployment host |
Every layer has its own suite. The unit, integration and component suites run on Vitest, and the end-to-end suite runs on Playwright.
| Layer | Tools | Command | Needs Docker |
|---|---|---|---|
| Shared schemas | Vitest | pnpm shared test |
No |
| Server unit | Vitest, vi.mock |
pnpm be test:unit |
No |
| Server integration | Vitest, Supertest, Testcontainers (MongoDB 8, Redis 8) | pnpm be test:integration |
Yes |
| Client | Vitest, jsdom, Testing Library, MSW | pnpm fe test |
No |
| End-to-end | Playwright (Chromium) | pnpm e2e:up โ pnpm test:e2e โ pnpm e2e:down |
Yes |
pnpm test # every Vitest suite (shared, server unit + integration, client) with coverage
pnpm be test:unit # the fast loop while working on the server - no containers
pnpm be test:integration # starts throwaway MongoDB and Redis containers, runs, removes them
pnpm e2e:up # MongoDB + Redis for Playwright (e2e/docker-compose.yml)
pnpm test:e2e # starts its own API and Vite dev server, then drives Chromium
pnpm e2e:down # stop and delete the E2E containers and their data- Docker must be running for
pnpm be test:integration,pnpm testand the E2E suite. Testcontainers pullsmongo:8.0andredis:8-alpineon the first run. - Chromium for Playwright is a one-time download:
pnpm e2e exec playwright install chromium. - Network access to Cloudflare for E2E. The login step goes through the real Turnstile widget using Cloudflare's always-pass test keys, so both the browser and the API call
challenges.cloudflare.com.
- Unit and component tests sit next to the file they cover as
*.spec.ts(x)inside each workspace'ssrc/(for exampleapps/server/src/sockets/board/board.broadcast.spec.ts). Build configs exclude them, so nothing test-related reachesdist/or the client bundle. - Server integration tests live in
apps/server/test/integration/, grouped by feature (api/,providers/,utils/) rather than mirroringsrc/, because each one exercises several layers at once. - End-to-end tests live in
e2e/tests/as*.e2e.ts. Seee2e/README.mdfor how to run, debug and write them.
The conventions behind these suites - why the cache and bloom filter are tested against real Redis, how the integration containers are wired, what the client test helpers provide - are documented in the Testing section of CLAUDE.md.
No test run touches real services or data:
NODE_ENV=testmakes the server skipapps/server/.enventirely. Each Vitest config ande2e/e2e.envsupplies every variable itself, so a missing value can never fall back to your real MongoDB Atlas, Brevo or Cloudinary credentials.- Integration tests get fresh containers for every run, mock the three outside services (Brevo, Cloudinary, Turnstile) and empty the database and Redis before each test.
- End-to-end tests run on their own ports (API
3100, client5174, MongoDB27019, Redis6381) and their own database (trellify_e2e), so they never reuse a running dev server.
.github/workflows/ci.yml runs two jobs in parallel:
ci: lint โ knip โ knip:production โ format check โ CSP hash check โpkg:buildโtypecheckโapps:buildโpnpm testโ SonarCloud.pnpm testwriteslcovcoverage for the server, client and shared packages, and SonarCloud picks it up.e2e: installs Chromium, startse2e/docker-compose.ymland runs Playwright. On CI a failing test is retried once and reported as an inline annotation.
When the e2e job fails it uploads the Playwright HTML report, including screenshots and traces of the failing tests, as the playwright-report artifact, kept for 7 days. Find it at Actions โ the failed run โ Summary โ Artifacts, unzip it, and open it with:
pnpm e2e exec playwright show-report <path-to-unzipped-folder>Tests do not run in the pre-commit hook, because the integration and E2E suites need Docker. CI is the gate.
The project includes a Postman collection with pre-configured requests.
-
Import Collection
- Open Postman
- Click Import
- Select
postman/collections/Trellify.postman_collection.json
-
Import Environment
- Click Import
- Select
postman/environments/Trellify.postman_environment.json
-
Configure Environment
- Select "Trellify" environment in Postman
- Update variables if needed:
host:http://localhost:3000email/password: the account to log in withturnstileToken: any non-empty value passes when the server runs with Cloudflare's always-pass test secret
-
Run the requests in order
- Login stores the auth cookies and
userId - Create new board, Create new column and Create new card store
boardId,columnIdandcardId, and every other request reads them from the environment - Add comment stores
commentIdand Get invitations storesinvitationId otherColumnId,memberUserId,verifyTokenandresetTokenare filled in by hand
- Login stores the auth cookies and
Load tests run with k6 against an isolated docker-compose stack built from the production Dockerfile, with its own MongoDB and Redis. No real data is touched. The stacks are defined in k6/docker-compose.yml (one server) and k6/docker-compose.multi.yml (three servers behind nginx).
pnpm loadtest:up # start the isolated stack
pnpm loadtest:seed # seed users, boards and cards
pnpm k6 smoke # gate - all checks must pass
pnpm k6 mixed load # the main run
pnpm loadtest:down # tear it all downpnpm k6 with no arguments prompts for the scenario and profile; passing them runs straight away.
| Script | Purpose |
|---|---|
loadtest:up[:multi] |
Start the stack, 1 replica or 3 behind nginx |
loadtest:down[:multi] |
Remove containers and volumes |
loadtest:seed |
Seed data and mint JWTs for the virtual users |
k6 |
Run any scenario against any profile |
k6:peak-rps |
Peak RPS from a --ts time-series file |
k6:traffic-mix |
Derive the real traffic mix from Prometheus |
Scenarios: smoke, mixed, board-read, board-write, auth, socket-fanout.
Profiles: smoke, baseline, load, stress, spike, soak, capacity.
Flags: --prom pushes to Prometheus, --ts writes a time-series file for pnpm k6:peak-rps.
Full instructions and configuration: k6/README.md.
Unlighthouse runs Lighthouse (performance, accessibility, best practices, SEO) against the deployed site, https://trellify.duonggiabao.com by default (override with UNLIGHTHOUSE_SITE). Everything is configured in unlighthouse.config.ts. Routes are listed explicitly because the sitemap only holds two URLs.
pnpm lighthouse # public pages, interactive UI at http://localhost:5678
pnpm lighthouse:auth # authenticated pages, reads .env.unlighthouse
pnpm lighthouse:ci # public pages, headless: check the score budgets, exit non-zero on failure
pnpm lighthouse:ci:auth # authenticated pages, headless| Script | Scans | Output |
|---|---|---|
lighthouse |
/login, /register, /forgot-password, 404 |
.unlighthouse/public |
lighthouse:auth |
/boards, /boards/:id, /settings/account |
.unlighthouse/authenticated |
lighthouse:ci |
Public set, headless | .unlighthouse/public + static HTML |
lighthouse:ci:auth |
Authenticated set, headless | .unlighthouse/authenticated + static HTML |
The two sets are scanned separately because AuthLayout redirects a logged-in user away from /login and /register. Scans run on the desktop preset, one page at a time. The headless scripts also fail when a page could not be measured at all (no report, a runtime error, or a redirect such as a protected page landing on /login), which unlighthouse-ci on its own reports as a pass.
Budgets (ci.budget): performance 80, accessibility 90, best practices 75, SEO 60. Best practices is capped around 0.81 by deprecation warnings from the script Cloudflare injects (/cdn-cgi/challenge-platform). SEO is 0.63 on noindex pages, which is intentional.
Use a dedicated test account with one demo board, never a real account. Log in in the browser, then copy these values from DevTools โ Application into .env.unlighthouse at the repo root (gitignored):
UNLIGHTHOUSE_REFRESH_TOKEN= # cookie refreshToken, valid for 14 days
UNLIGHTHOUSE_PERSIST_ROOT= # Local Storage value of persist:root, copied as-is
UNLIGHTHOUSE_BOARD_ID= # optional, adds /boards/:idEach scan trades the refresh token for a fresh access token through GET /api/v1/users/refresh_token, so there is no access token to copy. When the scan stops with "Refresh token rejected", the refresh token has expired: log in again and replace it.
Cloudflare's Bot Fight Mode is on for the zone and answers GitHub-hosted runners (datacenter IPs) with a Managed Challenge, so every page comes back as "Just a momentโฆ". On the Free plan, Bot Fight Mode cannot be skipped by a WAF custom rule, and a self-hosted runner is not an option for a public repository. Run the scans locally instead, before and after sizeable frontend changes.
Local numbers are only as clean as the machine: antivirus web protection that injects scripts into pages (Kaspersky does) keeps the network busy until Lighthouse gives up after 45 seconds and roughly halves the performance score. Exclude trellify.duonggiabao.com from it before scanning, and delete .unlighthouse/ to avoid reading old reports.
The project uses Conventional Commits:
<type>[optional scope]: <description>
[optional body]
[optional footer(s)]Types:
feat: New featurefix: Bug fixdocs: Documentation updatestyle: Formatting changes that don't affect code logicrefactor: Code refactoringperf: Performance improvementtest: Adding or fixing testschore: Build tasks, package manager configs, etc.
Examples:
git commit -m "feat(auth): add user login functionality"
git commit -m "fix(api): resolve user data fetching issue"
git commit -m "docs: update installation guide"
git commit -m "style(client): format code with prettier"Git hooks are managed by lefthook and configured in lefthook.yaml. They are installed automatically by the prepare script on pnpm install:
- pre-commit:
- Verify
pnpm-lock.yamlis in sync (scripts/check-lockfile.sh) - Verify the CSP hashes for the client's inline scripts (
scripts/check-csp-hashes.sh) - Run
knipto detect unused files, exports, and dependencies - Format staged files with Prettier
- Run
pnpm lint:fix
- Verify
- commit-msg: Validate the message against Conventional Commits via commitlint
- post-commit: Print a success message
Note
knipalso runs in CI (bothknipandknip:production). An exported symbol nobody imports, or a dependency nobody uses, will fail the build - delete it or wire it up rather than leaving it dangling.The test suites are not part of any hook - run
pnpm test(andpnpm test:e2ewhen touching user flows) before pushing, or let CI run them. See ๐งช Automated Testing.
main: Production branchfeature/feature-name: For new featuresbugfix/bug-description: For bug fixeshotfix/issue-description: For urgent production issues
-
Create a new branch Always branch off from the latest version of
main.git checkout main git pull origin main git checkout -b feature/your-feature-name
-
Work on your feature Make your code changes and commit them using the Conventional Commits format:
git add . git commit -m "feat(auth): add login functionality"
-
Rebase with the latest main branch Before pushing, make sure your branch is up to date with
main:git fetch origin git rebase origin/main
-
Push your branch to remote
git push origin feature/your-feature-name
-
Create a Pull Request (PR) Open a PR to merge your branch into
mainusing the projectโs PR template. Wait for review and approval before merging. -
After Merge โ Sync and Clean Up Once your PR is merged:
git checkout main git pull origin main git branch -d feature/your-feature-name # delete local branch git push origin --delete feature/your-feature-name # delete remote branch