📚 Documentation

NexusCMS Deploy is a self-hosted service that listens to push and tag webhooks from GitHub, GitLab, and Bitbucket, then delivers rich Discord embeds to the right channel automatically — with smart routing, encrypted secrets, and delivery analytics.

This documentation covers everything from first deployment to advanced API usage.

Architecture overview

The application is a Node.js 22 + Express server with a SQLite (default) or PostgreSQL backend. All state is stored in the database — there is no external cache or broker required. An in-memory notification queue with exponential backoff handles Discord delivery retries.

text
  Git provider  ─── POST /webhooks/{github|gitlab|bitbucket} ──▶  Signature validation
                                                                         │
                                                              Parse & store deploy event
                                                                         │
                                                             Match Discord routing rules
                                                                         │
                                                           Notification queue (retries)
                                                                         │
                                                              Discord Webhook URL ──▶ 💬

🚀 Quick Start

Get NexusCMS Deploy running in under five minutes using Docker Compose.

  1. 1

    Clone the repository

    bash
    git clone https://github.com/your-org/nexuscms-deploy.git
    cd nexuscms-deploy
  2. 2

    Create your environment file

    Copy the example and fill in your values:

    bash
    cp .env.example .env

    Minimum required variables:

    env
    DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/...
    JWT_SECRET=your-super-secret-jwt-key-min-32-chars
    ENCRYPTION_KEY=64-char-hex-key   # see Configuration section
  3. 3

    Start the stack

    bash
    docker compose up -d

    The dashboard will be available at http://localhost:3000.

  4. 4

    Register your first user

    bash
    curl -s -X POST http://localhost:3000/api/auth/register \
      -H "Content-Type: application/json" \
      -d '{"email":"[email protected]","password":"changeme","name":"Admin"}' | jq

    Save the returned token — you'll use it as a Bearer token for all API calls.

  5. 5

    Register a webhook in your Git provider

    Point the webhook URL to your server and select push events:

    text
    https://your-server.com/webhooks/github
    https://your-server.com/webhooks/gitlab
    https://your-server.com/webhooks/bitbucket

Push a commit and watch a Discord notification appear within seconds.

⚙️ Configuration

All configuration is done via environment variables. Copy .env.example to .env and adjust as needed.

Environment variables reference

VariableDescriptionDefault
Server
PORTHTTP port the server listens on.3000
NODE_ENVdevelopment | productiondevelopment
DASHBOARD_ORIGINCORS allowed origin for the dashboard. Use * in dev.*
Authentication
JWT_SECRETSecret used to sign JWT tokens. Generate with openssl rand -hex 48. Required.
ENCRYPTION_KEY64-char hex key for AES-256-GCM encryption of webhook secrets. Generate with node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"— (plaintext)
Discord
DISCORD_WEBHOOK_URLDefault Discord webhook URL for unmatched deploys. Required.
DISCORD_MAX_RETRIESNumber of delivery retries on failure.3
DISCORD_RETRY_DELAY_MSInitial backoff delay in milliseconds (doubles each retry).1000
Webhook Signing
GITHUB_WEBHOOK_SECRETShared secret for HMAC-SHA256 validation of GitHub payloads.
GITLAB_WEBHOOK_TOKENSecret token validated against the X-Gitlab-Token header.
BITBUCKET_WEBHOOK_SECRETShared secret for HMAC-SHA256 validation of Bitbucket payloads.
Database
DB_CLIENTsqlite | postgressqlite
DB_PATHPath to the SQLite file (SQLite only)../data/deploys.db
DATABASE_URLFull PostgreSQL connection string. Example: postgresql://user:pass@host:5432/dbname
DB_SSLSet true to enable SSL for PostgreSQL.false
Filtering
ALLOWED_BRANCHESComma-separated list of branches to process. Leave empty to accept all.— (all)

Generating secrets

bash
# JWT_SECRET (48-byte hex)
node -e "console.log(require('crypto').randomBytes(48).toString('hex'))"

# ENCRYPTION_KEY (32-byte hex)
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"

# Webhook secret (random 32-byte string)
openssl rand -hex 32

🐳 Docker Deployment

