Everything you need
to know.

Deploy, configure, and extend Cannelle — a self-hosted business lifecycle management platform for professional services.

Overview

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.

// key capabilities

Sales & CRMLead/prospect/client pipeline, account managers, follow system
Project ManagementQuotes, projects, tasks, deadlines, templates, cloning
Resource ManagementResource database, availability tracking, task-based search, assignments
InvoicingDraft/send/track invoices, recurring billing, ageing analysis, PDF export
ReportingRevenue, payments, sales pipeline, client analytics, lead sources
CollaborationWorkspaces with real-time messaging for internal and external users
DashboardConfigurable widget board — open projects, revenue chart, workspaces, following feed, whiteboard, uninvoiced projects, draft invoices
Activity FeedFollow clients, quotes, projects, resources, and invoices; per-user activity feed and dashboard widget
IntegrationsWebhook endpoints with 16 automatic event triggers across the quote, project, and invoice lifecycle

Architecture

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.

monorepo
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.example

// design principles

1.

Separation of concerns

All business logic, database access, and shared types live in @cannelle/core. Both SvelteKit apps are thin routing layers.

2.

Internal vs. external apps

@cannelle/community serves internal staff; @cannelle/atelier serves external clients and resources with a locked-down portal.

3.

Direct database access

Page load functions (+page.server.ts) call core services directly via Prisma. No intermediate REST layer for reads.

4.

API routes for mutations

Client-side mutations go through +server.ts API endpoints with Joi validation.

5.

Shared UI components

@cannelle/ui provides workspace chat, file list, and generic components consumed by both apps.

// importing from @cannelle/core

typescript
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';

Tech Stack

layertechnology
FrameworkSvelteKit 2 with Svelte 5 (runes mode)
LanguageTypeScript (strict mode)
DatabasePostgreSQL 17 with Prisma ORM
StylingTailwind CSS 4
AuthSelf-hosted — magic link + cookie sessions
EmailNodemailer (SMTP) with Handlebars templates
File StorageLocal filesystem or AWS S3 (adapter pattern)
PDFPuppeteer (headless Chromium)
Real-timeWebSocket (Community app) + Server-Sent Events (Atelier)
i18nParaglide JS
ChartsChart.js
ValidationJoi
BuildVite 7, Turborepo, npm workspaces
Deploymentadapter-node — Docker or any Node.js host

Data Model

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.

// core entities

Company

Tenant root. All data belongs to a company. Stores metadata, addresses, SMTP config (encrypted), and PDF template settings.

User

Internal team members. Permissions stored as a JSON map of domain → level (0–3). Authenticated via magic link.

Client

Customers and leads. Progresses through LEAD → PROSPECT → CLIENT states with financial, contact, and address data.

Resource

External vendors and freelancers. Tracks services, availability, certifications (NDA, GDPR, ISO), and rates.

Project

Client engagements. Lifecycle: DRAFT → QUOTE → PROJECT → COMPLETED. Supports templates and cloning.

Task

Work items within a project. Ships with four built-in types — Translation, Copywriting, Interpreting, and Generic. New types can be added without schema changes.

Assignment

Links tasks to resources as purchase orders with pricing, deadlines, and performance ratings.

Invoice

Client billing with line items, tax, discount, and full payment lifecycle including reminders and collections.

// key design patterns

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.

Core Modules

@cannelle/core/services exports 100+ functions organized by domain. All business logic, validation, and database access lives here.

serviceresponsibility
companyCompany CRUD, PDF template config, user listing
usersUser CRUD, role checks, manager queries, dependency analysis
clientsClient pipeline (lead → prospect → client), search, follow
suppliersResource lifecycle, service matching, task-based search, history search
projectsProject/quote CRUD, templates, cloning, state transitions
tasksTask CRUD within projects, ordering, state tracking
task-definitionsPer-company task type configuration with workflows and pricing
assignmentsResource-to-task assignments (purchase orders)
invoicesInvoice lifecycle, recurring billing, ageing analysis, templates
attachmentsFile upload/download via storage adapter
emailsEmail history, template management, sending
availabilityUser/resource availability calendar
reportsRevenue, payment, sales, client, and lead analytics
integrationsWebhook endpoint management; fires 16 automatic event triggers across the quote, project, and invoice lifecycle
followingsUser watch subscriptions on entities; per-user activity feed queries
logsAudit trail logging and querying
onboardingSample data seeding for new companies

// utilities

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

Application Workflows

// sales pipeline

LEAD PROSPECT CLIENT
QUOTE
PROJECT
INVOICE

// project lifecycle

DRAFT QUOTE PROJECT COMPLETED

Also: DRAFT → CANCELLED at any point

// invoice lifecycle

DRAFT SENT REMINDER1 REMINDER2 COLLECTION PAID / GIVEN UP

