You've built your SaaS. It works beautifully on localhost. You're ready to launch on Product Hunt tomorrow.
But then that voice in your head whispers: "What if it crashes when users actually show up?"
That voice is right to worry. Most indie hackers skip critical production steps because they don't know what to check. Then they launch, get traction, and everything falls apart.
I've seen it happen dozens of times: App hits #1 on Product Hunt → traffic spike → database crashes → users can't sign up → negative reviews → momentum dies.
This doesn't have to be you.
This checklist covers the 20 critical things you MUST verify before your first real user. No fluff. Just the things that will actually save you from production disasters.
Let's make sure your launch is a success story, not a cautionary tale.
Security: The Non-Negotiables
These aren't optional. Skip even one and you're inviting disaster.
1. No Secrets in Code
# Search for leaked secrets RIGHT NOW
grep -r "sk-" . --exclude-dir=node_modules
grep -r "API_KEY" . --exclude-dir=node_modules
grep -r "mongodb://" . --exclude-dir=node_modules
grep -r "postgresql://" . --exclude-dir=node_modules
# If you find ANY, move them to .env immediately
Why it matters: Bots scan GitHub 24/7 for API keys. Once committed, they're in git history forever. I've seen OpenAI bills hit $2,000 in 6 hours from leaked keys.
The fix:
# .env (NEVER commit this)
OPENAI_API_KEY=sk-proj-abc123...
DATABASE_URL=postgresql://prod.db:5432/myapp
# .env.example (commit this)
OPENAI_API_KEY=your_key_here
DATABASE_URL=postgresql://localhost:5432/myapp
2. HTTPS Everywhere
No excuses. If your app doesn't have HTTPS, users will see "Not Secure" in their browser.
- Vercel/Netlify/Railway: SSL is automatic
- Custom domain: Get free SSL from Let's Encrypt
- Self-hosted: Use Caddy (auto-SSL) or Nginx + Certbot
Test it:
curl -I https://yourapp.com
# Should return 200, not redirect to http://
3. Input Validation on ALL Endpoints
Never trust user input. Ever.
import { z } from 'zod'
// Define schema
const createPostSchema = z.object({
title: z.string().min(1).max(200),
content: z.string().min(10).max(10000),
tags: z.array(z.string()).max(5).optional()
})
// Validate in API route
export async function POST(req: Request) {
try {
const body = await req.json()
const validated = createPostSchema.parse(body)
// NOW it's safe to use
} catch (error) {
if (error instanceof z.ZodError) {
return Response.json({ error: error.errors }, { status: 400 })
}
}
}
Why it matters: Users will send you: null values, SQL injection attempts, 10MB payloads, emoji-only strings, Unicode that breaks your database.
4. Rate Limiting
Prevent abuse before it drains your wallet.
// Minimal rate limiting (works anywhere)
import { Ratelimit } from '@upstash/ratelimit'
const ratelimit = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(100, '15 m'), // 100 requests per 15 min
})
export async function middleware(request: Request) {
const ip = request.headers.get('x-forwarded-for') ?? 'unknown'
const { success } = await ratelimit.limit(ip)
if (!success) {
return new Response('Too many requests', { status: 429 })
}
}
Why it matters: One angry user can make 10,000 requests in a minute. Without rate limiting, they'll blow through your OpenAI credits or crash your database.
5. CORS Configuration
Why it matters: Too restrictive = your app doesn't work. Too permissive = security hole.
// ❌ BAD: Allows any site to call your API
res.setHeader('Access-Control-Allow-Origin', '*')
// ✅ GOOD: Only your domains
const allowedOrigins = [
'https://yourapp.com',
'https://www.yourapp.com'
]
const origin = req.headers.get('origin')
if (origin && allowedOrigins.includes(origin)) {
res.setHeader('Access-Control-Allow-Origin', origin)
}
Reliability: Won't Crash Under Load
You don't need to handle 1M users on day one. But you should survive your first 100 without crashing.
6. Database Connection Pooling
Problem: AI code creates a new database connection for every request. After 100 requests, you run out of connections.
Fix:
// ❌ BAD: New connection every request
import { Pool } from 'pg'
export async function GET() {
const pool = new Pool() // LEAKS CONNECTIONS
const result = await pool.query('SELECT * FROM users')
}
// ✅ GOOD: Reuse connections
import { Pool } from 'pg'
const pool = new Pool({
max: 20, // Max connections
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 2000
})
export async function GET() {
const client = await pool.connect()
try {
const result = await client.query('SELECT * FROM users')
return Response.json(result.rows)
} finally {
client.release() // CRITICAL
}
}
7. Graceful Shutdown
When you deploy, Node shouldn't just kill your process mid-request.
// server.ts
const server = app.listen(3000)
process.on('SIGTERM', async () => {
console.log('SIGTERM received, shutting down gracefully')
// Stop accepting new requests
server.close(() => {
console.log('HTTP server closed')
})
// Close database connections
await db.close()
// Give existing requests 10s to finish
setTimeout(() => {
console.error('Forcing shutdown after timeout')
process.exit(1)
}, 10000)
})
Why it matters: Without graceful shutdown, deployments can corrupt database writes or lose in-flight requests.
8. Error Boundaries (React/Next.js)
Problem: One component crash shouldn't white-screen your entire app
// app/error.tsx (Next.js 13+)
'use client'
export default function Error({
error,
reset,
}: {
error: Error
reset: () => void
}) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="max-w-md p-8 bg-red-50 border border-red-200 rounded">
<h2 className="text-xl font-bold mb-4">Something went wrong</h2>
<p className="text-gray-700 mb-4">{error.message}</p>
<button
onClick={reset}
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
>
Try again
</button>
</div>
</div>
)
}
9. Database Migrations
Don't SSH into production and run SQL manually. Automate it.
# Prisma example
npx prisma migrate dev --name add_user_roles
# Deploy to production (in CI/CD)
npx prisma migrate deploy
Add to your CI/CD pipeline:
# .github/workflows/deploy.yml
- name: Run database migrations
run: npx prisma migrate deploy
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
Observability: Know When Things Break
If you don't have monitoring, you won't know your app is down until users tell you (and they will... in 1-star reviews).
10. Error Monitoring (Sentry)
npm install @sentry/nextjs
npx @sentry/wizard@latest -i nextjs
// sentry.client.config.ts
Sentry.init({
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
environment: process.env.NODE_ENV,
tracesSampleRate: 0.1,
})
Why it matters: When your app crashes at 3 AM, Sentry emails you with the exact error, stack trace, and user context.
11. Health Check Endpoint
// app/api/health/route.ts
export async function GET() {
try {
// Check database
await db.query('SELECT 1')
// Check external APIs
const openaiKey = process.env.OPENAI_API_KEY
if (!openaiKey) throw new Error('OpenAI key missing')
return Response.json({
status: 'healthy',
timestamp: new Date().toISOString()
})
} catch (error) {
return Response.json(
{ status: 'unhealthy', error: error.message },
{ status: 503 }
)
}
}
Set up uptime monitoring:
- Go to uptimerobot.com (free)
- Monitor your /health endpoint every 5 minutes
- Get SMS/email when it's down
12. Structured Logging
// ❌ BAD: Can't search logs later
console.log('User logged in:', userId)
// ✅ GOOD: Structured and searchable
logger.info('user_login', {
userId,
timestamp: new Date().toISOString(),
ip: req.ip,
userAgent: req.headers['user-agent']
})
Use a log aggregator:
- Axiom (free tier)
- Logtail (free tier)
- Papertrail (free tier)
Why it matters: When debugging production issues, console.log disappears. Structured logs in a searchable database save hours.
Performance: Fast for Real Users
13. Database Indexes
-- Find slow queries (PostgreSQL)
SELECT query, mean_exec_time
FROM pg_stat_statements
ORDER BY mean_exec_time DESC
LIMIT 10;
-- Add indexes to frequently queried columns
CREATE INDEX idx_users_email ON users(email);
CREATE INDEX idx_posts_user_id ON posts(user_id);
CREATE INDEX idx_created_at ON posts(created_at DESC);
Why it matters: Queries that take 50ms on localhost can take 5 seconds in production with 10,000 rows.
14. API Response Caching
// Simple in-memory cache
const cache = new Map()
export async function GET(req: Request) {
const cacheKey = new URL(req.url).pathname
// Check cache first
if (cache.has(cacheKey)) {
return Response.json(cache.get(cacheKey))
}
// Fetch fresh data
const data = await fetchExpensiveData()
// Cache for 5 minutes
cache.set(cacheKey, data)
setTimeout(() => cache.delete(cacheKey), 5 * 60 * 1000)
return Response.json(data)
}
For Redis-based caching:
import { Redis } from '@upstash/redis'
const redis = Redis.fromEnv()
export async function GET(req: Request) {
const cached = await redis.get('dashboard_metrics')
if (cached) return Response.json(cached)
const metrics = await calculateMetrics()
await redis.set('dashboard_metrics', metrics, { ex: 300 }) // 5 min TTL
return Response.json(metrics)
}
15. Image Optimization
// ❌ BAD: Serving 5MB images
<img src="/uploads/photo.jpg" />
// ✅ GOOD: Next.js Image optimization
import Image from 'next/image'
<Image
src="/uploads/photo.jpg"
width={800}
height={600}
alt="Photo"
priority // Load above the fold images first
/>
Deployment: Safe and Reversible
16. CI/CD Pipeline
Never deploy manually. Automate it.
# .github/workflows/deploy.yml
name: Deploy
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm test
- name: Type check
run: npm run typecheck
- name: Deploy to Vercel
run: vercel deploy --prod
env:
VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
17. Rollback Strategy
Can you rollback a bad deploy in < 5 minutes?
Test it:
- Deploy to production
- Immediately rollback
- Verify app still works
Vercel: vercel rollback
Railway: Click "Rollback" in dashboard
Docker: Keep previous image tagged, redeploy with docker-compose up -d --no-deps app:previous
18. Environment Parity
Dev, staging, and prod should be as similar as possible.
# ❌ BAD: Different databases in dev and prod
Dev: SQLite
Prod: PostgreSQL
# ✅ GOOD: Same stack everywhere
Dev: PostgreSQL (Docker)
Staging: PostgreSQL (Railway)
Prod: PostgreSQL (Railway)
Why it matters: Code that works with SQLite can break with PostgreSQL's stricter type system.
Final Pre-Launch Checklist
Print this. Check every box before you launch.
Security:
- No secrets in code (checked with grep)
- HTTPS configured
- Input validation on all endpoints
- Rate limiting enabled
- CORS configured correctly
Reliability:
- Database connection pooling
- Graceful shutdown configured
- Error boundaries added
- Database migrations automated
Observability:
- Error monitoring installed (Sentry)
- Health check endpoint created
- Uptime monitoring configured
- Structured logging implemented
Performance:
- Database indexes on queried columns
- API responses cached where appropriate
- Images optimized
Deployment:
- CI/CD pipeline set up
- Can rollback in < 5 minutes
- Dev/staging/prod use same stack
- Environment variables documented in .env.example
Documentation:
- README has setup instructions
- Deployment process documented
- Runbook for common issues created
Summary
Production readiness isn't about perfection. It's about avoiding disasters.
You don't need a $20k/month infrastructure on day one. You need a setup that:
- Won't leak secrets (security)
- Won't crash under 100 users (reliability)
- Tells you when things break (observability)
- Can be fixed quickly (deployment safety)
Start here. Launch. Learn from real users. Iterate.
The biggest mistake is waiting for perfection. Ship this checklist, get users, improve from real feedback.
Nervous about your production setup? Get a free async vibe audit – we'll review your repo and send a personalized Loom video showing exactly what will break before it happens.