# Posthawk — Self-Hosting Guide

Run Posthawk on your own infrastructure with Docker Compose. The platform is **free forever** to self-host; you only pay AWS SES for the actual email volume (~$0.10 per 1,000 emails).

This page is the AI-agent-friendly view of <https://posthawk.dev/docs/self-hosting>.

---

## What you're deploying

Three containers + Redis + Supabase:

- **`posthawk-web`** — Next.js 16 dashboard (port 3000)
- **`posthawk-worker`** — NestJS API + BullMQ worker (port 3001, plus 587 for SMTP submission)
- **`posthawk-smtp-server`** — Inbound SMTP receiver (port 25, optional)
- **`redis`** — job queue + rate limiting (bundled in compose)
- **`supabase`** — Postgres + Auth + Storage (cloud or self-hosted; not bundled)

The web container only talks to the worker over HTTP and to Supabase. The worker handles all sending via AWS SES; you bring your own AWS account.

## Prerequisites

| What | Why |
| --- | --- |
| Docker + Docker Compose | Runtime for the three containers |
| A Supabase project | Free tier is fine — Postgres, Auth, Storage |
| AWS account with SES | Sending happens through your own SES — this is non-negotiable |
| A domain you control | At least one hostname for the dashboard, one for the worker API |
| 2 GB RAM minimum | More if you'll be doing 100k+ emails/month |
| Open egress to AWS SES | TCP 443 outbound to `email.<region>.amazonaws.com` |
| (Optional) Open inbound port 25 | Only if you want to receive email via the SMTP server |

> Many cloud providers block outbound port 25 by default. If you only send via SES API (recommended), this doesn't matter — SES uses HTTPS, not SMTP.

## Step 1 — Create a Supabase project

1. Sign up at <https://supabase.com> and create a new project (free tier is enough to start).
2. From **Settings → API**, copy:
   - Project URL (`SUPABASE_URL`)
   - `anon` key (`SUPABASE_ANON_KEY`)
   - `service_role` key (`SUPABASE_SERVICE_ROLE_KEY`) — keep this secret
   - JWT secret (`SUPABASE_JWT_SECRET`)

Self-hosting Supabase is also supported — see the official Supabase docker-compose if you want full sovereignty.

## Step 2 — Apply the database schema

Download the schema from **https://posthawk.dev/self-host/schema.sql**, then in Supabase **SQL Editor** paste the entire file and run it. This is the single consolidated bundle: ~40 tables, all RLS policies, RPC functions, triggers, and the three Storage buckets (avatars, template-images, contact-imports) with their policies. Verify with `SELECT count(*) FROM information_schema.tables WHERE table_schema = 'public';`.

Or via the Supabase CLI:

```bash
supabase link --project-ref YOUR_PROJECT_REF
supabase db push
```

## Step 3 — Configure your environment

Create `docker-compose.yml` and `.env` in a fresh directory.

### `.env`

```bash
# Supabase (required)
SUPABASE_URL=https://your-project-id.supabase.co
SUPABASE_ANON_KEY=your-anon-key
SUPABASE_SERVICE_ROLE_KEY=your-service-role-key
SUPABASE_JWT_SECRET=your-jwt-secret

# web↔worker server-to-server auth (required, same value in both; openssl rand -hex 32)
INTERNAL_API_SECRET=replace-with-64-char-hex

# Redis (bundled in docker-compose)
REDIS_PASSWORD=your-secure-redis-password

# Domains
WEB_DOMAIN=posthawk.yourdomain.com
API_DOMAIN=api.yourdomain.com

# AWS SES (required for sending)
AWS_ACCESS_KEY_ID=your-aws-access-key
AWS_SECRET_ACCESS_KEY=your-aws-secret-key
AWS_REGION=us-east-1

# Inbound SMTP (optional)
SMTP_HOSTNAME=mail.yourdomain.com
```

### `docker-compose.yml`

