WarmHawk
Docs / Self-hosting / Architecture

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

ServiceRole
nginxThe only container that publishes a host port (80/443). TLS termination, reverse proxy to api.
apiFastify — the /v1 public API, plus internal-only /internal/* routes for n8n callbacks.
workerBullMQ worker — picks up dispatch jobs from the queue and actually sends.
migrateRuns Prisma migrations once on startup, then exits — api/worker wait for it to complete successfully.
postgresSource of truth: leads, campaigns, mailboxes, domains, execution logs, replies.
redisBacks the BullMQ dispatch queue — AOF-durable, not just a cache. See Backups & Redis durability.
n8nRuns the dispatch and reply-poll workflows, calling api’s internal-only routes exclusively.
uptime-kumaBundled health/uptime monitoring for every service, on by default, free.
certbotCertificate 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 ps
API details (Tier 0 / self-hosters) · Tail or follow logs for the failing service▸
docker compose logs --tail=200 api
docker compose logs -f worker

A 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=8443

Already 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.