Deploy, configure, and extend Cannelle — a self-hosted business lifecycle management platform for professional services.
Cannelle is an open-core, self-hosted business lifecycle management platform designed for professional services companies — particularly agencies working with remote teams and contractors. It covers the full client relationship lifecycle from lead to invoice, with built-in collaboration tools for both internal staff and external contacts.
Community Edition
Source-available under the Business Source License 1.1. Self-hosted, full-featured, no restrictions for most use cases.
Premium Edition
Closed-source edition sharing the same core library. Additional modules and managed hosting.
| Sales & CRM | Lead/prospect/client pipeline, account managers, follow system |
| Project Management | Quotes, projects, tasks, deadlines, templates, cloning |
| Resource Management | Resource database, availability tracking, task-based search, assignments |
| Invoicing | Draft/send/track invoices, recurring billing, ageing analysis, PDF export |
| Reporting | Revenue, payments, sales pipeline, client analytics, lead sources |
| Collaboration | Workspaces with real-time messaging for internal and external users |
| Dashboard | Configurable widget board — open projects, revenue chart, workspaces, following feed, whiteboard, uninvoiced projects, draft invoices |
| Activity Feed | Follow clients, quotes, projects, resources, and invoices; per-user activity feed and dashboard widget |
| Integrations | Webhook endpoints with 16 automatic event triggers across the quote, project, and invoice lifecycle |
Cannelle is a monorepo built with npm workspaces and Turborepo. Business logic
lives in @cannelle/core;
both SvelteKit apps are thin presentation layers. Community is the
internal staff application — the main platform used by your team to manage clients, projects,
resources, and invoices. Atelier is a separate portal for
external contacts (clients and resources) to exchange messages, upload files, and track their
assignments.
cannelle-sveltekit/
├── packages/
│ ├── core/ @cannelle/core — Prisma, services, auth, email, storage, PDF, WebSocket, SSE
│ └── ui/ @cannelle/ui — shared Svelte components (workspaces, modals, pagination)
├── apps/
│ ├── community/ @cannelle/community — internal staff app (port 3000 / 5173 dev)
│ └── atelier/ @cannelle/atelier — external user portal (port 3001 / 5174 dev)
├── docker/ Dockerfile, docker-compose.yml, nginx, certbot scripts
├── turbo.json
└── .env.exampleSeparation of concerns
All business logic, database access, and shared types live in @cannelle/core. Both SvelteKit apps are thin routing layers.
Internal vs. external apps
@cannelle/community serves internal staff; @cannelle/atelier serves external clients and resources with a locked-down portal.
Direct database access
Page load functions (+page.server.ts) call core services directly via Prisma. No intermediate REST layer for reads.
API routes for mutations
Client-side mutations go through +server.ts API endpoints with Joi validation.
Shared UI components
@cannelle/ui provides workspace chat, file list, and generic components consumed by both apps.
import { getDb } from '@cannelle/core/db';
import { Permission } from '@cannelle/core/types';
import { createProject, getInvoice } from '@cannelle/core/services';
import { sendEmail } from '@cannelle/core/email';
import { getStorage } from '@cannelle/core/storage';
import { validateSession } from '@cannelle/core/auth';
import { validateExternalSession } from '@cannelle/core/auth/external';
import { broadcast } from '@cannelle/core/sse';| layer | technology |
|---|---|
| Framework | SvelteKit 2 with Svelte 5 (runes mode) |
| Language | TypeScript (strict mode) |
| Database | PostgreSQL 17 with Prisma ORM |
| Styling | Tailwind CSS 4 |
| Auth | Self-hosted — magic link + cookie sessions |
| Nodemailer (SMTP) with Handlebars templates | |
| File Storage | Local filesystem or AWS S3 (adapter pattern) |
| Puppeteer (headless Chromium) | |
| Real-time | WebSocket (Community app) + Server-Sent Events (Atelier) |
| i18n | Paraglide JS |
| Charts | Chart.js |
| Validation | Joi |
| Build | Vite 7, Turborepo, npm workspaces |
| Deployment | adapter-node — Docker or any Node.js host |
The Prisma schema defines 38 models. Every entity is scoped to a companyId.
Multi-tenancy is supported but disabled by default via the CANNELLE_MULTITENANT environment variable.
Company
User
Client
Resource
Project
Task
Assignment
Invoice
JSON metadata fields — Flexible metadata, financials, contacts, and addresses JSON columns avoid frequent schema migrations.
State machines — Core entities follow defined state transitions with timestamp tracking.
Soft deletion — Entities with dependencies are marked inactive rather than hard-deleted, preserving referential integrity and audit history.
Ownership verification — Every mutation confirms the requested resource belongs to the user's company, preventing cross-company data access.
ID obfuscation — Numeric database IDs are encoded into opaque hash strings for public URLs, making direct enumeration impossible while remaining fully reversible server-side.
Storage adapter — File storage is abstracted behind a common interface — swap between local filesystem and S3 with a single environment variable.
Encrypted secrets — Sensitive per-company configuration is encrypted at rest.
External user isolation — ExternalUser, ExternalSession, and ExternalToken are fully separate from internal User/Session models.
@cannelle/core/services exports 100+ functions organized by domain.
All business logic, validation, and database access lives here.
| service | responsibility |
|---|---|
| company | Company CRUD, PDF template config, user listing |
| users | User CRUD, role checks, manager queries, dependency analysis |
| clients | Client pipeline (lead → prospect → client), search, follow |
| suppliers | Resource lifecycle, service matching, task-based search, history search |
| projects | Project/quote CRUD, templates, cloning, state transitions |
| tasks | Task CRUD within projects, ordering, state tracking |
| task-definitions | Per-company task type configuration with workflows and pricing |
| assignments | Resource-to-task assignments (purchase orders) |
| invoices | Invoice lifecycle, recurring billing, ageing analysis, templates |
| attachments | File upload/download via storage adapter |
| emails | Email history, template management, sending |
| availability | User/resource availability calendar |
| reports | Revenue, payment, sales, client, and lead analytics |
| integrations | Webhook endpoint management; fires 16 automatic event triggers across the quote, project, and invoice lifecycle |
| followings | User watch subscriptions on entities; per-user activity feed queries |
| logs | Audit trail logging and querying |
| onboarding | Sample data seeding for new companies |
ID Encoding
Hashids-based obfuscation prevents ID enumeration in public URLs
Pagination
clampPagination() enforces a maximum of 100 items per page
Encryption
Sensitive per-company data is encrypted at rest
HTML Escaping
escapeHtml() for XSS protection on user-generated content
Cannelle can push events to external systems via webhook endpoints. There are 16 automatic triggers across the quote, project, and invoice lifecycle — each delivering a structured JSON payload with the event name, timestamp, and the full entity at the time of the event.
// note
Data is pushed to webhook endpoints as raw database records. Entity IDs are not obfuscated — numeric IDs are sent as-is, not as Hashids.
| category | events |
|---|---|
| Quote | quote.created, quote.updated, quote.sent, quote.converted, quote.cancelled |
| Project | project.created, project.updated, project.completed, project.cancelled |
| Invoice | invoice.created, invoice.updated, invoice.sent, invoice.reminder1, invoice.reminder2, invoice.collection, invoice.paid |
Cannelle has a built-in subscription model: users follow entities they care about — clients, quotes, projects, resources, or invoices — and receive a personalised activity feed showing everything that has changed on those entities. The feed is surfaced both on the dashboard and on a dedicated page. Step-by-step usage is covered in the User Guide.
A configurable two-column widget board. Visibility and order are saved per-user in localStorage.
Widgets can be reordered by drag-and-drop.
Open Projects
Projects currently in active state with progress indicators
Revenue Chart
Monthly revenue bar chart for the current year
Workspaces
Recently active workspaces with unread message counts
Following Feed
Latest activity across followed entities
Whiteboard
Latest company announcement
Uninvoiced Projects
Completed projects awaiting invoicing
Draft Invoices
Invoices in DRAFT state ready to send
// passwordless only
Cannelle uses magic link authentication exclusively — no passwords are stored or managed anywhere in the system.
Enter email
User enters their email on the login page
Token generated
A SHA256 token (30-minute expiry) is created and a magic link email is sent
Link clicked
Visiting the link verifies the token and creates a 30-day session
Session set
An httpOnly session cookie is set. Sessions auto-extend past 50% of their lifetime
Permissions are stored as a flat JSON map in user.settings:
| level | constant | meaning |
|---|---|---|
| 0 | NONE | No access |
| 1 | READ | View only |
| 2 | WRITE | View + create / edit |
| 3 | ADMIN | Full access including delete and configuration |
Domains: clientssalesprojectssuppliersinvoicesreportssettings
Clients and resources accessing the Atelier portal use a separate, fully isolated
authentication system (ExternalUser, ExternalSession, ExternalToken).
First-time access is via an invitation link sent by an internal user. Subsequent logins
use the same magic link flow. Sessions last 90 days.
Magic link requests
5 per 15 min per IP
Signup
10 per 60 min per IP
Token verification
10 per 15 min per IP
Cannelle uses a storage adapter pattern that supports both local filesystem and AWS S3.
Switching providers requires only a change to the STORAGE_PROVIDER environment variable.
| variable | description |
|---|---|
| STORAGE_PROVIDER | "local" or "s3" |
| STORAGE_LOCAL_PATH | Local upload directory (default: ./uploads) |
| S3_BUCKET | S3 bucket name |
| S3_REGION | AWS region |
| AWS_ACCESS_KEY_ID | AWS access key |
| AWS_SECRET_ACCESS_KEY | AWS secret key |
{mount}/cannelle/uploads/{companyId}/
├── logo/ Company logo
├── documents/ Company documents
├── templates/ PDF templates
├── users/{userId}/avatars/ User avatars
├── clients/{clientId}/{uuid}/ Client files
├── suppliers/{supplierId}/{uuid}/ Resource files
├── projects/{projectId}/{uuid}/ Project files
├── projects/{projectId}/tasks/{taskId}/ Task files
├── documents/invoices/{invoiceId}/{uuid}/ Invoice files
└── resources/{workspaceId}/{uuid}/ Workspace filesWhen using S3, files are served via presigned download URLs (2-hour expiry) and uploaded via presigned upload URLs (5-minute expiry) for direct client-to-S3 uploads.
Emails are sent via SMTP using Nodemailer with Handlebars templates. SMTP is configured globally via environment variables.
| template | purpose |
|---|---|
| magic-link | Authentication magic link email |
| invitation | New user invitation to the platform |
| invitation-user | User-to-user workspace invitation |
| generic | General-purpose email with custom content |
Beyond the system templates, companies can define their own pre-written email texts via the settings UI. All sent emails are logged in the Emails table.
Two complementary transports are used — WebSocket for the internal Community app, and Server-Sent Events (SSE) for the lighter-weight Atelier portal.
PDFs are generated server-side using Puppeteer (headless Chromium) for invoices, project quotes, and resource assignments.
Download template
HTML template is downloaded from storage (local or S3)
Compile with Handlebars
Template is compiled with entity data. Custom helpers: formatNumber, formatDate, formatCurrency, multiply, divide, add, truncate
Render to PDF
Headless Chromium renders the HTML to an A4 PDF
Upload result
Generated PDF is uploaded back to storage
Return buffer
PDF buffer is returned to the client for immediate download
// resource note
Puppeteer requires approximately 512 MB of RAM to launch headless Chromium. Allocate at least 1 GB to your Docker container in production.
All endpoints require a valid session cookie unless noted. The Community app and Atelier app each expose their own separate APIs.
| method | endpoint | description |
|---|---|---|
| POST | /api/auth/magic-link | Request magic link email |
| POST | /api/auth/verify | Verify magic link token |
| POST | /api/auth/signup | Create new company + admin user |
| POST | /api/auth/logout | Invalidate session |
| method | endpoint | description |
|---|---|---|
| GET | /api/clients | List clients (supports ?state= filter) |
| POST | /api/clients | Create client |
| GET | /api/clients/[id] | Get client details |
| PATCH | /api/clients/[id] | Update client |
| POST | /api/clients/[id]/follow | Follow client |
| GET | /api/clients/export | Export clients |
| method | endpoint | description |
|---|---|---|
| GET | /api/projects/state/[state] | List projects by state |
| POST | /api/projects/create | Create project or quote |
| GET | /api/projects/[id] | Get project with tasks and assignments |
| PATCH | /api/projects/[id] | Update project |
| POST | /api/projects/[id]/clone | Clone project from template |
| GET | /api/projects/[id]/pdf | Download project PDF |
| GET | /api/projects/templates | List project templates |
| POST | /api/projects/[id]/tasks/create | Create task |
| PATCH | /api/projects/[id]/tasks/[taskId] | Update task |
| DELETE | /api/projects/[id]/tasks/[taskId] | Delete task |
| POST | /api/projects/[id]/assignments/create | Create assignment |
| PATCH | /api/projects/[id]/assignments/[aId] | Update assignment |
| GET | /api/projects/[id]/assignments/[aId]/pdf | Download assignment PDF |
| method | endpoint | description |
|---|---|---|
| GET | /api/suppliers | List resources |
| POST | /api/suppliers | Create resource |
| GET | /api/suppliers/[id] | Get resource |
| PATCH | /api/suppliers/[id] | Update resource |
| POST | /api/suppliers/search | Search resources |
| POST | /api/suppliers/search-by-task | Search by task type |
| POST | /api/suppliers/search-by-history | Search by project history |
| method | endpoint | description |
|---|---|---|
| GET | /api/invoices | List invoices |
| POST | /api/invoices | Create invoice |
| GET | /api/invoices/[id] | Get invoice |
| PATCH | /api/invoices/[id] | Update invoice |
| DELETE | /api/invoices/[id] | Delete / cancel invoice |
| PUT | /api/invoices/[id]/state | Change invoice state |
| GET | /api/invoices/[id]/pdf | Download invoice PDF |
| GET | /api/invoices/recurring | List recurring invoices |
| method | endpoint | description |
|---|---|---|
| GET | /api/reports/[type] | Revenue, payment, sales analytics |
| GET | /api/search?keyword= | Global cross-entity search |
| POST | /api/attachments/upload | Upload file |
| GET | /api/notifications | List notifications |
| POST | /api/websocket/auth | Get WebSocket auth token |
| GET | /files/[...path] | Download file from storage |
Requires a valid atelier_session cookie.
| method | endpoint | description |
|---|---|---|
| POST | /api/workspaces | Create workspace (QUESTION or QUOTE_REQUEST) |
| PUT | /api/workspaces/[id]/read | Mark workspace as read |
| GET | /api/messages?workspaceId= | Paginated message list |
| POST | /api/messages | Send text message |
| GET | /api/files?workspaceId= | List workspace files |
| POST | /api/files | Upload file (20 MB max) |
| GET | /api/sse/[workspaceId] | SSE stream for real-time events |
| POST | /api/auth/logout | Invalidate external session |
1. clone the repository
git clone https://github.com/cannelleio/community.git
cd cannelle-sveltekit2. install dependencies
npm install3. configure environment
cp .env.example .env
# Edit .env with your database credentials and secrets4. set up the database
npm run db:generate # generate Prisma client
npm run db:migrate # run migrations (creates tables)5. start the development servers
npm run dev
# Community app → http://localhost:5173
# Atelier portal → http://localhost:5174| npm run dev | Start dev servers for all workspaces |
| npm run build | Production build |
| npm run check | TypeScript validation |
| npm run lint | ESLint |
| npm run format | Prettier auto-format |
| npm run test | Run all tests |
| npm run db:generate | Generate Prisma client |
| npm run db:migrate | Run database migrations |
| npm run db:push | Push schema to database (dev only) |
| npm run db:studio | Open Prisma Studio |
The docker/ directory contains everything needed to build and deploy Cannelle.
Two separate Docker images are built from a shared monorepo Dockerfile.
The default docker-compose.yml starts four services:
| service | description |
|---|---|
| db | PostgreSQL 17 — data persisted in pgdata volume |
| migrate | Runs prisma migrate deploy once on startup, then exits |
| community | Internal staff app on port 3000 — uploads persisted in shared volume |
| atelier | External user portal on port 3001 — shares the same uploads volume |
Both images are built from the repository root using build arguments to select the app:
# Community
docker build \
-f docker/Dockerfile \
--build-arg APP_NAME=@cannelle/community \
--build-arg APP_PATH=apps/community \
--build-arg APP_PORT=3000 \
-t your-repo/cannelle-community:latest \
.
# Atelier
docker build \
-f docker/Dockerfile \
--build-arg APP_NAME=@cannelle/atelier \
--build-arg APP_PATH=apps/atelier \
--build-arg APP_PORT=3001 \
-t your-repo/cannelle-atelier:latest \
.// separate .env for docker
The docker/ directory has its own .env.example — distinct from the root .env used for local development. It includes Docker-specific variables such as image names and tags.
cd docker
cp .env.example .env
# Set image names, database credentials, secrets, and BASE_URL
docker compose up -d# One-time Let's Encrypt bootstrap
./init-letsencrypt.sh
# Subsequent starts
docker compose -f docker-compose.yml -f docker-compose.proxy.yml up -dpgdata
PostgreSQL data — survives container restarts and upgrades
uploads
Shared file storage — mounted by both Community and Atelier
Secrets — Use an .env file — never commit secrets to git
Database backups — Use automated pg_dump or your managed provider's backup feature
Upload persistence — Always mount the uploads volume — files are lost on container restart without it
RAM — Puppeteer requires ~512 MB RAM. Allocate at least 1 GB to the container
TLS — Use the proxy stack or place Cannelle behind Nginx, Caddy, or Traefik for HTTPS
S3 in production — Consider S3 over local storage for durability and scalability
Firewall — Only expose ports 80 and 443 publicly — block direct access to 3000, 3001, and 5432
openssl rand -hex 32 # JWT_SECRET
openssl rand -hex 16 # HASHID_SALT
openssl rand -hex 32 # ENCRYPTION_KEY
openssl rand -hex 16 # ENCRYPTION_SALT| variable | req | default | description |
|---|---|---|---|
| DATABASE_URL | yes | — | PostgreSQL connection string |
| JWT_SECRET | yes | — | Secret for token signing |
| HASHID_SALT | yes | — | Salt for ID obfuscation |
| ENCRYPTION_KEY | yes | — | Key for AES-256-GCM encryption |
| ENCRYPTION_SALT | yes | — | Salt for AES-256-GCM encryption |
| BASE_URL | yes | http://localhost:5173 | Public Community app URL |
| ATELIER_URL | yes | http://localhost:5174 | Public Atelier URL (invitation emails) |
| ATELIER_ENABLED | — | true | Enable Atelier integration — Workspaces tab and contact Invite button. Set to "false" to disable. |
| MAGIC_LINK_EXPIRY_MINUTES | yes | 15 | Magic link validity period |
| SMTP_HOST | yes | — | SMTP server hostname |
| SMTP_PORT | yes | 465 | SMTP server port |
| SMTP_USER | yes | — | SMTP username |
| SMTP_PASS | yes | — | SMTP password |
| EMAIL_FROM | yes | — | Default sender address |
| STORAGE_PROVIDER | yes | local | "local" or "s3" |
| STORAGE_LOCAL_PATH | — | ./uploads | Local upload directory |
| S3_BUCKET | — | — | S3 bucket name |
| S3_REGION | — | — | AWS region |
| AWS_ACCESS_KEY_ID | — | — | AWS access key |
| AWS_SECRET_ACCESS_KEY | — | — | AWS secret key |
| CANNELLE_MULTITENANT | yes | false | Enable multi-tenant mode |
// smtp is critical
Cannelle uses magic link authentication exclusively — make sure your SMTP settings are valid before starting the application. If the app cannot send emails, you will not be able to log in.
Cannelle ships with four built-in task types — Basic, Copywriting, Interpreting, and Translation. You can add your own types without any schema changes — task data is stored as arbitrary JSON.
apps/community/src/components/tasks/
├── _registry.ts ← type → component mapping
├── generic/ ← "Basic" task type
│ ├── TaskNew.svelte
│ ├── TaskEdit.svelte
│ ├── Settings.svelte
│ └── Pricing.svelte
├── copywriting/
├── interpreting/
├── translation/
└── TaskManager.svelte ← resolves components from registry1. copy an existing type folder
cp -r apps/community/src/components/tasks/generic/ \
apps/community/src/components/tasks/my-service/2. register in _registry.ts
import MyServiceTaskNew from './my-service/TaskNew.svelte';
import MyServiceTaskEdit from './my-service/TaskEdit.svelte';
import MyServiceSettings from './my-service/Settings.svelte';
import MyServicePricing from './my-service/Pricing.svelte';
// Add to the registry object:
'my-service': {
components: {
taskNew: MyServiceTaskNew,
taskEdit: MyServiceTaskEdit,
settings: MyServiceSettings,
pricing: MyServicePricing
},
metadata: {
label: 'My Service',
title: 'My Custom Service',
description: 'Shown on the settings page.',
hasLanguages: false
}
}// after rebuilding
The new type appears automatically in Settings → Task Definitions. Enable it with the toggle. It will appear in the "Add task" dropdown when creating tasks in any project.
Source-available for most use cases. See the LICENSE file in the repository for full details.
view on github →