How do I self-host RevenueDot?

Most apps should use RevenueDot Cloud, which needs no server. This guide is for teams that run their own. The server is AGPL-3.0 and has no billing. Organizations, custom roles, single sign-on and the other ee/ features need an Enterprise licence key (RevenueDot Enterprise).

Clone the repository, copy .env.example to .env, and run docker compose up -d. You get one container that serves the SDK API, the REST API, store notifications and the dashboard on port 8787, next to Postgres 16 with a persistent volume. The server applies database migrations itself when it starts, so upgrades are a pull and a restart.

Shell
git clone https://github.com/revenuedot/revenuedot.git
cd revenuedot
cp .env.example .env          # set POSTGRES_PASSWORD before the first start
docker compose up -d          # pulls ghcr.io/revenuedot/revenuedot and starts RevenueDot and Postgres
curl http://localhost:8787/v1/health        # {"status":"ok"}

Open http://localhost:8787/login and sign up. The first account is the owner. After that, sign-up is closed to everyone except the addresses you invite to a project, unless you set REVENUEDOT_ALLOW_SIGNUP=true. Set up email so password resets, invites and alerts reach people.

The image is ghcr.io/revenuedot/revenuedot, built for amd64 and arm64 on every change to main and tagged latest, by date (2026.10.03) and by commit. docker pull ghcr.io/revenuedot/revenuedot:latest needs no account. Compose pulls it; set REVENUEDOT_IMAGE=ghcr.io/revenuedot/revenuedot:2026.10.03 in .env to pin a build, and run docker compose build to build the same image from the checkout instead.

What runs#

Piece What it does
revenuedot container (Node.js 24) One process: SDK API (/v1), REST API (/v2), dashboard sign-in (/auth), OAuth for MCP clients (/oauth), store notifications (/v1/notifications/...) and the dashboard's web app. A background job runs every 30 seconds: it records expirations, runs the daily Google Play voided-purchase check, re-checks store credentials, sends webhooks and sends alert emails
db container (Postgres 16) Every customer, purchase, event and setting, in the revenuedot-data volume

One container is enough for most apps. To run two or more against the same database, behind a load balancer, see High availability: migrations and the background job take Postgres locks, so each webhook and email still goes out once. The server answers GET /healthz (process alive) and GET /readyz (database reachable, not shutting down) for health checks, and finishes requests in flight when it gets SIGTERM.

Settings#

Edit .env next to docker-compose.yml. Store credentials (Apple keys, Google service accounts) are not environment variables: each app holds its own, set in the dashboard or with the REST API, and the server stores them encrypted (see Encryption key).

Variable Default What it does
POSTGRES_PASSWORD revenuedot in Compose Password of the bundled Postgres. Set it before the first start: it is written into the volume then, and changing it later also needs ALTER USER in Postgres
REVENUEDOT_PORT 8787 Host port for everything
REVENUEDOT_IMAGE ghcr.io/revenuedot/revenuedot:latest The server image. Pin a date tag (2026.10.03) or a commit tag so a restart never picks up a newer build than you tested
REVENUEDOT_ALLOW_SIGNUP false true lets anyone who reaches the dashboard create an account. Invited addresses can always create one
REVENUEDOT_SMTP_URL, REVENUEDOT_MAIL_FROM, REVENUEDOT_MAIL_REPLY_TO, REVENUEDOT_PUBLIC_URL unset Outgoing email. See Email
REVENUEDOT_SIGNING_KEY unset Base64 Ed25519 seed. Turns on response signing. Not passed through by the default docker-compose.yml; see below
REVENUEDOT_ENCRYPTION_KEY unset Base64 of 32 random bytes. Encrypts store keys, integration credentials and two-factor secrets. See Encryption key

Inside the container the server reads:

Variable Default What it does
DATABASE_URL pglite://./.data/dev postgres://user:password@host:5432/db for Postgres (Compose sets it). A pglite:// path uses an embedded Postgres on disk, for development only
PORT 8787 Port the server listens on
DASHBOARD_DIST the built dashboard in the image Folder of the dashboard's built files. If it has no index.html, only the API is served
REVENUEDOT_ALLOW_SIGNUP unset (owner only) See above
REVENUEDOT_SIGNING_KEY unset See above
REVENUEDOT_ENCRYPTION_KEY unset See above
REVENUEDOT_SMTP_URL and the other mail variables unset See Email
REVENUEDOT_PAY_URL <this server>/pay Where purchase links, funnels and redemption links live. A URL without a path, such as https://pay.example.com, serves them at that host's root. Redemption emails link here, else to REVENUEDOT_PUBLIC_URL + /pay, never to the host a request named. See Custom domains
REVENUEDOT_CUSTOM_DOMAIN_TARGET the pay host The host customers' custom domains must CNAME to
REVENUEDOT_LICENSE_KEY unset Turns on RevenueDot Enterprise features the key covers. Not passed through by the default docker-compose.yml; add it to docker-compose.override.yml like the signing key
REVENUEDOT_EE_DEV unset true turns on every Enterprise feature for development and testing only (development mode)
REVENUEDOT_INSIGHTS_DIGEST unset on writes and emails the weekly growth insights digest with your model key. It needs email. The one-click opt-out link needs REVENUEDOT_ENCRYPTION_KEY or REVENUEDOT_SIGNING_KEY

Set the signing key#

Generate a key once (pnpm tsx scripts/signing-keygen.ts in a checkout with pnpm install done), add it to .env, and pass it to the container with a docker-compose.override.yml, which Compose reads automatically:

YAML
# docker-compose.override.yml
services:
  revenuedot:
    environment:
      REVENUEDOT_SIGNING_KEY: ${REVENUEDOT_SIGNING_KEY}

Check it with curl http://localhost:8787/.well-known/revenuedot-signing-key. Stock RevenueCat SDKs still cannot verify these signatures; see Trusted Entitlements.

Encryption key#

Every secret the server keeps is encrypted with AES-256-GCM: the App Store In-App Purchase and App Store Connect .p8 keys, the Google Play service account JSON, Apple and Amazon shared secrets, Stripe, Paddle, Roku and Galaxy Store keys, integration and data export credentials, and two-factor secrets. The API and the dashboard never return them; they show only whether each one is set.

The key is REVENUEDOT_ENCRYPTION_KEY (make one with openssl rand -base64 32). Without it the server uses a key derived from REVENUEDOT_SIGNING_KEY. With neither, secrets are stored unencrypted in their own column, and encrypted within a minute of a key being set. Keep the key with your database backups but not in them: without it, saved store keys must be entered again.

Upgrading from a version that stored Apple and Google keys unencrypted needs no step: within a minute of starting, the server encrypts them in place. It logs secrets backfill: with counts and app ids, never a value.

To rotate the key:

  1. Set REVENUEDOT_ENCRYPTION_KEY to the new key, a comma, then the old one (NEW,OLD), and restart. The first key encrypts; both decrypt. If you used the key derived from the signing key, set only NEW: the derived key keeps working while REVENUEDOT_SIGNING_KEY is unchanged.
  2. Wait until the log shows secrets backfill: and then stays quiet (a few minutes). Every secret is now encrypted with the new key.
  3. Set REVENUEDOT_ENCRYPTION_KEY to NEW alone and restart. Rotate REVENUEDOT_SIGNING_KEY only after this, since the derived key opens nothing once it changes.

A secret encrypted with a key the server no longer has cannot be read: the app's Check credentials says so, and the key must be entered again.

Email#

RevenueDot sends email for password resets, team invites and alerts. A self-hosted server sends it through any SMTP provider (your own mail server, Amazon SES, Postmark, Resend, Mailgun, SendGrid and others). Add these to .env; the default docker-compose.yml passes them to the container:

