ForgeSendDocs

Docker setup

ForgeSend runs entirely inside Docker Compose: PostgreSQL, Redis, and the backend and frontend themselves are all real Compose services, built from the repository's own Dockerfiles. There's no separate npm install/npm run build step to run yourself, and no process manager like PM2 to configure — Docker Compose is the supported self-hosting path.

Prerequisites

  • Docker Engine with the Compose plugin — this guide assumes the docker compose CLI, not the legacy standalone docker-compose binary. Confirm with docker compose version.
  • A domain (or at least a stable hostname) you control, if you want anything beyond localhostCORS_ORIGIN, COOKIE_DOMAIN, and the OAuth redirect vars below all depend on knowing this upfront.
  • Optionally, Cloudflare-proxied DNS for that domain — only needed if you want email open/click tracking to work. See Email tracking.
  • A valid ForgeSend self-hosted commercial licence — the free Solo tier includes this automatically, so most self-hosters don't need to obtain one separately. See Licensing.

No Node.js or PM2 install is needed on the host. Everything — building the app, running it, and applying database migrations — happens inside the Docker images.

Step 1 — Clone and create your .env

git clone https://github.com/your-org/forgesend.git
cd forgesend

There's no .env.example shipped in the repo — every variable the app reads is documented below, sourced directly from apps/backend/src/config/env.validation.ts. Create a real .env file at the repo root yourself with the values you need, and never commit it.

touch .env

Step 2 — Environment variables

Required — the app will not boot without these

VariableNotes
DATABASE_URLSet automatically by docker-compose.yml for the backend service — you don't need to set this yourself unless running the backend outside Docker.
REDIS_URLSame — set automatically for Docker Compose use.
CORS_ORIGINThe frontend's real URL as your users' browsers will see it, e.g. https://app.yourdomain.com.
JWT_SECRET32+ random bytes. Generate with node -e "console.log(require('crypto').randomBytes(32).toString('base64'))".
ENCRYPTION_KEY_BASE64A separate key from JWT_SECRET, generated the same way — encrypts OAuth tokens and SMTP passwords at rest. Do not reuse one key for both.

Strongly recommended for anything beyond localhost

VariableNotes
COOKIE_DOMAINSet this to your own domain. If left unset, the session cookie defaults to .forgesend.cloud — login will appear to succeed but the cookie will never actually work on your domain. Use a leading dot for subdomain sharing, e.g. .yourdomain.com.
OAUTH_CALLBACK_BASE_URLYour backend's real public URL, e.g. https://api.yourdomain.com. Defaults to http://localhost:4000. Required for Google/Microsoft inbox OAuth to work at all.
OAUTH_SUCCESS_REDIRECT_URL / OAUTH_FAILURE_REDIRECT_URLYour frontend's real URLs for the post-OAuth redirect.
NEXT_PUBLIC_API_URLFrontend build-time variable, not a backend variable — see the build-time note below. The URL your users' browsers use to reach the backend.

Optional — the app boots and runs fine with these unset; each just gates one feature

VariableGates
GOOGLE_OAUTH_CLIENT_ID / GOOGLE_OAUTH_CLIENT_SECRETGoogle Workspace inbox connection.
MICROSOFT_OAUTH_CLIENT_ID / MICROSOFT_OAUTH_CLIENT_SECRET / MICROSOFT_OAUTH_TENANTMicrosoft 365 inbox connection.
ANTHROPIC_API_KEYAI features — Sequence Copilot, the in-app assistant, reply classification. Your own Anthropic key.
VOYAGE_API_KEY / VOYAGE_EMBED_MODELThe in-app assistant's and Help Center's doc-retrieval embeddings.
SMTP_HOST / SMTP_PORT / SMTP_USER / SMTP_PASS / SMTP_FROM_ADDRESSOutbound system/admin notification emails (support tickets, fair-use alerts) — not campaign sending, which always uses each connected inbox's own credentials.
NTFY_TOPIC / NTFY_SERVER_URLPush notifications via ntfy.sh or your own self-hosted ntfy instance. NTFY_SERVER_URL defaults to https://ntfy.sh.
REGISTRATION_ENABLEDLeave this false (the default). Self-serve public registration is closed by design — see Step 4.
STRIPE_SECRET_KEY / STRIPE_WEBHOOK_SECRETIn-app Stripe billing (credit top-ups, warmup subscriptions). Genuinely optional for this MVP — a self-hosted instance is licensed, not Stripe-metered internally. Leave unset unless you specifically want in-app Stripe checkout to work.
STRIPE_PRICE_* (12 vars)Specific Stripe Price IDs for each purchasable pack/plan. Irrelevant if you're not using Stripe.
MAILIVERY_API_KEY / MAILIVERY_WEBHOOK_SECRETInbox warmup (Mailivery integration) — one shared key for the whole instance.
FULLENRICH_API_KEY / FULLENRICH_WEBHOOK_URLLead search and enrichment (FullEnrich integration) — same shared-key model as Mailivery.
INBOX_HEALTH_WEIGHTED_ROTATIONFeature flag (true/false, default false) for inbox-rotation health weighting.
SUPPORT_EMAILWhere outbound admin/fair-use/support-ticket notification emails go. Set this to your own inbox — if unset, these fall back to a generic placeholder, never to ForgeSend's own address.
APP_BASE_URLBackend-side base URL used to build links inside those emails, and Stripe Checkout return URLs. Should match your frontend's real URL. Defaults to http://localhost:3000.

Frontend-only variables (build-time, not read by the backend at all)

NEXT_PUBLIC_* variables are baked into the client JavaScript bundle at next build time, not read from the environment when the container starts.

