PlunkPlunk
Self-Hosting

Docker Deployment

Deploy with Docker Compose

Quick Start

  1. Download docker-compose.yml
  2. Configure environment variables
  3. Run docker compose up -d

Minimal .env

# Required
JWT_SECRET="$(openssl rand -base64 32)"
DB_PASSWORD="your-secure-password"

# Domains (point these to your server)
API_DOMAIN="api.yourdomain.com"
DASHBOARD_DOMAIN="app.yourdomain.com"
LANDING_DOMAIN="www.yourdomain.com"
WIKI_DOMAIN="docs.yourdomain.com"
USE_HTTPS="true"

# AWS SES
AWS_SES_REGION="us-east-1"
AWS_SES_ACCESS_KEY_ID="your-key"
AWS_SES_SECRET_ACCESS_KEY="your-secret"
SES_CONFIGURATION_SET="plunk-tracking"

See Environment Variables for all options.

Services

ServicePurpose
plunkAll apps + nginx (API, Web, Landing, Wiki, SMTP)
postgresPostgreSQL 16 database
redisRedis 7 queue
minioS3-compatible storage
ntfyNotifications

Ports

PortService
80Nginx (HTTP) — fronts API, dashboard, landing, wiki
465SMTP (implicit TLS)
587SMTP (STARTTLS)
8080API server (proxied through nginx; not usually exposed externally)
9000Minio API
9001Minio web console

When deploying behind a reverse proxy or load balancer, expose port 80 (and 465/587 if using SMTP). Postgres, Redis, and Minio are kept internal to the Docker network by default.

Health check

The API exposes a health endpoint at /health for orchestrators (Docker Compose, Kubernetes, load balancers):

curl https://api.yourdomain.com/health
# { "status": "ok", "time": ..., "uptime": ... }

Use this for liveness/readiness probes. It returns 200 when the API process is up and able to serve requests.

Running Individual Services

Set SERVICE environment variable:

SERVICE=api    # API only
SERVICE=worker # Worker only
SERVICE=web    # Dashboard only
SERVICE=all    # Everything (default)

SMTP TLS Certificates

For TLS on ports 465/587, provide certificates via one of these methods:

Traefik acme.json (Dokploy, Coolify)

Mount the acme.json file and set SMTP_DOMAIN:

environment:
  SMTP_DOMAIN: "smtp.yourdomain.com"
volumes:
  - /path/to/acme.json:/certs/acme.json:ro

Plunk automatically extracts the certificate for SMTP_DOMAIN from acme.json. Traefik rewrites acme.json in place, so mounting the single file picks up renewals.

PEM Files

Mount the directory that holds privkey.pem and fullchain.pem, not the files themselves: a single-file bind mount keeps pointing at the old file once a renewal replaces it.

volumes:
  - /path/to/certs:/certs:ro

Certbot

Certbot's live/<domain>/ files are relative symlinks into archive/, and certbot makes both directories readable by root only, while the Plunk container runs as UID 1001. Have certbot copy the files into a certificates directory after every issuance and renewal with a deploy hook, and mount that directory as in PEM Files:

certbot certonly -d smtp.yourdomain.com --deploy-hook \
  'install -m 644 "$RENEWED_LINEAGE/fullchain.pem" /path/to/certs/ && install -m 600 -o 1001 "$RENEWED_LINEAGE/privkey.pem" /path/to/certs/'

For a certificate certbot already manages, add the hook with certbot reconfigure --cert-name smtp.yourdomain.com --deploy-hook '...'.

If no certificates are mounted, SMTP runs without TLS.

Certificates issued or renewed while Plunk is running are picked up within about a minute, without restarting the container.

Building from Source

docker build -t plunk:custom .