The included docker-compose.yml sets up the application with a PostgreSQL 16 database and a persistent volume.

Starting the stack

bash
# Start all services in the background
docker compose up -d

# View live logs
docker compose logs -f app

# Stop the stack
docker compose down

Health check

The container exposes a health endpoint that Docker uses internally and you can poll externally:

bash
curl http://localhost:3000/health
# → {"status":"ok","timestamp":"2026-04-25T18:00:00.000Z"}

Upgrading

bash
git pull
docker compose build --no-cache
docker compose up -d
⚠️

Always back up your ./data volume before upgrading, especially when using SQLite.


🔗 Webhook Setup

GitHub

  1. Go to your repository → Settings → Webhooks → Add webhook.
  2. Set Payload URL to https://your-server.com/webhooks/github.
  3. Set Content type to application/json.
  4. Set Secret to the value you used for GITHUB_WEBHOOK_SECRET.
  5. Select Just the push event (or individual events — only push is processed).
  6. Enable the webhook and click Add webhook.
ℹ️

NexusCMS Deploy validates the X-Hub-Signature-256 header. Requests with missing or invalid signatures are rejected with 401.

GitLab

  1. Go to your project → Settings → Webhooks.
  2. Set URL to https://your-server.com/webhooks/gitlab.
  3. Set Secret token to the value you used for GITLAB_WEBHOOK_TOKEN.
  4. Check Push events and Tag push events.
  5. Click Add webhook.
ℹ️

GitLab sends the token in the X-Gitlab-Token header. Only Push Hook and Tag Push Hook events are processed; all others return 200 ignored.

Bitbucket Cloud

  1. Go to your repository → Repository settings → Webhooks → Add webhook.
  2. Set URL to https://your-server.com/webhooks/bitbucket.
  3. Set Secret to the value you used for BITBUCKET_WEBHOOK_SECRET.
  4. Under Triggers, select Repository push.
  5. Click Save.

Per-repository Signing Secrets

The global environment variables (GITHUB_WEBHOOK_SECRET etc.) apply to all repositories. For per-repository secrets, use the Secrets tab in the dashboard or the Webhook Secrets API. Per-repository secrets take precedence over the global one when present.


🔐 API Reference — Authentication

All protected endpoints require a Bearer JWT token in the Authorization header. Obtain a token by registering or logging in.

ℹ️

Include the token in every protected request:
Authorization: Bearer <your-token>

POST /api/auth/register
Create a new user account. Returns the user profile and a JWT token.

Request body

FieldTypeRequiredDescription
emailstringrequiredUser email address.
passwordstringrequiredMinimum 8 characters.
namestringoptionalDisplay name.

Response — 201

json
{
  "user": { "id": 1, "email": "[email protected]", "name": "Admin", "plan": "starter" },
  "token": "eyJhbGciOiJIUzI1NiIs..."
}
POST /api/auth/login
Authenticate with email and password. Returns the user profile and a fresh JWT token.

Request body

FieldTypeRequiredDescription
emailstringrequired
passwordstringrequired

Response — 200

json
{ "user": { ... }, "token": "eyJhbGciOiJIUzI1NiIs..." }
GET /api/auth/me 🔒 Auth required
Returns the authenticated user's profile and current plan usage.

Response — 200

json
{
  "user": { "id": 1, "email": "[email protected]", "name": "Admin", "plan": "pro" },
  "plan": { "routes": { "used": 3, "limit": null }, "secrets": { "used": 5, "limit": null } }
}
PATCH /api/auth/me 🔒 Auth required
Update your display name or password.

Request body

FieldTypeRequiredDescription
namestringoptionalNew display name.
passwordstringoptionalNew password (min 8 chars).
POST /api/auth/rotate-key 🔒 Auth required
Rotate your API key. The old key is invalidated immediately.

Response — 200

json
{ "api_key": "nx_live_a1b2c3d4..." }
GET /api/auth/plans
Returns the available subscription plans and their feature limits. No authentication required.

📋 API Reference — Deploys

All deploy endpoints require authentication.

GET /api/deploys 🔒 Auth required
Returns a paginated list of deploy events, newest first.

