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.
Docker & Docker Compose
Free tier or self-hosted
Verified domain
RAM minimum
Next.js dashboard for managing emails, templates, and settings.
NestJS API that processes email jobs, queues, and webhook delivery.
BullMQ job queue and rate limiting. Persisted with appendonly.
Inbound email receiver. Optional — only needed for receiving mail.
All containers connect to Supabase (PostgreSQL + Auth) and AWS SES (email delivery) as external services.
Six steps from zero to a fully deployed email infrastructure on your own servers.
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.
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
Create a docker-compose.yml and .env file with your Supabase keys, Redis password, AWS SES credentials, and domain settings.
Run docker-compose up -d to pull images and start all services. Verify everything is healthy with docker-compose ps.
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
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.
Create this file in your project directory. It defines all four services with health checks, restart policies, and internal networking.
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:Create a .env file next to your docker-compose.yml with these values. Generate strong passwords with openssl rand -hex 32.
# 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.comPut 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# 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;}docker-compose logs -f workerdocker-compose restart workerdocker-compose downdocker-compose pull && docker-compose up -ddocker-compose exec redis redis-cli -a $REDIS_PASSWORD BGSAVEcurl http://localhost:3000/api/healthDokploy 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.
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 -dCheck docker-compose logs for errors. Ensure all environment variables are set.
Verify URL and keys are correct. Check that your server IP isn't blocked by Supabase.
Run docker-compose ps redis to check status. Verify REDIS_PASSWORD matches.
Verify AWS SES credentials, domain verification status, and that you're out of the SES sandbox.
Many cloud providers block port 25 by default. Use a VPS provider or request an unblock from your host.
Check docker-compose logs worker for startup errors. Ensure Supabase URL and service role key are correct.
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.
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
Clone the repo, configure your environment, and start sending emails from your own infrastructure.
We use analytics cookies to understand how you use our site and improve your experience. Privacy Policy