← Back to list

The Junior Dev’s Guide to Production Deployments: 15 Critical Mistakes and How to Avoid Them

Transitioning from local development (localhost) to a live production server is one of the most eye-opening experiences for a junior…

Khairul Basar · 2026-06-10 05:45 · 0 claps · 12.4 min read
#continuous-deployment #software-engineering #software-deployment #guidline
Open on Medium ↗

The Junior Dev’s Guide to Production Deployments: 15 Critical Mistakes and How to Avoid Them

Transitioning from local development (localhost) to a live production server is one of the most eye-opening experiences for a junior software engineer. In local development, if your app crashes, you restart it. If it runs out of memory, you restart your IDE.

In production, none of those manual interventions are acceptable. A production server must be self-healing, secure, monitored, and persistent.

Here is a deep technical breakdown of the Top 15 deployment mistakes made by junior developers, with concrete examples, configuration files, and the exact commands you need to master.

1. The “Foreground Run” Trap (No Process Manager)

The Mistake

Running your backend or frontend server directly in an open SSH terminal session using commands like npm start, python main.py, or node server.js, and then closing the terminal window. Or, running it using nohup node server.js & without a supervisor.

Technical “Why It Fails”

When you close your terminal or lose SSH connection, the operating system sends a Hangup signal (SIGHUP) to the parent shell. This automatically terminates all child processes running under that session. Your app will immediately go offline.

If you use nohup or &, the app runs in the background but has no auto-restart capabilities. If your app encounters an unhandled exception or the VM restarts, it will crash and stay down forever.

How to Fix It

Always use a Process Manager (like PM2 for Node.js) or configure a systemd service (for Python, Go, Java, etc.).

The PM2 Way (For Node/Next.js):

# Start your app with a custom name
pm2 start server.js --name "my-app"
# Ensure it restarts automatically on crash
pm2 startup
pm2 save

The systemd Way (For general applications):

Create a service file: sudo nano /etc/systemd/system/my-app.service

[Unit]
Description=My Production App
After=network.target
[Service]
Type=simple
User=deployer
WorkingDirectory=/var/www/my-app
ExecStart=/usr/bin/node server.js
Restart=always
RestartSec=3
Environment=NODE_ENV=production
[Install]
WantedBy=multi-user.target

Enable and start the service:

sudo systemctl daemon-reload
sudo systemctl enable my-app
sudo systemctl start my-app

2. No Server-Level Boot Persistence (The Reboot Problem)

The Mistake

Running PM2 or systemd services successfully in the background, but failing to configure them to survive a physical VM or server restart.

Technical “Why It Fails”

Cloud providers (like Azure, AWS, GCP) frequently migrate VMs or reboot hardware for patching and updates. When a virtual machine restarts, it boots into a clean operating system state. If your application processes are not hooked into the system initialization manager (init or systemd), your web server (like Nginx) will start up, but your backend app will remain offline, leading to a 502 Bad Gateway error.

How to Fix It

Tell the OS system manager to launch your process supervisor on boot.

For PM2:

# 1. Generate the systemd configuration command
pm2 startup
# 2. Copy the command outputted by the terminal and run it with sudo. Example:
sudo env PATH=$PATH:/usr/bin /usr/lib/node_modules/pm2/bin/pm2 startup systemd -u deployer --hp /home/deployer
# 3. Save current running processes so PM2 restores them on boot
pm2 save

For systemd:

# Instruct systemd to start the service on system boot
sudo systemctl enable my-app.service

3. Leaving Ports Open to the Public (No Firewall/Proxy)

The Mistake

Running your Node/Next.js app on port 3000 or 3010, or databases like MongoDB on 27017 and Redis on 6379, and opening these ports publicly in your cloud security groups (Azure NSG / AWS Security Groups) so the application can be accessed.

Technical “Why It Fails”

Exposing application servers or databases directly to the public internet bypasses Nginx (reverse proxy), exposing you to:

  1. Security Vulnerabilities: Direct port scanning bots will target your database or API. Open Redis/Mongo ports are prime targets for automated ransomware attacks.
  2. Missing features: You lose SSL/TLS termination, HTTP request compression (gzip/brotli), caching headers, and rate limiting provided by Nginx.