// resource vetting

UNVERIFIED INTAKE ASSESSMENT CONTRACTING ACTIVE

// integrations (webhooks)

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.

categoryevents
Quotequote.created, quote.updated, quote.sent, quote.converted, quote.cancelled
Projectproject.created, project.updated, project.completed, project.cancelled
Invoiceinvoice.created, invoice.updated, invoice.sent, invoice.reminder1, invoice.reminder2, invoice.collection, invoice.paid

// activity feed & followings

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.

// dashboard

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

Authentication & Authorization

// passwordless only

Cannelle uses magic link authentication exclusively — no passwords are stored or managed anywhere in the system.

// magic link flow (internal)

1.

Enter email

User enters their email on the login page

2.

Token generated

A SHA256 token (30-minute expiry) is created and a magic link email is sent

3.

Link clicked

Visiting the link verifies the token and creates a 30-day session

4.

Session set

An httpOnly session cookie is set. Sessions auto-extend past 50% of their lifetime

// permission model

Permissions are stored as a flat JSON map in user.settings:

levelconstantmeaning
0NONENo access
1READView only
2WRITEView + create / edit
3ADMINFull access including delete and configuration

Domains: clientssalesprojectssuppliersinvoicesreportssettings

// atelier — external user auth

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.

// rate limiting

Magic link requests

5 per 15 min per IP

Signup

10 per 60 min per IP

Token verification

10 per 15 min per IP

File Storage

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.

variabledescription
STORAGE_PROVIDER"local" or "s3"
STORAGE_LOCAL_PATHLocal upload directory (default: ./uploads)
S3_BUCKETS3 bucket name
S3_REGIONAWS region
AWS_ACCESS_KEY_IDAWS access key
AWS_SECRET_ACCESS_KEYAWS secret key

// upload paths

