You are not behind. You are mid-build.
You are four months in. You can ship a feature in a morning. Your customers (or your alpha users) are starting to do interesting things in the product. And somewhere between three and six days a month you find yourself fixing TLS, debugging why a Stripe webhook failed, googling pm2 restart flags, or panicking that you do not actually have a recent database backup.
None of that work moves the product forward. All of it is necessary. The math is the well-known one for any solo founder: every hour you spend on infra is an hour you do not spend on the thing that is actually differentiating your SaaS.
This page is what a 6-week sprint looks like when you hand the infra work to somebody whose entire job is this. It is also a complete recipe. If you have the time and want to do it yourself, read the chapters straight through and you will end up with the same posture I would hand back. If you don't have the time, the same chapters tell you exactly what you are paying for and roughly how long each piece takes, so you can evaluate the trade honestly.
The code below is from the actual Penumbra Tech backend that serves this site. None of it is hypothetical.
Week 0 — the 30-minute audit
Before any work begins, the audit. You can do this on your own codebase in half an hour with grep and a checklist. Most of the answers will be either “not yet” or “sort of.” That is the starting point.
The ten things to check
# 1. Are sessions backed by a real store, or in-memory?
grep -rn "express-session\|MemoryStore" --include="*.js" .
# 2. Is bcrypt cost 10+ (12 is better for production)?
grep -rn "bcrypt.hash\|bcrypt.compare" --include="*.js" .
# 3. Are there rate limits on auth mutations?
grep -rn "express-rate-limit\|rateLimit(" --include="*.js" .
# 4. Is Stripe webhook registered BEFORE express.json()?
grep -B2 -A2 "stripe\|webhook" server.js | grep -i "raw\|json"
# 5. Are secrets read from env with a fail-fast in prod?
grep -rn "process.env.SESSION_SECRET\|process.env.DB_PASSWORD" .
# 6. Is /api/health (or equivalent) defined?
grep -rn "/health\|/healthz" --include="*.js" .
# 7. Is there a global error handler?
grep -rn "app.use((err" --include="*.js" .
# 8. Are uncaughtException / unhandledRejection handled?
grep -rn "uncaughtException\|unhandledRejection" --include="*.js" .
# 9. Is X-Powered-By disabled?
grep -rn "x-powered-by\|disable.*powered" --include="*.js" .
# 10. Does anything do nightly DB backups?
crontab -l 2>/dev/null | grep -i "dump\|backup"
find . -name "*.sh" -path "*backup*" 2>/dev/nullThe number of those that come back empty is the rough shape of week 1. For most pre-launch SaaS, it is six-to-nine empty.
Week 1 — secrets, auth, rate limits
The first week of real work is the security baseline. Three changes that together close the largest blast radii: hardened session config, rate-limited mutations, and a startup that refuses to boot in a misconfigured state.
Fail-fast on missing secrets
The process.env.SESSION_SECRET || 'dev-default' fallback you see in every Express tutorial is fine in dev and dangerous in prod. If .env is misplaced or the env var isn't loaded, the app cheerfully boots with a hardcoded secret that is public on GitHub. Refuse to start instead.
if (process.env.NODE_ENV === 'production') {
const secret = process.env.SESSION_SECRET;
if (!secret || /change-me|insecure|example/i.test(secret)) {
console.error(
'FATAL: SESSION_SECRET is missing or matches an example value. ' +
'Set a long random value in .env before starting.'
);
process.exit(1);
}
if (process.env.COOKIE_SECURE !== 'true') {
console.error('FATAL: COOKIE_SECURE must be "true" in production.');
process.exit(1);
}
}Timing-constant login
Hash passwords with bcrypt at cost 12. Then a subtle thing: when somebody tries to log in with an email that does not exist, do not skip the bcrypt comparison. If you do, your login route returns in 5ms for a non-existent user and 250ms for an existing one, and an attacker can enumerate your user list by timing alone. The fix is to always run the comparison, against a fake hash if necessary.
const FAKE_HASH = bcrypt.hashSync('placeholder-for-timing', 12);
const user = await findUserByEmail(email);
const hash = user ? user.password_hash : FAKE_HASH;
const ok = await bcrypt.compare(submittedPassword, hash);
if (!user || !ok) {
// Same response either way. Same timing either way.
return res.status(401).json({ error: 'Invalid credentials' });
}Tiered rate limits
One global limit on /api/* as the safety net, a much tighter limit on auth mutations, and an even tighter one on anything that sends an email (forgot-password, contact form). Lets a real user use the product freely while making credential stuffing and email-flooding bots economically painful.
const globalLimiter = rateLimit({
windowMs: 60 * 1000, max: 100,
standardHeaders: true, legacyHeaders: false,
message: { error: 'Too many requests' }
});
app.use('/api', globalLimiter);
const authMutationLimiter = rateLimit({
windowMs: 15 * 60 * 1000, max: 10,
message: { error: 'Too many attempts' }
});
app.use('/api/auth/login', authMutationLimiter);
app.use('/api/auth/register', authMutationLimiter);
const forgotPasswordLimiter = rateLimit({
windowMs: 60 * 60 * 1000, max: 5,
message: { error: 'Too many password reset requests' }
});
app.use('/api/auth/forgot-password', forgotPasswordLimiter);Week 2 — Stripe (and what comes after Stripe)
If your SaaS takes payments via Stripe, there is exactly one mistake that will quietly burn three hours of your life and another six explaining it to the next engineer. It happens in the first ten lines of server.js.
The Stripe webhook ordering trap
Stripe signs webhooks with HMAC over the raw request bytes. If express.json() parses the body before your webhook handler runs, the bytes change (whitespace, key order), and signature verification silently fails forever. The fix is one line of careful ordering.
// Stripe webhook MUST be mounted BEFORE express.json():
app.post(
'/api/payments/webhook',
express.raw({ type: 'application/json' }),
paymentsWebhookHandler
);
// THEN the global JSON parser:
app.use(express.json());Webhook idempotency
Stripe delivers webhooks at-least-once. Your handler will eventually be invoked twice for the same event (network flake, retry, your worker restarted mid-process). Stash every event id you process so you can short-circuit the second one.
// SQL: a tiny table is enough
CREATE TABLE stripe_events (
id VARCHAR(255) PRIMARY KEY,
type VARCHAR(64),
received_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
// handler:
const event = stripe.webhooks.constructEvent(
rawBody, req.headers['stripe-signature'], webhookSecret
);
const [existing] = await pool.query(
'SELECT id FROM stripe_events WHERE id = ?', [event.id]
);
if (existing.length) return res.status(200).send('ok'); // already processed
await pool.query(
'INSERT INTO stripe_events (id, type) VALUES (?, ?)',
[event.id, event.type]
);
// ... now actually process the event ...Server-side state is the truth
Never trust the client to tell you what plan a user is on. The browser can lie; the URL can lie; the form parameters can lie. The Stripe webhook is the only place you flip a user's role / plan / subscription status. The frontend reads the role from your backend, not from Stripe Elements state. This is the same discipline that keeps a malicious user from upgrading themselves with curl.
Week 3 — deploys without 3am surprises
Most solo SaaS deploys are some variant of “SSH in, git pull, npm install, restart pm2 with my fingers crossed.” That works fine until it doesn't, and the time it doesn't is always 11pm the night before a customer demo. Two improvements eliminate most of that risk.
Atomic deploys with a rollback
Your deploy script almost certainly does rm -rf $WEB_ROOT/* followed by cp -r dist/* $WEB_ROOT/. If the copy fails mid-flight (disk full, permission flap, network blip on a remote copy), the site is broken with no rollback path. Replace with a versioned-directory + symlink-swap pattern that takes a millisecond to roll back.
STAMP=$(date +%s) sudo mkdir -p /var/www/myapp-$STAMP sudo cp -r dist/* /var/www/myapp-$STAMP/ # Atomic swap: ln -sfn replaces the symlink in one syscall. sudo ln -sfn /var/www/myapp-$STAMP /var/www/myapp # Keep the last 3 deploys for instant rollback. cd /var/www && ls -t -d myapp-* | tail -n +4 | xargs -r sudo rm -rf # nginx serves /var/www/myapp (the symlink), no reload needed.
To roll back: sudo ln -sfn /var/www/myapp-PREV /var/www/myapp. That is the entire rollback. No git revert, no rebuild, no second deploy.
Graceful process restarts
pm2 restart is a hard restart: in-flight requests fail. pm2 reload spawns a new worker first, waits for it to start, then drains and stops the old one. Same result, no dropped requests.
# In your deploy script, swap restart -> reload: pm2 reload your-backend --update-env # For PM2 cluster mode, this gives you zero-downtime deploys. # For a single-process app, it gives you "no client request gets killed # mid-flight when you push" which is the same thing in practice.
Week 4 — monitoring you will actually answer
Most solo SaaS monitoring stories are “a customer emails me when the site is down.” That is not a monitoring story; that is your customers monitoring your uptime for you, and reporting through a channel you don't read at 2am. Three pieces fix this.
An /api/health endpoint that actually tests health
Cheap liveness probe for UptimeRobot / future load balancers / PM2. Pings the DB with a trivial SELECT 1 so a healthy 200 actually means “the worker can talk to MySQL,” not just “the process is up.”
app.get('/api/health', async (req, res) => {
try {
await pool.query('SELECT 1');
res.json({ status: 'ok', uptime: process.uptime() });
} catch (err) {
res.status(503).json({ status: 'degraded', reason: 'db_unreachable' });
}
});UptimeRobot (free tier, 50 monitors)
Sign up. New monitor → HTTP(s) → URL https://yourdomain.com/api/health → 5-minute interval → email alert contact. Verify the alert by stopping your backend on purpose; you should get a “DOWN” email within 6-10 minutes and an “UP” one shortly after you restart.
Point it at /api/health, not the homepage. Your static frontend keeps loading even when the backend is dead, because nginx serves the HTML directly. The whole point of the health endpoint is that it exercises the live application path including the database round-trip. UptimeRobot must hit that path, not the static file. This is the single most common monitoring mistake and the one that lets a backend die overnight without you finding out.
Process-level catches + logrotate
Node will silently exit on an unhandled promise rejection. PM2 restarts the worker but every in-flight request fails. Wire all three handlers so the cause is traceable in PM2 logs.
app.use((err, req, res, next) => {
console.error('Unhandled error:', err);
if (res.headersSent) return next(err);
res.status(500).json({ error: 'Internal server error' });
});
process.on('uncaughtException', (err) => {
console.error('uncaughtException:', err);
process.exit(1); // clean exit so PM2 restarts cleanly
});
process.on('unhandledRejection', (reason) => {
console.error('unhandledRejection:', reason);
process.exit(1);
});pm2 install pm2-logrotate pm2 set pm2-logrotate:max_size 10M pm2 set pm2-logrotate:retain 7 pm2 set pm2-logrotate:compress true pm2 set pm2-logrotate:rotateInterval '0 0 * * *'
Week 5 — backups and the recovery test
A backup that has never been restored is a file, not a backup. Two pieces: a nightly automated dump, and a one-time manual restore into a scratch database to prove the dump file actually contains your data.
Nightly mysqldump cron
Loads DB credentials from your existing .env so the script stays in sync with prod. Uses --defaults-extra-file with a 600-perm tempfile so the password never appears in ps. Sanity-checks the dump size and fails loud if the file is suspiciously small (less than 1KB usually means mysqldump errored silently).
#!/usr/bin/env bash
set -euo pipefail
ENV_FILE="$HOME/your-repo/backend/.env"
DB_USER="$(grep ^DB_USER= "$ENV_FILE" | cut -d= -f2-)"
DB_PASSWORD="$(grep ^DB_PASSWORD= "$ENV_FILE" | cut -d= -f2-)"
DB_NAME="$(grep ^DB_NAME= "$ENV_FILE" | cut -d= -f2-)"
sudo mkdir -p /backups && sudo chown "$USER:$USER" /backups
TMP_CNF="$(mktemp)"
trap 'rm -f "$TMP_CNF"' EXIT
cat >"$TMP_CNF" <<EOF
[client]
user=$DB_USER
password=$DB_PASSWORD
EOF
chmod 600 "$TMP_CNF"
mysqldump --defaults-extra-file="$TMP_CNF" \
--single-transaction --quick --routines --triggers \
--skip-lock-tables --set-gtid-purged=OFF --no-tablespaces \
"$DB_NAME" \
| gzip -c > "/backups/${DB_NAME}_$(date +%F).sql.gz"
find /backups -maxdepth 1 -name "${DB_NAME}_*.sql.gz" -mtime +14 -delete
echo "[$(date -Iseconds)] backup OK"chmod +x backup-db.sh sudo touch /var/log/your-backup.log sudo chown $USER:$USER /var/log/your-backup.log ( crontab -l 2>/dev/null ; \ echo "0 3 * * * $HOME/backup-db.sh >> /var/log/your-backup.log 2>&1" ) \ | crontab -
Restore drill (do this once)
Restore last night's dump into a scratch DB and confirm it has your data. Pick a weekend, ten minutes. If you skip this step you have a backup script, not a backup strategy.
sudo mysql -e "CREATE DATABASE recovery_test;" gunzip < /backups/yourdb_$(date +%F).sql.gz | sudo mysql recovery_test sudo mysql recovery_test -e "SHOW TABLES; SELECT COUNT(*) FROM users;" # Should list your tables and show a non-zero user count. sudo mysql -e "DROP DATABASE recovery_test;"
Week 6 — security headers and the handover
The last week is the part most engagements skip and the part that decides whether the engagement was worth what you paid. Security headers everywhere they should be, and a written handover doc so future-you can operate the system without having to remember anything.
Five security headers in nginx
Default nginx ships with none of these. Adding them takes ten minutes and meaningfully changes your site's posture. Important nginx gotcha: if any child location block calls add_header (even just for cache control), nginx replaces every parent add_header for that location. Factor the security headers into an include file and call it from every location that adds anything.
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; add_header X-Content-Type-Options "nosniff" always; add_header X-Frame-Options "DENY" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always; add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://js.stripe.com; style-src 'self' 'unsafe-inline'; img-src 'self' https: data:; font-src 'self'; connect-src 'self' https://api.stripe.com; frame-src 'self' https://js.stripe.com; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" always;
server {
listen 443 ssl;
include /path/to/security-headers.snippet.conf;
location /api/ {
# inherits the include above (no add_header here)
proxy_pass http://127.0.0.1:3000;
}
location ~* ^/assets/ {
include /path/to/security-headers.snippet.conf; # MUST repeat
add_header Cache-Control "public, max-age=31536000, immutable" always;
try_files $uri =404;
}
location / {
try_files $uri /index.html;
}
}Verify with curl -sI https://yourdomain.com/ | grep -iE 'strict|frame|csp|referrer|nosniff'. You should see all five. If any one is missing on any specific path, that path has an add_header somewhere that is replacing the parent set.
The handover doc
A single Markdown file at the root of your repo. Six sections, each one short. The point is not to be comprehensive — it is to be findable when something breaks and the engineer is panicking.
# Production runbook ## How to deploy `./deploy.sh` from the repo root. Builds frontend, pm2 reload backend, reloads nginx, smoke-tests /api/health. Logs to /var/log/deploy.log. ## How to roll back `sudo ln -sfn /var/www/myapp-PREVIOUS /var/www/myapp` — instant. Find PREVIOUS with `ls -t /var/www/myapp-* | head`. ## Common incidents - 502 from nginx: backend down. `pm2 logs my-backend --lines 100`. - 503 with maintenance:true: somebody toggled maintenance mode. Flip back in the admin panel. - /api/health says db_unreachable: MySQL down. `sudo systemctl status mysql`. ## Backups Nightly mysqldump to /backups/. Retention 14 days. Test restore quarterly with the script in ops/restore-drill.sh. ## Where everything lives - App code: /home/USER/your-repo - Web root: /var/www/myapp (symlink to /var/www/myapp-TIMESTAMP) - nginx config: /etc/nginx/sites-available/myapp - TLS certs: /etc/letsencrypt/live/yourdomain.com/ - PM2 logs: ~/.pm2/logs/ - DB backups: /backups/ ## Contacts - UptimeRobot dashboard: <url> - Stripe dashboard: <url> - Domain registrar: <name> - DNS: Route 53, hosted zone <id>
The honest chapter
You have read the whole thing. Either you are now going to spend the next month doing this work yourself, or you are going to hire somebody. Both are correct answers depending on the variables. Here is the honest math.
If you DIY this
Expect 60-100 hours of real work. Not 60 hours of typing — 60 hours of typing, waiting, googling error messages, trying fixes that don't work, eventually finding the right one. The chapters above are the recipe; what you cannot see in the recipe is the time between the steps. That is where the actual cost lives.
The hidden second cost is the ongoing tax. Once you have built this stack, you maintain it: a Node CVE every two weeks, an Ubuntu kernel upgrade every six, a TLS cert that almost always auto-renews. Budget two hours a month after the build is done, more if you add features that touch infrastructure.
If you hire this
A 6-week Build Sprint engagement runs $10K-$25K depending on scope. The high end is appropriate if your app has unusual integrations, a complex auth model, or you want me to also do the database schema review. Lower end if your app is conventional and the work is mostly the chapters above.
The trade is straightforward. At a value-of-time over $80/hour, hiring is cheaper than DIY before you count the difference in what you ship to your customers during those weeks. At a value-of-time below $40/hour and a tolerance for the learning curve, DIY is fine. Between those numbers, it depends on whether you would rather be writing infra or writing product.
What you actually pay for
Speed. A consultant who has done this twenty times ships week 1's work in a day. You will ship the same work in a week the first time. That is not a criticism — it is what learning costs. The consultant is the one who already paid.
Pattern recognition. Half of the chapters above are not in the official docs of the things they describe. Stripe's webhook ordering footgun is famous in the engineer community but absent from the Stripe quick-start. Same for nginx's add_header inheritance rule, the mysqldump tablespaces grant, and the bcrypt timing leak. The consultant has stepped on all of them already; the recipe you are reading is the compressed lessons from those mistakes.
A second pair of eyes. The most useful thing about hiring somebody for a 6-week sprint is not the code they write — it is the questions they ask in week 1. The ones that go “wait, what happens if a user deletes their account while a Stripe webhook is in flight?” The chapters above will not catch every one of those for you. A senior engineer will.
You build the product. I take the rest off your plate.
Whether you build it yourself with the recipe above or you wire me $20K to do it, the chapters are yours. The 30-minute intro is the next step either way.