Better Helpdesk
Operations

Go to production

The checklist between a helpdesk that works on your machine and one your customers can rely on, and what to do on every upgrade after that.

Work through this page once before the first deploy, with the app from Install in an existing app in front of you. Every section is one thing to check or set.

Migrate before you deploy

Run the migration command in your release step, against the production database, before the new version of your app starts:

npx better-helpdesk-migrate

It reads the connection string from HELPDESK_DATABASE_URL, or APP_DATABASE_URL when that is unset, opens a single connection, applies the migrations that have not run yet, and exits. It fits next to your own migrations in the same step.

The order matters: migrate first, then deploy. The new version's queries expect the schema its migrations create, and a query that uses a new table or column fails until its migration has run.

Connect over TLS

The migration command and your app open their connections separately, and each has its own setting. The settings below are for Postgres. On SQL Server the command encrypts by default, as SQL Server describes. On MySQL, turn TLS on in the pool your app creates, and run the migrations through that pool, as in Run the migrations from your own code.

For the command, DATABASE_SSL=true turns on TLS and checks the server's certificate. DATABASE_SSL=no-verify encrypts without checking it, for a database with a self-signed certificate. A sslmode in the connection string replaces what DATABASE_SSL sets.

For your app, add sslmode=verify-full to HELPDESK_DATABASE_URL:

HELPDESK_DATABASE_URL=postgres://user:password@db.example.com:5432/app?sslmode=verify-full

pg 8 treats sslmode=require the same way and prints a warning that its meaning changes in pg 9, so name verify-full and the behavior stays put. If your provider signs its certificates with a CA of its own, pass the CA to the pool and leave sslmode out of the URL, since a sslmode in the URL replaces the pool's ssl option:

lib/helpdesk.ts
new pg.Pool({
  connectionString: process.env.HELPDESK_DATABASE_URL,
  ssl: { ca: process.env.HELPDESK_DATABASE_CA },
});

Keep the pool alive

On Postgres, use the pool from Build the helpdesk: one pool kept on globalThis, with error listeners on the pool and on each connection. Without the listeners, the first restart or failover of your database ends a connection with an error nobody handles, and Node.js stops your app.

Set the production URLs

adminUrl must be the URL your team opens in production, such as https://app.example.com/helpdesk/. The handler accepts changes only from its origin, so a value left at localhost refuses every reply with a 403. Set it per environment, and give each preview deployment its own if your team uses the agent UI there.

If the widget runs on another origin, such as a marketing site, list that origin on the inbox in allowedOrigins, exactly as the browser sends it, with no trailing slash. Add the widget has the details.

Tell the helpdesk the client's address

Visitors who are not signed in share a budget of writes per hour per client address (anonymousRateLimit, 20 by default). The address comes from a header, and which header is right depends on what sits in front of your app.

By default the helpdesk takes the last address in X-Forwarded-For, which is the one your own proxy appended, then X-Real-IP. That is right behind exactly one proxy that appends to X-Forwarded-For. In other setups it goes wrong in one of three ways:

  • Behind two proxies, such as a CDN in front of a load balancer, the last address is the CDN's. Every visitor who comes through the same CDN server shares one budget, and real people start getting 429s.
  • With no proxy, next start fills in X-Forwarded-For with the connecting address only when the request has none. A client that sends its own can put a different address in it on every request and never reach the limit.
  • With neither header, which happens on a server that sets neither, every visitor counts as unknown and they all share one budget.

When your platform names the client in a header of its own, read that header with clientIp:

Cloudflare sends CF-Connecting-IP, the address of the client that connected to Cloudflare:

lib/helpdesk.ts
export const helpdesk = buildHelpdesk({
  // ...
  clientIp: request => request.headers.get('cf-connecting-ip'),
});

The header is only as trustworthy as the route to your server. If your origin also accepts requests that do not come through Cloudflare, anyone can send it with any value, so let only Cloudflare reach the origin.

When clientIp returns null, the request counts as unknown. Security model lists every limit.

Set the secrets

Each secret switches on one feature, so set only the ones you use, and keep them in your platform's secret store, never in the repository. Without inboundWebhookSecret or jobsSecret, their route refuses everyone; without identityTokenSecret, the helpdesk ignores identity tokens.