Query parameters

ParameterTypeRequiredDescription
pageintegeroptionalPage number (default: 1).
limitintegeroptionalItems per page (default: 20, max: 100).
repositorystringoptionalFilter by repository name (partial match).
providerstringoptionalgithub | gitlab | bitbucket
branchstringoptionalFilter by branch name (partial match).

Response — 200

json
{
  "data": [
    {
      "id": 42,
      "repository": "org/frontend",
      "branch": "main",
      "provider": "github",
      "commit_count": 3,
      "discord_sent": true,
      "created_at": "2026-04-25T17:00:00.000Z"
    }
  ],
  "total": 284,
  "page": 1,
  "limit": 20
}
GET /api/deploys/:id 🔒 Auth required
Returns a single deploy record with full commit list.
GET /api/deploys/:id/changelog 🔒 Auth required
Returns the structured release changelog for a tag-push deploy as JSON. Returns 404 if the deploy is not a release or has no changelog.
GET /api/deploys/:id/changelog.json 🔒 Auth required
Downloads the release changelog as a .json file attachment.
GET /api/deploys/:id/changelog.md 🔒 Auth required
Downloads the release changelog as a Markdown .md file attachment.
POST /api/deploys/:id/retry 🔒 Auth required
Re-sends the Discord notification for a deploy that was not yet sent. Returns 409 if the notification was already delivered.

Response — 200

json
{ "ok": true, "deployId": 42, "messageId": "1234567890123456789" }

🔀 API Reference — Discord Routes

Routing rules define which Discord channel receives notifications based on repository, branch and event type.

GET /api/routes 🔒 Auth required
Returns all routing rules ordered by priority (lowest first).
GET /api/routes/analytics 🔒 Auth required
Returns per-route delivery statistics for the given time window.

Query parameters

ParameterTypeDescription
windowMinutesintegerLook-back window in minutes (default: 1440 = 24 h).
POST /api/routes 🔒 Auth required
Creates a new Discord routing rule.

Request body

