Add Docker Compose configuration for Warmbox with Nginx Proxy Manager integration. Introduce new npm-specific compose file and update existing services to utilize Docker networks without host port exposure. Enhance documentation for deployment instructions and clarify setup for Cloudflare Tunnel and Nginx Proxy Manager.

This commit is contained in:
Jonny Nguyen 2026-06-26 12:32:42 -07:00
parent cc355b32d5
commit f98cc61cf7
5 changed files with 247 additions and 151 deletions

View file

@ -1,7 +1,7 @@
# Replace warmbox.yourdomain.com with your hostname. # Replace warmbox.yourdomain.com with your hostname (e.g. warmbox.jonnyn.com).
# Caddy obtains Let's Encrypt certificates automatically. # Only use this profile if Nginx Proxy Manager is NOT already bound to 80/443.
warmbox.yourdomain.com { warmbox.jonnyn.com {
encode gzip encode gzip
reverse_proxy warmbox:3000 reverse_proxy warmbox:3000
} }

14
docker-compose.npm.yml Normal file
View file

@ -0,0 +1,14 @@
# Use with: docker compose -f docker-compose.yml -f docker-compose.npm.yml up -d --build
#
# Attaches Warmbox to your existing Nginx Proxy Manager network so NPM can proxy
# warmbox.jonnyn.com → http://warmbox:3000
services:
warmbox:
networks:
- warmbox_internal
- npm_network
networks:
npm_network:
external: true

View file