Shell
REVENUEDOT_SMTP_URL=smtp://user:password@smtp.example.com:587
REVENUEDOT_MAIL_FROM=RevenueDot <no-reply@example.com>
REVENUEDOT_MAIL_REPLY_TO=team@example.com
REVENUEDOT_PUBLIC_URL=https://revenuedot.example.com
Variable What it does
REVENUEDOT_SMTP_URL The SMTP server. smtp:// connects on port 587 and upgrades to TLS with STARTTLS when the server offers it. smtps:// uses TLS from the start, on port 465. Write special characters in the user name or password URL-encoded: @ is %40, : is %3A, / is %2F
REVENUEDOT_MAIL_FROM The sender, as Name <address> or a bare address. Use an address on a domain your SMTP provider may send for (SPF and DKIM set up), or the emails land in spam. Default: RevenueDot <no-reply@localhost>, which most providers reject
REVENUEDOT_MAIL_REPLY_TO Optional. Where replies go
REVENUEDOT_PUBLIC_URL The address people use to open the dashboard, for the links in emails. Unset: links use the address the request came in on (X-Forwarded-Host behind a proxy). Alert emails have no request, so they use the last address the dashboard was opened on since the server started, or http://localhost:8787. Set it for correct alert links

Without REVENUEDOT_SMTP_URL, nothing is sent. Every email, links included, is printed to the server log instead, so you can still copy a reset or invite link:

Shell
docker compose logs revenuedot

When the server starts, its log says which it does: Email: SMTP (REVENUEDOT_SMTP_URL). or Email: not configured; emails are printed to this log. A failed send is logged with the SMTP error and never blocks sign-up, invites or the background job.

Reset a password without email#

When someone cannot get a reset email, reset the password straight in the database with the revenuedot CLI. It needs DATABASE_URL, the server's Postgres:

Shell
DATABASE_URL=postgres://revenuedot:secret@localhost:5432/revenuedot npx revenuedot admin reset-password dev@example.com

With Docker Compose, run it inside the server container, which has DATABASE_URL set already:

Shell
docker compose exec revenuedot pnpm --filter revenuedot cli admin reset-password dev@example.com
  • Without --password, the CLI generates a 20-character password and prints it once. Copy it then; it is not stored anywhere readable.
  • --password <new password> sets a password you choose (at least 8 characters).
  • Every session of the user is signed out, and open reset links stop working.
  • --database-url <url> can replace the DATABASE_URL variable.

Put HTTPS in front#

The App Store and Google Pub/Sub send notifications only to public HTTPS URLs, and apps should never talk to your server over plain HTTP. Put a reverse proxy with TLS in front of port 8787, for example Caddy:

Text
# Caddyfile
revenuedot.example.com {
  reverse_proxy localhost:8787
}

RevenueDot builds the notification URLs and the SDK's proxy URL it shows in the dashboard from X-Forwarded-Host and X-Forwarded-Proto. Caddy, nginx and most load balancers send these; check the app page shows https://revenuedot.example.com/v1/notifications/.... Session cookies get the Secure flag when the request URL is HTTPS.

Use your own Postgres#

Point DATABASE_URL at any Postgres 16 database and remove the db service, or run the server without Docker:

Shell
pnpm install
pnpm --filter @revenuedot/dashboard build
DATABASE_URL=postgres://revenuedot:secret@db.internal:5432/revenuedot pnpm --filter @revenuedot/server start

The server creates its tables on the first start. Managed Postgres (RDS, Cloud SQL, Neon, Railway, Supabase) works; the server keeps a pool of 10 connections.

Local development#

Without DATABASE_URL, pnpm dev stores data in an embedded Postgres (PGlite) under .data/dev, so you need no database server to try changes:

Shell
pnpm install
pnpm --filter @revenuedot/dashboard build   # optional: without it only the API is served
pnpm dev                                    # http://localhost:8787, restarts on changes
Edit this page on GitHub ↗ View as Markdown Last updated