Skip to content
SupportSeal

Docs

Self-hosting

Run your own installation with Docker Compose, including environment, email, reverse proxy, backups and upgrades.

A self-hosted installation is one app container and one PostgreSQL database. It serves exactly one Workspace with unlimited Products and agents. It never requires Stripe, the hosted API or external telemetry. This page summarises the installation guide. The full guide on GitHub is canonical.

Quick start#

git clone https://github.com/pietervw/supportseal.git
cd supportseal
cp .env.example .env        # then edit (see the reference below)
docker compose up -d

Open http://<host>:3000. On a fresh installation the root page redirects to /register, and the first account becomes the Workspace Admin. Once the Workspace exists, public registration closes automatically. Further users are invited from Settings → Team. A teammate with no account cannot yet join that way. See issue 180.

The three services#

docker compose up starts three services.

ServiceWhat it does
dbPostgreSQL 17 with a persistent volume.
migrateOne-shot prisma migrate deploy before the app boots.
appThe Next.js standalone build on port 3000. The health check is /api/health.

Environment reference#

Copy .env.example to .env and fill in at least the required values. Stripe variables are for hosted mode only and are never needed here.

VariableRequiredNotes
DATABASE_URLyesFor example postgresql://supportseal:pass@db:5432/supportseal. If you point it at your own PostgreSQL instead of the bundled db service, that database must run with timezone=UTC and a clock in sync with the app host. Otherwise the app logs a [db-alignment] error at boot, because message-stream cursors depend on it.
BETTER_AUTH_SECRETyesA random private server secret that signs login sessions and widget test-page links. If it is unset, test-page links use INBOUND_WEBHOOK_SECRET instead. Generate it with openssl rand -base64 32, then save the output as this environment variable. Never put it in browser code.
NEXT_PUBLIC_APP_URLyesPublic origin, for example https://support.example.com. It drives embed snippets, emails and auth. Compose passes it to the image build as a build argument.
NEXT_PUBLIC_APP_NAMEnoBrand name shown in the UI. It defaults to SupportSeal.
POSTGRES_PASSWORDnoPassword for the bundled db service. docker-compose.yml defaults it to supportseal.
STORAGE_DIRnoAttachment storage directory. The default is .dev-data/uploads. Mount a volume.
INBOUND_WEBHOOK_SECRETfor emailShared secret for the inbound-mail webhook. Use a URL-safe value such as openssl rand -hex 32, because the SendGrid Inbound Parse URL embeds it.
INBOUND_EMAIL_DOMAINfor emailDomain that receives forwarded support mail, for example inbound.example.com.
SENDGRID_API_KEYfor email (either)API key with the Mail Send scope only. Outbound mail goes through the SendGrid API. One outbound provider (SENDGRID_API_KEY or SMTP_URL) is required to deliver replies. SendGrid takes precedence when both are set.
SMTP_URLfor email (either)smtps://user:[email protected]:465. The SMTP alternative to SENDGRID_API_KEY. Without an outbound provider, replies are recorded but not delivered.
OUTBOUND_EMAIL_LOCALnoLocal part of the managed sender. The default is no-reply.
INBOUND_MX_HOSTSnoFor custom inbound mail domains. The MX host or hosts customers point their domain at, comma-separated. If unset, the live MX records of INBOUND_EMAIL_DOMAIN are used.
MAIL_DNS_SERVERSnoComma-separated host[:port] list that overrides the resolvers used for custom mail domain verification.
CRON_SECRETnoEnables POST /api/internal/escalations/sweep with Bearer auth, so a scheduler can trigger the unanswered-request alert sweep every minute. Alerts also fire from in-app activity and timers without it.
HOSTED_MODEnoLeave it unset for self-hosting. 1 enables the multi-Workspace SaaS modules (billing). Never set it on a self-hosted installation.
TRUSTED_PROXY_HEADERnoHeader your proxy sets with the real client IP, for example cf-connecting-ip. The widget's per-IP rate limits use it instead of X-Forwarded-For. Read the precondition first.

Email setup#

Email needs an inbound path and an outbound provider. The widget works without either.

Inbound mail#

  1. Find the generated address

    Open Settings → Products → your Product → Email. It shows the generated inbound address, product_xxx@your-inbound-domain.

  2. Forward your support address

    With your mail provider, forward [email protected] to that address.

  3. Point the webhook at the app

    Point your provider's webhook or forwarder at https://<your-host>/api/email/inbound with the header x-supportseal-inbound-secret: <INBOUND_WEBHOOK_SECRET>. If your provider cannot set headers, embed ?secret= in the destination URL instead.

