Campster is a self-hostable, open-source email marketing platform for cross-functional teams. Centralize campaigns, contacts, analytics, and transactional emails under one system with full data ownership.
Stack: NestJS API · Next.js 14 UI · PostgreSQL · Kysely ORM · Nodemailer
- Features
- Prerequisites
- Local Setup
- Environment Variables
- Running Locally
- Database Migrations
- Self-Hosting
- Running Tests
- Linting
- Pre-commit Hooks
- Project Structure
- Runbook
- Self-hostable — full control over your data and infrastructure
- Campaign management — create, schedule, and send bulk email campaigns
- Contact lists — import via CSV, manage segments
- Email templates — visual drag-and-drop builder
- Transactional emails — API-triggered emails with templates
- Real-time analytics — opens, clicks, bounces
- A/B testing — compare subject lines and content
- Role-based access control — fine-grained team permissions
- Multiple email providers — SMTP or AWS SES per project (IAM credentials, environment credential chain, or SES SMTP interface)
| Requirement | Version | Notes |
|---|---|---|
| Node.js | 20+ | 20 LTS recommended |
| Yarn | 1.x | used throughout the repo |
| PostgreSQL | 14+ | required; SQLite is not supported |
| Git | 2.x+ | |
| Docker + Compose | v2+ | for self-hosting only |
PostgreSQL must be running before you start the API. The quickest way to spin one up locally:
# With Docker (no local Postgres install required)
docker run -d \
--name campster-db \
-e POSTGRES_DB=campster \
-e POSTGRES_USER=campster \
-e POSTGRES_PASSWORD=campster \
-p 5432:5432 \
postgres:16-alpinegit clone https://github.com/Abhi-Bhat18/campster.git
cd campsteryarn installThis installs dependencies for both packages/api and packages/client via Yarn workspaces.
cp packages/api/.env.development packages/api/.envOpen packages/api/.env and set the values for your environment — at minimum the database credentials and SMTP server. See Environment Variables for a full reference.
cp packages/client/.env packages/client/.env.localThe default NEXT_PUBLIC_API_URL=http://localhost:1335 works out of the box for local development.
cd packages/api
yarn seed
cd ../..This creates all tables and inserts seed data. The API will not start correctly without running this first.
In two separate terminals:
# Terminal 1 — API (http://localhost:1335)
cd packages/api && yarn start:dev
# Terminal 2 — Client (http://localhost:3000)
cd packages/client && yarn devOpen http://localhost:3000 in your browser.
# ── Server ─────────────────────────────────────────────────────────────────────
PORT=1335
NODE_ENV=development
# ── Authentication ─────────────────────────────────────────────────────────────
JWT_SECRET=replace-with-a-random-secret-at-least-32-chars
JWT_EXPIRES_IN=7d
# ── Database (PostgreSQL required) ─────────────────────────────────────────────
DB_NAME=campster
DB_USER=campster
DB_PASSWORD=campster
DB_HOST=localhost
DB_PORT=5432
# ── Email sending (SMTP) ────────────────────────────────────────────────────────
MAIL_HOST=smtp.example.com # your SMTP server hostname or IP
MAIL_PORT=587 # 587 (STARTTLS) or 465 (SSL) or 25
MAIL_USER=you@example.com
MAIL_PASS=yourpassword
# ── Campaign processing ─────────────────────────────────────────────────────────
BATCH_SIZE=1000 # contacts per batch
CONCURRENT_BATCHES=5 # batches processed in parallel
# ── CORS ────────────────────────────────────────────────────────────────────────
CORS_DOMAINS=http://localhost:3000,http://127.0.0.1:3000
# ── Service URLs ────────────────────────────────────────────────────────────────
API_HOST=http://localhost:1335
CLIENT_HOST=http://localhost:3000
# ── Bounce handling (optional) ─────────────────────────────────────────────────
BOUNCE_API_KEY=your-bounce-webhook-secret# URL the browser uses to reach the API
NEXT_PUBLIC_API_URL=http://localhost:1335Each project can optionally use AWS SES instead of the global SMTP server. Per-project SES configs are stored encrypted in the database.
-
Set the encryption key (required to store any SES credentials):
# Generate a random key node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" # Add to your .env: SES_ENCRYPTION_KEY=<generated-value>
-
Install the AWS SDK (included as a dependency, runs automatically with
yarn install):cd packages/api && yarn install
-
Run the new migration so the
ses_configstable is created:cd packages/api && yarn seed
| Method | Description | Required env vars |
|---|---|---|
iam_credentials |
Explicit AWS access key + secret stored encrypted in the database | SES_ENCRYPTION_KEY |
environment |
Default AWS credential chain (env vars, IAM role, instance profile) | AWS_REGION + AWS credentials via env/role |
ses_smtp |
SES SMTP endpoint with SMTP username/password (stored encrypted) | SES_ENCRYPTION_KEY |
All endpoints require authentication (JWT cookie).
# IAM credentials method
curl -X POST http://localhost:1335/ses/config \
-H 'Content-Type: application/json' \
-d '{
"project_id": "YOUR_PROJECT_ID",
"auth_method": "iam_credentials",
"region": "us-east-1",
"access_key_id": "AKIAIOSFODNN7EXAMPLE",
"secret_access_key": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
}'
# Environment credential chain (uses AWS_ACCESS_KEY_ID / IAM role / etc.)
curl -X POST http://localhost:1335/ses/config \
-H 'Content-Type: application/json' \
-d '{
"project_id": "YOUR_PROJECT_ID",
"auth_method": "environment",
"region": "us-east-1"
}'
# SES SMTP interface
curl -X POST http://localhost:1335/ses/config \
-H 'Content-Type: application/json' \
-d '{
"project_id": "YOUR_PROJECT_ID",
"auth_method": "ses_smtp",
"smtp_host": "email-smtp.us-east-1.amazonaws.com",
"smtp_port": 587,
"smtp_user": "SMTP_USERNAME_FROM_AWS_CONSOLE",
"smtp_pass": "SMTP_PASSWORD_FROM_AWS_CONSOLE"
}'curl http://localhost:1335/ses/config/YOUR_PROJECT_IDcurl -X DELETE http://localhost:1335/ses/config/YOUR_PROJECT_ID- Campaign emails automatically use the project's SES config if one exists; otherwise fall back to the global SMTP settings.
- Credentials (
access_key_id,secret_access_key, SMTP user/pass) are encrypted with AES-256-GCM before being written to the database. The encryption key never leaves your server environment. GET /ses/config/:project_idreturnshas_access_key/has_smtp_credentialsboolean flags instead of raw secrets.
# API in watch mode (reloads on file change)
cd packages/api && yarn start:dev
# API in production mode (compiled output)
cd packages/api && yarn build && yarn start:prod
# Client in dev mode (hot reload)
cd packages/client && yarn dev
# Client in production mode
cd packages/client && yarn build && yarn startBoth from the repo root using the Makefile:
make dev # starts the API in watch mode
make build-api # builds the API
make build-client # builds the clientCampster uses Kysely for type-safe query building and Kysely's built-in migrator for schema migrations. Migration files live in packages/api/src/migrations/.
cd packages/api
yarn seedcd packages/api
npx ts-node --files -r tsconfig-paths/register src/migrate-down.tsCreate a new numbered file in packages/api/src/migrations/ (e.g. 15_new_table.ts) following the existing pattern, then run yarn seed.
This is the fastest way to get a production-ready instance running. The setup below starts PostgreSQL, the API, and the UI as separate containers with a shared private network.
Step 1 — Create a .env file at the directory where you'll run Compose:
cat > .env <<'EOF'
# Generate a strong secret: openssl rand -hex 32
JWT_SECRET=replace-with-a-strong-random-secret
# PostgreSQL credentials (used by both the db service and the API)
POSTGRES_DB=campster
POSTGRES_USER=campster
POSTGRES_PASSWORD=change-this-password
# SMTP — replace with your mail server
MAIL_HOST=smtp.example.com
MAIL_PORT=587
MAIL_USER=you@example.com
MAIL_PASS=yourpassword
# Public URL your browser uses to reach the API (change for real domain)
NEXT_PUBLIC_API_URL=http://localhost:1335
EOFStep 2 — Create docker-compose.yml:
version: "3.9"
services:
db:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_DB: ${POSTGRES_DB:-campster}
POSTGRES_USER: ${POSTGRES_USER:-campster}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-campster}
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-campster}"]
interval: 5s
timeout: 5s
retries: 10
networks:
- campster-network
api:
image: abhishekbhat18/campster-api:latest
# build:
# context: ./packages/api # uncomment to build from source
restart: unless-stopped
ports:
- "1335:1335"
environment:
NODE_ENV: production
PORT: 1335
JWT_SECRET: ${JWT_SECRET}
JWT_EXPIRES_IN: 7d
DB_HOST: db
DB_PORT: 5432
DB_NAME: ${POSTGRES_DB:-campster}
DB_USER: ${POSTGRES_USER:-campster}
DB_PASSWORD: ${POSTGRES_PASSWORD:-campster}
MAIL_HOST: ${MAIL_HOST}
MAIL_PORT: ${MAIL_PORT:-587}
MAIL_USER: ${MAIL_USER}
MAIL_PASS: ${MAIL_PASS}
BATCH_SIZE: 1000
CONCURRENT_BATCHES: 5
CORS_DOMAINS: ${CLIENT_HOST:-http://localhost:3000}
API_HOST: http://api:1335
CLIENT_HOST: ${CLIENT_HOST:-http://localhost:3000}
BOUNCE_API_KEY: ${BOUNCE_API_KEY:-}
depends_on:
db:
condition: service_healthy
networks:
- campster-network
ui:
image: abhishekbhat18/campster-ui:latest
# build:
# context: ./packages/client # uncomment to build from source
restart: unless-stopped
ports:
- "3000:3000"
environment:
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-http://localhost:1335}
depends_on:
- api
networks:
- campster-network
volumes:
postgres_data:
networks:
campster-network:
driver: bridgeStep 3 — Start everything:
docker compose up -d
docker compose logs -f api # follow API logsThe UI is available at http://localhost:3000.
Update to a new version:
docker compose pull
docker compose up -dStop and remove containers (data is preserved in the volume):
docker compose downWipe all data including the database:
docker compose down -vBuild and run just the API:
# Build
cd packages/api
docker build -t campster-api:latest .
# Run (replace env vars with your values)
docker run -d \
--name campster-api \
-p 1335:1335 \
-e NODE_ENV=production \
-e PORT=1335 \
-e JWT_SECRET=change-me \
-e DB_HOST=your-db-host \
-e DB_PORT=5432 \
-e DB_NAME=campster \
-e DB_USER=campster \
-e DB_PASSWORD=change-me \
-e MAIL_HOST=smtp.example.com \
-e MAIL_PORT=587 \
-e MAIL_USER=you@example.com \
-e MAIL_PASS=yourpassword \
-e CORS_DOMAINS=http://localhost:3000 \
-e API_HOST=http://localhost:1335 \
-e CLIENT_HOST=http://localhost:3000 \
campster-api:latestBuild and run just the UI:
# Build (NEXT_PUBLIC_API_URL is baked into the image at build time)
cd packages/client
docker build \
--build-arg NEXT_PUBLIC_API_URL=http://your-api-host:1335 \
-t campster-ui:latest .
# Run
docker run -d \
--name campster-ui \
-p 3000:3000 \
campster-ui:latestBuild both from the repo root using Make:
make docker-build-apiThe containers are stateless — persistence is entirely in PostgreSQL. For production Kubernetes:
| What | Recommendation |
|---|---|
| Database | External managed Postgres (RDS, Cloud SQL, Supabase, Neon) — do not run Postgres as an in-cluster deployment in production |
| Secrets | Store JWT_SECRET, DB credentials, SMTP password in Kubernetes Secrets or a secrets manager; never bake them into images |
| Migrations | Run migrations as an init container or a one-off Job before deploying new API pods |
| SMTP | Use a transactional email service (SES, Postmark, Mailgun) for reliability at scale |
Example init container snippet:
initContainers:
- name: migrate
image: abhishekbhat18/campster-api:latest
command: ["yarn", "seed"]
env:
- name: DB_HOST
valueFrom:
secretKeyRef: { name: campster-secrets, key: db-host }
# ... other DB env varsUnit tests live next to source files as *.spec.ts. E2E tests live in packages/api/test/.
# Run all API unit tests
cd packages/api && yarn test
# Watch mode (re-runs on file change)
cd packages/api && yarn test:watch
# Coverage report (output → packages/api/coverage/)
cd packages/api && yarn test:cov
# End-to-end tests (requires a running DB)
cd packages/api && yarn test:e2eTest files live under packages/client/src/__tests__/ and match *.test.{ts,tsx} or *.spec.{ts,tsx}.
# Run all client unit tests
cd packages/client && yarn test
# Watch mode
cd packages/client && yarn test:watch
# Coverage report (output → packages/client/coverage/)
cd packages/client && yarn test:cov# Run tests in both packages sequentially
yarn test
# Or target a single package
yarn test:api
yarn test:clientBoth packages have ESLint configured. Run linting from the repo root:
# Lint all packages
yarn lint
# Lint individual packages
yarn lint:api # NestJS API (ESLint + Prettier)
yarn lint:client # Next.js client (eslint-config-next)Auto-fix lint errors:
# API (runs eslint --fix)
cd packages/api && yarn lint
# Client (runs next lint)
cd packages/client && yarn lintThis project uses Husky and lint-staged to enforce code quality before every commit.
What runs on git commit:
- lint-staged — ESLint runs on staged TypeScript files in the changed package
- Unit tests — all tests must pass before the commit is accepted
yarn install # installs dependencies and runs `husky` via the prepare scriptThat's it — Husky registers the .husky/pre-commit hook automatically.
git commit --no-verify -m "your message"campster/
├── packages/
│ ├── api/ # NestJS backend (port 1335)
│ │ ├── src/
│ │ │ ├── app.module.ts # Root module — wires all feature modules
│ │ │ ├── main.ts # Bootstrap — CORS, validation, cookie parser
│ │ │ ├── config/ # configuration() factory (env → typed config)
│ │ │ ├── migrations/ # Kysely migration files (numbered, append-only)
│ │ │ ├── seed.ts # Runs migrateToLatest() + seeds roles/admin user
│ │ │ └── modules/
│ │ │ ├── auth/ # JWT login, session cookies
│ │ │ ├── user/ # User accounts
│ │ │ ├── project/ # Projects (workspaces per team)
│ │ │ ├── campaign/ # Email campaign CRUD + scheduling
│ │ │ ├── contact/ # Individual contacts
│ │ │ ├── contact-list/# Mailing lists
│ │ │ ├── email/ # Email sending (nodemailer) + BullMQ processor
│ │ │ ├── email-template/ # Template management
│ │ │ ├── transactional/ # Transactional email API
│ │ │ ├── analytics/ # Open/click/bounce tracking
│ │ │ ├── bounce/ # Bounce webhook handler
│ │ │ ├── role/ # RBAC roles
│ │ │ └── database/ # Kysely connection + migration runner
│ │ ├── Dockerfile
│ │ └── package.json
│ └── client/ # Next.js 14 frontend (port 3000)
│ ├── src/
│ │ ├── app/ # Next.js App Router pages
│ │ ├── components/ # Shared UI components (shadcn/ui + Radix)
│ │ ├── lib/ # API client, auth helpers
│ │ └── utils/ # Utilities
│ ├── Dockerfile
│ └── package.json
├── api-endpoints/ # Bruno API collection (import into Bruno)
├── haraka/ # Optional self-hosted SMTP (Haraka)
├── docker-compose.yaml # Self-hosting starting point
├── makefile # Convenience targets
└── package.json # Yarn workspaces root
Complete sequence from zero to a running local instance (copy-paste ready):
# 0. Start a local Postgres instance (skip if you already have one)
docker run -d \
--name campster-db \
-e POSTGRES_DB=campster \
-e POSTGRES_USER=campster \
-e POSTGRES_PASSWORD=campster \
-p 5432:5432 \
postgres:16-alpine
# 1. Clone and install (husky pre-commit hooks are registered automatically via `prepare`)
git clone https://github.com/Abhi-Bhat18/campster.git
cd campster
yarn install
# 2. Configure the API
cp packages/api/.env.development packages/api/.env
# Edit packages/api/.env — at minimum set DB credentials and JWT_SECRET
# Default DB values (campster/campster) match the Docker container above
# 3. Configure the client (defaults work for local dev)
cp packages/client/.env packages/client/.env.local
# 4. Run migrations and seed data
cd packages/api && yarn seed && cd ../..
# 5. Start the API (leave running in this terminal)
cd packages/api && yarn start:dev &
# 6. Start the client (leave running in a second terminal)
cd packages/client && yarn dev &
# 7. Open the app
open http://localhost:3000
# 8. Run API unit tests
cd packages/api && yarn test
# 9. Run client unit tests
cd packages/client && yarn test
# Or run all tests from the repo root
yarn test
# 10. Verify pre-commit hook is active
git commit --allow-empty -m "test: verify pre-commit hook"
# Hook will run lint-staged + yarn test; commit proceeds only if both pass# 1. Create secrets
cat > .env <<'EOF'
JWT_SECRET=$(openssl rand -hex 32)
POSTGRES_DB=campster
POSTGRES_USER=campster
POSTGRES_PASSWORD=$(openssl rand -hex 16)
MAIL_HOST=smtp.example.com
MAIL_PORT=587
MAIL_USER=you@example.com
MAIL_PASS=yourpassword
NEXT_PUBLIC_API_URL=http://localhost:1335
EOF
# 2. Pull and start
docker compose up -d
# 3. Check logs
docker compose logs -f api
# 4. Open the app
open http://localhost:3000