Mastering Zero-Downtime Deployments: Node.js and React Application Updates with PM2

Look, I’ll be honest with you – deploying changes into production used to make me nervous. If you make one mistake, your users get error pages, your boss gets angry emails, and you have to quickly undo changes at 2 AM. But here’s the deal: it doesn’t have to be like that.

I have been in charge of production deployments for years, and I have learned that the key is not just knowing the commands but also understanding the workflow. Today, I’m going to show you step by step how I update a working Node.js and React app without missing a single request. We will be using a functioning Todo app that runs on an operational VPS, and I’ll show you how to do it all.

This guide will help you sleep better at night, whether you’re fixing a bug quickly or adding a big new feature.

Understanding Your Production-like Environment

Before we do anything in production, let’s take a minute to get acquainted about what we’re working with. This is the stack we have to work with:

  • Nginx on port 80 – and handles incoming traffic and serves our React files.
  • Node.js Backend – on port 5000 is our Express API, which is kept running by PM2.
  • React Frontend – is made up of static files built with Vite. PostgreSQL stores all of our data.
  • PostgreSQL – stores all of our data.
  • PM2 – the underrated hero that makes everything work.

Nginx is like your front door, and React is like your living room that guests see. Your kitchen is Node.js where the real work gets done, and PostgreSQL is your storage room. PM2? That’s your security system making sure the kitchen is always open.

Let’s Checkout What’s Running

First, connect to your server using SSH. I’m using my VPS at 85.29.10.87:

ssh devopslinx@85.29.10.87

Now, let’s check what processes are actually running:

ps aux | grep -E 'node|nginx|postgres' | grep -v grep

Here’s what I see on my server:

postgres 1620071  0.0  0.3 223252 31360 ?        Ss   Mar02   0:16 /usr/lib/postgresql/16/bin/postgres -D /var/lib/postgresql/16/main
devopsl+ 1621070  0.2  0.8 11786920 72380 ?      Ssl  Mar02   8:36 node /home/devopslinx/todo-app/backend/server.js
root     1621722  0.0  0.0  11160  1976 ?        Ss   Mar02   0:00 nginx: master process /usr/sbin/nginx -g daemon on; master_process on;
www-data 1621723  0.0  0.0  12880  4920 ?        S    Mar02   0:00 nginx: worker process

PostgreSQL is humming along (PID 1620071), my Node.js backend is running (PID 1621070), and Nginx has its master and worker processes ready to handle traffic.

Now let’s check PM2 – this is where the magic happens:

pm2 list
┌────┬─────────────┬─────────────┬─────────┬─────────┬──────────┬────────┬──────┬───────────┬──────────┬──────────┬──────────┬──────────┐
│ id │ name        │ namespace   │ version │ mode    │ pid      │ uptime │ ↺    │ status    │ cpu      │ mem      │ user     │ watching │
├────┼─────────────┼─────────────┼─────────┼─────────┼──────────┼────────┼──────┼───────────┼──────────┼──────────┼──────────┼──────────┤
│ 0  │ todo-api    │ default     │ 1.0.0   │ fork    │ 1621070  │ 2D     │ 0    │ online    │ 0%       │ 70.2mb   │ devopsl… │ disabled │
└────┴─────────────┴─────────────┴─────────┴─────────┴──────────┴────────┴──────┴───────────┴──────────┴──────────┴──────────┴──────────┘

Perfect! The app has been running for 2 days straight with zero restarts (see that ↺ column?). This is the baseline we want to maintain.

Quick sanity check on the app structure:

ls -la ~/todo-app/
total 16
drwxr-xr-x 4 devopslinx devopslinx 4096 Mar 02 02:27 .
drwxr-xr-x 9 devopslinx devopslinx 4096 Mar 02 02:30 ..
drwxr-xr-x 3 devopslinx devopslinx 4096 Mar 02 05:28 backend
drwxr-xr-x 3 devopslinx devopslinx 4096 Mar 02 05:26 frontend

Let’s verify the database is responding:

PGPASSWORD=todopass123 psql -h localhost -U todouser -d tododb -c 'SELECT COUNT(*) FROM todos;'
 count
-------
     3
(1 row)

And finally, let’s ping the API to make sure it’s responding:

curl -s http://localhost:5000/api/health
{"status":"OK","message":"Server is running"}

Alright, everything looks good. We’re ready to deploy some changes.

Deploying Backend Changes (The Safe Way)