A Product can also own a custom mail domain. Set INBOUND_MX_HOSTS to the host or hosts your inbound provider listens on. Your mail provider must also accept the domain, for example as an Inbound Parse hostname. Verification proves the customer's DNS, not your provider configuration.

Outbound mail#

Configure one outbound provider, either SMTP_URL or a SendGrid API key. Agent replies are then delivered from a sender named after the Product, such as "Acme Support" <no-reply@…>, with a reply-to that threads back into the same Conversation. Without a provider, replies are recorded but not delivered.

SendGrid#

SendGrid can replace the webhook and the outbound steps. Mail to [email protected] still needs forwarding to your Product's inbound address. Alternatively, point your product domain's MX at mx.sendgrid.net and use it as INBOUND_EMAIL_DOMAIN.

  • Inbound. Add an Inbound Parse hostname in SendGrid. It must be the same domain as INBOUND_EMAIL_DOMAIN. Create the MX record SendGrid shows you. Set the destination URL to https://<your-host>/api/email/inbound?secret=<INBOUND_WEBHOOK_SECRET>. Use a URL-safe secret, or percent-encode it.
  • Outbound. Set SENDGRID_API_KEY with the Mail Send scope only. Authenticate the sending domain in SendGrid, or verify the no-reply@… address (OUTBOUND_EMAIL_LOCAL) as a single sender. When both SENDGRID_API_KEY and SMTP_URL are set, SendGrid wins.

HTTPS and reverse proxy#

Terminate TLS in front of the app with Caddy, Traefik, nginx or similar. Three requirements apply.

  • Do not buffer server-sent events. The app already sends X-Accel-Buffering: no, which was sufficient through a stock nginx configuration. proxy_buffering off; and a raised proxy_read_timeout for /api/ are harmless extra headroom.
  • Never gzip text/event-stream. Compressing the event stream at the proxy holds messages back even when buffering is disabled. Exclude text/event-stream from gzip_types in nginx or from the encode matcher in Caddy. The app's 15 second heartbeats keep idle streams alive past default 60 second proxy read timeouts on their own.
  • Pass the client headers. Preserve the Host header and pass X-Forwarded-For. The widget's per-IP rate limiter uses it. The limiter also caps how many anonymous chat sessions one IP may start per minute.

Trusted client IP header#

By default the per-IP rate limits read the client IP from the first value of X-Forwarded-For. That is correct behind a plain nginx, Caddy or Traefik setup, where the proxy overwrites the header. Ignoring the header would put every visitor behind the proxy into one shared bucket.

If your origin sits behind a proxy that provides a dedicated client-IP header, such as cf-connecting-ip on Cloudflare, set TRUSTED_PROXY_HEADER=cf-connecting-ip. Only that header is then used, and a spoofed X-Forwarded-For is ignored. Requests missing the header all share one bucket, so a misconfiguration fails closed.

Backups#

  • Database. Use standard pg_dump or pg_dumpall on the db service, or a managed snapshot. It holds every Workspace, Product, Conversation, message and the delivery ledger.
  • Attachments. Back up the volume mounted at STORAGE_DIR, or your S3-compatible bucket if you swap the storage adapter.

Upgrades#

git pull
docker compose build
docker compose run --rm migrate   # applies pending prisma migrations
docker compose up -d

The migrate step is idempotent. Check GET /api/health after upgrading.

Security notes#

  • Keep BETTER_AUTH_SECRET and INBOUND_WEBHOOK_SECRET secret. Rotate one by setting a new value and restarting. Sessions are invalidated, and widgets re-issue visitor cookies automatically.
  • The widget key in the embed snippet is public by design. It only works on the domains you allowlisted for that Product.
  • Registration closes itself once the Workspace exists. Users join by invitation only.
  • Attachments are validated by type sniffing and size caps, and are served with nosniff and authorized access only.

Uninstall#

docker compose down -v removes containers, the network and volumes. That deletes all data.

The full guide#

The GitHub guide also covers the maintenance script that purges stale anonymous chat visitors, and other details this page leaves out. Read docs/self-hosting.md on GitHub (opens on GitHub) for the canonical text.