@ -9,19 +9,32 @@ services:
NODE_ENV: production NODE_ENV: production
volumes: volumes:
- warmbox-data:/data - warmbox-data:/data
ports: # No host ports — reach Warmbox via Docker network only (NPM or Cloudflare Tunnel).
# Bind to localhost only when using the Caddy profile (recommended). # For LAN debugging only, temporarily add: ports: ["127.0.0.1:3001:3000"]
# For LAN testing without Caddy, change to "3000:3000".
- "127.0.0.1:3000:3000"
healthcheck: healthcheck:
test: ['CMD', 'node', '-e', "fetch('http://127.0.0.1:3000/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"] test: ['CMD', 'node', '-e', "fetch('http://127.0.0.1:3000/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
interval: 30s interval: 30s
timeout: 5s timeout: 5s
retries: 3 retries: 3
start_period: 40s start_period: 40s
networks:
- warmbox_internal
# Optional HTTPS reverse proxy — requires a public DNS name pointing at this host. # Cloudflare Tunnel connector — shares warmbox_internal with the app.
# Start with: docker compose --profile caddy up -d cloudflared:
profiles: [cloudflare]
image: cloudflare/cloudflared:latest
restart: unless-stopped
depends_on:
warmbox:
condition: service_healthy
command: tunnel run
environment:
TUNNEL_TOKEN: ${CLOUDFLARE_TUNNEL_TOKEN}
networks:
- warmbox_internal
# Standalone Caddy — only if you do NOT already run Nginx Proxy Manager on 80/443.
caddy: caddy:
profiles: [caddy] profiles: [caddy]
image: caddy:2-alpine image: caddy:2-alpine
@ -36,23 +49,14 @@ services:
- ./deploy/Caddyfile:/etc/caddy/Caddyfile:ro - ./deploy/Caddyfile:/etc/caddy/Caddyfile:ro
- caddy-data:/data - caddy-data:/data
- caddy-config:/config - caddy-config:/config
networks:
# Optional Cloudflare Tunnel — HTTPS without opening router ports (great for home servers). - warmbox_internal
# 1. Create a tunnel in Cloudflare Zero Trust → copy token
# 2. Set CLOUDFLARE_TUNNEL_TOKEN in .env
# 3. docker compose --profile cloudflare up -d
cloudflared:
profiles: [cloudflare]
image: cloudflare/cloudflared:latest
restart: unless-stopped
depends_on:
warmbox:
condition: service_healthy
command: tunnel run
environment:
TUNNEL_TOKEN: ${CLOUDFLARE_TUNNEL_TOKEN}
volumes: volumes:
warmbox-data: warmbox-data:
caddy-data: caddy-data:
caddy-config: caddy-config:
networks:
warmbox_internal:
name: warmbox_internal

View file

@ -6,186 +6,261 @@ Warmbox is a **long-running worker** (cron dispatcher, rescuer, SMTP/IMAP). It n
- Outbound SMTP/IMAP from the server - Outbound SMTP/IMAP from the server
- A public HTTPS URL for the dashboard and OAuth callbacks - A public HTTPS URL for the dashboard and OAuth callbacks
**Recommended for your case:** Ubuntu home server + Docker + **Cloudflare Tunnel** (free HTTPS, no router port forwarding). **Not suitable:** Netlify, GitHub Pages, Vercel — no background jobs, no SQLite, no mail sockets.
**Not suitable:** Netlify, GitHub Pages, Vercel static — no background jobs, no SQLite, no mail sockets.
--- ---
## Quick comparison ## Your setup (`warmbox.jonnyn.com`)
| Option | Best for | Pros | Cons | You already have:
|--------|----------|------|------|
| **Ubuntu + Docker + Cloudflare Tunnel** | Home server (your preference) | Free, persistent, full control | You maintain the box | - DNS **A** record → home public IP
| Ubuntu + Docker + Caddy | Home server with public IP + domain | Simple TLS | Open ports 80/443 on router | - **Nginx Proxy Manager** (NPM) on Docker network `npm_network`
| Hetzner / DigitalOcean VPS + same Docker | No home server | Always on, static IP | ~$56/mo |
| Railway / Fly.io | Managed container | Easy deploy | Cost, less control for 30-day test | **Recommended for you: Option B — Nginx Proxy Manager** (simplest; reuses what you have).
Use **Option A — Cloudflare Tunnel** only if you want HTTPS without opening ports 80/443, or if your ISP blocks inbound traffic.
Do **not** run Caddy (Option C) alongside NPM — both want ports 80 and 443.
--- ---
## 1. Prepare the Ubuntu server ## 1. Prepare the server
```bash
# SSH into your home server
sudo apt update && sudo apt upgrade -y
sudo apt install -y git docker.io docker-compose-v2
sudo usermod -aG docker $USER
# log out and back in so docker group applies
```
Clone the repo:
```bash
git clone https://github.com/YOUR_ORG/warmbox.git
cd warmbox
```
---
## 2. Configure environment
```bash ```bash
cd ~/warmbox # or wherever you cloned the repo
cp .env.example .env cp .env.example .env
nano .env nano .env
``` ```
Set these **before first start**: Required in `.env`:
```bash ```bash
# Generate once — keep forever (changing it breaks encrypted mailbox passwords) ENCRYPTION_KEY=<64 hex chars openssl rand -hex 32>
openssl rand -hex 32 # → ENCRYPTION_KEY API_KEY=<openssl rand -hex 24>
PUBLIC_BASE_URL=https://warmbox.jonnyn.com
# Dashboard login password AI_API_KEY=sk-or-v1-...
openssl rand -hex 24 # → API_KEY
PUBLIC_BASE_URL=https://warmbox.yourdomain.com # your real public URL
AI_API_KEY=sk-or-v1-... # OpenRouter or compatible
``` ```
**Migrating from local:** copy your existing `prisma/dev.db` to the server **and** use the **same** `ENCRYPTION_KEY` from your local `.env`. Otherwise mailbox credentials cannot be decrypted. Keep `ENCRYPTION_KEY` forever. If migrating from your Mac, copy the same key and `prisma/dev.db`.
--- ---
## 3. Choose how the world reaches your server ## 2. Fix: “port 3000 already allocated”
### Option A — Cloudflare Tunnel (recommended for home) Warmbox listens on **port 3000 inside its container**. That does **not** conflict with `homepage` using host port `3000` — unless you publish Warmbox to the host with a `ports:` mapping.
No port forwarding. Works behind CGNAT. **NPM and Cloudflare Tunnel only need Docker network access** (`warmbox:3000`). No host port is required.
1. [Cloudflare Zero Trust](https://one.dash.cloudflare.com/) → **Networks****Tunnels** → Create tunnel ### Verify your compose file on the server
2. Name it `warmbox`, install connector via **Docker** — copy the token
3. In the tunnel **Public Hostname** tab, route `warmbox.yourdomain.com``http://warmbox:3000` (Docker network)
If cloudflared runs in the same compose file, use hostname `warmbox` and port `3000`.
4. Add to `.env`:
```bash ```bash
CLOUDFLARE_TUNNEL_TOKEN=eyJh... cd /srv/docker-configs/warmbox
PUBLIC_BASE_URL=https://warmbox.yourdomain.com grep -n ports docker-compose.yml docker-compose.npm.yml 2>/dev/null
docker compose -f docker-compose.yml -f docker-compose.npm.yml config | grep -A2 published
``` ```
5. Start: - **Correct:** no `published` / `ports` for the `warmbox` service (empty grep for warmbox ports).
- **Wrong:** lines like `"3000:3000"` or `"127.0.0.1:3000:3000"` under `warmbox`**delete the entire `ports:` block** for that service.
Pull the latest repo (or remove `ports:` manually), then:
```bash
docker compose -f docker-compose.yml -f docker-compose.npm.yml down
docker compose -f docker-compose.yml -f docker-compose.npm.yml up -d --build
```
### Optional: direct LAN access on a different host port
Only if you want to hit Warmbox without NPM (debugging). This uses host **3001**, not 3000:
```yaml
ports:
- "127.0.0.1:3001:3000"
```
NPM still uses forward port **3000** (container port), not 3001.
### Find what uses host port 3000
```bash
docker ps --format '{{.Names}} {{.Ports}}' | grep '0.0.0.0:3000'
```
In your case `homepage` owns host 3000 — leave it alone; Warmbox does not need that port.
---
## Option B — Nginx Proxy Manager (recommended for your home server)
Traffic path:
```text
Internet → warmbox.jonnyn.com (DNS A → home IP) → NPM :443 → warmbox:3000 (Docker)
```
### Step 1 — Start Warmbox on `npm_network`
```bash
docker compose -f docker-compose.yml -f docker-compose.npm.yml up -d --build
```
Verify:
```bash
docker compose -f docker-compose.yml -f docker-compose.npm.yml ps
docker compose -f docker-compose.yml -f docker-compose.npm.yml logs -f warmbox
curl -s http://warmbox:3000/health # run from any container on npm_network, or skip
```
### Step 2 — NPM proxy host
In Nginx Proxy Manager UI:
| Field | Value |
|--------|--------|
| Domain names | `warmbox.jonnyn.com` |
| Scheme | `http` |
| Forward hostname / IP | `warmbox` |
| Forward port | `3000` (container port — not your host port 3000 used by homepage) |
| Cache assets | off |
| Block common exploits | on |
| Websockets support | on |
**SSL** tab:
- Request a new SSL Certificate (Let's Encrypt)
- Force SSL + HTTP/2
If you use **Cloudflare orange cloud** (proxied) on the A record, set Cloudflare SSL mode to **Full** or **Full (strict)** so NPMs certificate works.
### Step 3 — Login
1. Open `https://warmbox.jonnyn.com`
2. API key = your `API_KEY` from `.env`
3. Leave **Base URL** blank
### Step 4 — OAuth redirect URIs
| Integration | Redirect URI |
|-------------|----------------|
| Google Postmaster | `https://warmbox.jonnyn.com/api/integrations/google/callback` |
| Microsoft Outlook | `https://warmbox.jonnyn.com/api/integrations/microsoft/callback` |
---
## Option A — Cloudflare Tunnel
Use this if you prefer **no inbound ports** on your router (tunnel only).
Traffic path:
```text
Browser → Cloudflare edge → cloudflared (Docker) → warmbox:3000
```
### Important DNS note
For tunnel-only access, let the **tunnel** own the hostname. In Cloudflare Zero Trust → your tunnel → **Public Hostname**:
| Field | Value |
|--------|--------|
| Subdomain | `warmbox` |
| Domain | `jonnyn.com` |
| Type | HTTP |
| URL | `warmbox:3000` |
Cloudflare usually creates a CNAME for you. You can **remove the A record** for `warmbox` if everything goes through the tunnel (avoids split routing).
### Step 1 — `.env`
```bash
CLOUDFLARE_TUNNEL_TOKEN=eyJh... # from Zero Trust → Tunnels → your tunnel → Docker install
PUBLIC_BASE_URL=https://warmbox.jonnyn.com
```
### Step 2 — Start (no NPM override file)
```bash ```bash
docker compose --profile cloudflare up -d --build docker compose --profile cloudflare up -d --build
``` ```
### Option B — Caddy on a public IP `cloudflared` and `warmbox` share the `warmbox_internal` network — hostname `warmbox` resolves inside that network.
1. Point DNS `warmbox.yourdomain.com` → your home public IP ### Step 3 — Verify
2. Forward router ports **80** and **443** to the Ubuntu server
3. Edit `deploy/Caddyfile` with your domain ```bash
4. Start: docker compose --profile cloudflare ps
docker compose --profile cloudflare logs -f cloudflared
docker compose --profile cloudflare logs -f warmbox
curl -s https://warmbox.jonnyn.com/health
```
### Tunnel + NPM together?
Pick **one** public entry for `warmbox.jonnyn.com`:
- **NPM only:** A record → home IP, no tunnel hostname for the same subdomain.
- **Tunnel only:** tunnel hostname, no A record to home IP for that subdomain.
Running both for the same hostname causes confusing SSL or routing issues.
---
## Option C — Caddy (standalone, no NPM)
Only if NPM is **not** using 80/443 on this host.
1. Edit `deploy/Caddyfile` — set `warmbox.jonnyn.com`
2. DNS A record → home public IP; forward router 80/443 to the server
3. Start:
```bash ```bash
docker compose --profile caddy up -d --build docker compose --profile caddy up -d --build
``` ```
### Option C — LAN / direct port (testing only) **Skip this** if Nginx Proxy Manager is already on the server.
---
## Migrate local database (optional)
```bash ```bash
# In docker-compose.yml change ports to "3000:3000" # Mac
docker compose up -d --build
```
Use `http://SERVER_LAN_IP:3000` — not suitable for OAuth or remote access.
---
## 4. First login
1. Open `https://warmbox.yourdomain.com`
2. Login with your `API_KEY`
3. Leave **Base URL** blank (dashboard and API are same origin in production)
---
## 5. Update OAuth redirect URIs
Replace `localhost` with your public URL in:
| Integration | Redirect URI |
|-------------|----------------|
| Google Postmaster | `https://warmbox.yourdomain.com/api/integrations/google/callback` |
| Microsoft Outlook | `https://warmbox.yourdomain.com/api/integrations/microsoft/callback` |
Re-connect integrations in **Settings** after deploy.
---
## 6. Migrate local data (optional)
If you already configured mailboxes and campaigns locally:
```bash
# On your Mac — stop local dev server first
scp prisma/dev.db user@home-server:/tmp/warmbox.db scp prisma/dev.db user@home-server:/tmp/warmbox.db
# On Ubuntu — before first start OR with stack stopped: # Server — stack stopped
docker compose down docker compose -f docker-compose.yml -f docker-compose.npm.yml down
docker volume create warmbox_warmbox-data 2>/dev/null || true
docker run --rm -v warmbox_warmbox-data:/data -v /tmp:/backup alpine \ docker run --rm -v warmbox_warmbox-data:/data -v /tmp:/backup alpine \
cp /backup/warmbox.db /data/warmbox.db cp /backup/warmbox.db /data/warmbox.db
docker compose --profile cloudflare up -d docker compose -f docker-compose.yml -f docker-compose.npm.yml up -d
``` ```
Copy the **same** `ENCRYPTION_KEY` and `API_KEY` (or set a new API_KEY and use that to log in). Use the **same** `ENCRYPTION_KEY` as on your Mac.
--- ---
## 7. Operations ## Day-to-day commands
**NPM deployment:**
```bash ```bash
# Logs docker compose -f docker-compose.yml -f docker-compose.npm.yml logs -f warmbox
docker compose logs -f warmbox docker compose -f docker-compose.yml -f docker-compose.npm.yml up -d --build
# Restart after code pull
git pull
docker compose --profile cloudflare up -d --build
# Backup database (run daily via cron)
docker compose exec warmbox sh -c 'cp /data/warmbox.db /data/warmbox-$(date +%F).db'
# Health check
curl -s https://warmbox.yourdomain.com/health
``` ```
### 30-day warmup checklist **Cloudflare deployment:**
- [ ] Container `restart: unless-stopped` (default in compose) ```bash
- [ ] `warmbox-data` volume backed up docker compose --profile cloudflare logs -f warmbox
- [ ] `ENCRYPTION_KEY` saved in your password manager docker compose --profile cloudflare up -d --build
- [ ] Campaign started and status **active** ```
- [ ] Settings → Jobs shows dispatcher/rescuer running (or check logs every few hours day 1)
- [ ] OAuth redirect URIs updated if using Postmaster / Microsoft
--- **Backup:**
## 8. Security notes (single-tenant MVP) ```bash
docker compose exec warmbox sh -c 'cp /data/warmbox.db /data/warmbox-$(date +%F).db'
- **Always set `API_KEY`** when exposed to the internet. ```
- Do not commit `.env` to git.
- Restrict dashboard access further with Cloudflare Access (optional) or VPN if desired.
- Mailbox app passwords live encrypted in SQLite — protect the volume and `ENCRYPTION_KEY`.
--- ---
@ -193,10 +268,11 @@ curl -s https://warmbox.yourdomain.com/health
| Symptom | Fix | | Symptom | Fix |
|---------|-----| |---------|-----|
| Login fails | Check `API_KEY` in `.env` matches what you enter; restart container after `.env` change | | `port 3000 already allocated` | Use updated compose (no host ports); `docker compose down`; find conflict with `docker ps \| grep 3000` |
| OAuth redirect error | `PUBLIC_BASE_URL` must match browser URL; update provider redirect URI | | NPM 502 Bad Gateway | Warmbox not on `npm_network`; wrong forward host (`warmbox` not `localhost`); check `docker compose logs warmbox` |
| Jobs not sending | `docker compose logs warmbox` — verify cron lines; confirm SMTP/IMAP from server IP isn't blocked | | Tunnel 502 / error | Public hostname URL must be `http://warmbox:3000`; both containers on `warmbox_internal`; check `logs cloudflared` |
| `ENCRYPTION_KEY` error on decrypt | Wrong key vs database — restore original key or re-add mailboxes | | Login fails | `API_KEY` in `.env` must match login; restart after `.env` change |
| Container unhealthy | `docker compose logs warmbox` — migration or port conflict | | OAuth redirect error | `PUBLIC_BASE_URL=https://warmbox.jonnyn.com` exactly; update Google/Microsoft redirect URIs |
| SSL errors with Cloudflare proxy | Cloudflare SSL mode **Full** or **Full (strict)** when NPM terminates TLS |
See also [self-host-hardening.md](./self-host-hardening.md) for operator workflows. See also [self-host-hardening.md](./self-host-hardening.md).

View file

@ -12,6 +12,8 @@
"build": "npm run build -w @warmbox/shared && npm run build -w @warmbox/api && npm run build -w @warmbox/web", "build": "npm run build -w @warmbox/shared && npm run build -w @warmbox/api && npm run build -w @warmbox/web",
"start": "npm run start -w @warmbox/api", "start": "npm run start -w @warmbox/api",
"docker:up": "docker compose up -d --build", "docker:up": "docker compose up -d --build",
"docker:up:npm": "docker compose -f docker-compose.yml -f docker-compose.npm.yml up -d --build",
"docker:up:cloudflare": "docker compose --profile cloudflare up -d --build",
"docker:logs": "docker compose logs -f warmbox", "docker:logs": "docker compose logs -f warmbox",
"db:migrate": "prisma migrate dev", "db:migrate": "prisma migrate dev",
"db:generate": "prisma generate", "db:generate": "prisma generate",