Self-hosting

May 18, 2026 · View on GitHub

DevPinger is a managed product first — the easiest path is to use @dev_pinger_bot. If you'd rather run your own instance (privacy, air-gap, custom domain), this page covers production deployment.

For development setup on a laptop, see LOCAL_SETUP.md.

What you need

  • A Linux VPS or container host (Hetzner, Fly.io, Railway, your kubernetes cluster, etc.)
  • A public domain pointing at your host
  • Postgres 16 + Redis 7 (managed services are fine: Supabase, Neon, Upstash, etc.)
  • A Telegram bot of your own (via @BotFather)
  • GitHub OAuth App + Atlassian OAuth App, with callback URLs pointing at your domain

Provisioning the bot

In Telegram, talk to @BotFather:

/newbot
<bot name>
<username ending in _bot>

Save the token. The username (without @) goes into TELEGRAM_BOT_USERNAME.

Provisioning OAuth apps

Same as LOCAL_SETUP.md sections 3 and 4, except the callback URLs point at your production domain instead of the Cloudflare Tunnel:

  • GitHub: https://<your-domain>/oauth/github/callback
  • Jira: https://<your-domain>/oauth/jira/callback

Environment

Copy .env.example and fill in:

NODE_ENV=production
PORT=3001
LOG_LEVEL=info
PUBLIC_BASE_URL=https://<your-domain>

DATABASE_URL=postgres://...
REDIS_URL=redis://...

TELEGRAM_BOT_TOKEN=...
TELEGRAM_BOT_USERNAME=...
TELEGRAM_WEBHOOK_SECRET=<openssl rand -hex 32>

ENCRYPTION_KEY=<node -e "console.log(require('crypto').randomBytes(32).toString('hex'))">

GITHUB_OAUTH_CLIENT_ID=...
GITHUB_OAUTH_CLIENT_SECRET=...
GITHUB_OAUTH_REDIRECT_URI=https://<your-domain>/oauth/github/callback

JIRA_OAUTH_CLIENT_ID=...
JIRA_OAUTH_CLIENT_SECRET=...
JIRA_OAUTH_REDIRECT_URI=https://<your-domain>/oauth/jira/callback

SENTRY_DSN=https://...@sentry.io/...   # optional

ENCRYPTION_KEY is what wraps OAuth tokens at rest. Rotating it without re-running the OAuth flow will break every connection. Treat it like a database password — back it up out-of-band, and don't generate a new one on every deploy.

Deploy

The repo ships infra/docker-compose.prod.yml which brings up redis, server, worker, and Caddy (TLS reverse proxy) in one command. Postgres is not part of this stack — point DATABASE_URL at a managed service (Supabase, Neon, RDS, etc.). After filling .env.prod at the repo root:

docker compose -f infra/docker-compose.prod.yml --env-file .env.prod up -d --build

Migrations are not auto-run by the prod compose stack itself; the shipped .github/workflows/ci.yml runs pnpm db:migrate against PRODUCTION_DATABASE_URL before the SSH deploy, so a push to main gets the schema applied and the containers rolled in one go. For manual deploys (no CI), apply the same command yourself before infra/deploy.sh:

DATABASE_URL=... pnpm --filter @devpinger/db migrate

Run that from any host with Node 22 + pnpm and network access to the database (your laptop with the prod DATABASE_URL exported works). The runner is idempotent — re-running on an up-to-date DB is a no-op.

Provider-specific walkthroughs:

  • Hetzner / generic VPS — Docker Compose + Caddy or Cloudflare Tunnel for HTTPS.
  • Fly.io — two Fly Machines (server + worker), managed Postgres, Upstash Redis.
  • Railway — managed Postgres + Redis + per-service Dockerfile deploys.

The production multi-stage build lives at infra/Dockerfile (two final targets, server and worker); the per-app Dockerfiles (apps/server/Dockerfile, apps/worker/Dockerfile) are simpler single-stage variants used by Fly and Railway. Both flavours run as node user with tini as PID 1, so signals propagate cleanly and in-flight webhooks finish before exit.

In production, NODE_ENV=production makes the server register a Telegram webhook at startup (POST /telegram/webhook) instead of long-polling. Make sure your reverse proxy forwards that path with the body intact.

Graceful shutdown

SIGTERM triggers an orderly shutdown:

  • serverbot.stop(), then close Fastify (drains in-flight HTTP). Force-exit after 10s if anything hangs.
  • worker — close every BullMQ worker (waits for the current job to finish), then queue schedulers and Redis. Force-exit after 30s.

Rolling deploys (docker compose up -d --build, Fly's rolling strategy, Railway's auto-rollover) are safe — no webhook deliveries or notification jobs are dropped.

Running migrations

The schema lives in packages/db/drizzle/. Run once before first boot and on every deploy that includes new SQL:

pnpm db:migrate

In CI / a deploy step, set DATABASE_URL and run the same command. The migration runner is idempotent — re-running on an up-to-date DB is a no-op.

Backups

Postgres holds users, connections (encrypted OAuth tokens), event history, and mute rules.

If you point DATABASE_URL at a managed service (Supabase, Neon, RDS), use that provider's backups — point-in-time recovery on paid tiers is the simplest path.

If you run Postgres yourself in Docker, the legacy helpers infra/backup-postgres.sh and infra/restore-postgres.sh do nightly pg_dump -Fc against a local devpinger-postgres container with 30-day retention. Cron snippet:

0 3 * * * /opt/devpinger/infra/backup-postgres.sh >> /var/log/devpinger-backup.log 2>&1

The encryption key is required to decrypt the connections; back it up separately so a DB-only leak doesn't expose tokens, and so a host loss doesn't leave the database undecryptable. Test restore at least once on a throwaway compose stack — an untested backup is a wish, not a backup.

Webhook secrets

Each GitHub repo subscription gets its own webhook secret minted at registration time via randomBytes(32) (see adapter.tssubscriptionCreate). Secrets live in subscriptions.webhook_secret encrypted at rest only insofar as the database disk is encrypted — treat database access and the encryption key as equivalent risk surfaces.

Monitoring

/health and /ready actively probe both Postgres (SELECT 1) and Redis (PING) with a 1s timeout each. Response shape:

{ "status": "ok", "db": "ok", "redis": "ok", "ts": "2026-05-15T…" }

On failure the endpoint returns HTTP 503 with status: "degraded" and the failing component flagged as "fail". Wire either path into your Kubernetes liveness/readiness probes, uptime monitor (BetterStack, Healthchecks.io), or load balancer.

If SENTRY_DSN is set, the server captures unhandled errors there (redacted with @devpinger/shared to strip secret-looking strings from messages and stack traces).