How to Fix It

  1. Use Nginx as a reverse proxy on ports 80 and 443.
  2. Configure Nginx to pass requests internally to localhost:3010.
  3. Configure the server firewall (UFW) and Cloud Security Groups to only allow ports 22 (SSH), 80 (HTTP), and 443 (HTTPS).

Setup UFW Firewall on Ubuntu:

# Allow SSH, HTTP, and HTTPS
sudo ufw allow 22/tcp
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
# Block everything else and enable UFW
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw enable

4. Neglecting Log Rotation (The Disk Full Crash)

The Mistake

Letting application console logs (out.log, err.log) and Nginx access logs write to the server disk indefinitely without rotation.

Technical “Why It Fails”

Over weeks or months, a busy application will generate gigabytes of log files. If left unchecked, these logs will consume 100% of the available disk space. Once a Linux disk is 100% full:

  • Databases (like MongoDB, PostgreSQL) will immediately shut down to prevent data corruption.
  • The system cannot write temporary files, causing active processes to crash.
  • SSH logins may fail, locking you out of your server.

How to Fix It

Configure log rotation to automatically compress, archive, and delete old logs.

For PM2 (using pm2-logrotate):

# Install the logrotate module inside PM2
pm2 install pm2-logrotate
# Configure it to rotate logs when they reach 10MB
pm2 set pm2-logrotate:max_size 10M
# Keep only the last 30 log files
pm2 set pm2-logrotate:retain 30

For Nginx/systemd:

Ensure logrotate is active on your Ubuntu system (it is usually enabled by default for Nginx logs in /etc/logrotate.d/nginx).

5. Running Processes as the Root User

The Mistake

Running your deployment scripts, PM2 instances, Node.js runtime, or Docker containers under the root (root) account.

Technical “Why It Fails”

If a hacker finds an exploit in your application (e.g., Remote Code Execution, File Upload vulnerabilities, or Path Traversal), they inherit the operating system permissions of the running process. If your process is running as root, the attacker gains instant, unrestricted control over your entire operating system, hardware, and secrets.

How to Fix It

Create a dedicated user with limited privileges to own and run the application.

# 1. Create a non-root user with no login password
sudo adduser --disabled-password --gecos "" deployer
# 2. Grant the user ownership of the application directory
sudo chown -R deployer:deployer /var/www/my-app
# 3. Always execute your app supervisor as that user
sudo su - deployer
pm2 start ecosystem.config.js

6. Hardcoding Configuration & Secrets in Version Control

The Mistake

Committing database passwords, JWT secret tokens, external API keys, or target server IPs directly into your Git repository (e.g., hardcoding inside server.js or committing .env files).

Technical “Why It Fails”

Git keeps a complete, permanent history of all changes. Even if you remove a hardcoded secret in a later commit, the secret remains visible in the commit history. Anyone who gets access to the repository (including former employees, third-party auditors, or attackers if the repo is leaked) will immediately compromise all connected services.

How to Fix It

Use environment variables and inject them at runtime. Add .env files to your .gitignore immediately.

In Code (Javascript/Typescript):

// ❌ WRONG
const dbPassword = "SuperSecretPassword123!";
//  RIGHT
const dbPassword = process.env.DATABASE_PASSWORD;

In Jenkins/CI-CD:

Store the secrets securely inside Jenkins Credentials. Write them to a .env file during the build stage:

stage('Inject Environment') {
    steps {
        withCredentials([string(credentialsId: 'db-password-prod', variable: 'DB_PASS')]) {
            sh "echo 'DATABASE_PASSWORD=${DB_PASS}' > .env"
        }
    }
}

7. No Automated Database Backups (Stored Off-Server)

The Mistake

Assuming your cloud VM is 100% reliable, or running a database backup script that saves the backup .tar or .sql files on the same VM hard drive.

Technical “Why It Fails”

VM disks can become corrupted, cloud accounts can get suspended, or ransomware attacks can encrypt your entire VM drive. If your backups are stored on the same server, you will lose both your live database and your backups simultaneously.

How to Fix It

Automate your database backups and upload them to a secure, external storage provider (like Amazon S3, Azure Blob Storage, or a remote backup server).

Script to Backup MongoDB to S3 (Example cron job):

Create a script /home/deployer/backup.sh:

#!/bin/bash
BACKUP_NAME="db-backup-$(date +%Y%m%d-%H%M%S)"
BACKUP_DIR="/tmp/$BACKUP_NAME"
# Dump database
mongodump --out "$BACKUP_DIR"
# Zip it
tar -czf "$BACKUP_DIR.tar.gz" -C "$BACKUP_DIR" .
# Upload to Amazon S3 (or Azure Blob)
aws s3 cp "$BACKUP_DIR.tar.gz" s3://my-prod-database-backups/
# Cleanup tmp files
rm -rf "$BACKUP_DIR" "$BACKUP_DIR.tar.gz"

Set up a cron job to run it every midnight (crontab -e):

0 0 * * * /bin/bash /home/deployer/backup.sh

8. Missing Health Checks (Silent Deployment Failure)

The Mistake

Declaring a deployment “successful” simply because the build commands succeeded and the server restart script exited with code 0.

Technical “Why It Fails”

An application might successfully boot, exit with code 0 to the shell, and then immediately crash loop 5 seconds later because of an incorrect database connection string or a missing environment variable. Without active health checks, your CI/CD pipeline will show a green “Success” mark while your users see a broken page.

How to Fix It

Implement a dedicated health check endpoint in your application (e.g., /api/health) and verify it during your CI/CD pipeline deployment stage.

Inside Next.js/Express App:

// GET /api/health
export async function GET() {
    try {
        // Test database connectivity
        await db.ping();
        return new Response(JSON.stringify({ status: "UP" }), { status: 200 });
    } catch (err) {
        return new Response(JSON.stringify({ status: "DOWN", error: err.message }), { status: 500 });
    }
}

Inside deploy.sh verification block:

# Wait for server startup
sleep 10
# Request HTTP headers and verify status code
STATUS_CODE=$(curl -o /dev/null -s -w "%{http_code}" http://localhost:3010/api/health)
if [ "$STATUS_CODE" -eq 200 ]; then
    echo "✅ Live check passed!"
else
    echo "❌ Live check failed with code $STATUS_CODE. Rolling back!"
    pm2 rollback
    exit 1
fi

9. Lack of CPU & Memory Constraints (Resource Starvation)

The Mistake

Running application processes without limiting the maximum RAM they are allowed to consume.

Technical “Why It Fails”

If your app has a memory leak (extremely common in Node.js apps that cache data globally in-memory), it will consume more and more RAM. Once the VM runs out of RAM, the Linux Kernel’s Out-Of-Memory (OOM) Killer will activate. To save the operating system from freezing, it will randomly kill the process consuming the most RAM — usually your database or your main web app.

How to Fix It

Instruct your process manager or container host to restart the application if it crosses a safe memory threshold.

Configure PM2 Memory Limit:

In your ecosystem.config.js file:

module.exports = {
  apps: [{
    name: 'kensei',
    script: 'server.js',
    // Restart process if it consumes more than 1GB RAM
    max_memory_restart: '1G'
  }]
};

10. SSL/TLS Inconsistencies and Protocol Defaults

The Mistake

Failing to redirect insecure HTTP requests (port 80) to HTTPS (port 443), or deploying production software with insecure SSL configurations (like allowing TLS 1.0 or TLS 1.1).

Technical “Why It Fails”

If HTTP is not forced to redirect to HTTPS, users might accidentally access your site insecurely, sending passwords or session tokens in plain text. Furthermore, old TLS protocols (v1.0, v1.1) contain known vulnerabilities (like BEAST and POODLE) that allow attackers to decrypt secure traffic.

How to Fix It

  1. Configure Nginx to perform a permanent 301 Redirect from Port 80 to 443.
  2. Restrict allowed TLS protocols to TLSv1.2 and TLSv1.3.

Nginx Production Configuration:

# Redirect HTTP to HTTPS
server {
    listen 80;
    server_name my-app.com www.my-app.com;
    return 301 https://$host$request_uri;
}
# HTTPS Server
server {
    listen 443 ssl http2;
    server_name my-app.com www.my-app.com;
    ssl_certificate /etc/ssl/cloudflare/origin.pem;
    ssl_certificate_key /etc/ssl/cloudflare/origin-key.pem;
    # Secure protocols ONLY
    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_prefer_server_ciphers on;
    ssl_ciphers 'ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384';
}

11. Incorrect Linux File Permissions and Ownership

The Mistake

Deploying application files where the ownership belongs to root or another user, while your application runs as a deployer user. Or, setting overly permissive file permissions like chmod 777 on everything to "just make it work."

Technical “Why It Fails”

Linux enforces strict file-level access control using three permission categories: Owner, Group, and Others. Each category has three flags: Read (r=4), Write (w=2), Execute (x=1).

When your deploy script creates files as root but PM2 runs as deployer, the app cannot read its own config files, write logs, or execute scripts. The process crashes with EACCES: permission denied.

Setting chmod 777 (read+write+execute for everyone) "solves" this but is a critical security hole—any user or compromised process on the server can read your SSL keys, overwrite your config, or inject malicious code into your scripts.

How to Fix It

# Correct ownership: deployer owns the app directory
sudo chown -R deployer:deployer /var/www/kensei
# Correct permissions:
# Directories → 755 (owner: rwx, group: r-x, others: r-x)
sudo find /var/www/kensei -type d -exec chmod 755 {} \;
# Files → 644 (owner: rw-, group: r--, others: r--)
sudo find /var/www/kensei -type f -exec chmod 644 {} \;
# Scripts must be executable → 755
chmod 755 /var/www/kensei/deploy.sh
# SSL private keys must be locked down → 600 (owner only)
sudo chmod 600 /etc/ssl/cloudflare/origin-key.pem

Permission Cheat Sheet:

PermissionNumericMeaningUse Caserwxr-xr-x755Owner full, others read+executeDirectories, scriptsrw-r--r--644Owner read+write, others read-onlyConfig files, app coderw-------600Owner only, nobody elseSSL private keys, .env filesrwxrwxrwx777Everyone has full accessNEVER use this in production

12. Not Copying Static Assets in Next.js Standalone Mode

The Mistake

Using Next.js output: 'standalone' for a lean production build, deploying the .next/standalone/ directory, and then wondering why all images are broken and CSS is missing (you get a white page with raw text).

Technical “Why It Fails”

When Next.js builds with output: 'standalone', it generates a self-contained server.js inside .next/standalone/. However, Next.js explicitly does NOT copy two critical folders into the standalone directory:

  1. **.next/static/** — Contains all compiled CSS, JavaScript chunks, and fonts.
  2. **public/** — Contains all your images, favicons, robots.txt, etc.

The standalone server.js expects these folders to exist relative to its own location:

.next/standalone/
├── server.js          ← runs here
├── .next/static/      ← expects CSS/JS here (NOT at ../.next/static/)
└── public/            ← expects images here (NOT at ../../public/)

If you just extract the tar archive without copying these folders, server.js will serve HTML but can't find any styles or images.

How to Fix It

Add a copy step in your deploy.sh after extracting the archive but before starting PM2:

# Extract the deployment archive
tar -xzf deploy-package.tar.gz && rm deploy-package.tar.gz
# Copy static assets INTO the standalone directory
cp -r .next/static .next/standalone/.next/static
cp -r public .next/standalone/public
# NOW start the app
pm2 start ecosystem.config.js --env production

Key Lesson: Always read the framework’s production deployment documentation. Next.js explicitly documents this requirement at nextjs.org/docs/app/api-reference/config/next-config-js/output.

13. No Rollback Strategy (Deploying Without a Safety Net)

The Mistake

Deploying a new version of your application directly over the existing one, with no way to revert if something goes wrong. If the new release has a critical bug, you’re stuck with a broken production site while you scramble to build and deploy a fix.

Technical “Why It Fails”

Without backups of the previous working version, your only option during a failed deployment is to:

  1. Identify the bug in code.
  2. Fix it locally.
  3. Push, build, and deploy again.

This entire cycle can take 15–30 minutes. During that time, your production site is completely down for all users.

How to Fix It

Always create a backup of the current working deployment before overwriting it.

In your deploy.sh:

APP_DIR="/var/www/kensei"
# Create a timestamped backup of the current working version
if [ -d "$APP_DIR/.next" ]; then
    BACKUP="backup-$(date +%Y%m%d-%H%M%S).tar.gz"
    tar -czf "$APP_DIR/backups/$BACKUP" -C "$APP_DIR" .next ecosystem.config.js package.json
    echo "✅ Backup created: $BACKUP"
    # Keep only the last 5 backups (delete older ones)
    cd "$APP_DIR/backups" && ls -t | tail -n +6 | xargs -r rm --
fi

Emergency Rollback Procedure:

If a deployment breaks the site, SSH into the VPS and run:

cd /var/www/kensei
# Stop the broken version
pm2 stop kensei
# List available backups
ls -la backups/
# Restore the last working version
tar -xzf backups/backup-20260610-143022.tar.gz
# Copy static assets back into standalone
cp -r .next/static .next/standalone/.next/static
cp -r public .next/standalone/public
# Restart
pm2 restart kensei

14. Not Configuring Swap Memory on Small VMs

The Mistake

Running production applications on small cloud VMs (1GB or 2GB RAM) without configuring swap space, then experiencing random process kills during build or high-traffic periods.

Technical “Why It Fails”

When your VM runs out of physical RAM (common during next build, npm install, or traffic spikes), the Linux kernel activates the OOM (Out-Of-Memory) Killer. It picks the process consuming the most memory and sends it a SIGKILL signal—instantly terminating it without any chance for graceful shutdown.

On a 1GB RAM VM, just running pnpm install + next build can easily exceed available memory. Without swap, the kernel has no overflow buffer and will kill your Node.js process or even your database.

Swap is a portion of disk space that acts as “emergency overflow RAM.” It’s slower than real RAM (because it reads/writes to disk), but it prevents the OOM Killer from activating.

How to Fix It

Create a 2GB swap file on your Ubuntu VM:

# 1. Create a 2GB swap file
sudo fallocate -l 2G /swapfile
# 2. Secure the file permissions (only root should access swap)
sudo chmod 600 /swapfile
# 3. Format it as swap space
sudo mkswap /swapfile
# 4. Enable it immediately
sudo swapon /swapfile
# 5. Make it permanent (survives reboot)
echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab
# 6. Verify swap is active
free -h

Expected output after setup:

total        used        free
Mem:          1.0Gi       512Mi       488Mi
Swap:         2.0Gi         0B        2.0Gi    ← Swap is active!

Optimize Swappiness:

# Tell the kernel to only use swap when RAM is nearly full (default is 60, which is too aggressive)
sudo sysctl vm.swappiness=10
# Make it permanent
echo 'vm.swappiness=10' | sudo tee -a /etc/sysctl.conf

15. Leaving SSH Password Authentication Enabled

The Mistake

Allowing password-based SSH login on your production server instead of switching entirely to SSH key authentication.

Technical “Why It Fails”

Automated bots continuously scan the internet for servers with port 22 (SSH) open. They attempt thousands of common username/password combinations per minute (brute-force attacks). If your deployer or root user has a weak or reused password, it's only a matter of time before an attacker gains full shell access.

Even strong passwords are vulnerable because:

  1. Passwords can be intercepted if your local network is compromised.
  2. Passwords can be phished or socially engineered.
  3. SSH keys use 256-bit or 4096-bit cryptographic algorithms that are computationally infeasible to brute-force.

How to Fix It

Step 1: Ensure SSH Key Access Works First

# Test that you can log in with your SSH key (from your local machine)
ssh -i ~/.ssh/my-key deployer@your-server-ip

Step 2: Disable Password Authentication

Edit the SSH daemon configuration:

sudo nano /etc/ssh/sshd_config

Find and set these values:

PasswordAuthentication no
PermitRootLogin no
PubkeyAuthentication yes

Step 3: Restart SSH Daemon

sudo systemctl restart sshd

Step 4: Verify Password Login is Blocked

From your local machine, try to force a password login:

ssh -o PubkeyAuthentication=no deployer@your-server-ip
# Expected output: "Permission denied (publickey)."

⚠️ CRITICAL WARNING: Do NOT disable password authentication until you have verified that SSH key login works perfectly. If you lock yourself out, you’ll need to use the cloud provider’s serial console (Azure Serial Console / AWS EC2 Instance Connect) to regain access.


메타데이터
post_id
f59aa7dd295b
slug
the-junior-devs-guide-to-production-deployments-15-critical-mistakes-and-how-to-avoid-them-f59aa7dd295b
url
https://medium.com/@khairulrucse26/the-junior-devs-guide-to-production-deployments-15-critical-mistakes-and-how-to-avoid-them-f59aa7dd295b
canonical_url
https://medium.com/@khairulrucse26/the-junior-devs-guide-to-production-deployments-15-critical-mistakes-and-how-to-avoid-them-f59aa7dd295b
author_url
https://medium.com/@khairulrucse26
status
ok
fetched_at
2026-06-22 05:41:33