The Startup Infrastructure Trap
Here's a pattern we see constantly: a two-person startup with zero revenue sets up AWS with an EKS cluster, RDS Multi-AZ, ElastiCache, CloudFront, and a $400/mo bill before writing their first line of product code. Then they spend 30% of their engineering time managing infrastructure instead of building features.
The best infrastructure for a startup is the one you stop thinking about. It should be cheap enough to ignore, fast enough to not bottleneck you, and simple enough that you never debug infrastructure when you should be debugging product.
TCO Analysis: What Infrastructure Actually Costs
Let's compare three common setups across startup stages:
| Stage | AWS "Standard" | Vercel + Supabase | RAW Bare Metal |
|---|---|---|---|
| Pre-revenue ($0 MRR) | $150-400/mo | $45-70/mo | $6/mo |
| Early ($1k MRR) | $300-800/mo | $100-200/mo | $11-21/mo |
| Growing ($10k MRR) | $800-2,500/mo | $300-600/mo | $42-84/mo |
AWS estimate: EC2 + RDS + ElastiCache + CloudFront + S3 + egress. Vercel: Pro plan + Supabase Pro. RAW: raw-2 to raw-8 ARM servers.
At $10k MRR, the AWS-default startup is spending 8-25% of revenue on infrastructure. The bare metal startup is spending under 1%. That's not just a cost difference — it's extra runway, more hiring budget, or just less stress.
The Recommended Stack
This stack runs everything a typical SaaS startup needs on a single bare metal server. It's what we use ourselves and what dozens of YC companies have shipped on:
# The full startup stack on one server
┌─────────────────────────────────────────────┐
│ Bare Metal Server (RAW raw-4 ARM) │
│ 4 vCPU · 8 GB RAM · 80 GB NVMe · $11/mo │
│ │
│ ┌───────────┐ ┌──────────────────────┐ │
│ │ Nginx │──│ Your App (Docker) │ │
│ │ :80/:443 │ │ Next.js / Express │ │
│ └───────────┘ │ / Django / Rails │ │
│ └──────────────────────┘ │
│ ┌───────────┐ ┌──────────────────────┐ │
│ │ PostgreSQL│ │ Redis Cache │ │
│ │ :5432 │ │ :6379 │ │
│ └───────────┘ └──────────────────────┘ │
│ ┌──────────────────────────────────────┐ │
│ │ Background Workers (Bull / Celery) │ │
│ └──────────────────────────────────────┘ │
└─────────────────────────────────────────────┘Why This Stack
- Nginx: Reverse proxy, SSL termination, static file serving, gzip. Battle-tested for 20 years. Uses 5 MB of RAM.
- Docker: Package your app consistently. Deploy with
docker compose up -d. No "works on my machine." - PostgreSQL: The best open-source database. Handles JSON, full-text search, vector embeddings, geospatial — you won't outgrow it.
- Redis: Caching, sessions, rate limiting, job queues. 1-2 MB overhead. Massively reduces database load.
Set Up the Full Stack in 20 Minutes
1. Deploy the Server
# One command to get a server
npx rawhq deploy
# SSH in
ssh root@your-server-ip
# Install Docker
curl -fsSL https://get.docker.com | sh2. Docker Compose for Everything
# /opt/app/docker-compose.yml
services:
app:
build: .
restart: unless-stopped
ports:
- "3000:3000"
environment:
NODE_ENV: production
DATABASE_URL: postgres://app:${DB_PASSWORD}@db:5432/myapp
REDIS_URL: redis://redis:6379
depends_on:
db:
condition: service_healthy
redis:
condition: service_started
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
interval: 30s
timeout: 5s
retries: 3
worker:
build: .
restart: unless-stopped
command: npm run worker
environment:
DATABASE_URL: postgres://app:${DB_PASSWORD}@db:5432/myapp
REDIS_URL: redis://redis:6379
depends_on:
- db
- redis
db:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_DB: myapp
POSTGRES_USER: app
POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app"]
interval: 10s
timeout: 5s
retries: 5
command:
- postgres
- -c
- shared_buffers=1GB
- -c
- effective_cache_size=3GB
- -c
- work_mem=32MB
- -c
- random_page_cost=1.1
- -c
- max_connections=200
redis:
image: redis:7-alpine
restart: unless-stopped
command: redis-server --maxmemory 256mb --maxmemory-policy allkeys-lru
volumes:
- redisdata:/data
volumes:
pgdata:
redisdata:3. Nginx with SSL
# Install Nginx + Certbot
apt install -y nginx certbot python3-certbot-nginx# /etc/nginx/sites-available/app
upstream app {
server 127.0.0.1:3000;
keepalive 64;
}
server {
listen 80;
server_name yourdomain.com;
location / {
proxy_pass http://app;
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;
}
# Cache static assets
location /_next/static/ {
proxy_pass http://app;
add_header Cache-Control "public, max-age=31536000, immutable";
}
gzip on;
gzip_types text/plain text/css application/json application/javascript;
gzip_min_length 256;
}# Enable site and get SSL
ln -s /etc/nginx/sites-available/app /etc/nginx/sites-enabled/
rm /etc/nginx/sites-enabled/default
nginx -t && systemctl reload nginx
# Get free SSL certificate
certbot --nginx -d yourdomain.comStaging Environment
Every startup needs staging. On bare metal, you have two clean options:
Option A: Same Server, Different Port (Free)
# /opt/staging/docker-compose.yml
services:
app:
build: .
ports:
- "3001:3000" # Different port
environment:
NODE_ENV: staging
DATABASE_URL: postgres://app:${DB_PASSWORD}@db:5432/staging
db:
image: postgres:16-alpine
environment:
POSTGRES_DB: staging
POSTGRES_USER: app
POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes:
- staging_pgdata:/var/lib/postgresql/data
volumes:
staging_pgdata:# Nginx config for staging subdomain
# /etc/nginx/sites-available/staging
server {
listen 80;
server_name staging.yourdomain.com;
location / {
proxy_pass http://127.0.0.1:3001;
# ... same proxy headers as production
}
}Option B: Separate $6/mo Server (Recommended)
For $6/mo, get a dedicated staging server. Completely isolated from production, same OS and config. Deploy with the same Docker Compose file, just different environment variables.
CI/CD Pipeline
Automated deployments on every push to main:
# .github/workflows/deploy.yml
name: Deploy
on:
push:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_PASSWORD: test
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports: ['5432:5432']
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm test
env:
DATABASE_URL: postgres://postgres:test@localhost:5432/postgres
deploy:
needs: test
runs-on: ubuntu-latest
steps:
- name: Deploy to production
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.SERVER_HOST }}
username: root
key: ${{ secrets.SSH_KEY }}
script: |
cd /opt/app
git pull origin main
docker compose build --no-cache app
docker compose up -d --no-deps app worker
docker image prune -f
echo "Deployed $(git rev-parse --short HEAD) at $(date)"Monitoring (Without Datadog Prices)
You don't need Datadog ($23/host/mo) or New Relic to monitor a startup. Here's a free monitoring stack:
Uptime Monitoring
# Free option: cron + curl + notification
# /opt/scripts/healthcheck.sh
#!/bin/bash
if ! curl -sf --max-time 5 https://yourdomain.com/health > /dev/null; then
curl -X POST "https://api.telegram.org/bot${TELEGRAM_TOKEN}/sendMessage" \
-d "chat_id=${CHAT_ID}" \
-d "text=🚨 ALERT: yourdomain.com is DOWN at $(date)"
fi
# Run every minute
# * * * * * /opt/scripts/healthcheck.shResource Monitoring
# Quick server health check
cat << 'EOF' > /opt/scripts/stats.sh
#!/bin/bash
echo "=== Server Stats ==="
echo "CPU: $(top -bn1 | grep 'Cpu(s)' | awk '{print $2}')% used"
echo "RAM: $(free -h | awk '/^Mem:/{print $3"/"$2}')"
echo "Disk: $(df -h / | awk 'NR==2{print $3"/"$2" ("$5")"}')"
echo "Docker:"
docker stats --no-stream --format " {{.Name}}: CPU {{.CPUPerc}} | MEM {{.MemUsage}}"
EOF
chmod +x /opt/scripts/stats.shLog Aggregation
# Docker logs are already structured
docker compose logs --tail 100 app
docker compose logs --tail 100 --since 1h app
# For persistent logs, add to docker-compose.yml:
services:
app:
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"Scaling Strategy
When to upgrade, and how:
Stage 1: Single Server ($6-21/mo)
One server runs everything. This handles more than you think:
- A raw-4 ARM (4 vCPU, 8 GB) handles 1,000-5,000 concurrent users for a typical SaaS app
- PostgreSQL on the same server handles 10,000+ queries/second
- Redis caching reduces database load by 80-90%
Stage 2: Separate Database ($11-42/mo total)
When your database needs dedicated resources, move it to its own server:
# Server 1: App + Redis + Nginx ($11/mo)
# Server 2: PostgreSQL ($11/mo)
# Total: $22/mo
# Update DATABASE_URL to point to Server 2's private IP
DATABASE_URL=postgres://app:password@10.0.0.2:5432/myappStage 3: Horizontal Scaling ($42-168/mo total)
Add more app servers behind a load balancer:
# Nginx load balancer config
upstream app {
server 10.0.0.3:3000; # App server 1
server 10.0.0.4:3000; # App server 2
keepalive 64;
}
# Server 1: Nginx load balancer ($6/mo)
# Server 2-3: App servers ($11/mo each)
# Server 4: PostgreSQL + Redis ($21/mo)
# Total: $49/mo for a highly available setupStage 4: When to Consider PaaS
Move to managed services when:
- You're at $50k+ MRR and can afford to trade money for engineering time
- You need global edge deployment (multiple continents)
- You need auto-scaling for unpredictable traffic spikes (viral products)
- You've hired a platform team and want them working on product-specific infra, not OS patches
Until then, bare metal gives you 10-50x better cost efficiency and the same (or better) performance. The complexity tax of AWS/GCP is not worth paying until you're generating enough revenue to justify it.
Security Essentials
# Run this on every new server
#!/bin/bash
# 1. Firewall — only allow what you need
ufw default deny incoming
ufw default allow outgoing
ufw allow 22/tcp
ufw allow 80/tcp
ufw allow 443/tcp
ufw enable
# 2. SSH hardening
sed -i 's/#PasswordAuthentication yes/PasswordAuthentication no/' /etc/ssh/sshd_config
sed -i 's/#PermitRootLogin yes/PermitRootLogin prohibit-password/' /etc/ssh/sshd_config
systemctl restart sshd
# 3. Auto security updates
apt install -y unattended-upgrades
dpkg-reconfigure -plow unattended-upgrades
# 4. Fail2ban — block brute force attempts
apt install -y fail2ban
systemctl enable fail2ban
systemctl start fail2banThe Bottom Line
Infrastructure should be a solved problem for startups, not an ongoing project. The stack above — one bare metal server, Docker Compose, Nginx, PostgreSQL, Redis — handles everything from prototype to $10k MRR.
Start with a $6/mo server. Ship your product. Optimize infrastructure only when it becomes the bottleneck — not before. Your users don't care if you're running on AWS or bare metal. They care if your product works.
Start Building
Deploy your startup's infrastructure in under 5 minutes. Free 7-day trial, no credit card required.
$ npx rawhq deploySee Pricing →