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:

StageAWS "Standard"Vercel + SupabaseRAW 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 | sh

2. 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.com

Staging 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.sh

Resource 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.sh

Log 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/myapp

Stage 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 setup

Stage 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 fail2ban

The 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 →