What I Discovered After Shipping a Production App (That No Tutorial Ever Taught Me)
WhatIDiscoveredAfterShippingaProductionApp(ThatNoTutorialEverTaughtMe)

I thought knowing how to code was enough.
I'd built projects, followed tutorials, connected APIs, deployed things to Netlify. I felt ready. Then I shipped a real production application — a rideshare platform with real users, real payments, and real consequences when things broke — and discovered I had been living in a very comfortable illusion.
Tutorials teach you how to build features. Production teaches you how to survive after you've built them. These are completely different educations.
Here's what I wish someone had told me before I shipped my first real app.
1. Your Database Port Is Open to the Internet by Default
This one kept me up at night when I first understood it.
When you spin up a PostgreSQL server on a raw VPS, port 5432 — the port Postgres listens on — is publicly accessible by default. That means anyone on the internet can attempt to connect directly to your database. Not to your API. To the database itself.
I was running a production application without knowing this was happening.
The fix is a firewall rule that sounds simple but nobody in any tutorial ever mentioned it:
# Block all incoming traffic to Postgres from the public internet
ufw deny 5432
# Only allow connections from localhost (your own server)
ufw allow from 127.0.0.1 to any port 5432
Or in Docker Compose, bind the port to localhost only:
postgres:
image: postgres:16-alpine
ports:
- "127.0.0.1:5432:5432" # ✅ internal only
# NOT "5432:5432" # ❌ this exposes to the public internet
The broader lesson here is the difference between a VPS and a VPC — two terms that sound identical but mean completely different things. A VPS is the machine you rent. A VPC is the private network security boundary around it. When you use Railway or Render, they manage both for you. When you use a raw VPS, you get the machine but you're responsible for the security boundary yourself.
Every production server needs both.
2. Redis Doesn't Remember Anything After a Restart
Redis is an in-memory database. That means when the server restarts — planned or unplanned — everything stored in memory is gone by default.
If you're using BullMQ for background jobs (sending emails, processing payments, triggering notifications), every queued job disappears on restart. Users who were waiting for a confirmation email, payment jobs that hadn't completed, retry jobs for failed operations — all of it, silently gone.
The fix is enabling AOF (Append Only File) persistence, which logs every write command to disk as it happens:
# redis.conf
appendonly yes
appendfsync everysec # flush to disk every second
With AOF enabled, if Redis crashes and restarts, it replays every logged command and fully recovers the queue state. You lose at most one second of data instead of everything.
This is the kind of configuration that nobody mentions in a "Getting Started with Redis" tutorial, but that will absolutely burn you in production without it.
3. Bugs Don't Announce Themselves
In a tutorial project, when something breaks, you know immediately — you're sitting right there watching it.
In production, a critical error can happen at 2am, affect dozens of users, and you won't know until someone tweets about it or a user messages support. By the time you find out, the problem has been happening for hours.
You need error monitoring software that catches exceptions automatically and alerts you before users do. Sentry is the standard. When your NestJS app throws an unhandled error, Sentry captures it with the full stack trace, the user's context, the sequence of events that led to it, and how many people were affected — all in real time.
The moment I added Sentry to my production app, I discovered errors I had no idea were happening. Not critical ones, but real failures that real users were experiencing silently. Without Sentry, I would never have known.
Monitoring is not optional. It's how you find out your app is broken before your users tell you.
4. "It Works on My Machine" Is a Real Production Crisis
Every developer has said this. It sounds like an excuse. In production, it's a genuine emergency.
Your local machine runs Node 18. Your server runs Node 20. Your local OS is macOS. Your server is Ubuntu. Your local environment has a .env file with certain variables. Your server has different ones, or is missing some entirely.
Any one of these mismatches can cause behaviour that works perfectly locally and fails completely in production, with no obvious error message explaining why.
Docker exists entirely to solve this problem. A Docker container packages your application with its exact runtime — the specific Node version, OS libraries, environment configuration — into an image. That image runs identically on your laptop, your teammate's Windows machine, and your Ubuntu server.
The shift in thinking is: you're no longer deploying code. You're deploying an environment that contains your code. The environment is the same everywhere, which means "it works on my machine" and "it works in production" become the same statement.
5. Database Migrations Can Kill Your App Mid-Deploy
Imagine you have a column in your database called phone. You want to rename it to phoneNumber. You write a migration, deploy it, and your app crashes for every user currently using it.
Why? Because while your new code is deploying, your old code is still running — and the old code is looking for a column called phone that no longer exists. You've broken production for the window between when the migration runs and when all old instances shut down.
The solution is something called the Expand-Contract pattern, and it's one of those concepts that feels obvious in hindsight but that I had never heard of:
Deploy 1 (Expand): Add new column phoneNumber. Write to BOTH
columns in code. Old code still works.
Deploy 2 (Backfill): Copy all data from phone into phoneNumber
for existing rows.
Deploy 3 (Contract): Remove old phone column. Clean up dual-write.
Three deploys instead of one. Zero downtime. No users affected.
The rule: your new database state must always be backwards-compatible with your currently running code. If the old code can't work with the new schema, your deploy window is a production outage.
6. CI/CD Is the Difference Between "Deploy and Pray" and Actual Engineering
For a long time, my deploy process was: finish the feature, push to GitHub, SSH into the server, pull the code, restart the process, refresh the browser, hope nothing broke.
That's not a deployment process. That's a ritual.
A CI/CD pipeline (Continuous Integration / Continuous Deployment) means that every push to your main branch automatically runs your tests, builds your Docker image, and deploys to your server — without you touching anything. More importantly, if the tests fail, the deploy never happens. Broken code never reaches production.
With GitHub Actions, this looks like:
name: Deploy
on:
push:
branches: [main]
jobs:
test-and-deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npm test # if this fails, deploy stops here
- name: Deploy
run: npx @railway/cli up
env:
RAILWAY_TOKEN: ${{ secrets.RAILWAY_TOKEN }}
The psychological shift matters too. When you know tests run automatically on every push, you stop being afraid of deploying. Deploys become boring and frequent instead of stressful and rare. That's exactly what you want.
7. Technical Debt Is a Real Cost, Not Just Messy Code
Early in a project, you make shortcuts. You hardcode a value instead of making it configurable. You write a function that does three things instead of one. You skip the error handling because you're moving fast.
That's fine. That's how products get shipped.
The problem is when those shortcuts accumulate invisibly. Every shortcut you take today adds time to the next feature that touches the same code. A module that took two hours to write with shortcuts might take eight hours to modify later, because nobody fully understands it anymore — including you.
This is technical debt. And unlike financial debt, it doesn't send you statements. It just silently makes everything slower.
The thing I had to learn was how to talk about it — not to other developers, but to clients and product owners who don't care about code quality. The framing that actually works:
"We can ship this payment feature in three days. But the code around it has some shortcuts from last month that will make every future payment feature take twice as long. If we take one extra day now to clean it up, the next four payment features each save a day. That's three days invested for twelve days recovered."
Technical debt expressed as a business trade-off is a conversation stakeholders can engage with. Technical debt expressed as "the code is messy" is a conversation they'll dismiss.
The Real Lesson
Every one of these things is invisible until something breaks. You don't go looking for Redis persistence documentation on a calm Tuesday afternoon. You find it at 2am when a deploy restarted your server and a queue full of payment jobs disappeared.
That's how production teaches you — through failure, under pressure, with real consequences.
Tutorials cannot replicate that. They can only teach you how to build features in ideal conditions. Production teaches you how to build systems that survive non-ideal ones.
The gap between those two educations is enormous. And most self-taught developers don't even know the gap exists until they're standing in it.
I'm documenting everything I learn as I go — the production failures, the configuration decisions, the architecture patterns that nobody explains until you need them. If you're a self-taught developer who suspects you might be missing something, you probably are. And that's completely okay. The first step is knowing what questions to ask.
These lessons came from building a real rideshare application in production — handling payments, real-time location, background job queues, and third-party integrations. If any of this resonates, follow along for more.
Have a question about "What I Discovered After Shipping a Production App (That No Tutorial Ever Taught Me)"?
Our AI can answer specific questions based on the content of this article.
Share this article