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.
On this page
Prerequisites
- Docker Engine with the Compose plugin — this guide assumes the
docker composeCLI, not the legacy standalonedocker-composebinary. Confirm withdocker compose version. - A domain (or at least a stable hostname) you control, if you want anything beyond
localhost—CORS_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
| Variable | Notes |
|---|---|
DATABASE_URL | Set automatically by docker-compose.yml for the backend service — you don't need to set this yourself unless running the backend outside Docker. |
REDIS_URL | Same — set automatically for Docker Compose use. |
CORS_ORIGIN | The frontend's real URL as your users' browsers will see it, e.g. https://app.yourdomain.com. |
JWT_SECRET | 32+ random bytes. Generate with node -e "console.log(require('crypto').randomBytes(32).toString('base64'))". |
ENCRYPTION_KEY_BASE64 | A 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
| Variable | Notes |
|---|---|
COOKIE_DOMAIN | Set 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_URL | Your 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_URL | Your frontend's real URLs for the post-OAuth redirect. |
NEXT_PUBLIC_API_URL | Frontend 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
| Variable | Gates |
|---|---|
GOOGLE_OAUTH_CLIENT_ID / GOOGLE_OAUTH_CLIENT_SECRET | Google Workspace inbox connection. |
MICROSOFT_OAUTH_CLIENT_ID / MICROSOFT_OAUTH_CLIENT_SECRET / MICROSOFT_OAUTH_TENANT | Microsoft 365 inbox connection. |
ANTHROPIC_API_KEY | AI features — Sequence Copilot, the in-app assistant, reply classification. Your own Anthropic key. |
VOYAGE_API_KEY / VOYAGE_EMBED_MODEL | The in-app assistant's and Help Center's doc-retrieval embeddings. |
SMTP_HOST / SMTP_PORT / SMTP_USER / SMTP_PASS / SMTP_FROM_ADDRESS | Outbound 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_URL | Push notifications via ntfy.sh or your own self-hosted ntfy instance. NTFY_SERVER_URL defaults to https://ntfy.sh. |
REGISTRATION_ENABLED | Leave this false (the default). Self-serve public registration is closed by design — see Step 4. |
STRIPE_SECRET_KEY / STRIPE_WEBHOOK_SECRET | In-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_SECRET | Inbox warmup (Mailivery integration) — one shared key for the whole instance. |
FULLENRICH_API_KEY / FULLENRICH_WEBHOOK_URL | Lead search and enrichment (FullEnrich integration) — same shared-key model as Mailivery. |
INBOX_HEALTH_WEIGHTED_ROTATION | Feature flag (true/false, default false) for inbox-rotation health weighting. |
SUPPORT_EMAIL | Where 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_URL | Backend-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.
| Variable | Purpose |
|---|---|
NEXT_PUBLIC_API_URL | The backend URL browsers call. The one frontend variable you actually need to set. |
NEXT_PUBLIC_APP_BASE_URL | Used by the admin email-template preview UI. |
NEXT_PUBLIC_SUPPORT_EMAIL | Your 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)
- Go to Google Cloud Console → APIs & Services → Credentials.
- Create an OAuth 2.0 Client ID (Web application).
- Add authorised redirect URI:
{OAUTH_CALLBACK_BASE_URL}/inboxes/oauth/google/callback(your backend's URL, not the frontend's). - Copy the Client ID and Secret into your
.envasGOOGLE_OAUTH_CLIENT_ID/GOOGLE_OAUTH_CLIENT_SECRET.
Microsoft (Outlook inbox connection)
- Go to Azure Active Directory → App registrations → New registration.
- Add redirect URI:
{OAUTH_CALLBACK_BASE_URL}/inboxes/oauth/microsoft/callback. - Under Certificates & secrets, create a new client secret.
- Copy the Application (client) ID, secret, and tenant ID into your
.envasMICROSOFT_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
- Run
docker compose up -d --buildand confirm all four containers are healthy (docker compose ps). - Create your admin account with the bootstrap script — see Step 4. There is no web registration form to use instead.
- Log in at
/loginand connect an inbox from the Inboxes page. - Run a DNS preflight check from the Deliverability page.
- 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