lib/helpdesk.ts
export const helpdesk = buildHelpdesk({
  // ...
  identityTokenSecret: process.env.HELPDESK_IDENTITY_SECRET,
  inboundWebhookSecret: process.env.HELPDESK_INBOUND_SECRET,
  jobsSecret: process.env.HELPDESK_JOBS_SECRET,
});

Generate each one with openssl rand -base64 32. Here is every variable a production install reads:

VariableRead byWhat it is
HELPDESK_DATABASE_URLThe migration command and lib/helpdesk.tsThe connection string.
APP_DATABASE_URLThe migration commandUsed when HELPDESK_DATABASE_URL is unset.
DATABASE_SSLThe migration and import commandsPostgres only. true turns on TLS and checks the certificate; no-verify skips the check.
HELPDESK_ADMIN_URLadminUrlThe URL your team opens the agent UI at.
HELPDESK_IDENTITY_SECRETidentityTokenSecretSigns identity tokens for a widget on another origin. buildHelpdesk throws on a secret shorter than 32 bytes. See Sign customers in from another origin.
HELPDESK_INBOUND_SECRETinboundWebhookSecretThe bearer token your mail relay sends to the inbound route. See Receive email.
HELPDESK_JOBS_SECRETjobsSecretThe bearer token your scheduler sends to the jobs route. See Run jobs and retention.

The migration command reads the first three names itself. The others are the names these guides use; your lib/helpdesk.ts passes them in, so you may call them what you like.

Rating links in the resolution email need no secret from you. The helpdesk generates their key on first use and keeps it in the helpdesk.setting table, so it travels with your database.

Schedule the jobs

Emails, reminders, snoozes and retention all wait for runJobs. Without a schedule, customers never hear about a reply. Run it every minute or every few minutes, as Run jobs and retention describes.

On Vercel's Hobby plan a cron job runs at most once a day, and a more frequent schedule fails the deploy. A daily run is too rare for email, so on Hobby use the jobs route with a scheduler of your own, or move to a plan with per-minute cron jobs.

Keep support reachable when your product is down

The helpdesk runs inside the app it is mounted in and shares its database, its sign-in and its fate. When that app is your product, an outage of the product is also an outage of support, at the moment customers need it most. Two things keep a channel open:

  • Mount the helpdesk in an app of its own when your product's uptime is the thing customers write in about: a small Next.js app on separate hosting with its own database, as the marketing site at better-helpdesk.com does for itself. Your product then embeds the widget cross-origin with an identity token, and your website carries the same widget on a public inbox for anyone who cannot sign in. Agents sign into that app through whatever identify you give it; if your usual identity provider can be part of the same outage, give the support app a sign-in that is not.
  • Give the mail relay somewhere to go. The example Cloudflare Email Worker forwards each message to FALLBACK_ADDRESS when the inbound route does not answer, so mail waits in a mailbox instead of bouncing; the Postmark and Mailgun relays retry on their own for a while. When the app is back, the mail that waited still threads in by its message ids.

Point your status page at GET {basePath}/health/, which answers { ok: true } once the database has answered one query and 503 otherwise, so the support channel is a component customers can see the state of. Name the channel that stays up in your support documentation before anyone needs it.

Allow the helpdesk in your Content Security Policy

If your app sends a Content-Security-Policy, the helpdesk needs these sources in it:

  • style-src 'unsafe-inline': the widget puts a <style> element in its shadow root, and the agent UI renders one in the page. Neither takes a nonce.
  • connect-src with the origin of your storage bucket's upload URL, if you store attachments: the widget and the agent UI post files straight to it.
  • img-src blob: and the origin of your storage bucket's download URL: the widget and the agent UI preview an attached image from a local blob: URL before it is sent, and both the widget and the agent UI show image attachments from the URL presignDownload returns. Add the origin of your agents' avatars too, if identify returns an image.

With a captcha, the provider's origin goes in script-src and frame-src too.

On a page of another origin that loads the widget, that page's policy needs the handler's origin in connect-src, and, for the script tag, the origin of widget.js in script-src.

Upgrading

A minor version adds; a major version may change the API and the schema. For every upgrade:

  1. Read the notes for each version between yours and the new one on the GitHub releases page. They list every pull request a release contains.
  2. Update the package with npm install better-helpdesk@<version>.
  3. Run npx better-helpdesk-migrate against production before the new version of your app is deployed, as in Migrate before you deploy.
  4. If you serve widget.js from a copy in public/, make sure your build copies the new one.

On this page