FieldTypeRequiredDescription
namestringrequiredHuman-readable label for the rule.
webhook_urlstringrequiredDiscord webhook URL to deliver to.
match_repositorystringoptionalGlob pattern, e.g. org/*. Empty matches all.
match_branchstringoptionalGlob pattern, e.g. release/*. Empty matches all.
match_event_typestringoptionalall (default) | push | tag
priorityintegeroptionalLower number = higher priority (default: 10). First match wins.
enabledbooleanoptionalWhether the rule is active (default: true).
PUT /api/routes/:id 🔒 Auth required
Replaces an existing routing rule. Same body as POST.
DELETE /api/routes/:id 🔒 Auth required
Permanently deletes a routing rule.

Response — 200

json
{ "ok": true }
POST /api/routes/test 🔒 Auth required
Sends a test embed to a given Discord webhook URL to verify connectivity.

Request body

FieldTypeRequiredDescription
webhook_urlstringrequiredThe Discord webhook URL to test.
POST /api/routes/simulate 🔒 Auth required
Simulates which routing rule would match a given deploy context — without sending any notification.

Request body

FieldTypeRequiredDescription
repositorystringrequiredRepository name, e.g. org/frontend.
event_typestringoptionalpush (default) | tag
branchstringoptionalRequired when event_type is push.
tag_namestringoptionalRequired when event_type is tag.

Response — 200

json
{
  "matched": { "id": 3, "name": "Production", "webhook_url": "https://discord..." },
  "fallbackToDefault": false
}

🔑 API Reference — Webhook Secrets

Per-repository signing secrets take precedence over the global environment variable when validating incoming webhook payloads. Secrets are stored encrypted with AES-256-GCM.

GET /api/webhook-secrets 🔒 Auth required
Lists all configured secrets. Actual secret values are never returned — only a masked preview (e.g. abc***xyz).

Query parameters

ParameterTypeDescription
providerstringFilter by provider: github | gitlab | bitbucket
repositorystringFilter by repository name.
POST /api/webhook-secrets 🔒 Auth required
Stores a new signing secret for a specific provider and repository combination.

Request body

FieldTypeRequiredDescription
providerstringrequiredgithub | gitlab | bitbucket
repositorystringrequiredRepository name, e.g. org/repo.
secretstringrequiredThe webhook signing secret.
PUT /api/webhook-secrets/:id 🔒 Auth required
Updates an existing secret record.
DELETE /api/webhook-secrets/:id 🔒 Auth required
Permanently deletes a webhook secret.

📊 API Reference — Stats & Queue

GET /api/stats 🔒 Auth required
Returns summary statistics for the dashboard — total deploys, success rate, top repositories, etc.
GET /api/queue 🔒 Auth required
Returns the current in-memory notification queue metrics — pending jobs, processing count, and recent errors.
GET /health Public
Public health check endpoint. Returns {"status":"ok"} when the server is running. Suitable for Docker health checks and load-balancer probes.

Response — 200

json
{ "status": "ok", "timestamp": "2026-04-25T18:00:00.000Z" }

🗺️ Guide — Smart Routing

Route different deploy events to different Discord channels using glob-based matching rules.

How rules are evaluated

  1. All enabled rules are sorted by priority (ascending — lower number wins).
  2. Each rule is tested against the incoming deploy's repository, branch, and event_type.
  3. The first matching rule wins and its webhook_url is used.
  4. If no rule matches, the global DISCORD_WEBHOOK_URL is used as fallback.

Glob pattern examples

PatternMatches
org/*Any repository in the org namespace.
*/frontendAny frontend repository regardless of owner.
release/*Any branch starting with release/.
mainExact match for the main branch.
(empty)Matches everything — acts as a wildcard.

Example setup

json — POST /api/routes
// Rule 1 — priority 1: tag releases → #releases
{
  "name": "Tag Releases",
  "webhook_url": "https://discord.com/api/webhooks/.../releases",
  "match_event_type": "tag",
  "priority": 1
}

// Rule 2 — priority 5: production branch → #prod-deploys
{
  "name": "Production",
  "webhook_url": "https://discord.com/api/webhooks/.../prod",
  "match_branch": "main",
  "match_event_type": "push",
  "priority": 5
}

// Rule 3 — priority 10: everything else → #staging-deploys
{
  "name": "Staging catch-all",
  "webhook_url": "https://discord.com/api/webhooks/.../staging",
  "priority": 10
}

📝 Guide — Release Changelogs

When a tag push is received, NexusCMS Deploy automatically generates a structured changelog grouped by conventional commit type.

How it works

  1. A tag push event is received (e.g. v2.3.0).
  2. Commits are parsed for conventional commit prefixes: feat, fix, chore, docs, refactor, perf, test, etc.
  3. Breaking changes (! suffix or BREAKING CHANGE footer) are detected and surfaced.
  4. The version is automatically classified as major, minor, or patch.
  5. The changelog is stored on the deploy and available via the changelog API endpoints.

Downloading a changelog

bash
# Download as Markdown
curl -H "Authorization: Bearer $TOKEN" \
  http://localhost:3000/api/deploys/42/changelog.md -o changelog.md

# Download as JSON
curl -H "Authorization: Bearer $TOKEN" \
  http://localhost:3000/api/deploys/42/changelog.json -o changelog.json

🔐 Guide — Secret Encryption

Webhook signing secrets are stored at rest using AES-256-GCM authenticated encryption when ENCRYPTION_KEY is configured.

Generating the key

bash
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
# e.g. → 4a7f3c2e1b9d8a6f5e4c3b2a1f0e9d8c7b6a5f4e3d2c1b0a9f8e7d6c5b4a3f2e

Add this value to your .env file:

env
ENCRYPTION_KEY=4a7f3c2e1b9d8a6f5e4c3b2a1f0e9d8c7b6a5f4e3d2c1b0a9f8e7d6c5b4a3f2e
⚠️

Back up your ENCRYPTION_KEY securely. If you lose it, all stored webhook secrets become unrecoverable and must be re-entered.

Key rotation

There is no automatic key rotation at this time. To rotate: delete all secrets from the dashboard, update ENCRYPTION_KEY, restart the application, and re-enter all secrets.