Here’s where most people mess up. They SSH in, edit the file, restart the server, and hope for the best. Don’t do that. Let me show you the professional approach.

Step 1: Always Backup First

I cannot stress this enough – ALWAYS backup before you touch production code. I’ve been saved by backups more times than I care to admit:

cd ~/todo-app/backend
cp server.js server.js.backup.$(date +%Y%m%d-%H%M%S)

Let’s verify it worked:

ls -lh server.js*
-rwxr-xr-x 1 devopslinx devopslinx 3.3K Mar 02 02:30 server.js
-rwxr-xr-x 1 devopslinx devopslinx 3.3K Mar 02 10:45 server.js.backup.20240302-104523

Good. Now if something goes wrong, we have a timestamped backup we can quickly restore.

Step 2: Making the Code Change

For this example, I’m adding a statistics endpoint to the API. Let’s see what we’re working with first:

head -20 ~/todo-app/backend/server.js
const express = require('express');
const cors = require('cors');
const pool = require('./db');
require('dotenv').config();

const app = express();
const PORT = process.env.PORT || 5000;

app.use(cors());
app.use(express.json());

// Health check endpoint
app.get('/api/health', (req, res) => {
  res.json({ status: 'OK', message: 'Server is running' });
});

// Get all todos
app.get('/api/todos', async (req, res) => {
  try {

Now I’ll add my new endpoint. First, let me create the code snippet:

cat > /tmp/stats_endpoint.txt << 'EOF'

// Get todo statistics
app.get('/api/stats', async (req, res) => {
  try {
    const stats = await pool.query(
      'SELECT COUNT(*) as total, SUM(CASE WHEN completed = true THEN 1 ELSE 0 END) as completed, SUM(CASE WHEN completed = false THEN 1 ELSE 0 END) as pending FROM todos'
    );

    res.json({
      total: parseInt(stats.rows[0].total),
      completed: parseInt(stats.rows[0].completed),
      pending: parseInt(stats.rows[0].pending)
    });
  } catch (err) {
    console.error(err.message);
    res.status(500).json({ error: 'Server error' });
  }
});
EOF

Now I’ll insert it into the server file right after the health check:

head -15 server.js > /tmp/server_new.js && \
echo "" >> /tmp/server_new.js && \
cat /tmp/stats_endpoint.txt >> /tmp/server_new.js && \
echo "" >> /tmp/server_new.js && \
tail -n +16 server.js >> /tmp/server_new.js && \
mv /tmp/server_new.js server.js

Let’s double-check the new code is in there:

head -35 server.js
const express = require('express');
const cors = require('cors');
const pool = require('./db');
require('dotenv').config();

const app = express();
const PORT = process.env.PORT || 5000;

app.use(cors());
app.use(express.json());

// Health check endpoint
app.get('/api/health', (req, res) => {
  res.json({ status: 'OK', message: 'Server is running' });
});

// Get todo statistics
app.get('/api/stats', async (req, res) => {
  try {
    const stats = await pool.query(
      'SELECT COUNT(*) as total, SUM(CASE WHEN completed = true THEN 1 ELSE 0 END) as completed, SUM(CASE WHEN completed = false THEN 1 ELSE 0 END) as pending FROM todos'
    );

    res.json({
      total: parseInt(stats.rows[0].total),
      completed: parseInt(stats.rows[0].completed),
      pending: parseInt(stats.rows[0].pending)
    });
  } catch (err) {
    console.error(err.message);
    res.status(500).json({ error: 'Server error' });
  }
});

Perfect! The new endpoint is in place.

Step 3: The Zero-Downtime Reload

This is the part that used to scare me until I learned about PM2’s reload feature. Unlike restart, which just kills and restarts your app (causing downtime), reload is graceful. Here’s what happens:

  1. PM2 starts a new instance of your app
  2. Waits for it to be ready
  3. Routes new traffic to the new instance
  4. Gracefully shuts down the old instance
  5. Your users never see an error

Watch this:

pm2 reload todo-api
Use --update-env to update environment variables
[PM2] Applying action reloadProcessId on app [todo-api](ids: [ 0 ])
[PM2] [todo-api](0) ✓

That checkmark is what you want to see. Now let’s verify:

pm2 status
┌────┬─────────────┬─────────────┬─────────┬─────────┬──────────┬────────┬──────┬───────────┬──────────┬──────────┬──────────┬──────────┐
│ id │ name        │ namespace   │ version │ mode    │ pid      │ uptime │ ↺    │ status    │ cpu      │ mem      │ user     │ watching │
├────┼─────────────┼─────────────┼─────────┼─────────┼──────────┼────────┼──────┼───────────┼──────────┼──────────┼──────────┼──────────┤
│ 0  │ todo-api    │ default     │ 1.0.0   │ fork    │ 1703070  │ 9s     │ 1    │ online    │ 0%       │ 58.5mb   │ devopsl… │ disabled │
└────┴─────────────┴─────────────┴─────────┴─────────┴──────────┴────────┴──────┴───────────┴──────────┴──────────┴──────────┴──────────┘

See what happened?
– PID changed from 1621070 to 1703070 (we got a fresh process)
– Uptime reset to 9 seconds (brand new instance)
– Restart counter went from 0 to 1
– Status stayed online the entire time

That’s zero-downtime deployment in action.

Step 4: Testing the Changes

Never assume the deployment worked. Always test:

curl -s http://localhost:5000/api/stats
{"total":3,"completed":0,"pending":3}

Excellent! The new endpoint works. But wait – that’s just the backend. Let’s make sure it’s accessible through Nginx:

curl -s http://85.29.10.87/api/stats
{"total":3,"completed":0,"pending":3}

Perfect! And let’s verify we didn’t break anything:

curl -s http://85.29.10.87/api/health
{"status":"OK","message":"Server is running"}

All good. Now check the logs to make sure there are no hidden errors:

pm2 logs todo-api --lines 20 --nostream
[TAILING] Tailing last 20 lines for [todo-api] process
/home/devopslinx/.pm2/logs/todo-api-error.log last 20 lines:
/home/devopslinx/.pm2/logs/todo-api-out.log last 20 lines:
0|todo-api | Server is running on port 5000
0|todo-api | Server is running on port 5000

Clean logs, no errors. This is a successful deployment.

Step 5: Monitor for a Bit

I like to keep an eye on the app for a few minutes after deployment. PM2 makes this easy:

pm2 monit

This opens a real-time dashboard showing CPU, memory, and logs. Press Ctrl+C when you’re satisfied everything is stable.

You can also check detailed process info:

pm2 show todo-api
Describing process with id 0 - name todo-api
┌───────────────────┬──────────────────────────────────────────────────┐
│ status            │ online                                           │
│ name              │ todo-api                                         │
│ restarts          │ 1                                                │
│ uptime            │ 5m                                               │
│ script path       │ /home/devopslinx/todo-app/backend/server.js      │
│ script args       │ N/A                                              │
│ error log path    │ /home/devopslinx/.pm2/logs/todo-api-error.log    │
│ out log path      │ /home/devopslinx/.pm2/logs/todo-api-out.log      │
│ pid path          │ /home/devopslinx/.pm2/pids/todo-api-0.pid        │
│ mode              │ fork_mode                                        │
└───────────────────┴──────────────────────────────────────────────────┘

Everything looks solid. Deployment complete!

Deploying Frontend Changes

Frontend deployments are actually simpler than backend because there’s no process to restart. Nginx serves static files, so we just need to upload new ones.

Step 1: Build on Your Local Machine

On your development machine, make your React changes and build:

cd /path/to/your/local/todo-frontend
npm run build
vite v4.5.0 building for production...
✓ 342 modules transformed.
dist/index.html                   0.45 kB │ gzip:  0.30 kB
dist/assets/index-DCTgUJ87.css    4.01 kB │ gzip:  1.24 kB
dist/assets/index-5F_pVDlP.js   196.25 kB │ gzip: 63.45 kB
✓ built in 3.42s

Your optimized production files are now in the dist/ folder.

Step 2: Backup Current Frontend

On the server, create a backup:

cd ~/todo-app
cp -r frontend frontend.backup.$(date +%Y%m%d-%H%M%S)

Verify:

ls -ld frontend*
drwxr-xr-x 3 devopslinx devopslinx 4096 Mar 02 05:26 frontend
drwxr-xr-x 3 devopslinx devopslinx 4096 Mar 02 11:15 frontend.backup.20240302-111530

Step 3: Upload the New Build

From your local machine, use scp to upload:

scp -r dist/* devopslinx@85.29.10.87:~/todo-app/frontend/
index.html                                    100%  455     8.2KB/s   00:00
index-DCTgUJ87.css                            100% 4013    72.1KB/s   00:00
index-5F_pVDlP.js                             100%  196KB   3.2MB/s   00:00
vite.svg                                      100% 1497    27.5KB/s   00:00

Or if you prefer rsync (I do, because it’s faster for updates):

rsync -avz --delete dist/ devopslinx@85.29.10.87:~/todo-app/frontend/
sending incremental file list
./
index.html
assets/
assets/index-DCTgUJ87.css
assets/index-5F_pVDlP.js

sent 201,245 bytes  received 92 bytes  134,224.67 bytes/sec
total size is 200,712  speedup is 1.00

Step 4: Verify the Upload

Back on the server, check the files:

ls -lah ~/todo-app/frontend/
total 20K
drwxr-xr-x 3 devopslinx devopslinx 4.0K Mar 02 11:18 .
drwxr-xr-x 4 devopslinx devopslinx 4.0K Mar 02 02:27 ..
drwxr-xr-x 2 devopslinx devopslinx 4.0K Mar 02 11:18 assets
-rw-r--r-- 1 devopslinx devopslinx  455 Mar 02 11:18 index.html
-rw-r--r-- 1 devopslinx devopslinx 1.5K Mar 02 11:18 vite.svg

Test that Nginx is serving the new files:

curl -I http://85.29.10.87/
HTTP/1.1 200 OK
Server: nginx/1.18.0 (Ubuntu)
Date: Sat, 02 Mar 2024 11:20:15 GMT
Content-Type: text/html
Content-Length: 455
Last-Modified: Sat, 02 Mar 2024 11:18:42 GMT
Connection: keep-alive
ETag: "676d8b82-1c7"
Accept-Ranges: bytes

The Last-Modified timestamp matches our upload time – Nginx is serving the new files immediately. No restart needed!

Step 5: Clear Browser Cache

Here’s a gotcha that trips up a lot of people: browsers cache static files aggressively. Your users might not see the changes right away. To force a refresh:

  • Chrome/Firefox: Ctrl+Shift+R (Windows/Linux) or Cmd+Shift+R (Mac)
  • Or open in incognito/private mode

Pro tip: Vite automatically generates hashed filenames (like index-5F_pVDlP.js) which acts as cache-busting. When you change the code and rebuild, the filename changes, forcing browsers to download the new version.

Database Migrations (Proceed with Caution)

Database changes are where things can really go wrong if you’re not careful. Here’s my battle-tested approach:

Step 1: Backup, Backup, Backup

Did I mention you should backup? Because you should REALLY backup before touching the database:

pg_dump -h localhost -U todouser tododb > ~/backup_$(date +%Y%m%d_%H%M%S).sql

Verify it:

ls -lh ~/backup_*.sql | tail -1
-rw-rw-r-- 1 devopslinx devopslinx 99K Mar 02 11:25 backup_20240302_112530.sql

Step 2: Test in a Safe Environment First

Never test migrations directly in production. Create a test database:

PGPASSWORD=todopass123 psql -h localhost -U todouser -d tododb -c 'CREATE DATABASE tododb_test TEMPLATE tododb;'

Test your migration there first, make sure it works, then proceed.

Step 3: Run the Migration

Connect to the production database:

PGPASSWORD=todopass123 psql -h localhost -U todouser -d tododb

Let’s say we’re adding a priority field to todos:

ALTER TABLE todos ADD COLUMN priority VARCHAR(20) DEFAULT 'medium';
ALTER TABLE

Verify the change:

\d todos
                                        Table "public.todos"
   Column    |            Type             | Collation | Nullable |              Default
-------------+-----------------------------+-----------+----------+-----------------------------------
 id          | integer                     |           | not null | nextval('todos_id_seq'::regclass)
 title       | character varying(255)      |           | not null |
 description | text                        |           |          |
 completed   | boolean                     |           |          | false
 created_at  | timestamp without time zone |           |          | CURRENT_TIMESTAMP
 priority    | character varying(20)       |           |          | 'medium'::character varying

Exit PostgreSQL:

\q

Step 4: Update Code if Needed

If your schema change requires code updates, deploy those backend changes using the PM2 reload workflow we covered earlier.

The beauty of adding a column with a DEFAULT value is that it’s backward compatible – the existing code keeps working while the new code can start using the new field.

Step 5: Verify Everything Works

Query the updated table:

PGPASSWORD=todopass123 psql -h localhost -U todouser -d tododb -c 'SELECT id, title, priority FROM todos LIMIT 3;'
 id |        title        | priority
----+---------------------+----------
  1 | Welcome to Todo App | medium
  2 | Learn React         | medium
  3 | Learn Node.js       | medium
(3 rows)

All existing todos got the default value. Perfect!

When Things Go Wrong: Rollback Procedures

Let’s face it – sometimes deployments don’t go as planned. Here’s how to quickly recover.

Rolling Back Backend Code

Restore your backup:

cd ~/todo-app/backend
cp server.js.backup.20240302-104523 server.js
pm2 reload todo-api

Test to make sure the rollback worked:

curl -s http://localhost:5000/api/stats

If you get a 404 error, the rollback succeeded (assuming /api/stats was the new endpoint you added).

Rolling Back Frontend

Delete the new files and restore the backup:

cd ~/todo-app
rm -rf frontend/*
cp -r frontend.backup.20240302-111530/* frontend/

Force refresh your browser to see the old version.

Rolling Back Database Changes

Restore from your backup:

PGPASSWORD=todopass123 psql -h localhost -U todouser -d tododb < ~/backup_20240302_112530.sql

For simple schema changes, you can revert manually:

PGPASSWORD=todopass123 psql -h localhost -U todouser -d tododb -c 'ALTER TABLE todos DROP COLUMN priority;'

Essential PM2 Commands (Your Daily Toolkit)

Here are the PM2 commands I use most often:

Deployment Commands

Zero-downtime reload (my go-to for deployments):

pm2 reload todo-api

Quick restart with brief downtime:

pm2 restart todo-api

Restart and update environment variables:

pm2 restart todo-api --update-env

Stop the application:

pm2 stop todo-api

Start it back up:

pm2 start todo-api

Monitoring Commands

Watch logs in real-time:

pm2 logs todo-api

View last 50 log lines:

pm2 logs todo-api --lines 50 --nostream

Real-time CPU and memory monitoring:

pm2 monit

Detailed process information:

pm2 show todo-api

List all processes:

pm2 list

Log Management

View only errors:

pm2 logs todo-api --err

View only output:

pm2 logs todo-api --out

Clear all logs:

pm2 flush

Process Management

Save current process list:

pm2 save

Restore saved processes:

pm2 resurrect

Remove process from PM2:

pm2 delete todo-api

Troubleshooting Real Deployment Issues

Let me share three problems I’ve actually encountered and how I solved them.

Problem 1: PM2 Reload Fails Immediately

So you run pm2 reload todo-api and instead of seeing that beautiful checkmark, your app shows “errored”. Here’s what I do:

Check the status:

pm2 status
┌────┬─────────────┬─────────────┬─────────┬─────────┬──────────┬────────┬──────┬───────────┬──────────┬──────────┬──────────┬──────────┐
│ id │ name        │ namespace   │ version │ mode    │ pid      │ uptime │ ↺    │ status    │ cpu      │ mem      │ user     │ watching │
├────┼─────────────┼─────────────┼─────────┼─────────┼──────────┼────────┼──────┼───────────┼──────────┼──────────┼──────────┼──────────┤
│ 0  │ todo-api    │ default     │ 1.0.0   │ fork    │ 0        │ 0      │ 15   │ errored   │ 0%       │ 0b       │ devopsl… │ disabled │
└────┴─────────────┴─────────────┴─────────┴─────────┴──────────┴────────┴──────┴───────────┴──────────┴──────────┴──────────┴──────────┘

That restart counter at 15 tells me PM2 is repeatedly trying to start the app but it keeps crashing. Time to check the logs:

pm2 logs todo-api --lines 30 --nostream
0|todo-api | Error: Cannot find module 'express'
0|todo-api |     at Function.Module._resolveFilename (internal/modules/cjs/loader.js:815:15)

Ah! Missing dependencies. This usually happens when you pull code changes but forget to run npm install:

cd ~/todo-app/backend
npm install
pm2 restart todo-api

Problem solved.

Problem 2: New Endpoint Returns 502 Bad Gateway

You deployed the new code, PM2 shows online, but hitting the endpoint gives you a 502 error. Frustrating, right?

First, test the backend directly:

curl -v http://localhost:5000/api/stats

If this works but http://85.29.10.87/api/stats returns 502, the issue is with Nginx, not your code.

Check the Nginx error logs:

sudo tail -f /var/log/nginx/error.log
2024/03/02 11:45:23 [error] 1621723#1621723: *234 connect() failed (111: Connection refused) while connecting to upstream, client: 192.168.12.10, server: 85.29.10.87, request: "GET /api/stats HTTP/1.1", upstream: "http://127.0.0.1:5000/api/stats"

“Connection refused” means Nginx can’t connect to your backend. Let’s check if Node.js is actually listening:

pm2 status
sudo ss -tlnp | grep :5000

If port 5000 isn’t listening, your backend crashed. Restart it:

pm2 restart todo-api

Problem 3: Users Still See Old Frontend

You deployed new frontend files but users are complaining they don’t see the changes. I’ve been there!

Solution 1: Browser cache is the most common culprit. Have them hard refresh with Ctrl+Shift+R.

Solution 2: Verify the files actually uploaded. SSH into the server:

ls -lah ~/todo-app/frontend/index.html

Check the timestamp. If it’s old, your upload didn’t work. Try uploading again.

Solution 3: Make sure Nginx is pointing to the right directory:

sudo grep "root" /etc/nginx/sites-enabled/todo-app

Should show:

        root /home/devopslinx/todo-app/frontend;

If the path is wrong, fix the Nginx config and reload:

sudo systemctl reload nginx

Best Practices I’ve Learned the Hard Way

Use Version Control

Don’t edit files directly on the server and hope for the best. Use Git:

git add .
git commit -m "Add statistics endpoint"
git push origin main

On the server:

cd ~/todo-app/backend
git pull origin main
pm2 reload todo-api

This gives you a proper audit trail and makes rollbacks trivial.

Implement Health Checks

Before declaring victory, verify the deployment actually works. I use a simple script:

#!/bin/bash
# health-check.sh

if curl -s http://localhost:5000/api/health | grep -q "OK"; then
    echo "✓ Health check passed"
    exit 0
else
    echo "✗ Health check failed"
    exit 1
fi

Run it after deployment:

bash health-check.sh && echo "Deployment successful"

Monitor Resource Usage

After deploying, keep an eye on memory and CPU:

pm2 monit

Or watch status updates:

watch -n 2 'pm2 status'

Sometimes new code is less efficient than you thought.

Use Staged Deployments

For critical apps, deploy to staging first:

# Deploy to staging
pm2 start server.js --name todo-api-staging --env staging

# Test thoroughly
curl http://localhost:5001/api/stats

# If all good, deploy to production
pm2 reload todo-api

Automate Common Tasks

Create a deployment script to avoid mistakes:

#!/bin/bash
# deploy.sh

echo "Starting deployment..."

# Backup
cp server.js server.js.backup.$(date +%Y%m%d-%H%M%S)

# Pull latest code
git pull origin main

# Install dependencies
npm install --production

# Reload application
pm2 reload todo-api

# Health check
sleep 3
if curl -s http://localhost:5000/api/health | grep -q "OK"; then
    echo "✓ Deployment successful"
else
    echo "✗ Deployment failed, rolling back"
    cp server.js.backup.* server.js
    pm2 reload todo-api
    exit 1
fi

Make it executable:

chmod +x deploy.sh
./deploy.sh

Wrapping Up

Deployments don’t have to be scary, you know. The most important thing is to have a good workflow and stick to it:

  1. Always backup before making any changes in production.
  2. Use PM2 reload for backend updates with no downtime.
  3. Test immediately after deployment, don’t assume it worked.
  4. Monitor logs for at least a few minutes after deployment
  5. Know how to rollback quickly to where you were when things go wrong.

You can update your Node.js backend without losing any requests with PM2’s reload feature. Nginx serves static files directly, so changes to the frontend happen right away. And if you have the right backups, your system will always be up and running in less than 30 seconds.

I’ve been using this process for years in dozens of production apps. It has saved me from a lot of sleepless nights and having to roll back things in a hurry. I hope it does the same for you.

Happy deployment!

Asif Khan, author of LinuxPathfinder.com

Asif Khan

DevOps, Linux, and Cloud engineer with 10+ years building infrastructure that stays up, and automation so I do not have to fix it at 2am. My focus is automation, security, and systems built to survive failure, using Infrastructure as Code, CI/CD, containers, monitoring, and cloud platforms instead of manual fixes. This blog is where I write down what worked, and just as often what didn't the first time around. Real tutorials you can run yourself, troubleshooting notes pulled from actual outages, and the kind of detail you only get once you've hit the problem firsthand. If you're trying to get Linux, DevOps, and cloud infrastructure to behave, that's what you'll find here: reproducible solutions you can understand, test, and apply in your own environment.