Skip to content

Deployment

sarmakska edited this page May 31, 2026 · 2 revisions

Deployment

Three good options. Pick by ops preference. In every case, mount a volume for the dead-letter inbox so failures survive a redeploy.

Path A: Docker on a VPS (recommended)

Cheapest, predictable, full control.

docker build -t webhook-to-email .
docker run -d \
  --name webhook \
  --restart unless-stopped \
  -p 3000:3000 \
  --env-file .env \
  -v webhook-data:/app/data \
  webhook-to-email

docker-compose

docker compose up -d

docker-compose.yml ships in the repo with a healthcheck on /health, restart: unless-stopped, and a named volume webhook-data mounted at /app/data for the dead-letter inbox.

Recommended VPS

Provider Spec Monthly cost Notes
Hetzner CX22 2 vCPU, 4GB low Best price to performance
Fly.io shared 1x free to low Global edge, good for multi-region
Render starter low Simple deploys
Railway hobby low Easy to add a volume
DigitalOcean basic droplet low Familiar

Front with Caddy (auto-HTTPS)

your-domain.com {
  reverse_proxy localhost:3000
}

Run Caddy via systemd. HTTPS is provisioned automatically by Let's Encrypt.

Path B: Fly.io

fly launch
fly volumes create webhook_data --size 1
fly secrets set RESEND_API_KEY=re_... NOTIFY_EMAIL=you@... WEBHOOK_SECRET=...
fly deploy

Mount the volume at /app/data in fly.toml so the dead-letter inbox persists:

[mounts]
  source = "webhook_data"
  destination = "/app/data"

Scale with fly scale count 2 for two instances. Note that each instance keeps its own in-memory queue and its own dead-letter file on its own volume.

Path C: Render

  1. New, Web Service, connect the repo.
  2. Environment Node, build npm install, start npm start.
  3. Add env vars in Settings.
  4. Add a disk mounted at /app/data for the dead-letter inbox.

The free tier sleeps after idle; upgrade for always-on.

Custom domain

All three accept custom domains:

CNAME hooks.your-domain.com -> your-app.fly.dev (or the Render URL, or an A record to the VPS IP)

Then point Stripe, GitHub, Cal.com and Linear webhooks at https://hooks.your-domain.com/hooks/<source>.

Smoke test

curl https://your-domain.com/health
# {"ok":true}

curl -X POST https://your-domain.com/hooks/test \
  -H "Content-Type: application/json" \
  -d '{"hello":"world"}'
# 202 {"ok":true,"queued":true,...}

curl https://your-domain.com/dead-letter
# {"ok":true,"count":0,"items":[]}

The email arrives once the worker delivers it.

Monitoring

Metric Why
Queue depth (GET /) A growing depth means Resend is slow or failing
Dead-letter count (GET /dead-letter) Permanent failures that need attention
401 rate Wrong secret or an attack
500 rate Bugs in templates

Pipe stdout to a log aggregator (BetterStack, Axiom, Datadog) and alert on a rising dead-letter count.

Clone this wiki locally