```yaml
version: "3.8"

services:
  redis:
    image: redis:7-alpine
    restart: unless-stopped
    command: redis-server --appendonly yes --requirepass ${REDIS_PASSWORD}
    volumes:
      - redis-data:/data
    healthcheck:
      test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"]
      interval: 5s
      timeout: 3s
      retries: 5

  worker:
    image: ghcr.io/posthawk/posthawk-worker:latest
    restart: unless-stopped
    depends_on:
      redis:
        condition: service_healthy
    environment:
      - NODE_ENV=production
      - PORT=3001
      - REDIS_HOST=redis
      - REDIS_PORT=6379
      - REDIS_PASSWORD=${REDIS_PASSWORD}
      - SUPABASE_URL=${SUPABASE_URL}
      - SUPABASE_SERVICE_ROLE_KEY=${SUPABASE_SERVICE_ROLE_KEY}
      - SUPABASE_JWT_SECRET=${SUPABASE_JWT_SECRET}
      - INTERNAL_API_SECRET=${INTERNAL_API_SECRET}
      - ALLOWED_ORIGINS=https://${WEB_DOMAIN}
      - AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID}
      - AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY}
      - AWS_REGION=${AWS_REGION:-us-east-1}
    healthcheck:
      test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3001/health"]
      interval: 30s
      timeout: 10s
      start_period: 10s
      retries: 3

  web:
    image: ghcr.io/posthawk/posthawk-web:latest
    restart: unless-stopped
    ports:
      - "3000:3000"
    depends_on:
      worker:
        condition: service_healthy
    environment:
      - NODE_ENV=production
      - NEXT_PUBLIC_SUPABASE_URL=${SUPABASE_URL}
      - NEXT_PUBLIC_SUPABASE_ANON_KEY=${SUPABASE_ANON_KEY}
      - NEXT_PUBLIC_WORKER_URL=https://${API_DOMAIN}
      - NEXT_PUBLIC_EDITION=self-hosted
      - WORKER_URL=http://worker:3001
      - SUPABASE_SERVICE_ROLE_KEY=${SUPABASE_SERVICE_ROLE_KEY}
      - INTERNAL_API_SECRET=${INTERNAL_API_SECRET}
    healthcheck:
      test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/api/health"]
      interval: 30s
      timeout: 10s
      start_period: 10s
      retries: 3

  # Optional: inbound email receiver
  smtp-server:
    image: ghcr.io/posthawk/posthawk-smtp-server:latest
    restart: unless-stopped
    ports:
      - "25:2525"
    depends_on:
      redis:
        condition: service_healthy
    environment:
      - NODE_ENV=production
      - SMTP_HOSTNAME=${SMTP_HOSTNAME:-mail.localhost}
      - SMTP_PORT=2525
      - REDIS_HOST=redis
      - REDIS_PORT=6379
      - REDIS_PASSWORD=${REDIS_PASSWORD}
      - SUPABASE_URL=${SUPABASE_URL}
      - SUPABASE_SERVICE_ROLE_KEY=${SUPABASE_SERVICE_ROLE_KEY}
    healthcheck:
      test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3002/health"]
      interval: 30s
      timeout: 10s
      start_period: 10s
      retries: 3

volumes:
  redis-data:
```

## Step 4 — Start the stack

```bash
docker compose pull
docker compose up -d
docker compose ps        # all services should report (healthy)
```

Open `http://localhost:3000` to confirm the dashboard loads. You'll be prompted to create the first user — that account becomes the workspace owner.

## Step 5 — Reverse proxy with TLS

Put Nginx (or Caddy / Traefik) in front of the web and worker so they're served over HTTPS at your domains.

### Nginx example

```nginx
# Web dashboard
server {
    listen 443 ssl http2;
    server_name yourdomain.com;

    ssl_certificate /etc/letsencrypt/live/yourdomain.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/yourdomain.com/privkey.pem;

    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_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_cache_bypass $http_upgrade;
    }
}

# Worker API
server {
    listen 443 ssl http2;
    server_name api.yourdomain.com;

    ssl_certificate /etc/letsencrypt/live/api.yourdomain.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/api.yourdomain.com/privkey.pem;

    location / {
        proxy_pass http://localhost:3001;
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

# HTTP -> HTTPS redirect
server {
    listen 80;
    server_name yourdomain.com api.yourdomain.com;
    return 301 https://$server_name$request_uri;
}
```

Let's Encrypt:

```bash
sudo certbot --nginx -d yourdomain.com -d api.yourdomain.com
```

## Step 6 — Configure AWS SES

Create an IAM user with the following minimum policy:

```json
{
  "Version": "2012-10-17",
  "Statement": [{
    "Effect": "Allow",
    "Action": [
      "ses:SendEmail",
      "ses:SendRawEmail",
      "ses:GetAccount",
      "ses:GetSendQuota",
      "ses:GetIdentityVerificationAttributes",
      "ses:VerifyDomainIdentity",
      "ses:VerifyDomainDkim",
      "ses:PutIdentityMailFromAttributes"
    ],
    "Resource": "*"
  }]
}
```

Then in the SES console:

1. Verify your sending domain (`from` addresses must use a verified domain).
2. Add the DKIM CNAMEs SES gives you to your DNS.
3. Set a custom `MAIL FROM` for SPF alignment.
4. Request production access (out of the SES sandbox) — without this you can only send to verified addresses.

