Deployment
Run Helpdesk on a server with Docker Compose, a managed PostgreSQL and S3-compatible storage, and switch on the optional channels.
1. Prerequisites
- Docker ≥ 24 and Docker Compose v2 (
docker compose— notdocker-compose) - A domain name pointed at your server’s IP
- (Production) A managed PostgreSQL instance (Supabase, Neon, RDS, etc.) — the compose stack does not include a Postgres container
2. Clone and configure
# in your checkout of the repository
cp .env.example .env
Edit .env and set at minimum:
| Variable | Required | Notes |
|---|---|---|
DATABASE_URL |
Yes | postgresql://user:pass@host:5432/helpdesk |
JWT_SECRET |
Yes | Min 32 chars — openssl rand -hex 32 |
ENCRYPTION_KEY |
Yes | 64 hex chars — openssl rand -hex 32 |
APP_URL |
Yes | https://yourdomain.com |
API_URL |
Yes | https://yourdomain.com |
PUBLIC_API_URL |
Yes | https://yourdomain.com |
3. TLS setup
Option A — Manual certificate
Place your certificate files at:
infra/nginx/ssl/cert.pem
infra/nginx/ssl/key.pem
Mount them in docker-compose.yml under the nginx service:
volumes:
- ./infra/nginx/nginx.conf:/etc/nginx/nginx.conf:ro
- ./infra/nginx/ssl:/etc/nginx/ssl:ro
Option B — Let’s Encrypt (certbot)
certbot certonly --standalone -d yourdomain.com
# Symlink or copy the certs:
mkdir -p infra/nginx/ssl
cp /etc/letsencrypt/live/yourdomain.com/fullchain.pem infra/nginx/ssl/cert.pem
cp /etc/letsencrypt/live/yourdomain.com/privkey.pem infra/nginx/ssl/key.pem
Add a cron job to renew and reload nginx:
0 3 * * * certbot renew --quiet && docker compose -f /path/to/helpdesk/docker-compose.yml exec nginx nginx -s reload
4. First-time setup
# Build and start all services
docker compose up -d --build
# Run database migrations
docker compose exec api pnpm migrate
# Seed initial data (admin user, default mailbox, etc.)
docker compose exec api pnpm seed
Demo data (local only)
For a populated development instance (screenshots, UI work), pnpm --filter @casoon/helpdesk-api seed:demo fills a migrated database with invented data: admin admin@example.com and three agents (password from DEMO_PASSWORD), two mailboxes, 8 organizations, 25 customers, 45 tickets over the last three weeks with replies, notes, tags and history, SLA and business-hours calendar (one escalated ticket), 3 triggers, 2 macros, 3 saved replies, 2 overviews, 3 custom fields and an inactive webhook. All addresses use example.com, example.org or example.net; no mail is sent and no jobs are queued. The script refuses to run unless DATABASE_URL points to localhost (override with --force-remote, never on production) and does nothing if admin@example.com already exists.
Database timezone
Run PostgreSQL with timezone = 'UTC' (the default in the official image). Most timestamp columns are timestamp without time zone, and the API writes UTC; a server in another timezone shifts values written by the database (now()) against values written by the API.
Client IP and rate limits
Login and web-form rate limits key on the client IP. The API trusts x-real-ip (else the last x-forwarded-for hop), so:
- The API port (3000) must not be reachable from outside; only the reverse proxy may talk to it. Do not publish it on a public interface (
docker-compose.ymlmaps3000:3000for development; bind it to127.0.0.1or drop the mapping in production). - The proxy must overwrite
X-Real-IPwith the real peer address (the bundledinfra/nginx/nginx.confdoes:proxy_set_header X-Real-IP $remote_addr). A proxy that passes a client-suppliedX-Real-IPthrough lets anyone bypass the limits. - Behind a further proxy or CDN, set
X-Real-IPon the outermost proxy to the true client address.
Public web form
The contact form (/contact/<mailboxId>) is off by default; enable it per mailbox in the mailbox settings. It is unauthenticated and protected by a honeypot field, 5 submissions per 10 minutes per IP and 30 per hour per mailbox (both stored in login_attempts, so they hold across API replicas). The sender address is only claimed: tickets are marked as unverified. Possible future hardening: Cloudflare Turnstile on the form and a double opt-in mail before the ticket becomes visible.
Customer portal
The portal (/portal, API under /api/portal/*) is off by default. Set PORTAL_ENABLED=true and PORTAL_MAILBOX_ID=<mailbox id> (API refuses to start without the mailbox when enabled). That mailbox sends the sign-in links (with Auto-Submitted: auto-generated) and receives tickets created in the portal. With the portal off, every portal route answers 404.
- Customers sign in with a one-time link mailed to the address of an existing customer record; the request always gets the same answer, whether the address is known or not. Limits (in
login_attempts, so they hold across replicas): 10 requests per 15 minutes per IP, 3 links per hour per address, 20 link redemptions per 15 minutes per IP, 30 replies or new tickets per hour per customer. - The link is valid for 15 minutes and works once. Opening it shows a confirmation page; the token is consumed by the button (POST), so mail scanners cannot use it up.
- Tokens and sessions are stored as HMAC-SHA256 (keyed with
JWT_SECRET), never in clear. RotatingJWT_SECRETtherefore invalidates all portal sessions and open links. - The session lasts 7 days and lives in the
portal_sessioncookie (HttpOnly, Secure, SameSite=Lax), separate from the agent session; neither is accepted by the other’s API. Serve the web app over HTTPS, as browsers do not store Secure cookies over plain HTTP (except on localhost). - Customers see their own tickets and, if their customer record belongs to an organization, those of its other customers. They see only published customer and agent messages, no notes, drafts, tags or assignees, and answers from staff appear as “Support”.
- Attachments: up to 5 files of 25 MB per reply or ticket; executable and script types (
exe,js,html,svg, …) are refused. Downloads are always served asapplication/octet-streamattachments. - Run
pnpm migrate(migration0021_portal.sql) before enabling it.
Automated mail
Mails from automation actions (send_mail_customer, send_mail_agent) carry Auto-Submitted: auto-generated. send_mail_customer sends at most 3 mails per ticket and 10 per recipient address per 24 hours (counted from send_logs) and is skipped for tickets that contain a message with an unverified sender (web form, or failed SPF/DKIM/DMARC). The scheduler runs actions at most once per ticket and schedule until the ticket changes (schedule_runs).
Inbound mails with spf=fail, dkim=fail or dmarc=fail in Authentication-Results are flagged, not dropped. Helpdesk does not check SPF/DKIM/DMARC itself; it only reads the header, so your receiving MTA must write it and remove forged copies from the sender. Without that header, no mail is flagged.
Mailbox OAuth2 consent (Google, Microsoft 365)
Register an OAuth client with the provider and add the redirect URI below. Set OAUTH_REDIRECT_URI on the API to exactly that URL, normally <API_URL>/api/mailboxes/oauth/callback (e.g. https://yourdomain.com/api/mailboxes/oauth/callback); without it, starting the flow returns 503. Then set provider, client id and client secret on the mailbox and, as admin, call POST /api/mailboxes/:id/oauth/start; open the returned url in the browser. The callback exchanges the code (PKCE), stores the refresh token encrypted and redirects to <APP_URL>/settings/mailboxes?oauth=success|error. The callback needs the admin session cookie, and the state is single-use, expires after 10 minutes and is bound to the admin who started it. It is kept in the memory of the API process, so start and callback must reach the same instance. Google scope: https://mail.google.com/; Microsoft: IMAP/SMTP AccessAsUser plus offline_access.
5. Production overrides
For production-specific overrides (resource limits, log drivers, etc.) use a docker-compose.prod.yml:
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
6. Updating
git pull
docker compose pull
docker compose up -d --build
# Run any new migrations:
docker compose exec api pnpm migrate
7. Backup and restore
Set the required environment variables, then:
# Backup (dumps DB, uploads to S3/MinIO)
S3_ACCESS_KEY=... S3_SECRET_KEY=... S3_ENDPOINT=... ./scripts/backup.sh
# Restore a specific backup
BACKUP_FILE=backup_20250601_120000.sql.gz \
S3_ACCESS_KEY=... S3_SECRET_KEY=... S3_ENDPOINT=... \
./scripts/restore.sh
See scripts/backup.sh and scripts/restore.sh for all configurable variables (S3_BUCKET, POSTGRES_USER, POSTGRES_DB).
8. Health check URLs
| Service | URL |
|---|---|
| API health | https://yourdomain.com/api/health |
| MinIO console (dev only) | http://localhost:9001 |
9. AI features (optional)
Disabled unless configured. Set AI_PROVIDER (openai or anthropic), AI_MODEL, and AI_API_KEY on the API. AI_BASE_URL points the openai provider at any OpenAI-compatible server (e.g. a self-hosted Ollama or vLLM).
Data protection: POST /api/ai/conversations/:id/summary sends the ticket text (customer mails; internal notes only with AI_INCLUDE_NOTES=true) to the configured provider. Requests are limited to 20 per user per hour, counted in memory per API instance (with several replicas the limit multiplies). With a hosted provider this is a transfer to a third party: sign a data processing agreement and mention it in your privacy notice, or use a self-hosted model so the data stays on your infrastructure.
POST /api/ai/text (rewrite, shorten, translate, tone; input up to 8000 characters) sends the text an agent submits to the provider, shares the 20-per-hour limit above and returns 503 when AI is not configured.
An MCP server (stdio; read tools search_tickets, get_ticket, and draft_reply, which only saves a draft message and never sends; it needs MCP_DRAFT_USER_EMAIL = the agent the draft belongs to) is available via pnpm --filter @casoon/helpdesk-api mcp. It reads the database directly using DATABASE_URL; The MCP server has no authentication. Anyone who can start it or talk to its stdio has read access to all tickets, so run it only for trusted local stdio clients and never expose it over a network. Ticket content returned to the client is sent on to whatever model that client uses. Bodies are plain text wrapped in UNTRUSTED_TICKET_DATA markers (a mitigation against prompt injection, not a guarantee); internal notes are omitted unless MCP_INCLUDE_NOTES=true.