VariablePurpose
NEXT_PUBLIC_API_URLThe backend URL browsers call. The one frontend variable you actually need to set.
NEXT_PUBLIC_APP_BASE_URLUsed by the admin email-template preview UI.
NEXT_PUBLIC_SUPPORT_EMAILYour support address, shown in that same admin UI. Set this alongside the backend's SUPPORT_EMAIL above — they're independent variables for independent processes.
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY, NEXT_PUBLIC_STRIPE_PRICE_* (8 vars)Only relevant if you're using Stripe checkout — otherwise leave unset.

Because NEXT_PUBLIC_* variables are compiled into the frontend image at build time, changing one later requires rebuilding the frontend image (docker compose build frontend or docker compose up -d --build frontend) — restarting the container does nothing. The backend, by contrast, reads its variables at runtime, so restarting it after an env change is enough.

Step 3 — Build and start the stack

docker compose up -d --build

This builds and starts all four services defined in docker-compose.yml: postgres, redis, backend (port 4000), and frontend (port 3000).

docker compose logs -f backend

Watch this especially on first boot — the backend container runs prisma migrate deploy automatically every time it starts, before serving traffic, so there's no separate manual migration step for a normal single-instance deployment.

Only run migrations manually if you scale the backend to multiple concurrent replicas — concurrent migrate deploy runs against the same database can race. In that case, run migrations as a one-off step before starting replicas: docker compose run --rm backend npx prisma migrate deploy.

The frontend is reachable at http://localhost:3000, the backend at http://localhost:4000. This setup doesn't include a reverse proxy or TLS termination — put your own in front once you're on a real domain (Caddy, Nginx, or Cloudflare Tunnel all work).

Step 4 — Create your first account

REGISTRATION_ENABLED defaults to false and should stay that way — self-serve public registration is closed by design, not an oversight. The only way to create the first account on a fresh instance is the bootstrap script, run inside the backend container:

docker compose exec backend npx ts-node scripts/bootstrap-admin.ts \
  --email=you@yourdomain.com \
  --password="a genuinely strong password" \
  --name="Your Name" \
  --workspace-name="Your Company"

--name and --workspace-name are optional. On a shared machine, pass the password via the BOOTSTRAP_ADMIN_PASSWORD environment variable instead of a CLI flag, since flags land in shell history:

docker compose exec -e BOOTSTRAP_ADMIN_PASSWORD="a genuinely strong password" backend \
  npx ts-node scripts/bootstrap-admin.ts --email=you@yourdomain.com

The script refuses to run if any user already exists — it's a strictly one-time first-account tool, not a general admin-creation utility. Use the normal login flow (or a future invite system) after that.

Log in normally at /login afterward with the email and password you just created.

Step 5 — Reverse proxy and TLS

Point your domain to the frontend container (port 3000) using Nginx or OpenLiteSpeed. A minimal Nginx config for the frontend:

server {
    listen 80;
    server_name yourdomain.com;

    location / {
        proxy_pass http://localhost:3000;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection 'upgrade';
        proxy_set_header Host $host;
        proxy_cache_bypass $http_upgrade;
    }
}

Set up a similar vhost on a separate subdomain (e.g. api.yourdomain.com) proxying to the backend on port 4000 — this is what NEXT_PUBLIC_API_URL, CORS_ORIGIN, and OAUTH_CALLBACK_BASE_URL should all point at once it's live.

Enable SSL via Let's Encrypt:

certbot --nginx -d yourdomain.com -d api.yourdomain.com

Step 6 — OAuth apps

Google (Gmail inbox connection)

  1. Go to Google Cloud Console → APIs & Services → Credentials.
  2. Create an OAuth 2.0 Client ID (Web application).
  3. Add authorised redirect URI: {OAUTH_CALLBACK_BASE_URL}/inboxes/oauth/google/callback (your backend's URL, not the frontend's).
  4. Copy the Client ID and Secret into your .env as GOOGLE_OAUTH_CLIENT_ID / GOOGLE_OAUTH_CLIENT_SECRET.

Microsoft (Outlook inbox connection)

  1. Go to Azure Active Directory → App registrations → New registration.
  2. Add redirect URI: {OAUTH_CALLBACK_BASE_URL}/inboxes/oauth/microsoft/callback.
  3. Under Certificates & secrets, create a new client secret.
  4. Copy the Application (client) ID, secret, and tenant ID into your .env as MICROSOFT_OAUTH_CLIENT_ID / MICROSOFT_OAUTH_CLIENT_SECRET / MICROSOFT_OAUTH_TENANT.

Email tracking (opens/clicks)

Setting TRACKING_CNAME_TARGET enables the open/click-tracking subdomain feature, but the tracking subdomain must currently be Cloudflare-proxied (the orange cloud) — the verification flow's error messages explicitly reference Cloudflare's proxy state and SSL/TLS mode.

This is the current requirement, not a permanent limitation — a non-Cloudflare tracking path is planned follow-on work. Until then, either move the sending domain's DNS to Cloudflare (the free tier is sufficient), or leave TRACKING_CNAME_TARGET unset. Every other feature works fine without open/click tracking.

First-run checklist

  1. Run docker compose up -d --build and confirm all four containers are healthy (docker compose ps).
  2. Create your admin account with the bootstrap script — see Step 4. There is no web registration form to use instead.
  3. Log in at /login and connect an inbox from the Inboxes page.
  4. Run a DNS preflight check from the Deliverability page.
  5. Send a test campaign to yourself before going live.

Port 465 (SSL/TLS) is the recommended SMTP port for system emails and connected inboxes. Port 587 (STARTTLS) may be blocked on some VPS providers — check your provider's outbound port policy if connections fail.

Where to go next

Next: Self-hosting overview