In the Posthawk dashboard, Settings → Domains will let you add the same domain and walk you through DNS records (SPF, DKIM, DMARC, BIMI optional).

---

## Environment variable reference

| Variable | Required | Description | Example |
| --- | --- | --- | --- |
| `SUPABASE_URL` | yes | Project URL | `https://xxxxx.supabase.co` |
| `SUPABASE_ANON_KEY` | yes | Anon / public key | `eyJhbGci...` |
| `SUPABASE_SERVICE_ROLE_KEY` | yes | Service role key (secret) | `eyJhbGci...` |
| `SUPABASE_JWT_SECRET` | yes | Supabase JWT secret for worker auth | `...` |
| `INTERNAL_API_SECRET` | yes | Shared secret for web↔worker calls (same in both) | `64-char hex` |
| `REDIS_PASSWORD` | yes | Password for the bundled Redis | `a-strong-password` |
| `AWS_ACCESS_KEY_ID` | yes | IAM key with SES permissions | `AKIA...` |
| `AWS_SECRET_ACCESS_KEY` | yes | IAM secret | `...` |
| `AWS_REGION` | yes | SES region | `us-east-1` |
| `WEB_DOMAIN` | yes | Hostname of the dashboard | `posthawk.yourdomain.com` |
| `API_DOMAIN` | yes | Hostname of the worker API | `api.yourdomain.com` |
| `SMTP_HOSTNAME` | no | Hostname for inbound SMTP | `mail.yourdomain.com` |

---

## Common operations

```bash
# Tail worker logs
docker compose logs -f worker

# Restart one service
docker compose restart worker

# Stop everything (data persists in named volumes)
docker compose down

# Update to the latest images
docker compose pull && docker compose up -d

# Backup Redis on demand
docker compose exec redis redis-cli -a $REDIS_PASSWORD BGSAVE

# Check overall health
curl https://yourdomain.com/api/health
curl https://api.yourdomain.com/health
```

---

## Troubleshooting

| Symptom | What to check |
| --- | --- |
| Services won't start | `docker compose logs` for the failing service. Most often a missing env var or wrong Supabase URL. |
| "Can't connect to Supabase" | Project URL + keys are correct; your server's IP isn't blocked at the project level. |
| "Redis connection refused" | `docker compose ps redis` — verify it's healthy and `REDIS_PASSWORD` matches across all services. |
| Emails not sending | AWS SES creds are valid; sending domain is verified; you're out of the SES sandbox; the worker isn't seeing a circuit-breaker open due to recent throttling. |
| Port 25 inbound is blocked | Many providers block port 25. Switch provider or request an unblock — or skip the smtp-server container if you don't need inbound. |
| Worker health check failing | `docker compose logs worker` for startup errors. JWT secret mismatch with Supabase is the most common cause. |
| Dashboard 502 | Worker isn't healthy. The web container falls back gracefully but features depending on the worker (sending, automations) won't work. |

---

## Updating

Pinning to `:latest` makes upgrades a one-liner; pin to a specific tag (e.g. `:v1.4.2`) if you want determinism.

```bash
docker compose pull
docker compose up -d
```

Database schema lives in `supabase/self-host/schema.sql` — a single consolidated bundle. When upgrading, diff that file against your install (or re-paste relevant sections in the SQL Editor) before bouncing the worker.

---

## Architecture notes for AI agents

- `apps/web` is a Next.js 16 App Router app. Server actions and API routes call into Supabase + the worker.
- `apps/worker` is NestJS. It owns SES integration, BullMQ queues for sending / automations / contact-import, the inbound webhook receiver, and the Stripe billing integration (cloud only — feature-flagged via `NEXT_PUBLIC_EDITION`).
- `apps/smtp-server` is a standalone SMTP receiver for inbound. It writes parsed messages to `inbound_emails` then enqueues webhook deliveries.
- The platform is multi-tenant by `workspace_id` with row-level security. Self-hosted users still get the workspace abstraction; it's just that one user owns all the workspaces.
- Cloud-only features (Stripe, plan enforcement, AI assistant) are gated by `NEXT_PUBLIC_EDITION === 'cloud'` and won't render in self-hosted builds.

---

## Links

- Repo: distributed via private GHCR images, source on request — contact <support@posthawk.dev>
- API reference: <https://docs.posthawk.dev>
- Pricing: <https://posthawk.dev/pricing> (managed plans if you'd rather not run the infra)
- Managed deployment ($149/mo, I run it on your infra): <https://posthawk.dev/book>