{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 files

When 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.

Email System

Emails are sent via SMTP using Nodemailer with Handlebars templates. SMTP is configured globally via environment variables.

// built-in templates

templatepurpose
magic-linkAuthentication magic link email
invitationNew user invitation to the platform
invitation-userUser-to-user workspace invitation
genericGeneral-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.

Real-Time

Two complementary transports are used — WebSocket for the internal Community app, and Server-Sent Events (SSE) for the lighter-weight Atelier portal.

WebSocket — Community

  • → Workspace real-time messaging
  • → Typing indicators + presence
  • → Short-lived auth tokens (5 min)
  • → Auto-reconnect with exponential backoff
  • → Attached to the HTTP server in production

SSE — Atelier

  • → Per-workspace persistent SSE stream
  • → Keepalive pings every 30 seconds
  • → Messages and file uploads trigger broadcasts
  • → Offline members receive email notifications
  • → No auth token needed — session cookie is used

PDF Generation

PDFs are generated server-side using Puppeteer (headless Chromium) for invoices, project quotes, and resource assignments.

1.

Download template

HTML template is downloaded from storage (local or S3)

2.

Compile with Handlebars

Template is compiled with entity data. Custom helpers: formatNumber, formatDate, formatCurrency, multiply, divide, add, truncate

3.

Render to PDF

Headless Chromium renders the HTML to an A4 PDF

4.

Upload result

Generated PDF is uploaded back to storage

5.

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.

API Reference

All endpoints require a valid session cookie unless noted. The Community app and Atelier app each expose their own separate APIs.

// authentication

methodendpoint
POST/api/auth/magic-link
POST/api/auth/verify
POST/api/auth/signup
POST/api/auth/logout

// clients

methodendpoint
GET/api/clients
POST/api/clients
GET/api/clients/[id]
PATCH/api/clients/[id]
POST/api/clients/[id]/follow
GET/api/clients/export

// projects & tasks

methodendpoint
GET/api/projects/state/[state]
POST/api/projects/create
GET/api/projects/[id]
PATCH/api/projects/[id]
POST/api/projects/[id]/clone
GET/api/projects/[id]/pdf
GET/api/projects/templates
POST/api/projects/[id]/tasks/create
PATCH/api/projects/[id]/tasks/[taskId]
DELETE/api/projects/[id]/tasks/[taskId]
POST/api/projects/[id]/assignments/create
PATCH/api/projects/[id]/assignments/[aId]
GET/api/projects/[id]/assignments/[aId]/pdf

// resources

methodendpoint
GET/api/suppliers
POST/api/suppliers
GET/api/suppliers/[id]
PATCH/api/suppliers/[id]
POST/api/suppliers/search
POST/api/suppliers/search-by-task
POST/api/suppliers/search-by-history

// invoices

methodendpoint
GET/api/invoices
POST/api/invoices
GET/api/invoices/[id]
PATCH/api/invoices/[id]
DELETE/api/invoices/[id]
PUT/api/invoices/[id]/state
GET/api/invoices/[id]/pdf
GET/api/invoices/recurring

// other community endpoints

methodendpoint
GET/api/reports/[type]
GET/api/search?keyword=
POST/api/attachments/upload
GET/api/notifications
POST/api/websocket/auth
GET/files/[...path]

// atelier api

Requires a valid atelier_session cookie.

methodendpoint
POST/api/workspaces
PUT/api/workspaces/[id]/read
GET/api/messages?workspaceId=
POST/api/messages
GET/api/files?workspaceId=
POST/api/files
GET/api/sse/[workspaceId]
POST/api/auth/logout

Getting Started

// prerequisites

Node.js 22+npm 10+PostgreSQL 17+

// local development

1. clone the repository

bash
git clone https://github.com/cannelleio/community.git
cd cannelle-sveltekit

2. install dependencies

bash
npm install

3. configure environment

bash
cp .env.example .env
# Edit .env with your database credentials and secrets

4. set up the database

bash
npm run db:generate   # generate Prisma client
npm run db:migrate    # run migrations (creates tables)

5. start the development servers

bash
npm run dev
# Community app → http://localhost:5173
# Atelier portal → http://localhost:5174

// useful commands

npm run devStart dev servers for all workspaces
npm run buildProduction build
npm run checkTypeScript validation
npm run lintESLint
npm run formatPrettier auto-format
npm run testRun all tests
npm run db:generateGenerate Prisma client
npm run db:migrateRun database migrations
npm run db:pushPush schema to database (dev only)
npm run db:studioOpen Prisma Studio

Docker Deployment

The docker/ directory contains everything needed to build and deploy Cannelle. Two separate Docker images are built from a shared monorepo Dockerfile.

// compose stack

The default docker-compose.yml starts four services:

servicedescription
dbPostgreSQL 17 — data persisted in pgdata volume
migrateRuns prisma migrate deploy once on startup, then exits
communityInternal staff app on port 3000 — uploads persisted in shared volume
atelierExternal user portal on port 3001 — shares the same uploads volume

// building the images

Both images are built from the repository root using build arguments to select the app:

bash
# 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.

// starting the stack

bash
cd docker
cp .env.example .env
# Set image names, database credentials, secrets, and BASE_URL
docker compose up -d

// nginx proxy with ssl

bash
# One-time Let's Encrypt bootstrap
./init-letsencrypt.sh

# Subsequent starts
docker compose -f docker-compose.yml -f docker-compose.proxy.yml up -d

// data persistence

pgdata

PostgreSQL data — survives container restarts and upgrades

uploads

Shared file storage — mounted by both Community and Atelier

// recommendations

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

Configuration Reference

// generate secrets

bash
openssl rand -hex 32   # JWT_SECRET
openssl rand -hex 16   # HASHID_SALT
openssl rand -hex 32   # ENCRYPTION_KEY
openssl rand -hex 16   # ENCRYPTION_SALT

// environment variables

variablereq
DATABASE_URLyes
JWT_SECRETyes
HASHID_SALTyes
ENCRYPTION_KEYyes
ENCRYPTION_SALTyes
BASE_URLyes
ATELIER_URLyes
ATELIER_ENABLED
MAGIC_LINK_EXPIRY_MINUTESyes
SMTP_HOSTyes
SMTP_PORTyes
SMTP_USERyes
SMTP_PASSyes
EMAIL_FROMyes
STORAGE_PROVIDERyes
STORAGE_LOCAL_PATH
S3_BUCKET
S3_REGION
AWS_ACCESS_KEY_ID
AWS_SECRET_ACCESS_KEY
CANNELLE_MULTITENANTyes

// 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.

Custom Task Types

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.

// registry architecture

file structure
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 registry

// adding a custom type

1. copy an existing type folder

bash
cp -r apps/community/src/components/tasks/generic/ \
      apps/community/src/components/tasks/my-service/

2. register in _registry.ts

typescript
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.

Development Setup

// setup

1. Fork and clone the repository
2. Install dependencies: npm install
3. Copy .env.example to .env and configure
4. Start PostgreSQL (via Docker or locally)
5. Run migrations: npm run db:migrate
6. Start dev servers: npm run dev

// code conventions

Svelte 5 runes ($state, $derived, $effect) — no legacy $: or stores
TypeScript strict mode throughout
Prettier with tabs, single quotes, no trailing commas
Tailwind CSS 4 — always specify border/divide colors explicitly
Use +page.server.ts for data loading (direct Prisma via @cannelle/core)
Use +server.ts API routes for client-side mutations only
Validate all API inputs with Joi at boundaries
Shared business logic → packages/core; shared UI → packages/ui
No hardcoded user-facing strings — all text must go through Paraglide i18n
// business source license 1.1

Source-available for most use cases. See the LICENSE file in the repository for full details.

view on github →