Posthawk
Self-Hosting Guide

Deploy on your
own infrastructure

Run Posthawk on your own servers with Docker. Full control over your data, no vendor lock-in, no per-email fees. Everything unlocked, no limits.

Get Started
Docker

Docker & Docker Compose

Supabase

Free tier or self-hosted

AWS SES

Verified domain

2GB

RAM minimum

Architecture

Four containers,
one stack

Web

:3000

Next.js dashboard for managing emails, templates, and settings.

Worker

:3001

NestJS API that processes email jobs, queues, and webhook delivery.

Redis

:6379

BullMQ job queue and rate limiting. Persisted with appendonly.

SMTP

:25

Inbound email receiver. Optional — only needed for receiving mail.

All containers connect to Supabase (PostgreSQL + Auth) and AWS SES (email delivery) as external services.

Step by step

Up and running
in 10 minutes

Six steps from zero to a fully deployed email infrastructure on your own servers.

01

Create a Supabase project

Go to supabase.com and create a free project. Note down the Project URL, anon key, service_role key, and JWT secret from Settings > API.

Tip: Alternatively, self-host Supabase using their Docker guide for full control over your database.

02

Apply database schema

Go to SQL Editor in your Supabase dashboard. Copy the contents of supabase/migrations/20240101000000_initial_schema.sql and run it. This creates all 14 tables, RLS policies, functions, and indexes.

Tip: You can also use the Supabase CLI: supabase link --project-ref YOUR_REF && supabase db push

03

Configure environment

Create a docker-compose.yml and .env file with your Supabase keys, Redis password, AWS SES credentials, and domain settings.

04

Start Posthawk

Run docker-compose up -d to pull images and start all services. Verify everything is healthy with docker-compose ps.

05

Set up reverse proxy

Put Posthawk behind Nginx or Caddy with SSL. You need two domains: one for the web dashboard and one for the worker API.

Tip: Use certbot for free SSL: sudo certbot --nginx -d yourdomain.com -d api.yourdomain.com

06

Configure AWS SES

Create an IAM user with SES permissions (ses:SendEmail, ses:SendRawEmail, ses:GetAccount, ses:GetSendQuota). Verify your sending domain and request production access if in the SES sandbox.

Docker Compose

Your docker-compose.yml

Create this file in your project directory. It defines all four services with health checks, restart policies, and internal networking.

docker-compose.yml
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}      - JWT_SECRET=${JWT_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    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: 3volumes:  redis-data:
Configuration

Environment
variables

Create a .env file next to your docker-compose.yml with these values. Generate strong passwords with openssl rand -hex 32.

.env
# Supabase (Required)SUPABASE_URL=https://your-project-id.supabase.coSUPABASE_ANON_KEY=your-anon-keySUPABASE_SERVICE_ROLE_KEY=your-service-role-keyJWT_SECRET=your-jwt-secret# RedisREDIS_PASSWORD=your-secure-redis-password# DomainsWEB_DOMAIN=posthawk.yourdomain.comAPI_DOMAIN=api.yourdomain.com# AWS SES (Required)AWS_ACCESS_KEY_ID=your-aws-access-key-idAWS_SECRET_ACCESS_KEY=your-aws-secret-access-keyAWS_REGION=us-east-1# SMTP (Optional - for inbound email)SMTP_HOSTNAME=mail.yourdomain.com
Reference

All variables

VariableDescriptionRequired
SUPABASE_URLYour Supabase project URLRequired
SUPABASE_ANON_KEYSupabase anonymous/public keyRequired
SUPABASE_SERVICE_ROLE_KEYSupabase service role key (secret)Required
JWT_SECRETSupabase JWT secret for worker authRequired
REDIS_PASSWORDPassword for the Redis instanceRequired
AWS_ACCESS_KEY_IDAWS IAM access key for SESRequired
AWS_SECRET_ACCESS_KEYAWS IAM secret key for SESRequired
AWS_REGIONAWS region for SESRequired
WEB_DOMAINDomain for the web dashboardRequired
API_DOMAINDomain for the worker APIRequired
SMTP_HOSTNAMEHostname for inbound SMTPOptional
Production

Nginx reverse
proxy with SSL

Put Posthawk behind Nginx with TLS termination. You need two subdomains: one for the dashboard and one for the API.

sudo certbot --nginx -d yourdomain.com -d api.yourdomain.com
nginx.conf
# Web Dashboardserver {    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 APIserver {    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 redirectserver {    listen 80;    server_name yourdomain.com api.yourdomain.com;    return 301 https://$server_name$request_uri;}
Operations

Common commands

View logs

docker-compose logs -f worker

Restart a service

docker-compose restart worker

Stop everything

docker-compose down

Update images

docker-compose pull && docker-compose up -d

Backup Redis

docker-compose exec redis redis-cli -a $REDIS_PASSWORD BGSAVE

Health check

curl http://localhost:3000/api/health

Deploy with Dokploy

Dokploy is a self-hosted PaaS with automatic SSL, monitoring, and a web UI. Create a project, add a Compose service, paste your docker-compose.yml, set your env vars, and deploy. Dokploy handles SSL and domains automatically.

Updating

Pull the latest images and restart. Your data persists in Redis volumes and Supabase. No migration scripts needed for minor updates.

docker-compose pull && docker-compose up -d
Troubleshooting

Common issues

Services won't start

Check docker-compose logs for errors. Ensure all environment variables are set.

Can't connect to Supabase

Verify URL and keys are correct. Check that your server IP isn't blocked by Supabase.

Redis connection refused

Run docker-compose ps redis to check status. Verify REDIS_PASSWORD matches.

Emails not sending

Verify AWS SES credentials, domain verification status, and that you're out of the SES sandbox.

Port 25 blocked

Many cloud providers block port 25 by default. Use a VPS provider or request an unblock from your host.

Worker health check failing

Check docker-compose logs worker for startup errors. Ensure Supabase URL and service role key are correct.

Personal Recommendation

Where to host

We personally run Posthawk on Hetzner. Great performance, fair pricing, and excellent European data centers. Not sponsored — just a genuine recommendation from our own experience.

Hetzner|Cloud servers from €3.79/mo

A CX22 instance (2 vCPU, 4GB RAM) is more than enough to run the full Posthawk stack.

Use strong passwords for Redis and JWT secrets

Enable SSL/TLS with a reverse proxy

Only expose ports 80/443 publicly

Keep Docker images updated regularly

Set up automated Supabase backups

Ready to deploy?

Clone the repo, configure your environment, and start sending emails from your own infrastructure.

Cookie Preferences

We use analytics cookies to understand how you use our site and improve your experience. Privacy Policy