MeetNotify Bootstrap - Minimal Resource Architecture
Target Hardware: 2 VPS (4 cores, 2GB RAM each)
Monthly Cost: ~$20-40
Target Scale: 10K-100K events/day
Strategy: Start minimal, architect for growth
Reality Check
You have 2 VPS. That's it. But you want infrastructure-grade design.
The Strategy:
- Build with the RIGHT architecture principles
- Collapse services to fit hardware
- Use lightweight alternatives
- Design for easy scaling later
This is how REAL startups bootstrap. Not with Kubernetes and Kafka - with two servers and smart engineering.
Bootstrap Architecture
┌─────────────────────────────────────────────────────────────┐
│ VPS 1 (4 cores, 2GB) │
│ │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ Docker Compose Stack │ │
│ │ │ │
│ │ ┌──────────────┐ ┌──────────────┐ ┌─────────────┐ │ │
│ │ │ Nginx │ │ API Service │ │ PostgreSQL │ │ │
│ │ │ (Gateway) │ │ (Go) │ │ (11 GB) │ │ │
│ │ │ 80/443 │ │ Port 8080 │ │ │ │ │
│ │ └──────────────┘ └──────────────┘ └─────────────┘ │ │
│ │ │ │
│ │ ┌──────────────┐ ┌──────────────┐ │ │
│ │ │ Redis │ │ Worker Pool │ │ │
│ │ │ (Queue+Cache)│ │ (Go) │ │ │
│ │ │ 512 MB │ │ All channels│ │ │
│ │ └──────────────┘ └──────────────┘ │ │
│ └────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ VPS 2 (4 cores, 2GB) │
│ │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ Docker Compose Stack │ │
│ │ │ │
│ │ ┌──────────────┐ ┌──────────────┐ │ │
│ │ │ Worker Pool │ │ WebSocket │ │ │
│ │ │ (Go) │ │ Server │ │ │
│ │ │ Redundant │ │ (Go) │ │ │
│ │ └──────────────┘ └──────────────┘ │ │
│ │ │ │
│ │ ┌──────────────┐ ┌──────────────┐ │ │
│ │ │ Redis │ │ PostgreSQL │ │ │
│ │ │ (Replica) │ │ (Replica) │ │ │
│ │ │ │ │ Read-only │ │ │
│ │ └──────────────┘ └──────────────┘ │ │
│ └────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
Component Breakdown
VPS 1 (Primary)
1. Nginx (100 MB RAM)
- Reverse proxy
- TLS termination
- Rate limiting
- Load balancing
2. API Service (300 MB RAM)
- Single Go binary
- REST API endpoints
- Event validation
- Authentication
- Publishes to Redis queue
3. Worker Pool (400 MB RAM)
- Single Go binary with goroutines
- Consumes from Redis queues
- All channel delivery in one process
- Email, SMS, Push, Webhook, In-app workers
4. PostgreSQL (1 GB RAM)
- All persistent data
- Tenants, templates, subscriptions, preferences
- Delivery status (hot data only)
- Use connection pooling aggressively
5. Redis (200 MB RAM)
- Job queue (replaces Kafka)
- Cache layer
- Rate limit counters
- WebSocket pub/sub
VPS 2 (Secondary/Replica)
1. Worker Pool (600 MB RAM)
- Redundant workers
- Consume from same Redis queues
- Automatic failover if VPS 1 workers die
2. WebSocket Server (400 MB RAM)
- Real-time push notifications
- Connects to Redis pub/sub
- Scales independently
3. PostgreSQL Replica (800 MB RAM)
- Read-only replica
- Streaming replication from VPS 1
- Reduces load on primary
- Can promote to primary if VPS 1 dies
4. Redis Replica (200 MB RAM)
- Replica of VPS 1 Redis
- Read-only
- Automatic failover
Technology Stack (Lightweight)
| Component | Technology | Why |
|---|---|---|
| Language | Go | Low memory, high concurrency |
| API Framework | Fiber (or Chi) | Lightweight, fast |
| Queue | Redis Lists | Simple, reliable, no Kafka overhead |
| Database | PostgreSQL 15 | Proven, efficient, replication built-in |
| Cache | Redis | Dual purpose: queue + cache |
| Reverse Proxy | Nginx | Battle-tested, minimal resources |
| Orchestration | Docker Compose | Simple, no Kubernetes overhead |
| Monitoring | Prometheus + Node Exporter | Lightweight metrics |
What We're NOT Using:
- ❌ Kafka (too heavy, 3+ GB RAM minimum)
- ❌ Cassandra (too heavy, 4+ GB RAM per node)
- ❌ Kubernetes (overkill, management overhead)
- ❌ Separate microservices (too much overhead)
- ❌ ElasticSearch (too heavy)
Simplified Architecture
Flow: Event Publishing
Client
→ Nginx (TLS, rate limit)
→ API Service (validate, authenticate)
→ Redis LPUSH events:pending
→ Return 202 Accepted
Flow: Event Processing
Worker Pool (goroutine pool)
→ Redis BRPOP events:pending (blocking)
→ Resolve recipients (from DB/cache)
→ Apply preferences (from DB/cache)
→ Render template (from DB/cache)
→ LPUSH to channel-specific queue:
- email:pending
- sms:pending
- push:pending
- webhook:pending
- inapp:pending
Flow: Channel Delivery
Channel Worker (goroutine in same process)
→ BRPOP channel:pending
→ Call external provider (SendGrid, Twilio, etc)
→ Update delivery status in DB
→ On failure: ZADD channel:retry (sorted set by retry time)
→ Retry worker: ZRANGEBYSCORE (get ready to retry)
Data Models
PostgreSQL Schema (Simplified)
-- Tenants (multi-tenancy)
CREATE TABLE tenants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(255) NOT NULL,
api_key VARCHAR(255) UNIQUE NOT NULL,
api_secret_hash VARCHAR(255) NOT NULL,
status VARCHAR(50) DEFAULT 'active',
-- Rate limits
events_per_minute INT DEFAULT 100,
events_per_day INT DEFAULT 10000,
-- Quotas
email_quota_monthly INT DEFAULT 5000,
sms_quota_monthly INT DEFAULT 500,
created_at TIMESTAMP DEFAULT NOW()
);
-- Events (lightweight audit log, 7-day retention)
CREATE TABLE events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID REFERENCES tenants(id),
event_type VARCHAR(100) NOT NULL,
user_id VARCHAR(255),
data JSONB,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX idx_events_created ON events(created_at);
-- Auto-delete after 7 days with cron job
-- Deliveries (hot data only, move to archive after 30 days)
CREATE TABLE deliveries (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
event_id UUID REFERENCES events(id),
tenant_id UUID REFERENCES tenants(id),
user_id VARCHAR(255),
channel VARCHAR(50) NOT NULL,
status VARCHAR(50) DEFAULT 'pending',
attempt_count INT DEFAULT 0,
last_error TEXT,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX idx_deliveries_status ON deliveries(status, created_at);
CREATE INDEX idx_deliveries_tenant_user ON deliveries(tenant_id, user_id);
-- Templates
CREATE TABLE templates (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID REFERENCES tenants(id),
name VARCHAR(255) NOT NULL,
channel VARCHAR(50) NOT NULL,
subject TEXT,
body TEXT NOT NULL,
active BOOLEAN DEFAULT true,
created_at TIMESTAMP DEFAULT NOW(),
UNIQUE(tenant_id, name, channel)
);
-- User Preferences (simple version)
CREATE TABLE preferences (
tenant_id UUID REFERENCES tenants(id),
user_id VARCHAR(255),
email_enabled BOOLEAN DEFAULT true,
sms_enabled BOOLEAN DEFAULT false,
push_enabled BOOLEAN DEFAULT true,
quiet_hours_start TIME,
quiet_hours_end TIME,
timezone VARCHAR(50),
updated_at TIMESTAMP DEFAULT NOW(),
PRIMARY KEY(tenant_id, user_id)
);
-- Subscriptions (pub/sub)
CREATE TABLE subscriptions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID REFERENCES tenants(id),
topic_pattern VARCHAR(255) NOT NULL,
endpoint TEXT NOT NULL,
status VARCHAR(50) DEFAULT 'active',
created_at TIMESTAMP DEFAULT NOW()
);
-- Device tokens (push notifications)
CREATE TABLE device_tokens (
tenant_id UUID REFERENCES tenants(id),
user_id VARCHAR(255),
platform VARCHAR(20) NOT NULL,
token TEXT NOT NULL,
active BOOLEAN DEFAULT true,
created_at TIMESTAMP DEFAULT NOW(),
PRIMARY KEY(tenant_id, user_id, token)
);
Redis Data Structures
# Job Queues (Lists)
events:pending # Incoming events
email:pending # Email delivery tasks
sms:pending # SMS delivery tasks
push:pending # Push delivery tasks
webhook:pending # Webhook delivery tasks
inapp:pending # In-app delivery tasks
# Retry Queues (Sorted Sets - score = unix timestamp when to retry)
email:retry # ZADD email:retry 1642262400 {payload}
sms:retry
push:retry
webhook:retry
# Rate Limiting (Strings with TTL)
ratelimit:tenant:{id}:minute # INCR, EXPIRE 60
ratelimit:tenant:{id}:day # INCR, EXPIRE 86400
# Caching (Strings/Hashes)
cache:preference:{tenant}:{user} # TTL 3600 (1 hour)
cache:template:{tenant}:{name} # TTL 86400 (24 hours)
# Pub/Sub (for WebSocket coordination)
pubsub:notifications:{user_id} # PUBLISH for in-app
Memory Budget Breakdown
VPS 1 (2048 MB total)
| Component | Memory | Purpose |
|---|---|---|
| OS + System | 200 MB | Ubuntu/Debian base |
| Nginx | 100 MB | Reverse proxy |
| API Service | 300 MB | Event ingestion |
| Worker Pool | 400 MB | All channel workers |
| PostgreSQL | 900 MB | Primary database |
| Redis | 200 MB | Queue + Cache |
| Total | 2100 MB | Slightly over, but workable |
VPS 2 (2048 MB total)
| Component | Memory | Purpose |
|---|---|---|
| OS + System | 200 MB | Ubuntu/Debian base |
| Worker Pool | 600 MB | Redundant workers |
| WebSocket | 400 MB | Real-time push |
| PostgreSQL Replica | 700 MB | Read replica |
| Redis Replica | 200 MB | Cache replica |
| Total | 2100 MB | Slightly over, but workable |
Memory Management:
- Use swap (2 GB) for buffer
- Aggressive connection pooling
- Limit worker concurrency
- Periodic restarts if memory leaks
Throughput Capacity
Realistic Limits on This Hardware
Events Ingestion:
- API can handle ~500 req/sec (Go is fast)
- With 2 VPS = 1000 req/sec burst
- Daily capacity: ~86M events/day (way over target)
- Bottleneck will be delivery, not ingestion
Delivery Processing:
- Email: SendGrid rate limits you (~14/sec)
- SMS: Twilio rate limits you (~10/sec)
- Push: FCM can handle thousands/sec
- Webhook: Limited by external endpoint speed
Realistic Daily Throughput:
- 10K-100K events/day: ✅ Easy
- 1M events/day: ✅ Doable with tuning
- 10M events/day: ❌ Need to scale up
Where You'll Hit Limits:
- PostgreSQL connections (~100 max)
- Redis memory (200 MB = ~100K queued jobs)
- Worker goroutine limits (set to ~100 per process)
- Network I/O for webhooks
Docker Compose Configuration
VPS 1: docker-compose.yml
version: '3.8'
services:
nginx:
image: nginx:alpine
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf
- ./ssl:/etc/nginx/ssl
depends_on:
- api
restart: unless-stopped
mem_limit: 100m
cpus: 0.5
api:
build: ./api
environment:
- DATABASE_URL=postgres://user:pass@postgres:5432/meetnotify
- REDIS_URL=redis://redis:6379
- PORT=8080
depends_on:
- postgres
- redis
restart: unless-stopped
mem_limit: 300m
cpus: 1.0
worker:
build: ./worker
environment:
- DATABASE_URL=postgres://user:pass@postgres:5432/meetnotify
- REDIS_URL=redis://redis:6379
- SENDGRID_API_KEY=${SENDGRID_API_KEY}
- TWILIO_ACCOUNT_SID=${TWILIO_ACCOUNT_SID}
- TWILIO_AUTH_TOKEN=${TWILIO_AUTH_TOKEN}
- FCM_SERVER_KEY=${FCM_SERVER_KEY}
depends_on:
- postgres
- redis
restart: unless-stopped
mem_limit: 400m
cpus: 1.5
postgres:
image: postgres:15-alpine
environment:
- POSTGRES_USER=user
- POSTGRES_PASSWORD=pass
- POSTGRES_DB=meetnotify
volumes:
- postgres_data:/var/lib/postgresql/data
- ./init.sql:/docker-entrypoint-initdb.d/init.sql
restart: unless-stopped
mem_limit: 900m
cpus: 1.0
command: >
postgres
-c shared_buffers=256MB
-c effective_cache_size=512MB
-c max_connections=100
-c work_mem=4MB
redis:
image: redis:7-alpine
command: redis-server --maxmemory 200mb --maxmemory-policy allkeys-lru
volumes:
- redis_data:/data
restart: unless-stopped
mem_limit: 200m
cpus: 0.5
volumes:
postgres_data:
redis_data:
VPS 2: docker-compose.yml
version: '3.8'
services:
worker:
build: ./worker
environment:
- DATABASE_URL=postgres://user:pass@vps1-ip:5432/meetnotify
- REDIS_URL=redis://vps1-ip:6379
- SENDGRID_API_KEY=${SENDGRID_API_KEY}
- TWILIO_ACCOUNT_SID=${TWILIO_ACCOUNT_SID}
- TWILIO_AUTH_TOKEN=${TWILIO_AUTH_TOKEN}
- FCM_SERVER_KEY=${FCM_SERVER_KEY}
restart: unless-stopped
mem_limit: 600m
cpus: 2.0
websocket:
build: ./websocket
ports:
- "8081:8081"
environment:
- REDIS_URL=redis://redis-replica:6379
- PORT=8081
depends_on:
- redis-replica
restart: unless-stopped
mem_limit: 400m
cpus: 1.0
postgres-replica:
image: postgres:15-alpine
environment:
- POSTGRES_USER=replica
- POSTGRES_PASSWORD=replicapass
- PGDATA=/var/lib/postgresql/data/replica
volumes:
- postgres_replica_data:/var/lib/postgresql/data
restart: unless-stopped
mem_limit: 700m
cpus: 0.5
command: >
postgres
-c shared_buffers=128MB
-c max_connections=50
redis-replica:
image: redis:7-alpine
command: redis-server --replicaof vps1-ip 6379 --maxmemory 200mb
volumes:
- redis_replica_data:/data
restart: unless-stopped
mem_limit: 200m
cpus: 0.5
volumes:
postgres_replica_data:
redis_replica_data:
Go Service Architecture
Single Binary with Modules
meetnotify/
├── cmd/
│ ├── api/main.go # HTTP API server
│ ├── worker/main.go # Worker pool
│ └── websocket/main.go # WebSocket server
├── internal/
│ ├── auth/ # Authentication
│ ├── delivery/ # Channel delivery logic
│ │ ├── email.go
│ │ ├── sms.go
│ │ ├── push.go
│ │ └── webhook.go
│ ├── models/ # Data models
│ ├── queue/ # Redis queue abstraction
│ ├── db/ # PostgreSQL queries
│ ├── cache/ # Redis cache
│ └── config/ # Configuration
├── docker-compose.yml
└── go.mod
Worker Pool Implementation
package main
import (
"context"
"sync"
)
const (
EmailWorkers = 10
SMSWorkers = 5
PushWorkers = 10
WebhookWorkers = 20
)
func main() {
ctx := context.Background()
var wg sync.WaitGroup
// Start workers for each channel
for i := 0; i < EmailWorkers; i++ {
wg.Add(1)
go emailWorker(ctx, &wg)
}
for i := 0; i < SMSWorkers; i++ {
wg.Add(1)
go smsWorker(ctx, &wg)
}
for i := 0; i < PushWorkers; i++ {
wg.Add(1)
go pushWorker(ctx, &wg)
}
for i := 0; i < WebhookWorkers; i++ {
wg.Add(1)
go webhookWorker(ctx, &wg)
}
// Start retry scheduler
wg.Add(1)
go retryScheduler(ctx, &wg)
wg.Wait()
}
func emailWorker(ctx context.Context, wg *sync.WaitGroup) {
defer wg.Done()
queue := redis.NewQueue("email:pending")
for {
select {
case <-ctx.Done():
return
default:
// Blocking pop with 5-second timeout
job, err := queue.BRPop(5)
if err != nil || job == nil {
continue
}
// Process job
if err := processEmail(job); err != nil {
// Schedule retry
scheduleRetry("email", job, err)
} else {
// Update status
updateDeliveryStatus(job.ID, "delivered")
}
}
}
}
Scaling Path
When You Outgrow 2 VPS
Phase 1: Add More Workers (Still cheap)
- Add VPS 3 with just workers (8 cores, 4GB) - $10-20/mo
- All workers connect to same Redis + PostgreSQL
- Linear scaling for delivery throughput
Phase 2: Separate Database (Medium cost)
- Move PostgreSQL to managed service (DigitalOcean Managed DB) - $15-50/mo
- Removes memory pressure from VPS
- Better backups and monitoring
Phase 3: Add Kafka (When Redis queues hit limits)
- Deploy 3-node Kafka cluster - $60-120/mo
- Replace Redis Lists with Kafka topics
- Better durability and replay capability
Phase 4: Cloud Migration (When you have revenue)
- Move to AWS/GCP
- Use managed services (RDS, ElastiCache, MSK)
- Kubernetes for orchestration
- Same codebase, just different deployment
The Beauty:
- You architected correctly from day 1
- Same logical components (ingestion, orchestration, workers)
- Just running on fewer machines initially
- Easy to split apart as you scale
Monitoring (Lightweight)
Prometheus + Grafana
VPS 1: Add to docker-compose.yml
prometheus:
image: prom/prometheus:latest
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml
- prometheus_data:/prometheus
command:
- '--config.file=/etc/prometheus/prometheus.yml'
- '--storage.tsdb.retention.time=7d'
ports:
- "9090:9090"
mem_limit: 100m
cpus: 0.3
grafana:
image: grafana/grafana:latest
ports:
- "3000:3000"
volumes:
- grafana_data:/var/lib/grafana
environment:
- GF_SECURITY_ADMIN_PASSWORD=admin
mem_limit: 100m
cpus: 0.3
Key Metrics:
- Events ingested per minute
- Queue depth (Redis LLEN)
- Delivery success/failure rate
- API response time
- Memory/CPU usage
Alerting:
- If queue depth > 10,000 → Scale workers
- If error rate > 5% → Check provider status
- If memory > 90% → Restart or investigate leak
Cost Breakdown
Monthly Costs
| Item | Cost |
|---|---|
| VPS 1 (4 core, 2GB) | $10-20 |
| VPS 2 (4 core, 2GB) | $10-20 |
| Domain + SSL | $2 |
| SendGrid (5K emails/mo) | $15 (or free tier) |
| Twilio (500 SMS/mo) | $7.50 |
| FCM/APNs | Free |
| Total | $44.50-59.50/mo |
Compare to the $4,890/mo enterprise version!
Revenue Model
To Break Even:
- Charge $10/tenant/month
- Need 5 tenants
- Or charge per-event: $0.01/event = $100 for 10K events
Profitability:
- At 10 tenants × $50/mo = $500 revenue
- Cost: ~$60/mo
- Profit: $440/mo
- Margin: 88%
Security (Still Important)
Even on a budget, don't skip security:
✅ Free & Essential:
- Let's Encrypt SSL (free)
- API key authentication
- Rate limiting (built into code)
- Input validation
- SQL injection prevention (use parameterized queries)
- HTTPS only
✅ Cheap & Recommended:
- CloudFlare free tier (DDoS protection, CDN)
- Fail2Ban (block brute force)
- Automated backups to S3 ($1/mo)
❌ Skip for Now:
- Managed WAF (Web Application Firewall)
- Advanced DDoS protection
- Security audits
- Penetration testing
What You CAN'T Do on 2 VPS
Be realistic about limitations:
❌ No Multi-Region
You're running in one datacenter. If it goes down, you're down.
❌ No 99.99% Uptime
Expect 99% (7 hours downtime/year). That's okay for bootstrapping.
❌ Limited Burst Capacity
Can't suddenly handle 10x traffic spike.
❌ No Extensive Replay
Limited event history (7 days in DB, not archived).
❌ No Advanced Features
No A/B testing, no ML-based optimization, no complex analytics.
But That's Fine.
You're building an MVP. Ship fast, validate with customers, scale with revenue.
Implementation Checklist
Week 1: Foundation
- Provision 2 VPS (Hetzner, DigitalOcean, Vultr)
- Set up Docker + Docker Compose
- Configure PostgreSQL with schema
- Configure Redis
- Set up domain + SSL (Let's Encrypt)
Week 2: Core API
- Build API service (authentication, validation)
- Implement event publishing endpoint
- Connect to Redis queue
- Add rate limiting
- Basic health checks
Week 3: Workers
- Build worker pool binary
- Implement email worker (SendGrid)
- Implement retry logic
- Deploy workers on both VPS
Week 4: Additional Channels
- SMS worker (Twilio)
- Push worker (FCM/APNs)
- Webhook worker
- Template rendering
Week 5: Supporting Features
- Preference management API
- Template management API
- Delivery status tracking
- Basic admin endpoints
Week 6: Observability & Testing
- Prometheus + Grafana setup
- Load testing
- Failure testing
- Documentation
Week 7: Polish & Launch
- Security hardening
- Backup automation
- Monitoring alerts
- Onboard first tenant
The Honest Truth
This bootstrap version will:
✅ Handle 10K-100K events/day easily
✅ Support multi-tenancy
✅ Support multiple channels
✅ Cost ~$50/month
✅ Be architecturally sound
✅ Scale to the full version when you have money
It won't:
❌ Handle 10M events/day
❌ Have 99.99% uptime
❌ Survive datacenter failure
❌ Have all enterprise features
But here's the thing:
- AWS started in Jeff Bezos' garage
- WhatsApp served 1M users on 3 servers
- Instagram had 13 employees at $1B acquisition
You don't need enterprise infrastructure to start.
You need smart architecture + hustle.
Ship this. Get customers. Scale with revenue.
Next Steps
-
Pick your VPS provider:
- Hetzner (best value, Germany)
- DigitalOcean (easier UI, US)
- Vultr (global locations)
-
Set up the stack:
# VPS 1 docker-compose up -d # VPS 2 docker-compose up -d -
Deploy first version:
- Basic event publishing
- Email delivery only
- No fancy features yet
-
Get first customer:
- Offer free beta
- Validate they'll actually use it
- Iterate based on feedback
-
Add features incrementally:
- Don't build everything at once
- Listen to customer needs
- Scale based on usage
You got this. Start small, think big.
This is the REAL startup playbook.
Not the fantasy architecture you see in Medium articles.
The scrappy, resource-constrained, figure-it-out version.
Now go build it. 🚀