Nine containers, one published port.
A WarmHawk instance is a self-contained docker compose stack on your own server — every service except nginx sits on an internal-only Docker network, with zero assumptions about shared infrastructure on the host.
A WarmHawk instance runs nine docker compose services: nginx (the only one with a published port), the api and worker (Fastify + BullMQ, split so a slow send never blocks the API), postgres and redis (source of truth and the durable send queue), migrate (runs once), n8n (dispatch/reply-poll workflows), uptime-kuma (bundled monitoring), and certbot (renewal loop). Nothing but nginx is reachable from outside your server.
Every service, what it does
| Service | Role |
|---|---|
| nginx | The only container that publishes a host port (80/443). TLS termination, reverse proxy to api. |
| api | Fastify — the /v1 public API, plus internal-only /internal/* routes for n8n callbacks. |
| worker | BullMQ worker — picks up dispatch jobs from the queue and actually sends. |
| migrate | Runs Prisma migrations once on startup, then exits — api/worker wait for it to complete successfully. |
| postgres | Source of truth: leads, campaigns, mailboxes, domains, execution logs, replies. |
| redis | Backs the BullMQ dispatch queue — AOF-durable, not just a cache. See Backups & Redis durability. |
| n8n | Runs the dispatch and reply-poll workflows, calling api’s internal-only routes exclusively. |
| uptime-kuma | Bundled health/uptime monitoring for every service, on by default, free. |
| certbot | Certificate renewal loop (certbot renew every 12h) — see TLS & observability for the initial-issuance step. |
Only nginx publishes host ports (80/443). Every other service sits on an internal-only Docker network with no published port at all — there is no nginx location block for /internal/* either, so those routes are unreachable from outside the box even if you tried.
Reading logs when something won’t come up
API details (Tier 0 / self-hosters) · See every service's current state▸
docker compose psAPI details (Tier 0 / self-hosters) · Tail or follow logs for the failing service▸
docker compose logs --tail=200 api
docker compose logs -f workerA container in a restart loop: follow logs live rather than tailing once — you’ll catch the actual error right before each restart, not just the generic exit message.
The ports: concatenation gotcha
Docker Compose merges multiple -f files together, and for the ports: key specifically, that merge is a concatenation, not an override. Map a port for nginx in an overlay file and you don’t replace the base file’s mapping — you get both, bound at once.
API details (Tier 0 / self-hosters) · Wrong — adds a second binding, doesn't replace one▸
# docker-compose.override.yml
nginx:
ports:
- "8443:443"API details (Tier 0 / self-hosters) · Correct — override the variable, not the ports: block▸
# .env
HTTPS_PORT=8443Already stuck with duplicate bindings? docker compose config prints the fully-merged effective config so you can see exactly what’s bound before debugging further.
Resource limits
Every service carries cpus/memory limits (and reservations) sized for a modest single box — e.g. api: 1.0 CPU / 512M, worker: 0.5 CPU / 256M, postgres: 1.0 CPU / 768M. They exist to stop one runaway container from starving everything else on your box; it is not a multi-tenant isolation mechanism, since every install is already single-tenant. Running high volume on a larger box: raise these (don’t remove them) in your own overlay targeting just the deploy.resources block, which merges safely since it isn’t subject to the ports: concatenation behavior above.
Checking container health
API details (Tier 0 / self-hosters) · Health status at a glance▸
docker compose ps
# NAME STATUS
# warmhawk-nginx Up 2 hours
# warmhawk-api Up 2 hours (healthy)
# warmhawk-worker Up 2 hours (healthy)
# warmhawk-postgres Up 2 hours (healthy)
# warmhawk-redis Up 2 hours (healthy)A service stuck at (unhealthy) rather than crash-looping is usually still running but failing its own internal check — check that service’s logs specifically, since the process itself hasn’t necessarily exited. For the bundled uptime/alerting layer watching these same health checks continuously, see TLS & observability.
A fresh install or an update failing partway through has its own dedicated walkthroughs: install.sh troubleshooting and warmhawk update failures.
Questions
Architecture: questions worth answering up front
Which services are reachable from outside the server?+
Only nginx, on 80/443. Every other service (api, worker, postgres, redis, n8n, uptime-kuma) sits on an internal-only Docker network with no published host port — a customer’s own server has no shared edge proxy, so this package has to be a fully self-contained, zero-assumption unit.
Why is there both an "api" and a "worker" service?+
api serves the /v1 HTTP surface; worker is a separate BullMQ consumer process that actually performs sends pulled off the queue. Splitting them means a slow/stuck send never blocks the API from answering a request.
Why is my container listening on a port I did not expect?+
You are almost certainly running an overlay -f file that maps a port already mapped in the base file. Compose concatenates ports: entries across files rather than overriding them, so both mappings end up bound at once — use the base file’s ${VAR:-default} pattern and override the variable in .env instead of adding a second ports: entry.
What are the cpus/memory limits in the compose file for?+
They protect the single box the instance runs on from one runaway container (a worker stuck in a retry loop, for example) starving everything else — not multi-tenant isolation, since every install is already single-tenant by design.