Disclosure: I am a senior backend tech lead in Paris and I run HostingGuru, a small European PaaS. This article mentions HostingGuru once near the end. The content works on any platform you choose.
Last month, a non-tech founder messaged me at 23h on a Tuesday. Her Lovable-built waitlist had hit Product Hunt at noon Pacific, racked up 4,000 signups by dinner, and the dashboard was now returning a blank white screen. She had pushed nothing. Nothing changed. She also could not log in to her own admin panel.
I have seen this exact failure mode roughly 12 times in the last 8 months. Different founder, different tool (sometimes Lovable, sometimes Bolt, sometimes Cursor, sometimes Claude Code), same six holes. The code the model wrote was fine. Production exposed assumptions the model could not see.
This is a field guide to those six holes. If you have shipped (or are about to ship) anything you built with an AI coding tool, you will recognize at least three of them.
Why vibe-coded apps fail in production specifically
A coding model writes the code in front of it. It does not see your DNS, your env vars, your cold-start behavior, your database connection limits, your timezone, or the fact that your "production" environment is actually your laptop with ngrok pointed at it.
Models are also trained on a corpus where "it works locally" usually means "it works." For a side project on a single laptop, that is true. For a deployed app with real users on real networks across real timezones, it is wildly false. The model is not lying. It just has no production telemetry. You have to add that yourself.
Here are the six patterns. Each one I have personally pulled out of a vibe-coded codebase in the last year.
Pattern 1: The API key that shipped in the JS bundle
Roughly 7 out of 12 vibe-coded apps I audited had at least one API key directly in client-side code. The model put it there because the model was told "call the Stripe API from this React form." The model did not say "by the way, this key is now visible to every browser on Earth via View Source."
The tell: you grep the dist/ or .next/ build output for the first few characters of your key, and you find it.
# Run this against your built output before deploying
cd dist && grep -r "sk_live_" . || echo "Clean"
cd .next && grep -r "sk_live_" . || echo "Clean"
Fix: every secret call goes through a backend route. The client sends a request to your server. Your server, holding the secret in an env var, calls the third party. Even for a static site, you can run a tiny serverless function or a single Express route for this. Costs nothing, takes 15 minutes.
If you only learn one thing from this post, learn this one. Leaked Stripe keys, leaked OpenAI keys, and leaked Resend keys cost real money and real customer trust. I have seen one founder rack up $1,800 in OpenAI charges in 11 hours because a leaked key got scraped off a GitHub mirror.
Pattern 2: The unbounded query that nukes the database
The model writes SELECT * FROM users WHERE org_id = ? and it works fine because in development you have 4 users. The same query in production, when your largest customer has 80,000 users, returns 80,000 rows over the wire, into your Node process, into JSON, and onto the network. Your API server's memory spikes. Your database connection pool gets exhausted. Your dashboard returns the blank white screen.
This is the most common Lovable / Bolt failure I see, by a wide margin.
The fix is paginate everything and put an upper bound on every query. Pick a default limit (50 is fine for most things), enforce it at the query layer, and only allow the client to ask for more in explicit chunks.
// Bad (what the model wrote)
const users = await db.query('SELECT * FROM users WHERE org_id = $1', [orgId]);
// Better
const limit = Math.min(parseInt(req.query.limit) || 50, 200);
const offset = parseInt(req.query.offset) || 0;
const users = await db.query(
'SELECT * FROM users WHERE org_id = $1 ORDER BY id LIMIT $2 OFFSET $3',
[orgId, limit, offset]
);
While you are in there, add an index on the column you are filtering by. Most vibe-coded apps have zero indexes outside the ones the ORM created automatically on primary keys.
Pattern 3: The CORS wildcard that opens the front door
Every model I have worked with, when asked "I am getting a CORS error," will reliably suggest setting Access-Control-Allow-Origin: *. It removes the error. It also opens your API to every site on the public internet, which is a problem the moment you have a session cookie or an auth token.
The right answer is to whitelist your own domain (and your staging domain, and localhost:3000 for dev).
// Express
const allowedOrigins = [
'https://yourapp.com',
'https://staging.yourapp.com',
'http://localhost:3000',
];
app.use(cors({
origin: (origin, callback) => {
if (!origin || allowedOrigins.includes(origin)) {
callback(null, true);
} else {
callback(new Error('Not allowed by CORS'));
}
},
credentials: true,
}));
If your AI tool wrote app.use(cors()) with no options, you have a wildcard. Audit it.
Pattern 4: The timezone mismatch nobody notices until billing
Your server is on UTC. Your dev laptop is on Paris time, or PST, or Bangalore. Your users are everywhere. The model probably wrote new Date() in a dozen places and the dates look "correct" to you because you are the only person who has looked at them.
Then a customer in Tokyo says "your invoice has the wrong month" and you discover that your billing cron, which runs at "midnight" your time, actually fires on the wrong day for half your customer base.
Three rules that catch most of these:
Store everything in UTC in the database. Always. ISO 8601, with the Z. If your ORM is doing anything else, fight it.
Display in the user's timezone, computed on the client side or with the user's stored preference. Never assume server-local.
For scheduled jobs (billing runs, daily digests, weekly emails), use cron-style scheduling that is explicit about timezone, or schedule them in UTC and document the offset.
// Use Intl on the client for display
const userTz = Intl.DateTimeFormat().resolvedOptions().timeZone;
const formatted = new Intl.DateTimeFormat('en-US', {
timeZone: userTz,
dateStyle: 'medium',
timeStyle: 'short',
}).format(new Date(isoStringFromAPI));
This bug compounds with Pattern 2. If you have an unbounded query plus a bad timezone, you can ship invoices in the wrong currency to the wrong country with no audit trail. I have seen it.
Pattern 5: The file path that works on your laptop and nowhere else
fs.readFileSync('./data/seed.json') works perfectly when you run npm start from your project root. It explodes when your deploy runs from a different working directory, or when the file got excluded by your .gitignore, or when your build step did not copy it into the production bundle.
Same pattern: import config from '../../config/local.json'. The relative path resolves in development. In production, after the bundler did its thing, the path no longer exists.
The fix is to treat config as environment variables (the 12-factor way) and to load runtime data from a real data store, not from disk.
// Bad
const config = require('./config/local.json');
// Better
const config = {
apiUrl: process.env.API_URL,
dbUrl: process.env.DATABASE_URL,
stripeKey: process.env.STRIPE_SECRET_KEY,
};
// Validate at boot so you fail loudly, not silently
const required = ['API_URL', 'DATABASE_URL', 'STRIPE_SECRET_KEY'];
const missing = required.filter(k => !process.env[k]);
if (missing.length) {
console.error('Missing env vars:', missing.join(', '));
process.exit(1);
}
That process.exit(1) is doing a lot of work. It is the difference between "the app crashes loudly at boot with a clear error" and "the app appears to boot fine but then 500s on the first request that needs Stripe."
Pattern 6: The background work that never runs
Sending an email after signup. Charging a customer's card on the 1st of each month. Cleaning up expired sessions. The model wrote these as inline calls inside your HTTP handler, or as setTimeout calls that get dropped when the process restarts.
The first time you redeploy, the queued work disappears. The first time your server cold-starts, the in-memory timers vanish. The first time an email fails to send (because Mailgun returned a 429), your signup handler returns 500 and the user thinks the app is broken.
You need three things: a real background process (a worker), a real schedule (cron, not setTimeout), and retry logic with a dead-letter behavior.
Lightweight options that have worked for me on small teams: BullMQ on top of Redis, RQ for Python, Sidekiq for Rails, GoCron for Go. Or use your platform's managed worker tier if it has one.
The pattern in code:
// In the HTTP handler, do NOT send the email inline
await db.users.create({ email, hashedPassword });
await emailQueue.add('send-welcome', { userId: newUser.id });
return res.json({ ok: true });
// In the worker process
emailQueue.process('send-welcome', async (job) => {
const user = await db.users.findById(job.data.userId);
await mailer.send({
to: user.email,
template: 'welcome',
});
// BullMQ retries automatically with exponential backoff
});
If something fails repeatedly, it goes to the dead-letter queue and you see it in a dashboard. The user's signup never broke. The email gets retried until it succeeds, or you decide it is hopeless.
What I built, because I kept solving these manually
After roughly the eighth time a non-tech founder messaged me at midnight with a vibe-coded app on fire, I built HostingGuru. It is a small European PaaS that runs your app from a GitHub repo, gives you a worker process if you need one, and watches your logs with a pattern-detection layer that catches the failure modes above (retry loops, hot Sentry fingerprints, latency spikes, silent cron failures) and pings me on Telegram. Free tier never sleeps, EU and US regions, encrypted env vars by default.
I am not telling you to use it. The patterns above work on Render, Railway, Fly, Vercel, AWS, or a Hetzner box you set up yourself. The point is: pick a platform that lets you have a worker process, real env vars, and log access. If your current setup does not, fix that before anything else.
What to do tonight, regardless of which platform you use
If you have a vibe-coded app in production right now, run this sweep. 30 minutes, maybe 45.
grep -r "sk_live_\|api_key\|secret" dist/ .next/ public/ build/against your built output. If anything matches, move the call to a backend route before you sleep.Open your three most-used database queries. Check for
LIMIT. If it is missing, add it and a default of 50.Search your codebase for
cors()with no arguments, orAccess-Control-Allow-Origin: *. Replace with a whitelist of the 2 or 3 origins you actually need.Pick one date-handling code path (signup, billing, scheduled email). Verify it stores UTC and displays in user-local. If you cannot tell, log the
Zsuffix on the stored string and theIntl.DateTimeFormatoutput on the client.List your env vars. For each one, ask: would my app boot, or would it boot and then 500 on the first relevant request? Add a boot-time validator that crashes the process loudly if anything is missing.
Find the one most important background job (welcome email, daily report, billing run). Verify it runs in a worker process or scheduled cron, not inside an HTTP handler.
Set up one alert. Telegram, email, Slack, anything. The trigger does not need to be smart. "Error rate above 1% for 5 minutes" or "background job failed 3 times in a row" is enough to catch 80% of the bad nights.
The honest closing
Vibe-coded apps are not bad. They get founders shipping who otherwise could not ship, and that is wonderful. But the production gap is real, and every model I have used (Claude included) will confidently generate code that hides the six holes above. The model is doing what it was asked. You have to ask the next question.
I am curious: which of these six bit you the hardest? Or did you hit one I did not list? I have a strong suspicion there is a seventh I have not seen yet, and the comments here have been a good source of bug reports in the past.
Previous posts in this HostingGuru series:
- Heroku just went into "sustaining engineering mode." Here are 5 alternatives whose free tier actually doesn't sleep.
- I built my MVP with Claude Code. Now I need to deploy it. Here's what nobody tells you.
- Your AI app is silently burning $2,000/month and you don't know it. Here are the 5 patterns that bite founders.
- Telegram alerts for any production app, a 5-minute setup (no SaaS, no signup, just curl)
- How I built a Discord 'ship-tracker' bot in a weekend (and the 3-process architecture that keeps it alive 24/7)
- I migrated 12 client projects off Heroku. Here's the playbook (and the 7 things that bit me every single time).
- The Claude Code → production checklist: 15 things that aren't obvious until they bite you
- Your indie SaaS has zero working Postgres backups. Here's the 20-minute fix.
- Your Stripe webhook is going to silently drop a paid customer. Here are the 4 patterns that catch it before they chargeback.
- Your crontab is silently failing. The 5 silent killers of VPS-based cron jobs.
Top comments (0)