sudo bash install/install.sh
Or with Certbot TLS:
sudo bash install/install.sh --tls-hostname app.example.com
The installer prompts for super admin email and generates a random password. No organization is created — add tenants later from /admin.
Offline / upload install: build a zip with bash scripts/package-release.sh (or scripts/package-release.ps1 on Windows). Every default run of install/install.sh is a destructive fresh install with no confirmation prompt: it stops containers, deletes Docker volumes (down -v), removes the locally built images and data/ storage/ backup_data/, then rebuilds with --no-cache and mints new secrets. Database schema is applied via a one-shot db-migrate job before the API starts. Use --keep-data to upgrade in place instead (keeps the database, stored mail and backups, and takes a pre-upgrade backup first), or --keep-env to retain .env.production secrets while still wiping data volumes.
Unzipping a release and only restarting containers does not update the web UI — the Next.js app is compiled into the Docker image at build time. Use the update command:
# On the Ubuntu host, from the install directory (e.g. /opt/coldfeet):
unzip -o coldfeet-ubuntu24-YYYYMMDD.zip -d /opt/coldfeet
cd /opt/coldfeet
sudo bash install/install.sh update
That rebuilds web / api / worker, runs migrations, and recreates those containers without deleting volumes or secrets. When it finishes, check View Source on /auth/signin for:
<meta name="coldfeet-build" content="YYYYMMDD">
If that meta tag still shows an older value, the new image was not deployed (or a CDN is caching HTML — purge Cloudflare cache for / and /auth/*). Hard-refresh the browser (Ctrl+Shift+R).
Optional flags:
--public-hostname app.example.com — Cloudflare Full SSL mode (HTTPS dashboard, nginx on port 80)--mail-host mail.example.com — grey-cloud mail hostname for MX/SMTP (use with --public-hostname)--tls-hostname app.example.com — origin TLS via Certbot on 443 (optional; see DEPLOY-TLS-ORIGIN.md)--oauth-domain auth.yourdomain.com — hybrid mode: HTTPS on hostname for OAuth callbacks only (HTTP IP unchanged)--mx-port-25 — open UFW port 25 for standard MX--mail-role gateway|mailserver|hybrid — whether this server hands filtered mail
on to a mail server behind it, holds the mailboxes itself, or decides per domain
(see Mail roles below)--skip-ufw — skip firewall configuration--skip-clamav — skip ClamAV image (no attachment virus scanning)--non-interactive — scripted install (requires bootstrap env vars below)--relay-host host — SMTP relay host (default: host.docker.internal)--keep-data — upgrade in place: keep the database, stored mail and local backups
instead of the default destructive fresh installFor CI or automation, pass --non-interactive and set:
export BOOTSTRAP_SUPER_ADMIN_EMAIL=admin@example.com
# Password optional — auto-generated if omitted
export BOOTSTRAP_SUPER_ADMIN_PASSWORD='your-secure-password'
sudo bash install/install.sh --non-interactive --mail-host mail.example.com
--mail-host is required in every deployment mode (including the default HTTP-by-IP
path). The installer prompts for it interactively; in --non-interactive mode it
tries reverse DNS of SERVER_IP and otherwise exits with a clear error. An IP
address is never accepted as MAIL_HOST because it is announced in SMTP banners
and used for PTR / deliverability checks.
npm run env:generate
Creates .env, apps/api/.env, and apps/web/.env with generated secrets.
.env.production with all secrets (JWT, encryption, metrics, Grafana password, etc.)DEPLOYMENT_MODE=http by default (credentials-only login on HTTP IP)db-migrate, starts API, then web and monitoringThe installer asks what this server does with mail. The answer is stored as
MAIL_ROLE and can be changed later by re-running the installer.
| Role | What it does | Extra services |
|---|---|---|
gateway (default) | Filters and relays to the customer's own mail server | none |
mailserver | Filters and keeps the mailboxes here | Dovecot, Roundcube |
hybrid | Per domain, chosen in the console under Domains → Delivery | Dovecot, Roundcube |
Anything other than gateway also:
/webmail behind the same nginx as the consoleconfig/dovecot/crypt, which encrypts the Maildir
at rest. Losing that keypair loses every stored message. It is included in
encrypted backups; it is deliberately not included in unencrypted onesconfig/dovecot/tls and installs a
renewal hook to keep it current. With no ACME certificate yet, IMAP and POP3
start on a self-signed one and clients will warn until a real one is in placeA domain only delivers locally once it has mailboxes: mail for an address with no mailbox is refused at RCPT rather than accepted and bounced afterwards.
| Mode | Dashboard | Login | M365/Google |
|---|---|---|---|
http (default) | http://SERVER_IP | Credentials only | Hidden in UI |
cloudflare | https://PUBLIC_HOSTNAME via Cloudflare | Credentials + OAuth | Enabled when OAuth env vars set |
hybrid | http://SERVER_IP + https://oauth-domain for callbacks | Credentials + OAuth on HTTPS domain | Enabled when OAuth env vars set |
Cloudflare install example:
sudo bash install/install.sh \
--public-hostname app.example.com \
--mail-host mail.example.com
See DEPLOY-CLOUDFLARE.md for DNS, SSL, and cache rules.
| Type | Count | Where configured |
|---|---|---|
| Platform app hostname | One | Admin → Platform settings, or installer --public-hostname |
| Platform mail hostname | One | Admin → Platform settings, or --mail-host |
| Customer mail domains | Many per tenant | Domain wizard (/dashboard/domains/new or admin tenant detail) |
All customer MX records point to the platform mail hostname. The dashboard URL is shared for every tenant on the install.
To change the app domain after install: super admin → Platform settings → validate → apply (see DEPLOY-CLOUDFLARE.md).
Important: Credentials login always stays on http://SERVER_IP in pure HTTP mode. Hybrid mode does not redirect HTTP to HTTPS on the IP.
| Service | URL |
|---|---|
| Dashboard | http://YOUR_SERVER_IP |
| API Swagger | http://YOUR_SERVER_IP/api/docs |
| Health (via nginx) | http://YOUR_SERVER_IP/api/health — liveness (DB + Redis) |
| Health (API v1) | http://YOUR_SERVER_IP/api/v1/health — full readiness (ClamAV, queue) |
| Health (live) | http://YOUR_SERVER_IP/api/v1/health/live — liveness on API directly |
| OAuth Google | https://YOUR_DOMAIN/oauth/google |
| OAuth Microsoft | https://YOUR_DOMAIN/oauth/msft |
| Install summary | install-summary.txt in repo root after install |
| SMTP ingest | YOUR_SERVER_IP:2525 |
| Outbound SMTP | YOUR_SERVER_IP:587 |
| Webmail (mailserver/hybrid) | http://YOUR_SERVER_IP/webmail |
| IMAP / POP3 (mailserver/hybrid) | YOUR_SERVER_IP:993 / :995 |
| ManageSieve (mailserver/hybrid) | YOUR_SERVER_IP:4190 |
| Grafana | http://127.0.0.1:3002 on the server only (compose binds loopback; password in .env.production). SSH tunnel or reverse-proxy if you need remote access. |
| Prometheus | http://127.0.0.1:9090 on the server only (compose binds loopback). |
Log in with the email and password shown at end of install (also in data/super-admin-credentials.txt). Open /admin to create tenants and organizations.
docker compose -f docker-compose.prod.yml --env-file .env.production up -d postgres redis
docker compose -f docker-compose.prod.yml --env-file .env.production run --rm db-migrate
docker compose -f docker-compose.prod.yml --env-file .env.production up -d --build
bash install/bootstrap.sh .env.production # first install only
bash scripts/validate-env.sh .env.production
sudo bash install/backup.sh
sudo bash install/restore.sh backup_data/manual-YYYYMMDD-HHMMSS
Google and Microsoft SSO require HTTPS redirect URIs. For HTTP-only installs, use credentials login. SSO covers sign-in only — mail is always ingested over SMTP on the MX, never through a provider API.
For hybrid mode:
sudo bash install/install.sh --oauth-domain auth.yourdomain.com
https://auth.yourdomain.com for the sign-in callbacks onlyhttp://SERVER_IP for the dashboardENCRYPTION_KEY step required127.0.0.1:3002) and Prometheus (127.0.0.1:9090) are already loopback-only — use an SSH tunnel if you need remote access/etc/cron.d/coldfeet-backup (installed automatically).
Copy BACKUP_PASSPHRASE off this host and run the restore drill in
BACKUP-RESTORE.mdGREYLIST_ENABLED) defaults to
off so the gateway accepts then scans; set it to true only on sole-MX
installs — with a backup MX, greylist often makes senders bypass Coldfeet.--skip-fail2ban to opt out). Check with sudo fail2ban-client status.--skip-host-agent; the console then says nothing is reporting and
everything else carries on. Manage it separately with
sudo install/host-agent/install-host-agent.sh [--uninstall].DNS_RESOLVERS
points at it. Leave it that way: Spamhaus and the other blocklists refuse
queries arriving through public resolvers, so an install pointed at 1.1.1.1
has no working reputation scanning and no warning that it does not. The
Host protection page checks this and says so outright. Unbound also
validates DNSSEC, which is what stops a forged MX answer redirecting mail.
The installer verifies Unbound is healthy and answering after the stack
starts; hosts that block outbound UDP/53 can set DNS_MODE=doh in
.env.production as an escape hatch (blocklists will then be unreliable).
Domains start as pending and become active only when ownership and a
delivery destination (relay or local mailboxes) are proven. Fresh installs
seed a domain-less Sample Organization for the super admin by default; pass
--no-sample-tenant (or set SEED_SAMPLE_TENANT=false) to skip it. On an
already-bootstrapped host: set SEED_SAMPLE_TENANT=true in .env.production
and re-run bash install/bootstrap.sh .env.production. TLS certificates may
be ECDSA (certbot default); the console reads them with Node's
X509Certificate, not RSA-only parsers.ENCRYPTION_KEY is a migration, not an edit — see
KEY-ROTATION.mdColdfeet separates inbound ingest (platform MX) from outbound relay (egress workers — lightweight VPS nodes running only the Node.js egress agent, which delivers direct-to-MX; no local MTA is installed or required).
| Relay mode | When | Behaviour |
|---|---|---|
platform_local | No worker has ever heartbeated | Tenant mail may leave from the platform via nodemailer (bootstrap) |
worker_required | At least one worker has checked in (and is not disabled/failed) | Tenant mail must leave through a worker; if every worker is down, mail is deferred (retried up to 48h) rather than sent from the platform IP |
Platform-own mail (password resets, invites, alerts, NDRs, digests) never goes
through the egress path — it uses EmailForwarderService with a direct-MX
fallback when SMTP_RELAY_HOST is unset or unreachable.
Add workers from Admin → Workers. After the first successful heartbeat,
relay mode flips to worker_required automatically. Current mode is shown on
Admin → Settings → Outbound delivery.
Each worker's install script only provisions curl, certbot and the Node agent; scale by adding more workers (jobs are sharded per node). Workers that still run a Postfix install from before agent 3.x must be drained and cut over — see WORKER-CUTOVER.md — re-running the install script purges Postfix/SASL/rsyslog automatically once the queue is empty.
By default BullMQ processors (email-inbound, email-outbound, sandbox-recheck)
run in-process inside the API container. That is the correct single-box
layout: one process owns SMTP listeners, the cron scheduler, and the queues.
To add dedicated worker capacity on the same host:
docker compose -f docker-compose.prod.yml --env-file .env.production --profile worker up -d worker
The worker entrypoint runs apps/api/dist/queue/worker.bootstrap.js with
WORKER_ONLY=true, which disables SMTP listeners and the cron scheduler so they
do not double-fire. Keep exactly one scheduler (the API). Do not enable the
worker profile on a fresh install unless you have measured queue backlog.
npm run verify:install # build + unit tests + full Docker smoke test
npm run verify:boot # fast API boot against local postgres/redis
Release packaging (bash scripts/package-release.sh) runs both gates automatically — no zip is created if the API cannot boot in Docker.
sudo bash install/install.sh check # pre-flight
sudo bash install/install.sh health # compose ps + health curls
sudo bash install/install.sh logs # api + web logs
sudo bash install/install.sh credentials # show super admin login
sudo bash install/install.sh finish-nginx app.example.com # recover partial install
Install output is logged to install.log in the project root (not under data/).
sudo bash install/install.sh --tls-hostname your.domain.com
DNS A record must point to the server before install. Order: HTTP proxy → Certbot → HTTPS. If Certbot fails, the app remains on http://your.domain.com with a warning.
Use --keep-data to upgrade in place (keeps the database, stored mail and local
backups, and takes a pre-upgrade backup first). After this release's fail2ban
filter change (abuse-only SMTP matches, higher relay maxretry), clear any bans
earned under the old broader rules once:
sudo fail2ban-client unban --all
Existing bans persist across filter/jail updates; without the unban, legitimate senders that tripped greylist or pending-domain soft rejects may still be blocked.
| Error / symptom | Cause | Fix |
|---|---|---|
Queue name cannot contain : | Old BullMQ queue names | Rebuild API image (FRESH_BUILD=true); use latest code with email-inbound queues |
UndefinedModuleException / QuarantineModule | Module circular dependency | Rebuild with latest forwardRef fixes in quarantine/queue modules |
API Restarting (1) | Nest crash on boot | install/install.sh logs; fix app error, rebuild — not an install.sh issue |
dependency failed to start: api unhealthy | API never listened on 3001 | Check logs above; migrations may have succeeded while API still crashes |
| Migration failed | DB not ready or schema error | docker compose run --rm db-migrate |
Web container did not become ready / web on 3001 | PORT=3001 leaked to web via .env.production | Use fixed release (web pinned to 3000); or docker compose up -d --force-recreate web |
Admin/dashboard API calls fail (undefined/admin/...) | NEXT_PUBLIC_API_URL not baked at web build | Rebuild web with fixed Dockerfile build args (FRESH_BUILD=true) |
Sign-in does nothing / dashboard shown without login / /api/auth/session 404 in console | nginx routed /api/auth/* to the backend instead of the web app | Fixed: nginx proxies only /api/v1/ to the API; /api/auth/* stays on the web app. Re-apply config from latest zip and sudo systemctl reload nginx |
ERROR: nginx is not proxying /api/health during install | Check hit full readiness (/health) which is 503 while ClamAV warms up | Fixed: /api/health now proxies to liveness (/health/live = DB + Redis). Use latest zip |
Missing .env.production when running install.sh | Old broken wrapper | Use single install/install.sh from latest zip |
Certbot / fullchain.pem missing | TLS nginx before certs, or DNS/wrong IP | DNS A must point to server; sudo bash install/setup-certbot.sh --verify-acme your.domain |
| Certbot renewal fails later | HTTP 80 redirected HTTPS without ACME exception | Fixed in coldfeet-tls.conf; run sudo certbot renew --dry-run |
sudo bash install/install.sh health then re-apply config:
cd ~/coldfeet
HOST=otpvalidateendpoints.com # your hostname
sed "s|__PUBLIC_HOSTNAME__|$HOST|g" install/nginx/coldfeet-tls-http.conf > /etc/nginx/sites-available/coldfeet
sudo ln -sf /etc/nginx/sites-available/coldfeet /etc/nginx/sites-enabled/coldfeet
sudo rm -f /etc/nginx/sites-enabled/default
sudo nginx -t && sudo systemctl reload nginx
sudo bash install/setup-certbot.sh "$HOST"
sed "s|__PUBLIC_HOSTNAME__|$HOST|g" install/nginx/coldfeet-tls.conf > /etc/nginx/sites-available/coldfeet
sudo nginx -t && sudo systemctl reload nginx
docker compose -f docker-compose.prod.yml run --rm db-migratebash install/bootstrap.sh .env.production