Deploying Your First Production App the Right Way
The gap between "it runs" and "it's in production"
You built something. It runs on your laptop. You git push, SSH into a server, run node server.js, see it respond on port 3000, and call it deployed. Then you close your terminal, and the app dies. Or it survives until the server reboots. Or it works, but a user's password is in your logs, the app is running as root, and the database is open to the entire internet.
Every one of these is the same lesson: deploying isn't running your app on a different computer. It's running it in a way that survives reality. Reality means crashes, reboots, traffic spikes, attackers scanning your ports, and a future you who needs to know why it broke at 3am.
The good news: "production-ready" is not a vague vibe. It's a concrete, finite checklist. This article walks the whole thing, and by the end you'll have a mental model of the minimal real deployment plus the commands to build it.
Note: Who this is for: Beginners who can build an app but have never deployed one properly, or who deployed one and got burned. If you know how to SSH into a server and run a command, you have enough to follow. Examples use a Linux server and a Node app, but every concept maps to Python, Go, or anything else.
What "production" actually means
Production is the environment where your code meets real users, real traffic, and real consequences, so it has to survive crashes, reboots, and bad actors without you watching it. The leap from your laptop isn't about a fancier machine. It's about removing every assumption that quietly holds your dev setup together: that you're watching the terminal, that nobody's attacking you, that the process never crashes, that secrets in a file are fine.
Think of it like the difference between cooking at home and running a restaurant kitchen:
| In the real world | In tech |
|---|---|
| ๐ณ You watch the pan the whole time | A process manager watches the app |
| ๐ฅ Smoke alarm + sprinklers | Health checks + auto-restart |
| ๐ช Locked door, staff-only areas | HTTPS, private network, non-root user |
| ๐ Order tickets you can review | Structured logs |
| ๐ A manager noticing trouble | Basic monitoring + alerts |
Your laptop is home cooking. Production is a health-inspected commercial kitchen, same food, completely different rules. Each pair below is one item on the checklist. Let's see the shape of the whole thing first, then build it piece by piece.
The minimal production topology
Before any commands, get the picture in your head. Almost every small production app looks like this: traffic comes in over HTTPS, hits a load balancer, gets forwarded to your app on a private network, and your app talks to a managed database that the internet can never reach.
Notice what's exposed: only the load balancer. Your app server has no public IP. Your database has no public IP. This is the same layering as a VPC, public edge, private everything-else, and it's the difference between a deployment and a breach waiting to happen.
Step 1: Get config out of your code
On your laptop you hardcode the database URL or stick it in a committed file. In production that's two problems: you can't change it without a redeploy, and your secrets end up in Git forever. The fix is the oldest rule in the book: config lives in the environment, not the code. This is the heart of the Twelve-Factor App approach.
/etc/myapp/app.env # Config the app reads at startup, NEVER committed to Git
NODE_ENV=production
PORT=8080
DATABASE_URL=postgres://app:${DB_PASSWORD}@db.internal:5432/myapp
LOG_LEVEL=info
Warning: The mistake that leaks every secret: If you ever commit a .env file with a real password, assume it's compromised forever, rotate the credential immediately. Git history keeps deleted files. Add .env to .gitignore on day one, before the first commit, not after.
Step 2: Run it under a process manager
node server.js in your terminal dies when you log out or the process crashes. Production needs something that starts your app on boot, restarts it if it crashes, and runs it in the background. On Linux, the built-in answer is systemd, no extra tools, and it's what real servers use.
/etc/systemd/system/myapp.service
[Unit]
Description=My App
After=network.target
[Service]
User=appuser # NOT root, see step 5
EnvironmentFile=/etc/myapp/app.env # config from step 1
WorkingDirectory=/opt/myapp
ExecStart=/usr/bin/node server.js
Restart=always # crash? restart automatically
RestartSec=3
[Install]
WantedBy=multi-user.target
enable.sh
sudo systemctl daemon-reload
sudo systemctl enable myapp # start on every boot
sudo systemctl start myapp
sudo systemctl status myapp # is it healthy?
Tip: Restart=always is the single line that turns "my app died and I didn't notice for 6 hours" into "my app blipped for 3 seconds and recovered itself." It's free resilience.
Step 3: Add a health check endpoint
How does the load balancer know your app is alive and ready for traffic? It asks, repeatedly. You give it a tiny endpoint that returns 200 OK only when the app is genuinely healthy. If the check fails, the load balancer stops sending that instance traffic. This is non-negotiable: without it, a broken instance keeps receiving (and dropping) real user requests.
// A real health check verifies dependencies, not just "the process is up"
app.get("/healthz", async (_req, res) => {
try {
await db.query("SELECT 1"); // can we actually reach the DB?
res.status(200).json({ status: "ok" });
} catch {
res.status(503).json({ status: "unhealthy" });
}
});
Return 200 only when the app can do its job. A health check that always returns 200 (even when the database is down) is worse than none - it tells the load balancer to keep sending traffic into a black hole.
Step 4: Put HTTPS and a load balancer in front
Two jobs, one piece. HTTPS encrypts traffic so passwords and tokens aren't sent in plain text - non-negotiable in 2026, and browsers will shame you with "Not Secure" without it. A load balancer is the single public entry point that terminates TLS, runs your health checks, and forwards traffic to your app over the private network. Even with one app instance today, putting it behind a load balancer now means adding a second instance later is trivial - that's the whole story of load balancing and auto-scaling.
| Load Balancer | App Server | Database | |
|---|---|---|---|
| Public IP | Yes (the only one) | No | No |
| Handles HTTPS/TLS | Yes, terminates here | No (plain HTTP inside) | No |
| Reachable from | The internet | Only the load balancer | Only the app server |
| Health checked | It does the checking | Yes, by the LB | Yes, by the app's check |
What lives where, and what's exposed to the internet.
Tip: Get free, auto-renewing certificates from your cloud's certificate manager (AWS ACM, etc.) attached straight to the load balancer. No more manual cert renewals, no more expired-certificate outages at midnight.
Step 5: Don't run as root, and lock the network
If your app runs as root and an attacker finds a bug in it, they now own the entire server - install software, read every file, pivot to other machines. Run the app as a dedicated unprivileged user (appuser in step 2) that can do exactly one thing: run your app. If it gets compromised, the blast radius is tiny.
# A user with no login shell and no home dir, purely to run the app
sudo useradd --system --no-create-home --shell /usr/sbin/nologin appuser
sudo chown -R appuser:appuser /opt/myapp
Then lock the network with security groups (firewall rules):
- The load balancer accepts
:443from anywhere - The app accepts
:8080only from the load balancer - The database accepts
:5432only from the app
Each tier trusts only the tier directly in front of it.
Step 6: Logs and basic monitoring
When (not if) something breaks, logs are how you find out why. Two rules: log to stdout (let the platform collect it, don't write to files you'll forget about) and log structured JSON so you can search it. Then add the bare-minimum monitoring: an alert when the app is down, when errors spike, and when CPU or memory is pegged.
// Structured logs you can actually search later
function log(level: string, msg: string, extra = {}) {
console.log(JSON.stringify({
ts: new Date().toISOString(),
level,
msg,
...extra,
}));
}
log("info", "order created", { orderId: "o_123", userId: "u_45" });
// NEVER log secrets: no passwords, tokens, or full card numbers
Warning: The log that becomes a breach: Logging the full request body or auth headers "for debugging" is how passwords and session tokens end up sitting in plain text in your log store. Redact secrets before they're ever logged.
Common mistakes that cost hours
- No process manager. Running the app in a raw terminal or
nohup. It dies on logout or first crash and stays dead until a human notices. Usesystemd(or your platform's equivalent) withRestart=always. - Health check that always says OK. It returns
200even when the database is unreachable, so the load balancer keeps routing traffic to a broken instance. Check real dependencies. - Running as root. One app bug becomes full server takeover. Always run as a dedicated unprivileged user.
- Database in a public subnet with a password as the only defence. Bots scan the entire internet for open database ports constantly. The database should have no public IP, reachable only from the app tier.
- Secrets committed to Git. Once it's in history, it's compromised.
.gitignoreyour env files before the first commit, and use a secrets manager for anything sensitive. - No HTTPS. Plain HTTP sends passwords in cleartext over the wire. Terminate TLS at the load balancer with a managed certificate - it's free.
Where to go next
The whole article in 7 lines:
- Production means surviving crashes, reboots, and attackers without you watching, not just running on another computer.
- Config lives in the environment, never in committed code. Secrets out of Git, always.
- A process manager (systemd) restarts your app on crash and starts it on boot.
- A health check that tests real dependencies lets the load balancer route around broken instances.
- Put HTTPS + a load balancer in front; it's the only thing that should face the internet.
- Run as a non-root user and lock each tier's firewall to the tier in front of it.
- Log structured JSON to stdout (never secrets) and alert on down / errors / saturation.
You now have the checklist. The fastest way to make it stick is to actually deploy something small end-to-end, then deepen each piece:
- Scale beyond one instance: Load Balancing & Auto-Scaling Explained picks up exactly where this leaves off.
- Pick the right data store and let the cloud run it for you: Managed Databases: Relational vs NoSQL.
- Understand the private network everything sits on: Cloud Networking Fundamentals: VPC.
- Get hands-on with the server side in the browser: the Linux Lab.
Deploy one tiny app properly today - env config, systemd, health check, HTTPS, non-root, logs. Do it once and you'll never go back to node server.js in a terminal again.
Originally published on TheSimplifiedTech, where this guide is interactive, with in-browser terminal labs and diagrams. Learn cloud and DevOps by doing, no videos.
Comments
No comments yet. Start the discussion.