"It works on my laptop" and "it's in production" are two completely different worlds. This is the checklist that bridges them, env config, a process manager, health checks, HTTPS, logs, not running as root, a load balancer, and basic monitoring, explained so a beginner can ship today without leaving a security hole behind.
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.
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:
๐ณ You watch the pan the whole timeA process manager watches the app
๐ช Locked door, staff-only areasHTTPS, private network, non-root user
๐ Order tickets you can reviewStructured logs
๐ A manager noticing troubleBasic 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.
The minimal real deployment. The user only ever talks HTTPS to the load balancer, the single public-facing piece. The app runs on a private network and is the only thing allowed to reach the database, which has no public address at all.
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
bash
# 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
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
bash
[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
bash
sudo systemctl daemon-reload
sudo systemctl enable myapp # start on every boot
sudo systemctl start myapp
sudo systemctl status myapp # is it healthy?
Pro 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.
health.ts
typescript
// 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.
Pro 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.
harden.sh
bash
# 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 :443 from anywhere; the app accepts :8080 *only from the load balancer*; the database accepts :5432 *only from the app*. Each tier trusts only the tier directly in front of it. Practise this exact layering in the Linux Lab.
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.
logging.ts
typescript
// Structured logs you can actually search laterfunctionlog(level: string, msg: string, extra = {}) {
console.log(JSON.stringify({
ts: newDate().toISOString(),
level, msg, ...extra,
}));
}
log("info", "order created", { orderId: "o_123", userId: "u_45" });
// NEVER log secrets: no passwords, tokens, or full card numbers
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. Use systemd (or your platform's equivalent) with Restart=always.
Health check that always says OK. It returns 200 even 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. .gitignore your 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:
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.
Want to go deeper?
This article covers concepts taught hands-on in the Cloud Engineer and DevOps career paths, with real terminal labs, production scenarios, and structured lessons.