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 -dOpen 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.
| Service | What it does |
|---|---|
db | PostgreSQL 17 with a persistent volume. |
migrate | One-shot prisma migrate deploy before the app boots. |
app | The 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.
| Variable | Required | Notes |
|---|---|---|
DATABASE_URL | yes | For 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_SECRET | yes | A 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_URL | yes | Public 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_NAME | no | Brand name shown in the UI. It defaults to SupportSeal. |
POSTGRES_PASSWORD | no | Password for the bundled db service. docker-compose.yml defaults it to supportseal. |
STORAGE_DIR | no | Attachment storage directory. The default is .dev-data/uploads. Mount a volume. |
INBOUND_WEBHOOK_SECRET | for email | Shared 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_DOMAIN | for email | Domain that receives forwarded support mail, for example inbound.example.com. |
SENDGRID_API_KEY | for 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_URL | for 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_LOCAL | no | Local part of the managed sender. The default is no-reply. |
INBOUND_MX_HOSTS | no | For 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_SERVERS | no | Comma-separated host[:port] list that overrides the resolvers used for custom mail domain verification. |
CRON_SECRET | no | Enables 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_MODE | no | Leave it unset for self-hosting. 1 enables the multi-Workspace SaaS modules (billing). Never set it on a self-hosted installation. |
TRUSTED_PROXY_HEADER | no | Header 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#
Find the generated address
Open Settings → Products → your Product → Email. It shows the generated inbound address,
product_xxx@your-inbound-domain.Forward your support address
With your mail provider, forward
[email protected]to that address.Point the webhook at the app
Point your provider's webhook or forwarder at
https://<your-host>/api/email/inboundwith the headerx-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 tohttps://<your-host>/api/email/inbound?secret=<INBOUND_WEBHOOK_SECRET>. Use a URL-safe secret, or percent-encode it. - Outbound. Set
SENDGRID_API_KEYwith the Mail Send scope only. Authenticate the sending domain in SendGrid, or verify theno-reply@…address (OUTBOUND_EMAIL_LOCAL) as a single sender. When bothSENDGRID_API_KEYandSMTP_URLare 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 raisedproxy_read_timeoutfor/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. Excludetext/event-streamfromgzip_typesin nginx or from theencodematcher 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
Hostheader and passX-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_dumporpg_dumpallon thedbservice, 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 -dThe migrate step is idempotent. Check GET /api/health after upgrading.
Security notes#
- Keep
BETTER_AUTH_SECRETandINBOUND_WEBHOOK_SECRETsecret. 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
nosniffand 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.